Sieve playground: the server's interpreter in the browser #181
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+687
@@ -0,0 +1,687 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "mail-builder"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-parser"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hashify",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sieve-playground"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"http",
|
||||
"idna",
|
||||
"mail-parser",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sieve-rs",
|
||||
"whatlang",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sieve-rs"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arc-swap",
|
||||
"fancy-regex",
|
||||
"hashify",
|
||||
"mail-builder",
|
||||
"mail-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whatlang"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5e8f38b596e2a359b755342473520a99421e43658548c79489ee221b728c107"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,43 @@
|
||||
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# Modified by Coffey Labs in 2026 for INBUXA.
|
||||
|
||||
[package]
|
||||
name = "sieve-playground"
|
||||
description = "WebAssembly build of the server's Sieve interpreter, for the Sieve playground in inbuxa Admin"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
publish = false
|
||||
|
||||
# Its own workspace: the server's never builds it, and it builds for
|
||||
# wasm32-wasip1 (see build.sh).
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# The server's interpreter exactly: the vendored copy the root Cargo.toml
|
||||
# patches in, renamed extensions included.
|
||||
sieve-rs = { path = "../../vendor/sieve-rs" }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# What the server's function modules (src/functions) use; hyper's Uri is
|
||||
# http's, under the name the modules import it by.
|
||||
http = "1"
|
||||
idna = "1.1"
|
||||
whatlang = "0.18"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
gethostname = { path = "shim/gethostname" }
|
||||
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
# Sieve playground
|
||||
|
||||
A browser-only playground for Sieve scripts, shipped inside inbuxa Admin: the
|
||||
**Debug** button on a Sieve script opens it there with a copy of the script.
|
||||
It compiles and runs scripts with this repository's own Sieve interpreter,
|
||||
compiled to WebAssembly, so a script behaves as it does on the server: the
|
||||
same sieve-rs (`vendor/sieve-rs`, with the `vnd.inbuxa.*` extensions) and the
|
||||
same expression functions an account script can call. Scripts, messages and
|
||||
settings stay in the browser.
|
||||
|
||||
Ported from the playground in the upstream sieve-rs repository's `web/`
|
||||
directory (commit `549a2d57f7`), which targets sieve-rs 1.x.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/lib.rs`: the module's one export, `playground_call`, JSON in and out:
|
||||
`version`, `capabilities`, `defaults`, `compile` and `run`.
|
||||
- `src/run.rs`: compiles the main script and the include tabs and drives the
|
||||
interpreter's event loop the way the server's delivery does, carrying on
|
||||
after a runtime error.
|
||||
- `src/handler.rs`: answers the interpreter's events from the settings and
|
||||
records the actions.
|
||||
- `src/functions/`: the server's own function modules
|
||||
(`crates/common/src/scripts/functions`), included by path; tests fail if the
|
||||
playground's list stops matching the server's account-script list.
|
||||
- `src/settings.rs`: compiler and runtime options. The defaults are
|
||||
permissive; the server's limits for account scripts are lower.
|
||||
- `site/`: the static page and its ES modules. `worker.js` runs the module off
|
||||
the main thread and gives it the WASI calls it makes.
|
||||
- `shim/gethostname`: stands in for a crate with no WASI build.
|
||||
|
||||
## Why WASI
|
||||
|
||||
sieve-rs 0.7 and mail-builder read the clock with `SystemTime::now()`, which
|
||||
panics on `wasm32-unknown-unknown`. Built for `wasm32-wasip1`, they ask the
|
||||
host for the time instead, and `worker.js` answers with the playground's
|
||||
current-time setting, so `currentdate` and the `Date` of a vacation reply
|
||||
follow it. The module needs six WASI calls, all in `worker.js`.
|
||||
|
||||
0.7 does not count the instructions a run executes, and has no memory limit,
|
||||
so the playground shows neither.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
./build.sh # target/site
|
||||
./build.sh --admin ../inbuxa-admin # inbuxa-admin/sieve-playground
|
||||
```
|
||||
|
||||
It runs the tests, builds the module and stages it with `site/`. It needs the
|
||||
`wasm32-wasip1` Rust target. inbuxa Admin's build copies the staged directory,
|
||||
with Monaco from its own `node_modules`, into a directory named after a hash
|
||||
of its contents, so an Admin release never serves a stale copy.
|
||||
|
||||
When `vendor/sieve-rs` or the server's function modules change, rebuild with
|
||||
`--admin` and commit the result in inbuxa-admin.
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
// The vendored crate the server builds, so the playground names the
|
||||
// interpreter version the server runs.
|
||||
println!("cargo:rerun-if-changed=../../vendor/sieve-rs/Cargo.toml");
|
||||
let manifest = fs::read_to_string("../../vendor/sieve-rs/Cargo.toml").unwrap_or_default();
|
||||
let mut version = "unknown".to_string();
|
||||
let mut in_package = false;
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_package = line == "[package]";
|
||||
continue;
|
||||
}
|
||||
if in_package
|
||||
&& line.starts_with("version")
|
||||
&& let Some(value) = line.split('=').nth(1)
|
||||
{
|
||||
version = value.trim().trim_matches('"').to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("cargo:rustc-env=SIEVE_VERSION={version}");
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# Builds the Sieve playground: the server's Sieve interpreter for
|
||||
# wasm32-wasip1, beside the static site in site/. Monaco is not included:
|
||||
# inbuxa Admin's build adds it from its own node_modules.
|
||||
#
|
||||
# ./build.sh stage into target/site
|
||||
# ./build.sh --admin <dir> stage into <dir>/sieve-playground, an
|
||||
# inbuxa-admin checkout, with SOURCE.md naming
|
||||
# the commit it was built from
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
out="target/site"
|
||||
admin=""
|
||||
if [ "${1:-}" = "--admin" ]; then
|
||||
admin="${2:?--admin needs the inbuxa-admin directory}"
|
||||
[ -f "$admin/package.json" ] || { echo "$admin is not an inbuxa-admin checkout." >&2; exit 1; }
|
||||
out="$admin/sieve-playground"
|
||||
elif [ -n "${1:-}" ]; then
|
||||
echo "Usage: $0 [--admin <inbuxa-admin directory>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo test --quiet
|
||||
cargo build --release --target wasm32-wasip1
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
cp -r site/. "$out/"
|
||||
cp target/wasm32-wasip1/release/sieve_playground.wasm "$out/"
|
||||
|
||||
if [ -n "$admin" ]; then
|
||||
commit="$(git rev-parse HEAD)"
|
||||
dirty=""
|
||||
git diff --quiet HEAD -- . ../../vendor/sieve-rs ../../crates/common/src/scripts || dirty=" (with uncommitted changes)"
|
||||
version="$(sed -n 's/^version = "\(.*\)"/\1/p' ../../vendor/sieve-rs/Cargo.toml | head -1)"
|
||||
cat > "$out/SOURCE.md" <<EOF
|
||||
# Sieve playground
|
||||
|
||||
Generated: do not edit. This directory is built by
|
||||
\`tools/sieve-playground/build.sh --admin\` in inbuxa-server, from commit
|
||||
\`$commit\`$dirty, with the server's vendored sieve-rs $version. Change the
|
||||
source there, rebuild, and commit the result here.
|
||||
|
||||
The WebAssembly module is that commit's \`tools/sieve-playground\` crate, built
|
||||
for \`wasm32-wasip1\`; its source, and the site's, are in that directory under
|
||||
AGPL-3.0-only.
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Built the Sieve playground into $out ($(du -h "$out/sieve_playground.wasm" | cut -f1) of WebAssembly)."
|
||||
@@ -0,0 +1,13 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
# Stands in for gethostname, which mail-builder (through sieve-rs) builds by
|
||||
# default and which has no WASI implementation. The playground has no host
|
||||
# name: generated messages take local_hostname from the settings.
|
||||
[package]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
publish = false
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
pub fn gethostname() -> OsString {
|
||||
OsString::from("localhost")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
export const EML_ID = "eml";
|
||||
|
||||
class EmlState {
|
||||
constructor(inHeaders, boundaries) {
|
||||
this.inHeaders = inHeaders;
|
||||
this.boundaries = boundaries;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new EmlState(this.inHeaders, this.boundaries.slice());
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return (
|
||||
other instanceof EmlState &&
|
||||
other.inHeaders === this.inHeaders &&
|
||||
other.boundaries.length === this.boundaries.length &&
|
||||
other.boundaries.every((b, i) => b === this.boundaries[i])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const HEADER = /^([A-Za-z0-9!#$%&'*+.^_`|~-]+)(\s*:)/;
|
||||
const BOUNDARY_PARAM = /boundary\s*=\s*"?([^";\s]+)"?/i;
|
||||
|
||||
export function registerEml(monaco) {
|
||||
monaco.languages.register({ id: EML_ID, extensions: [".eml"], aliases: ["Email"] });
|
||||
monaco.languages.setTokensProvider(EML_ID, {
|
||||
getInitialState: () => new EmlState(true, []),
|
||||
tokenize(line, state) {
|
||||
const next = state.clone();
|
||||
const tokens = [];
|
||||
|
||||
if (next.boundaries.length) {
|
||||
const trimmed = line.trimEnd();
|
||||
const hit = next.boundaries.find((b) => trimmed === `--${b}` || trimmed === `--${b}--`);
|
||||
if (hit) {
|
||||
tokens.push({ startIndex: 0, scopes: "keyword.boundary" });
|
||||
if (trimmed.endsWith("--") && trimmed === `--${hit}--`) {
|
||||
next.boundaries = next.boundaries.filter((b) => b !== hit);
|
||||
next.inHeaders = false;
|
||||
} else {
|
||||
next.inHeaders = true;
|
||||
}
|
||||
return { tokens, endState: next };
|
||||
}
|
||||
}
|
||||
|
||||
if (next.inHeaders) {
|
||||
if (line.trim() === "") {
|
||||
next.inHeaders = false;
|
||||
return { tokens: [{ startIndex: 0, scopes: "" }], endState: next };
|
||||
}
|
||||
const boundary = BOUNDARY_PARAM.exec(line);
|
||||
if (boundary && !next.boundaries.includes(boundary[1])) next.boundaries.push(boundary[1]);
|
||||
const header = HEADER.exec(line);
|
||||
if (header) {
|
||||
tokens.push({ startIndex: 0, scopes: "attribute.name.header" });
|
||||
tokens.push({ startIndex: header[1].length, scopes: "delimiter" });
|
||||
tokens.push({ startIndex: header[0].length, scopes: "string.header" });
|
||||
} else if (/^\s/.test(line)) {
|
||||
tokens.push({ startIndex: 0, scopes: "string.header" });
|
||||
} else {
|
||||
tokens.push({ startIndex: 0, scopes: "invalid" });
|
||||
}
|
||||
return { tokens, endState: next };
|
||||
}
|
||||
|
||||
tokens.push({ startIndex: 0, scopes: /^>/.test(line) ? "comment.quote" : "" });
|
||||
return { tokens, endState: next };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function defineThemes(monaco) {
|
||||
const rules = (dark) => [
|
||||
{ token: "keyword", foreground: dark ? "c792ea" : "7a3eb1", fontStyle: "bold" },
|
||||
{ token: "type", foreground: dark ? "82aaff" : "1f5fbf" },
|
||||
{ token: "attribute.name", foreground: dark ? "f78c6c" : "b35300" },
|
||||
{ token: "variable", foreground: dark ? "ffcb6b" : "a0680a", fontStyle: "bold" },
|
||||
{ token: "string", foreground: dark ? "c3e88d" : "2f7d32" },
|
||||
{ token: "string.heredoc", foreground: dark ? "89ddff" : "0a7f94", fontStyle: "bold" },
|
||||
{ token: "string.escape", foreground: dark ? "89ddff" : "0a7f94" },
|
||||
{ token: "number", foreground: dark ? "f78c6c" : "b35300" },
|
||||
{ token: "comment", foreground: dark ? "6a7390" : "7d8595", fontStyle: "italic" },
|
||||
{ token: "attribute.name.header", foreground: dark ? "82aaff" : "1f5fbf", fontStyle: "bold" },
|
||||
{ token: "string.header", foreground: dark ? "d7dae0" : "3a3f4b" },
|
||||
{ token: "keyword.boundary", foreground: dark ? "c792ea" : "7a3eb1" },
|
||||
{ token: "comment.quote", foreground: dark ? "6a7390" : "7d8595" },
|
||||
{ token: "invalid", foreground: dark ? "ff7b82" : "c6313a" },
|
||||
];
|
||||
monaco.editor.defineTheme("playground-light", {
|
||||
base: "vs",
|
||||
inherit: true,
|
||||
rules: rules(false),
|
||||
colors: { "editor.background": "#ffffff", "editorGutter.background": "#ffffff" },
|
||||
});
|
||||
monaco.editor.defineTheme("playground-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: rules(true),
|
||||
colors: { "editor.background": "#1b1d24", "editorGutter.background": "#1b1d24" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
const RUN_TIMEOUT_MS = 8000;
|
||||
const MAX_RESTARTS = 3;
|
||||
const RESTART_WINDOW_MS = 60000;
|
||||
const BASE_BACKOFF_MS = 500;
|
||||
|
||||
export class EngineUnavailable extends Error {}
|
||||
|
||||
export class Engine {
|
||||
constructor() {
|
||||
this.pending = new Map();
|
||||
this.nextId = 1;
|
||||
this.listeners = new Set();
|
||||
this.restarts = [];
|
||||
this.failed = null;
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
onStatus(listener) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
emit(state, detail) {
|
||||
for (const listener of this.listeners) listener(state, detail);
|
||||
}
|
||||
|
||||
spawn() {
|
||||
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
||||
this.worker = worker;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this.resolveReady = resolve;
|
||||
this.rejectReady = reject;
|
||||
});
|
||||
this.ready.catch(() => {});
|
||||
worker.onmessage = (event) => {
|
||||
if (worker !== this.worker) return;
|
||||
const data = event.data;
|
||||
if (data.type === "ready") {
|
||||
this.resolveReady();
|
||||
} else if (data.type === "init-error") {
|
||||
this.crash(`Engine failed to load: ${data.error}`, { loadFailure: true });
|
||||
} else {
|
||||
this.settle(data);
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
event.preventDefault();
|
||||
if (worker === this.worker) {
|
||||
this.crash(`Engine failed to load: ${event.message || "the worker script could not be loaded"}`, { loadFailure: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
crash(reason, { loadFailure = false } = {}) {
|
||||
this.worker.terminate();
|
||||
this.rejectReady(new EngineUnavailable(reason));
|
||||
for (const { reject, timer } of this.pending.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(new EngineUnavailable(reason));
|
||||
}
|
||||
this.pending.clear();
|
||||
|
||||
let delay = 0;
|
||||
if (loadFailure) {
|
||||
const now = Date.now();
|
||||
this.restarts = this.restarts.filter((time) => now - time < RESTART_WINDOW_MS);
|
||||
if (this.restarts.length >= MAX_RESTARTS) {
|
||||
this.failed = reason;
|
||||
this.emit("failed", reason);
|
||||
return;
|
||||
}
|
||||
this.restarts.push(now);
|
||||
delay = BASE_BACKOFF_MS * 2 ** (this.restarts.length - 1);
|
||||
}
|
||||
this.emit("restarting", reason);
|
||||
const failedReady = this.ready;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
this.spawn();
|
||||
this.ready.then(resolve, reject);
|
||||
}, delay);
|
||||
});
|
||||
this.ready.catch(() => {});
|
||||
failedReady.catch(() => {});
|
||||
}
|
||||
|
||||
settle({ id, result, error, fatal }) {
|
||||
const entry = this.pending.get(id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(entry.timer);
|
||||
if (error) {
|
||||
entry.reject(new Error(error));
|
||||
if (fatal) this.crash(`The engine crashed: ${error}`);
|
||||
} else {
|
||||
entry.resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
async call(op, payload, timeout) {
|
||||
if (this.failed) throw new EngineUnavailable(this.failed);
|
||||
await this.ready;
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = timeout
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`The script did not finish within ${timeout / 1000} seconds and was stopped.`));
|
||||
this.crash("timeout");
|
||||
}, timeout)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.worker.postMessage({ id, op, payload });
|
||||
});
|
||||
}
|
||||
|
||||
version() {
|
||||
return this.call("version");
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
return this.call("capabilities");
|
||||
}
|
||||
|
||||
defaults() {
|
||||
return this.call("defaults");
|
||||
}
|
||||
|
||||
compile(request) {
|
||||
return this.call("compile", request, RUN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
run(request) {
|
||||
return this.call("run", request, RUN_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f5bd5"/><path d="M7 9h18l-6.5 7.5v6L13.5 25v-8.5z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 175 B |
@@ -0,0 +1,260 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
Modified by Coffey Labs in 2026 for INBUXA.
|
||||
-->
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sieve playground · inbuxa Admin</title>
|
||||
<meta name="description"
|
||||
content="Write, test and share Sieve email filters in your browser, with the Sieve interpreter of the inbuxa server." />
|
||||
<meta name="theme-color" content="#1b1d24" />
|
||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" data-name="vs/editor/editor.main" href="./vendor/monaco-0.52.2/vs/editor/editor.main.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="./" aria-label="Sieve playground">
|
||||
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M5 7h22l-8 9v7l-6 3V16z" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="brand-name">Sieve playground</span>
|
||||
</a>
|
||||
<span class="brand-by">inbuxa Admin</span>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="menu" id="workspace-menu">
|
||||
<button class="btn ghost menu-trigger" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="menu-label" id="workspace-name">Workspace</span>
|
||||
<svg class="chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div class="menu-panel" role="menu" hidden>
|
||||
<div class="menu-section">Workspaces</div>
|
||||
<div class="menu-list" id="workspace-list"></div>
|
||||
<div class="menu-sep"></div>
|
||||
<button type="button" role="menuitem" data-action="new-workspace">New workspace</button>
|
||||
<button type="button" role="menuitem" data-action="duplicate-workspace">Duplicate</button>
|
||||
<button type="button" role="menuitem" data-action="rename-workspace">Rename…</button>
|
||||
<button type="button" role="menuitem" data-action="delete-workspace">Delete…</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button type="button" role="menuitem" data-action="export-workspace">Export to file</button>
|
||||
<button type="button" role="menuitem" data-action="import-workspace">Import from file…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu" id="examples-menu">
|
||||
<button class="btn ghost menu-trigger" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
Examples
|
||||
<svg class="chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
<div class="menu-panel wide" role="menu" hidden>
|
||||
<div class="menu-section">Open an example as a new workspace</div>
|
||||
<div id="examples-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="toolbar-gap"></span>
|
||||
|
||||
<button class="btn ghost" type="button" id="share-button" title="Share this workspace">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 9l4-2M6 7l4 2M4.5 10a2 2 0 100-4 2 2 0 000 4zM11.5 6a2 2 0 100-4 2 2 0 000 4zM11.5 14a2 2 0 100-4 2 2 0 000 4z" /></svg>
|
||||
<span class="hide-narrow">Share</span>
|
||||
</button>
|
||||
<button class="btn ghost" type="button" id="settings-button" title="Settings">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 10a2 2 0 100-4 2 2 0 000 4zM13 8a5 5 0 00-.1-1l1.4-1.1-1.4-2.4-1.7.6a5 5 0 00-1.7-1L9.2 1.3H6.8l-.3 1.8a5 5 0 00-1.7 1l-1.7-.6L1.7 5.9 3.1 7a5 5 0 000 2l-1.4 1.1 1.4 2.4 1.7-.6a5 5 0 001.7 1l.3 1.8h2.4l.3-1.8a5 5 0 001.7-1l1.7.6 1.4-2.4L12.9 9a5 5 0 00.1-1z" /></svg>
|
||||
<span class="hide-narrow">Settings</span>
|
||||
</button>
|
||||
<button class="btn primary" type="button" id="run-button" title="Run (Ctrl+Enter)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 3l8 5-8 5z" /></svg>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="mobile-tabs" aria-label="Panels">
|
||||
<button type="button" data-panel="script" class="active">Script</button>
|
||||
<button type="button" data-panel="message">Message</button>
|
||||
<button type="button" data-panel="result">Result</button>
|
||||
</nav>
|
||||
|
||||
<main class="workbench" id="workbench">
|
||||
<section class="panel active" data-panel="script" aria-label="Sieve script">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title"><span class="step">1</span>Script</span>
|
||||
<div class="tabs" id="script-tabs" role="tablist"></div>
|
||||
<button class="icon-btn" type="button" id="add-script" title="Add an include script">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-host" id="script-editor"></div>
|
||||
<div class="problems" id="problems" data-state="idle"></div>
|
||||
</section>
|
||||
|
||||
<div class="gutter" data-gutter="0" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<section class="panel" data-panel="message" aria-label="Test message">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title"><span class="step">2</span>Message</span>
|
||||
<div class="tabs" id="message-tabs" role="tablist"></div>
|
||||
<button class="icon-btn" type="button" id="add-message" title="Add a test message">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-host drop-target" id="message-editor">
|
||||
<div class="drop-hint">Drop an .eml or .sieve file</div>
|
||||
</div>
|
||||
<details class="envelope" id="envelope">
|
||||
<summary>
|
||||
<span>Envelope</span>
|
||||
<span class="envelope-summary" id="envelope-summary"></span>
|
||||
</summary>
|
||||
<div class="envelope-grid">
|
||||
<label for="envelope-from">MAIL FROM</label>
|
||||
<input id="envelope-from" type="text" spellcheck="false" autocomplete="off" />
|
||||
<label for="envelope-to">RCPT TO</label>
|
||||
<input id="envelope-to" type="text" spellcheck="false" autocomplete="off" placeholder="comma separated" />
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="gutter" data-gutter="1" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<section class="panel" data-panel="result" aria-label="Result">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title"><span class="step">3</span>Result</span>
|
||||
<div class="tabs" role="tablist" id="result-tabs">
|
||||
<button type="button" role="tab" class="tab active" data-view="actions">Actions</button>
|
||||
<button type="button" role="tab" class="tab" data-view="messages">Messages <span class="count" id="messages-count"></span></button>
|
||||
<button type="button" role="tab" class="tab" data-view="variables">Variables</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-body" id="result-body">
|
||||
<div class="result-view active" data-view="actions" id="actions-view"></div>
|
||||
<div class="result-view" data-view="messages" id="messages-view"></div>
|
||||
<div class="result-view" data-view="variables" id="variables-view"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span class="status-item" id="engine-status" data-state="loading">Loading engine…</span>
|
||||
<span class="status-item" id="compile-status"></span>
|
||||
<span class="status-item" id="run-stats"></span>
|
||||
<button class="status-item status-link" type="button" id="privacy-link" title="How the playground handles your data">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 7V5a3.5 3.5 0 017 0v2M3.5 7h9v6.5h-9z" /></svg>
|
||||
<span class="hide-narrow">Runs in your browser</span>
|
||||
</button>
|
||||
<span class="status-spacer"></span>
|
||||
<a class="status-item engine-link" href="https://git.coffeylabs.org/inbuxa/inbuxa-server/src/branch/main/tools/sieve-playground" target="_blank" rel="noopener" title="The server's Sieve interpreter, and this playground's source">sieve-rs <span id="engine-version"></span></a>
|
||||
</footer>
|
||||
|
||||
<div class="drawer-backdrop" id="settings-backdrop" hidden></div>
|
||||
<aside class="drawer" id="settings-drawer" aria-label="Settings" hidden>
|
||||
<div class="drawer-head">
|
||||
<h2>Settings</h2>
|
||||
<span class="drawer-sub">Saved with this workspace</span>
|
||||
<button class="icon-btn" type="button" data-close title="Close">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="settings-body"></div>
|
||||
<div class="drawer-foot">
|
||||
<button class="btn ghost" type="button" id="settings-reset">Reset to defaults</button>
|
||||
<button class="btn ghost" type="button" id="settings-json">Edit as JSON</button>
|
||||
<span class="toolbar-gap"></span>
|
||||
<button class="btn primary" type="button" data-close>Done</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<dialog class="dialog" id="welcome-dialog" aria-labelledby="welcome-title">
|
||||
<form method="dialog">
|
||||
<h2 id="welcome-title">Sieve playground</h2>
|
||||
<p class="dialog-text">
|
||||
Write and test Sieve email filters against real messages, with the same Sieve interpreter your
|
||||
inbuxa server runs.
|
||||
</p>
|
||||
<ul class="welcome-points">
|
||||
<li>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 7V5a3.5 3.5 0 017 0v2M3.5 7h9v6.5h-9z" /></svg>
|
||||
<span><b>Everything runs in your browser.</b> Scripts are compiled and executed locally with WebAssembly.</span>
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 4.5h11v8h-11zM5 4.5V3h6v1.5" /></svg>
|
||||
<span><b>Nothing is stored on a server.</b> Your scripts, messages and settings are saved only in this browser.</span>
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 9l4-2M6 7l4 2M4.5 10a2 2 0 100-4 2 2 0 000 4zM11.5 6a2 2 0 100-4 2 2 0 000 4zM11.5 14a2 2 0 100-4 2 2 0 000 4z" /></svg>
|
||||
<span><b>Sharing is up to you.</b> A share link carries the workspace inside the link itself; nothing is uploaded.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="dialog-foot">
|
||||
<span class="toolbar-gap"></span>
|
||||
<button class="btn primary" value="close" autofocus>Start filtering</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="dialog" id="share-dialog">
|
||||
<form method="dialog">
|
||||
<h2>Share workspace</h2>
|
||||
<p class="dialog-text">
|
||||
The workspace is compressed into the link itself. Nothing is uploaded: whoever opens the link gets their own copy.
|
||||
</p>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="share-messages" checked />
|
||||
Include test messages
|
||||
</label>
|
||||
<p class="dialog-warning" id="share-privacy">Messages may contain personal information. Check them before sharing.</p>
|
||||
<div class="share-row">
|
||||
<input id="share-url" type="text" readonly spellcheck="false" />
|
||||
<button class="btn primary" type="button" id="share-copy">Copy link</button>
|
||||
</div>
|
||||
<p class="dialog-note" id="share-size"></p>
|
||||
<div class="dialog-foot">
|
||||
<button class="btn ghost" type="button" data-action="export-workspace">Export to file instead</button>
|
||||
<span class="toolbar-gap"></span>
|
||||
<button class="btn" value="close">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="dialog" id="prompt-dialog">
|
||||
<form method="dialog">
|
||||
<h2 id="prompt-title"></h2>
|
||||
<p class="dialog-text" id="prompt-text"></p>
|
||||
<input id="prompt-input" type="text" spellcheck="false" autocomplete="off" />
|
||||
<div class="dialog-foot">
|
||||
<span class="toolbar-gap"></span>
|
||||
<button class="btn" value="cancel" formnovalidate>Cancel</button>
|
||||
<button class="btn primary" value="ok" id="prompt-ok">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<input type="file" id="file-input" accept=".json,.eml,.sieve,.siv,.txt,message/rfc822" hidden />
|
||||
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||||
|
||||
<script>
|
||||
self.MonacoEnvironment = {
|
||||
getWorkerUrl: () => new URL("./vendor/monaco-0.52.2/vs/base/worker/workerMain.js", location.href).href,
|
||||
};
|
||||
</script>
|
||||
<script src="./vendor/monaco-0.52.2/vs/loader.js"></script>
|
||||
<script>
|
||||
window.monacoReady = new Promise((resolve, reject) => {
|
||||
require.config({ paths: { vs: "./vendor/monaco-0.52.2/vs" } });
|
||||
require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="./app.js" onerror="try { if (!sessionStorage.getItem('inbuxa-sieve.reloaded')) { sessionStorage.setItem('inbuxa-sieve.reloaded', '1'); location.reload(); } } catch (_) {}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { EML_ID } from "./eml-language.js";
|
||||
|
||||
export function h(tag, attrs = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs || {})) {
|
||||
if (value === undefined || value === null || value === false) continue;
|
||||
if (key === "class") el.className = value;
|
||||
else if (key === "dataset") Object.assign(el.dataset, value);
|
||||
else if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
|
||||
else el.setAttribute(key, value === true ? "" : value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
keep: '<path d="M2 9l2-6h8l2 6v4H2zM2 9h4l1 2h2l1-2h4"/>',
|
||||
fileinto: '<path d="M2 4h4l1.5 1.5H14V13H2z"/>',
|
||||
discard: '<path d="M3 4h10M6 4V2.5h4V4M4.5 4l.7 9.5h5.6l.7-9.5"/>',
|
||||
reject: '<circle cx="8" cy="8" r="5.5"/><path d="M4.2 11.8l7.6-7.6"/>',
|
||||
redirect: '<path d="M2 11c1-4 4-6 9-6M9 2.5L11.5 5 9 7.5"/>',
|
||||
vacation: '<path d="M8 2v12M3 7a5 5 0 0110 0zM8 14h-3"/>',
|
||||
notification: '<path d="M4 11V7a4 4 0 018 0v4l1 1.5H3zM6.5 14h3"/>',
|
||||
notify: '<path d="M4 11V7a4 4 0 018 0v4l1 1.5H3zM6.5 14h3"/>',
|
||||
created: '<path d="M4 2h5l3 3v9H4zM9 2v3h3M6.5 9.5h3M8 8v3"/>',
|
||||
envelope: '<path d="M2 4h12v8H2zM2 4l6 5 6-5"/>',
|
||||
error: '<circle cx="8" cy="8" r="5.5"/><path d="M8 5v3.5M8 11h0"/>',
|
||||
};
|
||||
|
||||
function icon(kind) {
|
||||
const span = h("span", { class: "event-icon", "aria-hidden": "true" });
|
||||
span.innerHTML = `<svg viewBox="0 0 16 16">${ICONS[kind] || ICONS.created}</svg>`;
|
||||
return span;
|
||||
}
|
||||
|
||||
const ROLE_BY_KIND = {
|
||||
keep: "Delivered",
|
||||
fileinto: "Delivered",
|
||||
vacation: "Vacation reply",
|
||||
redirect: "Redirected",
|
||||
notification: "Notification",
|
||||
};
|
||||
|
||||
export function messageRoles(output) {
|
||||
const roles = new Map();
|
||||
for (const event of output.events) {
|
||||
if (!event.messageId || event.kind === "created") continue;
|
||||
const role = ROLE_BY_KIND[event.kind];
|
||||
if (!role) continue;
|
||||
const set = roles.get(event.messageId) || new Set();
|
||||
set.add(role);
|
||||
roles.set(event.messageId, set);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
function roleLabel(roles, id) {
|
||||
const set = roles.get(id);
|
||||
if (!set || set.size === 0) return "Generated";
|
||||
return [...set].join(" · ");
|
||||
}
|
||||
|
||||
function summarize(output) {
|
||||
const lines = [];
|
||||
const events = output.events;
|
||||
const byKind = (kind) => events.filter((event) => event.kind === kind);
|
||||
const flags = (event) => event.detail.find((d) => d.label === "flags")?.value;
|
||||
const withFlags = (event) => (flags(event) ? [" with flags ", h("b", {}, flags(event))] : []);
|
||||
|
||||
for (const event of byKind("fileinto")) {
|
||||
const folder = event.summary.replace(/^File into /, "");
|
||||
lines.push(["Filed into ", h("b", {}, folder), ...withFlags(event)]);
|
||||
}
|
||||
for (const event of byKind("keep")) {
|
||||
lines.push([event.afterError ? "Kept in " : "Delivered to ", h("b", {}, "INBOX"), ...withFlags(event), event.afterError ? " because the script failed" : ""]);
|
||||
}
|
||||
for (const event of byKind("discard")) lines.push([h("b", {}, "Discarded"), " without notifying anyone"]);
|
||||
for (const event of byKind("reject")) {
|
||||
const reason = event.detail.find((d) => d.label === "reason")?.value || "";
|
||||
lines.push([h("b", {}, "Rejected"), reason ? `: "${reason}"` : ""]);
|
||||
}
|
||||
const redirects = byKind("redirect").map((event) => event.summary.replace(/^Redirect to /, ""));
|
||||
if (redirects.length) lines.push(["Redirected to ", h("b", {}, redirects.join(", "))]);
|
||||
for (const event of byKind("vacation")) lines.push(["Vacation reply sent to ", h("b", {}, event.summary.replace(/^Send a vacation reply to /, ""))]);
|
||||
for (const event of byKind("notification")) lines.push(["Notification sent to ", h("b", {}, event.summary.replace(/^Send a notification to /, ""))]);
|
||||
for (const event of byKind("notify")) lines.push([event.summary]);
|
||||
if (output.messageChanged) lines.push(["The message was ", h("b", {}, "modified"), " by the script"]);
|
||||
if (lines.length === 0) lines.push(["No actions were taken"]);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class ResultView {
|
||||
constructor({ monaco, root, onRevealLine, onRunAgain, onSelectView, getOriginal, getTheme }) {
|
||||
this.monaco = monaco;
|
||||
this.actionsView = root.querySelector("#actions-view");
|
||||
this.messagesView = root.querySelector("#messages-view");
|
||||
this.variablesView = root.querySelector("#variables-view");
|
||||
this.countEl = document.querySelector("#messages-count");
|
||||
this.onRevealLine = onRevealLine;
|
||||
this.onRunAgain = onRunAgain;
|
||||
this.onSelectView = onSelectView;
|
||||
this.getOriginal = getOriginal;
|
||||
this.getTheme = getTheme;
|
||||
this.output = null;
|
||||
this.selected = null;
|
||||
this.mode = "preview";
|
||||
this.sourceEditor = null;
|
||||
this.diffEditor = null;
|
||||
this.renderEmpty();
|
||||
}
|
||||
|
||||
renderEmpty() {
|
||||
this.output = null;
|
||||
this.countEl.textContent = "";
|
||||
this.actionsView.replaceChildren(
|
||||
h("div", { class: "empty" }, h("div", {}, h("strong", {}, "Nothing has run yet"), "Press ", h("kbd", {}, "Run"), " or ", h("kbd", {}, navigator.platform.includes("Mac") ? "⌘ Enter" : "Ctrl Enter"), " to filter the message through the script.")),
|
||||
);
|
||||
this.messagesView.replaceChildren(h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No messages yet"), "Modified messages, vacation replies and notifications appear here after a run.")));
|
||||
this.variablesView.replaceChildren(h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No variables yet"), "Global variables and duplicate ids appear here after a run.")));
|
||||
this.disposeEditors();
|
||||
}
|
||||
|
||||
renderFailure(message) {
|
||||
this.renderEmpty();
|
||||
this.actionsView.replaceChildren(h("div", { class: "banner" }, message));
|
||||
}
|
||||
|
||||
renderCompileErrors(count, onShow) {
|
||||
this.renderEmpty();
|
||||
this.actionsView.replaceChildren(
|
||||
h("div", { class: "banner" }, `The script has ${count} error${count === 1 ? "" : "s"} and cannot run.`, h("button", { type: "button", onclick: onShow }, "Show")),
|
||||
);
|
||||
}
|
||||
|
||||
render(output) {
|
||||
this.output = output;
|
||||
this.roles = messageRoles(output);
|
||||
this.countEl.textContent = output.messages.length ? String(output.messages.length) : "";
|
||||
this.renderActions();
|
||||
if (!output.messages.some((message) => message.id === this.selected)) {
|
||||
const delivered = output.messages.find((message) => roleLabel(this.roles, message.id).includes("Delivered"));
|
||||
this.selected = (delivered || output.messages[0])?.id ?? null;
|
||||
}
|
||||
this.renderMessages();
|
||||
this.renderVariables();
|
||||
}
|
||||
|
||||
renderActions() {
|
||||
const output = this.output;
|
||||
const nodes = [];
|
||||
|
||||
if (output.error) {
|
||||
const where = output.error.line > 0 ? ` (line ${output.error.line})` : "";
|
||||
nodes.push(
|
||||
h("div", { class: "banner warn" }, h("span", {}, h("b", {}, "Runtime error: "), output.error.message, where),
|
||||
output.error.line > 0 ? h("button", { type: "button", onclick: () => this.onRevealLine(output.error.script, output.error.line, output.error.column) }, "Go to line") : null),
|
||||
);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
h("div", { class: "outcome" },
|
||||
h("div", { class: "outcome-title" }, "Outcome"),
|
||||
h("div", { class: "outcome-lines" }, summarize(output).map((parts) => h("div", {}, ...parts))),
|
||||
h("div", { class: "outcome-actions" },
|
||||
output.messages.length ? h("button", { class: "btn", type: "button", onclick: () => this.onSelectView("messages") }, `View ${output.messages.length} message${output.messages.length === 1 ? "" : "s"}`) : null,
|
||||
h("button", { class: "btn", type: "button", title: "Run again with the duplicate ids and vacation history from this run", onclick: () => this.onRunAgain() }, "Run again as a redelivery"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const list = h("ol", { class: "timeline" });
|
||||
for (const event of output.events) {
|
||||
const chips = event.detail.map((detail) =>
|
||||
h("span", { class: `chip${detail.label === "warning" ? " warn" : ""}` }, h("span", { class: "k" }, detail.label), detail.value),
|
||||
);
|
||||
if (event.isFinal) chips.unshift(h("span", { class: "chip tag" }, event.afterError ? "implicit keep" : "final action"));
|
||||
if (event.messageId > 0 && event.kind !== "created") {
|
||||
chips.push(h("button", { class: "chip chip-link", type: "button", onclick: () => this.showMessage(event.messageId) }, `message #${event.messageId}`));
|
||||
}
|
||||
if (event.kind === "created") {
|
||||
chips.push(h("button", { class: "chip chip-link", type: "button", onclick: () => this.showMessage(event.messageId) }, "view"));
|
||||
}
|
||||
list.append(
|
||||
h("li", { class: `event${event.isFinal ? " final" : ""}${event.afterError || event.kind === "created" ? " muted" : ""}`, dataset: { kind: event.kind } },
|
||||
icon(event.kind),
|
||||
h("div", { class: "event-card" }, h("div", { class: "event-summary" }, event.summary), chips.length ? h("div", { class: "event-meta" }, chips) : null),
|
||||
),
|
||||
);
|
||||
}
|
||||
nodes.push(list);
|
||||
this.actionsView.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
showMessage(id) {
|
||||
this.selected = id;
|
||||
this.renderMessages();
|
||||
this.onSelectView("messages");
|
||||
}
|
||||
|
||||
renderMessages() {
|
||||
const output = this.output;
|
||||
this.disposeEditors();
|
||||
if (!output || output.messages.length === 0) {
|
||||
this.messagesView.replaceChildren(
|
||||
h("div", { class: "empty" }, h("div", {}, h("strong", {}, "No messages were generated"), "The original message was not modified and no replies or notifications were sent.")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = h("div", { class: "message-list" },
|
||||
output.messages.map((message) =>
|
||||
h("button", { type: "button", class: `message-pill${message.id === this.selected ? " active" : ""}`, onclick: () => { this.selected = message.id; this.renderMessages(); } },
|
||||
h("span", { class: "role" }, `#${message.id} ${roleLabel(this.roles, message.id)}`),
|
||||
h("span", { class: "subj" }, message.subject || "(no subject)"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const message = output.messages.find((m) => m.id === this.selected) || output.messages[0];
|
||||
const canDiff = roleLabel(this.roles, message.id).includes("Delivered") || roleLabel(this.roles, message.id).includes("Redirected");
|
||||
if (this.mode === "diff" && !canDiff) this.mode = "preview";
|
||||
|
||||
const setMode = (mode) => { this.mode = mode; this.renderMessages(); };
|
||||
const toolbar = h("div", { class: "message-toolbar" },
|
||||
h("div", { class: "segmented", role: "tablist" },
|
||||
h("button", { type: "button", class: this.mode === "preview" ? "active" : "", onclick: () => setMode("preview") }, "Preview"),
|
||||
h("button", { type: "button", class: this.mode === "source" ? "active" : "", onclick: () => setMode("source") }, "Source"),
|
||||
h("button", { type: "button", class: this.mode === "diff" ? "active" : "", disabled: !canDiff, title: canDiff ? "Compare with the input message" : "Only available for the delivered message", onclick: () => setMode("diff") }, "Changes"),
|
||||
),
|
||||
h("span", { class: "toolbar-gap" }),
|
||||
h("span", { class: "item-sub" }, `${message.size.toLocaleString()} bytes`),
|
||||
h("button", { class: "btn ghost", type: "button", onclick: () => copyText(message.raw) }, "Copy"),
|
||||
);
|
||||
|
||||
const frame = h("div", { class: `message-frame${this.mode === "preview" ? "" : " code"}` });
|
||||
this.messagesView.replaceChildren(list, toolbar, frame);
|
||||
|
||||
if (this.mode === "preview") {
|
||||
frame.append(this.preview(message));
|
||||
this.pendingMount = null;
|
||||
} else {
|
||||
this.pendingMount = { frame, message, mode: this.mode };
|
||||
if (this.messagesView.classList.contains("active")) this.mountEditor();
|
||||
}
|
||||
}
|
||||
|
||||
onShown() {
|
||||
if (this.pendingMount) this.mountEditor();
|
||||
this.layout();
|
||||
}
|
||||
|
||||
mountEditor() {
|
||||
const { frame, message, mode } = this.pendingMount;
|
||||
this.pendingMount = null;
|
||||
if (!frame.isConnected) return;
|
||||
const text = message.raw.replace(/\r\n/g, "\n");
|
||||
if (mode === "source") {
|
||||
if (!this.sourceHost) {
|
||||
this.sourceHost = h("div", { class: "viewer-host" });
|
||||
this.sourceEditor = this.monaco.editor.create(this.sourceHost, viewerOptions());
|
||||
}
|
||||
frame.append(this.sourceHost);
|
||||
this.swapModels(() => this.sourceEditor.setModel(this.monaco.editor.createModel(text, EML_ID)));
|
||||
this.sourceEditor.layout();
|
||||
} else {
|
||||
if (!this.diffHost) {
|
||||
this.diffHost = h("div", { class: "viewer-host" });
|
||||
this.diffEditor = this.monaco.editor.createDiffEditor(this.diffHost, { ...viewerOptions(), enableSplitViewResizing: false });
|
||||
}
|
||||
frame.append(this.diffHost);
|
||||
this.diffEditor.updateOptions({ renderSideBySide: frame.clientWidth > 700 });
|
||||
this.swapModels(() =>
|
||||
this.diffEditor.setModel({
|
||||
original: this.monaco.editor.createModel(this.getOriginal().replace(/\r\n/g, "\n"), EML_ID),
|
||||
modified: this.monaco.editor.createModel(text, EML_ID),
|
||||
}),
|
||||
);
|
||||
this.diffEditor.layout();
|
||||
}
|
||||
}
|
||||
|
||||
swapModels(attach) {
|
||||
const previous = this.viewerModels || [];
|
||||
attach();
|
||||
const source = this.sourceEditor?.getModel();
|
||||
const diff = this.diffEditor?.getModel();
|
||||
this.viewerModels = [source, diff?.original, diff?.modified].filter(Boolean);
|
||||
const keep = new Set(this.viewerModels);
|
||||
for (const model of previous) if (!keep.has(model)) model.dispose();
|
||||
}
|
||||
|
||||
preview(message) {
|
||||
const container = h("div", { class: "preview" });
|
||||
const original = parseHeaderNames(this.getOriginal());
|
||||
const main = ["From", "To", "Cc", "Subject", "Date"];
|
||||
let showAll = false;
|
||||
const table = h("table", { class: "headers-table" });
|
||||
const drawHeaders = () => {
|
||||
const rows = message.headers
|
||||
.filter((header) => showAll || main.some((name) => name.toLowerCase() === header.name.toLowerCase()))
|
||||
.map((header) => {
|
||||
const isNew = !original.has(`${header.name.toLowerCase()}:${header.value}`);
|
||||
return h("tr", { class: isNew && message.id > 0 && roleLabel(this.roles, message.id).includes("Delivered") ? "changed" : "" }, h("th", {}, header.name), h("td", {}, header.value));
|
||||
});
|
||||
table.replaceChildren(...rows);
|
||||
toggle.textContent = showAll ? "Show fewer headers" : `Show all ${message.headers.length} headers`;
|
||||
};
|
||||
const toggle = h("button", { type: "button", class: "headers-toggle", onclick: () => { showAll = !showAll; drawHeaders(); } });
|
||||
drawHeaders();
|
||||
container.append(table, toggle);
|
||||
|
||||
if (message.html && !message.text) {
|
||||
container.append(htmlFrame(message.html));
|
||||
} else if (message.html) {
|
||||
let showHtml = false;
|
||||
const body = h("div", {});
|
||||
const switcher = h("div", { class: "segmented" });
|
||||
const draw = () => {
|
||||
switcher.replaceChildren(
|
||||
h("button", { type: "button", class: showHtml ? "" : "active", onclick: () => { showHtml = false; draw(); } }, "Text"),
|
||||
h("button", { type: "button", class: showHtml ? "active" : "", onclick: () => { showHtml = true; draw(); } }, "HTML"),
|
||||
);
|
||||
body.replaceChildren(showHtml ? htmlFrame(message.html) : h("pre", { class: "body-text" }, message.text));
|
||||
};
|
||||
draw();
|
||||
container.append(h("div", { style: "margin-bottom:8px" }, switcher), body);
|
||||
} else {
|
||||
container.append(h("pre", { class: "body-text" }, message.text || "(empty body)"));
|
||||
}
|
||||
|
||||
if (message.attachments.length) {
|
||||
container.append(
|
||||
h("div", { class: "attachments" },
|
||||
message.attachments.map((a) => h("span", { class: "attachment" }, a.name, h("span", { class: "muted" }, a.contentType), h("span", { class: "muted" }, formatSize(a.size)))),
|
||||
),
|
||||
);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
renderVariables() {
|
||||
const output = this.output;
|
||||
const nodes = [];
|
||||
if (output.globalVariables.length) {
|
||||
nodes.push(
|
||||
h("table", { class: "vars" },
|
||||
h("thead", {}, h("tr", {}, h("th", {}, "Global variable"), h("th", {}, "Value"))),
|
||||
h("tbody", {}, output.globalVariables.map((v) => h("tr", {}, h("td", {}, v.name), h("td", {}, v.value)))),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
nodes.push(h("div", { class: "vars-note" }, "No global variables were set. Declare a variable with ", h("code", {}, 'global ["name"];'), " (requires \"include\") to inspect its final value here."));
|
||||
}
|
||||
nodes.push(
|
||||
h("table", { class: "vars" },
|
||||
h("thead", {}, h("tr", {}, h("th", {}, "Duplicate and vacation ids seen"))),
|
||||
h("tbody", {}, output.duplicateIds.length ? output.duplicateIds.map((id) => h("tr", {}, h("td", {}, id))) : h("tr", {}, h("td", {}, "None"))),
|
||||
),
|
||||
);
|
||||
this.variablesView.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
this.monaco.editor.setTheme(theme);
|
||||
}
|
||||
|
||||
layout() {
|
||||
this.sourceEditor?.layout();
|
||||
this.diffEditor?.layout();
|
||||
}
|
||||
|
||||
disposeEditors() {
|
||||
this.pendingMount = null;
|
||||
this.sourceHost?.remove();
|
||||
this.diffHost?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function viewerOptions() {
|
||||
return {
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
stickyScroll: { enabled: false },
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbersMinChars: 3,
|
||||
fontSize: 12.5,
|
||||
renderLineHighlight: "none",
|
||||
};
|
||||
}
|
||||
|
||||
function htmlFrame(html) {
|
||||
const csp = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; img-src data:; style-src \'unsafe-inline\'">';
|
||||
return h("iframe", { class: "body-html", sandbox: "", referrerpolicy: "no-referrer", srcdoc: `${csp}${html}`, title: "HTML body" });
|
||||
}
|
||||
|
||||
function parseHeaderNames(raw) {
|
||||
const set = new Set();
|
||||
const headerBlock = raw.replace(/\r\n/g, "\n").split(/\n\n/)[0];
|
||||
const unfolded = headerBlock.replace(/\n[ \t]+/g, " ");
|
||||
for (const line of unfolded.split("\n")) {
|
||||
const index = line.indexOf(":");
|
||||
if (index > 0) set.add(`${line.slice(0, index).trim().toLowerCase()}:${line.slice(index + 1).trim().split(/\s+/).join(" ")}`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
document.dispatchEvent(new CustomEvent("toast", { detail: "Copied to the clipboard" }));
|
||||
} catch (_) {
|
||||
document.dispatchEvent(new CustomEvent("toast", { detail: "Copy failed: clipboard access was blocked" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
From: Team Lead <[email protected]>
|
||||
To: [email protected], [email protected], [email protected]
|
||||
Cc: [email protected]
|
||||
Subject: MEETING MOVED TO FRIDAY
|
||||
Date: Sat, 12 Sep 2026 09:00:00 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
The weekly meeting moves to Friday 10:00.
|
||||
@@ -0,0 +1,44 @@
|
||||
# inbuxa expressions and loops
|
||||
#
|
||||
# "vnd.inbuxa.expressions" adds "let" and "eval" with arithmetic,
|
||||
# string functions and arrays. "vnd.inbuxa.while" adds loops. Both are
|
||||
# inbuxa extensions to Sieve.
|
||||
|
||||
require ["variables", "editheader", "fileinto", "mailbox", "imap4flags",
|
||||
"vnd.inbuxa.expressions", "vnd.inbuxa.while"];
|
||||
|
||||
# Header fields are available as values: the display name of the sender,
|
||||
# falling back to the address when the name is empty.
|
||||
let "sender" "header.from.name";
|
||||
if eval "is_empty(sender)" {
|
||||
let "sender" "header.from.addr";
|
||||
}
|
||||
addheader "X-Sender-Name" "${sender}";
|
||||
|
||||
let "recipients" "header.to:cc[*].addr[*]";
|
||||
let "total" "count(recipients)";
|
||||
let "shouting" "is_uppercase(header.subject)";
|
||||
|
||||
if eval "total > 3" {
|
||||
addflag "$ManyRecipients";
|
||||
}
|
||||
|
||||
let "i" "0";
|
||||
let "domains" "''";
|
||||
while "i < total" {
|
||||
let "domain" "email_part(recipients[i], 'domain')";
|
||||
let "domains" "domains + domain + ' '";
|
||||
let "i" "i + 1";
|
||||
}
|
||||
addheader "X-Recipient-Domains" "${domains}";
|
||||
|
||||
if eval "shouting" {
|
||||
addflag "$Shouting";
|
||||
let "subject" "to_lowercase(header.subject)";
|
||||
deleteheader "Subject";
|
||||
addheader "Subject" "${subject}";
|
||||
}
|
||||
|
||||
if eval "contains_ignore_case(header.subject, 'meeting')" {
|
||||
fileinto :create "Calendar";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
From: Billing <[email protected]>
|
||||
To: [email protected]
|
||||
Subject: Urgent: invoice 2026-0915 overdue
|
||||
Importance: high
|
||||
Date: Thu, 10 Sep 2026 08:00:00 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Dear customer,
|
||||
|
||||
Invoice 2026-0915 is now overdue. Please arrange payment at your
|
||||
earliest convenience.
|
||||
|
||||
Supplier Accounts
|
||||
@@ -0,0 +1,29 @@
|
||||
# Redirect, notify and copy
|
||||
#
|
||||
# Sends a copy of invoices to accounting, notifies Jane by email about
|
||||
# urgent messages, and rewrites the subject with "editheader" before the
|
||||
# message is kept.
|
||||
|
||||
require ["copy", "enotify", "variables", "editheader", "redirect-dsn",
|
||||
"fileinto", "fcc", "mailbox", "envelope"];
|
||||
|
||||
if header :contains "Subject" "invoice" {
|
||||
redirect :copy :notify "failure,delay" :ret "hdrs" "[email protected]";
|
||||
}
|
||||
|
||||
set "from" "";
|
||||
if anyof (header :is "Importance" "high", header :contains "Subject" "urgent") {
|
||||
if header :matches "From" "*" {
|
||||
set "from" "${1}";
|
||||
}
|
||||
notify :importance "1"
|
||||
:message "Urgent mail from ${from}"
|
||||
"mailto:[email protected]?subject=Urgent%20mail";
|
||||
}
|
||||
|
||||
if header :matches "Subject" "*" {
|
||||
deleteheader "Subject";
|
||||
addheader "Subject" "[Finance] ${1}";
|
||||
}
|
||||
|
||||
fileinto :copy :create "Finance";
|
||||
@@ -0,0 +1,13 @@
|
||||
require ["include", "variables"];
|
||||
|
||||
global ["priority", "project"];
|
||||
|
||||
set "project" "general";
|
||||
if header :matches "Subject" "[*]*" {
|
||||
set :lower "project" "${1}";
|
||||
}
|
||||
|
||||
set "priority" "normal";
|
||||
if header :contains "Subject" ["blocker", "outage"] {
|
||||
set "priority" "high";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Includes and global variables
|
||||
#
|
||||
# The "common" tab is an included script. It sets global variables that
|
||||
# this script reads back. Add your own tabs with the + button: a tab named
|
||||
# "foo" is available as include "foo".
|
||||
|
||||
require ["include", "variables", "fileinto", "mailbox"];
|
||||
|
||||
global ["priority", "project"];
|
||||
|
||||
include :personal "common";
|
||||
|
||||
if string :is "${priority}" "high" {
|
||||
fileinto :create "Projects/${project}/Priority";
|
||||
} else {
|
||||
fileinto :create "Projects/${project}";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
From: Ops Team <[email protected]>
|
||||
To: [email protected]
|
||||
Subject: [Apollo] Outage in the staging cluster
|
||||
Date: Fri, 11 Sep 2026 14:30:00 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
The staging cluster is down since 14:10 UTC. Investigating.
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"id": "welcome",
|
||||
"title": "Welcome tour",
|
||||
"description": "Duplicates, spam, lists, flags, MIME footers and vacation in one script.",
|
||||
"scripts": [{ "name": "main", "file": "welcome.sieve" }],
|
||||
"messages": [{ "name": "q3-report.eml", "file": "welcome.eml" }]
|
||||
},
|
||||
{
|
||||
"id": "lists",
|
||||
"title": "Mailing lists",
|
||||
"description": "Regex captures, body tests and plus addressing.",
|
||||
"scripts": [{ "name": "main", "file": "lists.sieve" }],
|
||||
"messages": [{ "name": "announcement.eml", "file": "lists.eml" }]
|
||||
},
|
||||
{
|
||||
"id": "spam",
|
||||
"title": "Spam and viruses",
|
||||
"description": "spamtest, virustest, reject and discard driven by the scores in Settings.",
|
||||
"scripts": [{ "name": "main", "file": "spam.sieve" }],
|
||||
"messages": [{ "name": "lottery.eml", "file": "spam.eml" }],
|
||||
"settings": { "spamScore": 6 }
|
||||
},
|
||||
{
|
||||
"id": "vacation",
|
||||
"title": "Out of office",
|
||||
"description": "Vacation replies with date windows and auto-reply safeguards.",
|
||||
"scripts": [{ "name": "main", "file": "vacation.sieve" }],
|
||||
"messages": [{ "name": "lunch.eml", "file": "vacation.eml" }]
|
||||
},
|
||||
{
|
||||
"id": "forward",
|
||||
"title": "Redirect and notify",
|
||||
"description": "redirect :copy with DSN options, enotify and editheader.",
|
||||
"scripts": [{ "name": "main", "file": "forward.sieve" }],
|
||||
"messages": [{ "name": "invoice.eml", "file": "forward.eml" }]
|
||||
},
|
||||
{
|
||||
"id": "include",
|
||||
"title": "Includes",
|
||||
"description": "Split a filter across scripts with include and global variables.",
|
||||
"scripts": [
|
||||
{ "name": "main", "file": "include-main.sieve" },
|
||||
{ "name": "common", "file": "include-common.sieve" }
|
||||
],
|
||||
"messages": [{ "name": "outage.eml", "file": "include.eml" }]
|
||||
},
|
||||
{
|
||||
"id": "expressions",
|
||||
"title": "Expressions and loops",
|
||||
"description": "The vnd.inbuxa.expressions and vnd.inbuxa.while extensions: let, eval, loops and built-in functions.",
|
||||
"scripts": [{ "name": "main", "file": "expressions.sieve" }],
|
||||
"messages": [{ "name": "meeting.eml", "file": "expressions.eml" }]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
From: Ferris <[email protected]>
|
||||
To: [email protected]
|
||||
Subject: [ANN] Release 1.99 is out
|
||||
Date: Mon, 7 Sep 2026 17:02:11 +0000
|
||||
Message-ID: <[email protected]>
|
||||
In-Reply-To: <[email protected]>
|
||||
List-Id: Rust users <rust-users.lists.rust-lang.example>
|
||||
List-Unsubscribe: <mailto:[email protected]>
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Hello everyone,
|
||||
|
||||
The new release is out. See the notes for the full list of changes,
|
||||
including a security advisory for older toolchains.
|
||||
|
||||
The release team
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sort mailing lists into folders
|
||||
#
|
||||
# Uses "regex" to pull the list name out of the List-Id header and
|
||||
# "variables" to build the folder name. Messages that mention a keyword
|
||||
# in the body are flagged, and replies to your own posts are marked read.
|
||||
|
||||
require ["fileinto", "mailbox", "imap4flags", "variables", "regex", "body",
|
||||
"subaddress", "envelope"];
|
||||
|
||||
if header :regex "List-Id" "<([a-z0-9-]+)\\.([a-z0-9.-]+)>" {
|
||||
set :lower "list" "${1}";
|
||||
set :lower "host" "${2}";
|
||||
|
||||
if body :text :contains ["release", "security advisory"] {
|
||||
addflag "\\Flagged";
|
||||
}
|
||||
|
||||
if header :contains "In-Reply-To" "@example.org>" {
|
||||
addflag "\\Seen";
|
||||
}
|
||||
|
||||
fileinto :create "Lists/${host}/${list}";
|
||||
stop;
|
||||
}
|
||||
|
||||
# Plus addressing: mail sent to [email protected] goes to "shopping".
|
||||
if envelope :detail :matches "to" "*" {
|
||||
if string :is "${1}" "" {
|
||||
keep;
|
||||
} else {
|
||||
set :lower "folder" "${1}";
|
||||
fileinto :create "${folder}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
From: "Prize Department" <[email protected]>
|
||||
To: [email protected]
|
||||
Subject: You have WON 1,000,000 dollars!!!
|
||||
Date: Sun, 6 Sep 2026 03:12:45 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
|
||||
<html><body><h1>Congratulations!</h1><p>Click <a href="http://lottery.example/claim">here</a> to claim your prize.</p></body></html>
|
||||
@@ -0,0 +1,28 @@
|
||||
# Quarantine spam and viruses
|
||||
#
|
||||
# The spam and virus scores come from Settings (Spam and virus). Try
|
||||
# changing them and running the script again.
|
||||
|
||||
require ["spamtest", "spamtestplus", "virustest", "relational",
|
||||
"comparator-i;ascii-numeric", "fileinto", "mailbox", "special-use",
|
||||
"imap4flags", "reject", "editheader", "variables"];
|
||||
|
||||
if virustest :value "eq" :comparator "i;ascii-numeric" "5" {
|
||||
reject "Message rejected: a virus was detected.";
|
||||
stop;
|
||||
}
|
||||
|
||||
if spamtest :value "ge" :comparator "i;ascii-numeric" "8" {
|
||||
discard;
|
||||
stop;
|
||||
}
|
||||
|
||||
set "score" "";
|
||||
if spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50" {
|
||||
if spamtest :percent :matches "*" {
|
||||
set "score" "${1}";
|
||||
}
|
||||
addheader "X-Spam-Score" "${score}%";
|
||||
addflag "$Junk";
|
||||
fileinto :specialuse "\\Junk" :create "Junk";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
From: Bob Smith <[email protected]>
|
||||
To: Jane Doe <[email protected]>
|
||||
Subject: Lunch next week?
|
||||
Date: Wed, 9 Sep 2026 11:20:00 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Hi Jane, are you free for lunch next Tuesday?
|
||||
|
||||
Bob
|
||||
@@ -0,0 +1,37 @@
|
||||
# Out of office with dates
|
||||
#
|
||||
# Replies only while the current date is inside the vacation window,
|
||||
# never to mailing lists or automated senders, and at most once every
|
||||
# three days per sender. Change the dates to see the reply disappear.
|
||||
|
||||
require ["vacation", "vacation-seconds", "date", "relational", "variables",
|
||||
"envelope"];
|
||||
|
||||
set "subject" "";
|
||||
if currentdate :value "ge" "date" "2026-01-01" {
|
||||
if currentdate :value "le" "date" "2026-12-31" {
|
||||
if not anyof (exists "List-Id",
|
||||
header :contains "Precedence" ["bulk", "list", "junk"],
|
||||
header :is "Auto-Submitted" ["auto-replied", "auto-generated"],
|
||||
envelope :localpart :is "from" ["mailer-daemon", "noreply", "no-reply"]) {
|
||||
if header :matches "Subject" "*" {
|
||||
set "subject" "${1}";
|
||||
}
|
||||
vacation :days 3
|
||||
:from "Jane Doe <[email protected]>"
|
||||
:addresses ["[email protected]", "[email protected]"]
|
||||
:subject "Out of office: ${subject}"
|
||||
:handle "summer-vacation"
|
||||
text:
|
||||
Hello,
|
||||
|
||||
I am on vacation and will read your message about "${subject}" when
|
||||
I am back. For urgent matters please contact [email protected].
|
||||
|
||||
Best regards,
|
||||
Jane
|
||||
.
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
Return-Path: <[email protected]>
|
||||
Received: from mail.example.com (mail.example.com [192.0.2.25])
|
||||
by mx.example.org with ESMTPS id 4F2A1B3C
|
||||
for <[email protected]>; Tue, 8 Sep 2026 09:41:07 +0000
|
||||
From: Alice Martin <[email protected]>
|
||||
To: Jane Doe <[email protected]>
|
||||
Subject: Q3 report and team offsite
|
||||
Date: Tue, 8 Sep 2026 09:41:02 +0000
|
||||
Message-ID: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="playground-boundary"
|
||||
|
||||
--playground-boundary
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Hi Jane,
|
||||
|
||||
The Q3 report is attached. Could you review the numbers before
|
||||
Thursday? We will go through them at the team offsite.
|
||||
|
||||
Thanks,
|
||||
Alice
|
||||
|
||||
--playground-boundary
|
||||
Content-Type: application/pdf; name="q3-report.pdf"
|
||||
Content-Disposition: attachment; filename="q3-report.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjEgMCBvYmoKPDwgL1R5cGUgL0NhdGFsb2cgPj4KZW5kb2Jq
|
||||
CnRyYWlsZXIKPDwgL1Jvb3QgMSAwIFIgPj4KJSVFT0YK
|
||||
|
||||
--playground-boundary--
|
||||
@@ -0,0 +1,105 @@
|
||||
# Welcome to the Sieve playground!
|
||||
#
|
||||
# Sieve (RFC 5228) is the standard language for filtering email on the
|
||||
# mail server. This script runs in your browser on the same interpreter
|
||||
# your inbuxa server runs.
|
||||
#
|
||||
# Press Run (Ctrl+Enter or Cmd+Enter) to filter the message in the middle
|
||||
# column, then open the Messages tab on the right to see what changed.
|
||||
# Hover over any command to read its documentation.
|
||||
#
|
||||
# Everything runs locally in your browser: scripts and messages are saved
|
||||
# in this browser only and are never sent to or stored on a server.
|
||||
|
||||
require ["envelope", "fileinto", "mailbox", "special-use", "imap4flags",
|
||||
"variables", "relational", "comparator-i;ascii-numeric",
|
||||
"spamtest", "spamtestplus", "date", "duplicate", "extlists",
|
||||
"editheader", "mime", "foreverypart", "extracttext", "replace",
|
||||
"vacation"];
|
||||
|
||||
# Drop the message if the very same one was delivered in the last hour.
|
||||
# Press "Run again" to simulate a second delivery.
|
||||
if duplicate :seconds 3600 {
|
||||
discard;
|
||||
stop;
|
||||
}
|
||||
|
||||
# Anything the spam filter scored 80% or higher goes to Junk.
|
||||
if spamtest :percent :value "ge" :comparator "i;ascii-numeric" "80" {
|
||||
fileinto :specialuse "\\Junk" :create "Junk";
|
||||
stop;
|
||||
}
|
||||
|
||||
# Mailing lists get a folder of their own, such as "Lists/rust-users".
|
||||
if header :matches "List-Id" "*<*.*>*" {
|
||||
set :lower "list" "${2}";
|
||||
fileinto :create "Lists/${list}";
|
||||
stop;
|
||||
}
|
||||
|
||||
# Capture a few details about the message with variables and wildcard
|
||||
# matches. Variables first assigned inside a block only live in that block,
|
||||
# so declare the ones used later at the top level.
|
||||
set "domain" "unknown";
|
||||
set "subject" "";
|
||||
set "sender" "there";
|
||||
if address :domain :matches "from" "*" {
|
||||
set :lower "domain" "${1}";
|
||||
}
|
||||
if header :matches "Subject" "*" {
|
||||
set "subject" "${1}";
|
||||
}
|
||||
if header :matches "From" "* <*>" {
|
||||
set "sender" "${1}";
|
||||
}
|
||||
|
||||
# People in your address book are flagged as important.
|
||||
if address :all :list "from" "addrbook:default" {
|
||||
addflag "\\Flagged";
|
||||
}
|
||||
|
||||
# Tag messages that carry a PDF anywhere in their MIME structure.
|
||||
if header :mime :anychild :contenttype "Content-Type" "application/pdf" {
|
||||
addflag "$HasPDF";
|
||||
}
|
||||
|
||||
# Note when the message arrived outside office hours.
|
||||
if anyof (currentdate :value "lt" :comparator "i;ascii-numeric" "hour" "08",
|
||||
currentdate :value "ge" :comparator "i;ascii-numeric" "hour" "18") {
|
||||
addheader "X-After-Hours" "yes";
|
||||
}
|
||||
|
||||
# Stamp the message so you can see it went through the filter.
|
||||
addheader :last "X-Sieve-Filter" "inbuxa Sieve playground";
|
||||
|
||||
# Append a footer to every plain text part.
|
||||
foreverypart {
|
||||
if header :mime :subtype "Content-Type" "plain" {
|
||||
extracttext "body";
|
||||
replace text:
|
||||
${body}
|
||||
--
|
||||
Filtered on its way in by a Sieve script.
|
||||
.
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
# Let the sender know you are away, at most once a week per sender.
|
||||
vacation :days 7 :subject "Away: ${subject}" text:
|
||||
Hi ${sender},
|
||||
|
||||
Thanks for your message. I am out of the office this week and will
|
||||
get back to you as soon as I return.
|
||||
|
||||
--
|
||||
This reply was sent by a Sieve script.
|
||||
.
|
||||
;
|
||||
|
||||
# Important mail stays in the inbox, the rest is sorted by domain.
|
||||
if hasflag "\\Flagged" {
|
||||
keep;
|
||||
} else {
|
||||
fileinto :create "Contacts/${domain}";
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { h } from "./result.js";
|
||||
|
||||
const list = (text) => text.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
const lines = (text) => text.split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("#"));
|
||||
|
||||
function pairs(text) {
|
||||
return lines(text).map((line) => {
|
||||
const index = line.indexOf("=");
|
||||
return index < 0 ? { name: line, value: "" } : { name: line.slice(0, index).trim(), value: line.slice(index + 1).trim() };
|
||||
});
|
||||
}
|
||||
|
||||
const USIZE_MAX = 4294967295;
|
||||
const RANGES = {
|
||||
spamScore: [0, 10],
|
||||
virusScore: [0, 5],
|
||||
maxMatchVariables: [0, 63],
|
||||
defaultVacationExpiry: [0, Number.MAX_SAFE_INTEGER],
|
||||
defaultDuplicateExpiry: [0, Number.MAX_SAFE_INTEGER],
|
||||
};
|
||||
const NULLABLE_INTEGERS = new Set(["currentTime"]);
|
||||
const SHAPES = {
|
||||
mailboxes: { name: "string", specialUse: "strings" },
|
||||
lists: { name: "string", values: "strings" },
|
||||
environment: { name: "string", value: "string" },
|
||||
globalVariables: { name: "string", value: "string" },
|
||||
metadata: { mailbox: "string", annotation: "string", value: "string" },
|
||||
};
|
||||
|
||||
const toStrings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);
|
||||
|
||||
function toInteger(value) {
|
||||
const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
return typeof number === "number" && Number.isFinite(number) ? Math.trunc(number) : null;
|
||||
}
|
||||
|
||||
function normalizeValue(key, value, fallback) {
|
||||
if (NULLABLE_INTEGERS.has(key)) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const number = toInteger(value);
|
||||
return number === null ? fallback : Math.min(Math.max(number, -8640000000000), 8640000000000);
|
||||
}
|
||||
if (value === undefined) return structuredClone(fallback);
|
||||
if (typeof fallback === "number") {
|
||||
const number = toInteger(value);
|
||||
if (number === null) return fallback;
|
||||
const [min, max] = RANGES[key] || [0, USIZE_MAX];
|
||||
return Math.min(Math.max(number, min), max);
|
||||
}
|
||||
if (typeof fallback === "boolean") return typeof value === "boolean" ? value : fallback;
|
||||
if (typeof fallback === "string") return typeof value === "string" ? value : fallback;
|
||||
if (Array.isArray(fallback)) {
|
||||
if (!Array.isArray(value)) return structuredClone(fallback);
|
||||
const shape = SHAPES[key];
|
||||
if (!shape) return toStrings(value);
|
||||
return value
|
||||
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
||||
.map((item) => Object.fromEntries(Object.entries(shape).map(([field, type]) => [field, type === "strings" ? toStrings(item[field]) : typeof item[field] === "string" ? item[field] : ""])))
|
||||
.filter((item) => Object.values(item).some((field) => (Array.isArray(field) ? field.length : field)));
|
||||
}
|
||||
return structuredClone(fallback);
|
||||
}
|
||||
|
||||
export function normalizeSettings(settings, defaults) {
|
||||
const source = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
|
||||
return Object.fromEntries(Object.entries(defaults).map(([key, fallback]) => [key, normalizeValue(key, source[key], fallback)]));
|
||||
}
|
||||
|
||||
const FORMATS = {
|
||||
text: { read: (v) => v ?? "", write: (v) => v },
|
||||
list: { read: (v) => (v || []).join(", "), write: list },
|
||||
number: { read: (v) => String(v ?? ""), write: (v) => Math.min(Math.max(Number.parseInt(v, 10) || 0, 0), USIZE_MAX) },
|
||||
bool: { read: (v) => !!v, write: (v) => !!v },
|
||||
pairs: { read: (v) => (v || []).map((p) => `${p.name ?? ""} = ${p.value ?? ""}`).join("\n"), write: pairs },
|
||||
mailboxes: {
|
||||
read: (v) => (v || []).map((m) => [m.name ?? "", ...(m.specialUse || [])].join(" ")).join("\n"),
|
||||
write: (text) => lines(text).map((line) => {
|
||||
const parts = line.split(/\s+(?=\\)/);
|
||||
return { name: parts[0], specialUse: parts.slice(1) };
|
||||
}),
|
||||
},
|
||||
lists: {
|
||||
read: (v) => (v || []).map((l) => `${l.name} = ${(l.values || []).join(", ")}`).join("\n"),
|
||||
write: (text) => pairs(text).map((p) => ({ name: p.name, values: list(p.value) })),
|
||||
},
|
||||
metadata: {
|
||||
read: (v) => (v || []).map((m) => `${m.mailbox ? `${m.mailbox} ` : ""}${m.annotation} = ${m.value}`).join("\n"),
|
||||
write: (text) => pairs(text).map((p) => {
|
||||
const split = /^(.*\S)\s+(\/\S*)$/.exec(p.name);
|
||||
return split ? { mailbox: split[1], annotation: split[2], value: p.value } : { mailbox: "", annotation: p.name, value: p.value };
|
||||
}),
|
||||
},
|
||||
time: {
|
||||
read: (v) => {
|
||||
if (v === null || v === undefined) return "";
|
||||
const date = new Date(v * 1000);
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
},
|
||||
write: (v) => (v ? Math.floor(new Date(v).getTime() / 1000) : null),
|
||||
},
|
||||
};
|
||||
|
||||
const SPAM_LABELS = ["0: not scanned", "1: not spam", "2", "3", "4", "5: maybe spam", "6", "7", "8", "9", "10: definitely spam"];
|
||||
const VIRUS_LABELS = ["0: not scanned", "1: clean", "2: virus replaced", "3: virus cured", "4: possibly infected", "5: infected"];
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
title: "Identity and envelope",
|
||||
sub: "who receives the message",
|
||||
open: true,
|
||||
fields: [
|
||||
{ key: "userAddress", label: "User address", format: "text" },
|
||||
{ key: "userFullName", label: "User full name", format: "text" },
|
||||
{ row: [
|
||||
{ key: "envelopeFrom", label: "Envelope from", format: "text", placeholder: "taken from the message" },
|
||||
{ key: "envelopeTo", label: "Envelope to", format: "list", placeholder: "the user address" },
|
||||
] },
|
||||
{ row: [
|
||||
{ key: "envelopeId", label: "Envelope id", format: "text" },
|
||||
{ key: "localHostname", label: "Local hostname", format: "text" },
|
||||
] },
|
||||
{ key: "currentTime", label: "Current time", format: "time", type: "datetime-local", help: "Leave empty to use the real clock. Affects currentdate and Date headers." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Mailboxes",
|
||||
sub: "for fileinto, mailboxexists and special-use",
|
||||
fields: [{ key: "mailboxes", label: "One mailbox per line, followed by its special-use attributes", format: "mailboxes", textarea: 7 }],
|
||||
},
|
||||
{
|
||||
title: "Spam and virus",
|
||||
sub: "spamtest and virustest",
|
||||
fields: [{ row: [
|
||||
{ key: "spamScore", label: "Spam score", format: "number", options: SPAM_LABELS },
|
||||
{ key: "virusScore", label: "Virus status", format: "number", options: VIRUS_LABELS },
|
||||
] }],
|
||||
},
|
||||
{
|
||||
title: "Environment and variables",
|
||||
sub: "environment, global variables, metadata",
|
||||
fields: [
|
||||
{ key: "environment", label: "Environment items (name = value)", format: "pairs", textarea: 6 },
|
||||
{ key: "globalVariables", label: "Initial global variables (name = value)", format: "pairs", textarea: 3 },
|
||||
{ key: "metadata", label: "Metadata: [mailbox] /annotation = value", format: "metadata", textarea: 3, help: "Without a mailbox the annotation is a server annotation." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lists and notifications",
|
||||
sub: "extlists, enotify, vacation",
|
||||
fields: [
|
||||
{ key: "lists", label: "External lists (list = value, value)", format: "lists", textarea: 3 },
|
||||
{ key: "validNotificationUris", label: "Allowed notification URI schemes", format: "list" },
|
||||
{ row: [
|
||||
{ key: "vacationDefaultSubject", label: "Default vacation subject", format: "text" },
|
||||
{ key: "vacationSubjectPrefix", label: "Vacation subject prefix", format: "text" },
|
||||
] },
|
||||
{ row: [
|
||||
{ key: "defaultVacationExpiry", label: "Default vacation period (s)", format: "number" },
|
||||
{ key: "defaultDuplicateExpiry", label: "Default duplicate period (s)", format: "number" },
|
||||
] },
|
||||
{ key: "vacationUseOrigRcpt", label: "Vacation uses the original recipient", format: "bool" },
|
||||
{ key: "protectedHeaders", label: "Protected headers (editheader cannot change them)", format: "list" },
|
||||
],
|
||||
},
|
||||
{ title: "Extensions", sub: "capabilities allowed at run time", capabilities: true },
|
||||
{
|
||||
title: "Runtime limits",
|
||||
sub: "per run",
|
||||
fields: [
|
||||
{ row: [{ key: "cpuLimit", label: "Max instructions", format: "number" }] },
|
||||
{ row: [{ key: "maxRedirects", label: "Max redirects", format: "number" }, { key: "maxOutMessages", label: "Max outgoing messages", format: "number" }] },
|
||||
{ row: [{ key: "maxNestedIncludes", label: "Max nested includes", format: "number" }, { key: "maxVariableSize", label: "Max variable size", format: "number" }] },
|
||||
{ row: [{ key: "maxHeaderSize", label: "Max header size", format: "number" }, { key: "maxReceivedHeaders", label: "Max Received headers", format: "number" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Compiler limits",
|
||||
sub: "applied when the script is compiled",
|
||||
fields: [
|
||||
{ row: [{ key: "maxScriptSize", label: "Max script size", format: "number" }, { key: "maxStringSize", label: "Max string size", format: "number" }] },
|
||||
{ row: [{ key: "maxNestedBlocks", label: "Max nested blocks", format: "number" }, { key: "maxNestedTests", label: "Max nested tests", format: "number" }] },
|
||||
{ row: [{ key: "maxNestedForeverypart", label: "Max nested foreverypart", format: "number" }, { key: "maxMatchVariables", label: "Max match variables", format: "number" }] },
|
||||
{ row: [{ key: "maxLocalVariables", label: "Max local variables", format: "number" }, { key: "maxVariableNameSize", label: "Max variable name size", format: "number" }] },
|
||||
{ row: [{ key: "maxIncludes", label: "Max includes", format: "number" }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export class SettingsDrawer {
|
||||
constructor({ drawer, backdrop, body, resetButton, jsonButton, defaults, capabilities, get, set }) {
|
||||
Object.assign(this, { drawer, backdrop, body, resetButton, jsonButton, defaults, capabilities, get, set });
|
||||
this.jsonMode = false;
|
||||
this.openGroups = new Set(GROUPS.filter((g) => g.open).map((g) => g.title));
|
||||
for (const el of drawer.querySelectorAll("[data-close]")) el.addEventListener("click", () => this.close());
|
||||
backdrop.addEventListener("click", () => this.close());
|
||||
drawer.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") this.close();
|
||||
});
|
||||
resetButton.addEventListener("click", () => {
|
||||
if (!confirm("Reset all settings of this workspace to their defaults?")) return;
|
||||
this.set(structuredClone(this.defaults));
|
||||
this.render();
|
||||
});
|
||||
jsonButton.addEventListener("click", () => {
|
||||
this.jsonMode = !this.jsonMode;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
this.jsonMode = false;
|
||||
this.render();
|
||||
this.drawer.hidden = false;
|
||||
this.backdrop.hidden = false;
|
||||
this.drawer.querySelector("input, textarea, select, button")?.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.jsonMode && !this.applyJson()) return;
|
||||
this.drawer.hidden = true;
|
||||
this.backdrop.hidden = true;
|
||||
}
|
||||
|
||||
update(key, value) {
|
||||
this.set({ ...this.get(), [key]: value });
|
||||
}
|
||||
|
||||
render() {
|
||||
this.jsonButton.textContent = this.jsonMode ? "Back to form" : "Edit as JSON";
|
||||
if (this.jsonMode) {
|
||||
const area = h("textarea", { class: "json-editor", spellcheck: "false", rows: 30, style: "min-height:60vh;margin-top:12px" });
|
||||
area.value = JSON.stringify(this.get(), null, 2);
|
||||
const error = h("p", { class: "dialog-warning", hidden: true });
|
||||
area.addEventListener("change", () => this.applyJson());
|
||||
this.jsonArea = area;
|
||||
this.jsonError = error;
|
||||
this.body.replaceChildren(area, error);
|
||||
return;
|
||||
}
|
||||
const settings = this.get();
|
||||
this.body.replaceChildren(
|
||||
...GROUPS.map((group) => {
|
||||
const details = h("details", { class: "settings-group", open: this.openGroups.has(group.title) },
|
||||
h("summary", {}, group.title, h("span", { class: "item-sub" }, group.sub)),
|
||||
h("div", { class: "settings-fields" }, group.capabilities ? this.capabilitiesField(settings) : group.fields.map((field) => this.field(field, settings))),
|
||||
);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) this.openGroups.add(group.title);
|
||||
else this.openGroups.delete(group.title);
|
||||
});
|
||||
return details;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
applyJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this.jsonArea.value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Settings must be a JSON object");
|
||||
this.set(normalizeSettings({ ...this.defaults, ...parsed }, this.defaults));
|
||||
this.jsonError.hidden = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.jsonError.textContent = `Invalid JSON: ${err.message}`;
|
||||
this.jsonError.hidden = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
field(spec, settings) {
|
||||
if (spec.row) return h("div", { class: "field-row" }, spec.row.map((item) => this.field(item, settings)));
|
||||
const format = FORMATS[spec.format];
|
||||
const value = format.read(settings[spec.key]);
|
||||
const id = `setting-${spec.key}`;
|
||||
if (spec.format === "bool") {
|
||||
const input = h("input", { type: "checkbox", id });
|
||||
input.checked = value;
|
||||
input.addEventListener("change", () => this.update(spec.key, input.checked));
|
||||
return h("label", { class: "check" }, input, spec.label);
|
||||
}
|
||||
let input;
|
||||
if (spec.options) {
|
||||
input = h("select", { id }, spec.options.map((label, index) => h("option", { value: index }, label)));
|
||||
input.value = value || "0";
|
||||
} else if (spec.textarea) {
|
||||
input = h("textarea", { id, rows: spec.textarea, spellcheck: "false" });
|
||||
input.value = value;
|
||||
} else {
|
||||
input = h("input", { id, type: spec.type || (spec.format === "number" ? "number" : "text"), spellcheck: "false", autocomplete: "off", placeholder: spec.placeholder, min: spec.format === "number" ? 0 : undefined });
|
||||
input.value = value;
|
||||
}
|
||||
input.addEventListener("change", () => {
|
||||
this.update(spec.key, format.write(input.value));
|
||||
input.value = format.read(this.get()[spec.key]);
|
||||
});
|
||||
return h("label", { class: "field", for: id }, h("span", {}, spec.label), input, spec.help ? h("small", {}, spec.help) : null);
|
||||
}
|
||||
|
||||
capabilitiesField(settings) {
|
||||
const enabled = new Set(settings.capabilities || []);
|
||||
const boxes = this.capabilities.map((name) => {
|
||||
const input = h("input", { type: "checkbox", value: name });
|
||||
input.checked = enabled.has(name);
|
||||
input.addEventListener("change", () => {
|
||||
const next = new Set(this.get().capabilities || []);
|
||||
if (input.checked) next.add(name);
|
||||
else next.delete(name);
|
||||
this.update("capabilities", this.capabilities.filter((c) => next.has(c)));
|
||||
});
|
||||
return h("label", { class: "check" }, input, name);
|
||||
});
|
||||
const setAll = (on) => {
|
||||
this.update("capabilities", on ? [...this.capabilities] : []);
|
||||
this.render();
|
||||
};
|
||||
const noCheck = h("input", { type: "checkbox" });
|
||||
noCheck.checked = !!settings.noCapabilityCheck;
|
||||
noCheck.addEventListener("change", () => this.update("noCapabilityCheck", noCheck.checked));
|
||||
return [
|
||||
h("div", { class: "caps-actions" },
|
||||
h("button", { type: "button", onclick: () => setAll(true) }, "Enable all"),
|
||||
h("button", { type: "button", onclick: () => setAll(false) }, "Disable all"),
|
||||
),
|
||||
h("div", { class: "caps" }, boxes),
|
||||
h("label", { class: "check" }, noCheck, "Allow commands without a matching require (not portable)"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
const SHARE_PARAM = "w";
|
||||
const FORMAT = 1;
|
||||
export const LONG_LINK = 8000;
|
||||
|
||||
function toBase64Url(bytes) {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function fromBase64Url(text) {
|
||||
const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binary = atob(base64 + "===".slice((base64.length + 3) % 4));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function pipe(bytes, stream) {
|
||||
const response = new Response(new Blob([bytes]).stream().pipeThrough(stream));
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export function settingsDiff(settings, defaults) {
|
||||
const diff = {};
|
||||
for (const [key, value] of Object.entries(settings || {})) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(defaults[key])) diff[key] = value;
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
export function portable(workspace, defaults, { includeMessages = true } = {}) {
|
||||
return {
|
||||
v: FORMAT,
|
||||
name: workspace.name,
|
||||
scripts: workspace.scripts.map(({ name, source }) => ({ name, source })),
|
||||
messages: includeMessages ? workspace.messages.map(({ name, source }) => ({ name, source })) : [],
|
||||
settings: settingsDiff(workspace.settings, defaults),
|
||||
};
|
||||
}
|
||||
|
||||
export async function encodeShareUrl(data) {
|
||||
const json = new TextEncoder().encode(JSON.stringify(data));
|
||||
const packed = await pipe(json, new CompressionStream("deflate-raw"));
|
||||
const url = new URL(location.href);
|
||||
url.search = "";
|
||||
url.hash = `${SHARE_PARAM}=${toBase64Url(packed)}`;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function hasSharedWorkspace() {
|
||||
return new URLSearchParams(location.hash.slice(1)).has(SHARE_PARAM);
|
||||
}
|
||||
|
||||
export async function decodeSharedWorkspace() {
|
||||
const value = new URLSearchParams(location.hash.slice(1)).get(SHARE_PARAM);
|
||||
if (!value) return null;
|
||||
const json = await pipe(fromBase64Url(value), new DecompressionStream("deflate-raw"));
|
||||
return validatePortable(JSON.parse(new TextDecoder().decode(json)));
|
||||
}
|
||||
|
||||
export function clearShareHash() {
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
}
|
||||
|
||||
export function validatePortable(data) {
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.scripts)) {
|
||||
throw new Error("This is not a Sieve playground workspace.");
|
||||
}
|
||||
const clean = (items) =>
|
||||
(Array.isArray(items) ? items : [])
|
||||
.filter((item) => item && typeof item.source === "string")
|
||||
.map((item, index) => ({ name: String(item.name || `item-${index + 1}`).slice(0, 80), source: item.source }));
|
||||
const scripts = clean(data.scripts);
|
||||
if (scripts.length === 0) throw new Error("The workspace has no scripts.");
|
||||
return {
|
||||
name: String(data.name || "Shared workspace").slice(0, 80),
|
||||
scripts,
|
||||
messages: clean(data.messages),
|
||||
settings: data.settings && typeof data.settings === "object" ? data.settings : {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
const RFC = (n, section) => `https://www.rfc-editor.org/rfc/rfc${n}${section ? `#section-${section}` : ""}`;
|
||||
|
||||
export const CONTROL = {
|
||||
require: { sig: "require <capabilities: string-list>", doc: "Declares the extensions the script uses. Every extension must be required before its commands, tests or tags appear.", rfc: RFC(5228, "3.2") },
|
||||
if: { sig: "if <test> <block>", doc: "Runs the block when the test is true. Can be followed by `elsif` and `else`.", rfc: RFC(5228, "3.1") },
|
||||
elsif: { sig: "elsif <test> <block>", doc: "Runs the block when all previous tests in the chain were false and this test is true.", rfc: RFC(5228, "3.1") },
|
||||
else: { sig: "else <block>", doc: "Runs the block when every previous test in the chain was false.", rfc: RFC(5228, "3.1") },
|
||||
stop: { sig: "stop", doc: "Ends the script. The implicit keep still applies unless another action cancelled it.", rfc: RFC(5228, "3.3") },
|
||||
include: { sig: 'include [:personal / :global] [:once] [:optional] <name: string>', doc: "Runs another script. In the playground, add a script tab with that name.", rfc: RFC(6609, "3.2"), capability: "include" },
|
||||
return: { sig: "return", doc: "Stops the current included script and continues in the script that included it.", rfc: RFC(6609, "3.3"), capability: "include" },
|
||||
global: { sig: "global <names: string-list>", doc: "Shares variables between the including and included scripts.", rfc: RFC(6609, "3.4"), capability: "include" },
|
||||
foreverypart: { sig: "foreverypart [:name <name: string>] <block>", doc: "Runs the block once for each MIME part of the message. Tests with `:mime` and commands such as `replace` act on the current part.", rfc: RFC(5703, "3"), capability: "foreverypart" },
|
||||
break: { sig: "break [:name <name: string>]", doc: "Leaves the innermost (or the named) `foreverypart` or `while` loop.", rfc: RFC(5703, "3"), capability: "foreverypart" },
|
||||
while: { sig: "while <expression: string> <block>", doc: "inbuxa extension. Repeats the block while the expression is true. Bounded by the CPU limit.", capability: "vnd.inbuxa.while" },
|
||||
continue: { sig: "continue", doc: "inbuxa extension. Skips to the next iteration of the enclosing loop.", capability: "vnd.inbuxa.while" },
|
||||
};
|
||||
|
||||
export const ACTIONS = {
|
||||
keep: { sig: "keep [:flags <flags: string-list>]", doc: "Files the message into the default mailbox (INBOX). This is what happens implicitly when no other action cancels it.", rfc: RFC(5228, "4.3") },
|
||||
discard: { sig: "discard", doc: "Silently throws the message away and cancels the implicit keep.", rfc: RFC(5228, "4.4") },
|
||||
fileinto: { sig: "fileinto [:copy] [:flags <flags>] [:create] [:specialuse <use>] [:mailboxid <id>] <mailbox: string>", doc: "Delivers the message into a mailbox and cancels the implicit keep (unless `:copy`).", rfc: RFC(5228, "4.1"), capability: "fileinto" },
|
||||
redirect: { sig: "redirect [:copy] [:notify <items>] [:ret <full|hdrs>] [:bytimerelative <n> / :bytimeabsolute <date>] <address: string>", doc: "Forwards the message to another address and cancels the implicit keep (unless `:copy`). Limited by the maximum redirects setting.", rfc: RFC(5228, "4.2") },
|
||||
reject: { sig: "reject <reason: string>", doc: "Refuses the message and sends the reason back to the sender.", rfc: RFC(5429, "2.2"), capability: "reject" },
|
||||
ereject: { sig: "ereject <reason: string>", doc: "Refuses the message at the SMTP level when possible.", rfc: RFC(5429, "2.1"), capability: "ereject" },
|
||||
vacation: { sig: "vacation [:days <n> / :seconds <n>] [:subject <s>] [:from <addr>] [:addresses <list>] [:mime] [:handle <s>] <reason: string>", doc: "Sends an automatic reply, at most once per sender in the given period. Tracked with duplicate ids: try Run again.", rfc: RFC(5230, "4"), capability: "vacation" },
|
||||
notify: { sig: "notify [:from <s>] [:importance <1|2|3>] [:options <list>] [:message <s>] <method: string>", doc: "Sends a notification, for example to a `mailto:` URI.", rfc: RFC(5435, "3"), capability: "enotify" },
|
||||
set: { sig: "set [:lower / :upper / :lowerfirst / :upperfirst / :quotewildcard / :quoteregex / :encodeurl / :length] <name: string> <value: string>", doc: "Stores a value in a variable. Use `${name}` to read it back inside strings.", rfc: RFC(5229, "4"), capability: "variables" },
|
||||
setflag: { sig: "setflag [<variable>] <flags: string-list>", doc: "Replaces the flags that will be stored with the message.", rfc: RFC(5232, "3.1"), capability: "imap4flags" },
|
||||
addflag: { sig: "addflag [<variable>] <flags: string-list>", doc: "Adds flags that will be stored with the message, such as `\\Seen` or `\\Flagged`.", rfc: RFC(5232, "3.2"), capability: "imap4flags" },
|
||||
removeflag: { sig: "removeflag [<variable>] <flags: string-list>", doc: "Removes flags from the set stored with the message.", rfc: RFC(5232, "3.3"), capability: "imap4flags" },
|
||||
addheader: { sig: "addheader [:last] <field: string> <value: string>", doc: "Adds a header field, at the top unless `:last` is given.", rfc: RFC(5293, "4"), capability: "editheader" },
|
||||
deleteheader: { sig: 'deleteheader [:index <n> [:last]] [<match-type>] [<comparator>] <field: string> [<values: string-list>]', doc: "Removes header fields, optionally only those matching the values.", rfc: RFC(5293, "5"), capability: "editheader" },
|
||||
replace: { sig: "replace [:mime] [:subject <s>] [:from <s>] <replacement: string>", doc: "Replaces the message body, or the current part inside `foreverypart`.", rfc: RFC(5703, "5"), capability: "replace" },
|
||||
enclose: { sig: "enclose [:subject <s>] [:headers <list>] <text: string>", doc: "Wraps the original message as an attachment of a new message.", rfc: RFC(5703, "6"), capability: "enclose" },
|
||||
extracttext: { sig: "extracttext [:first <n>] [<modifiers>] <variable: string>", doc: "Stores the text of the current MIME part in a variable. Only valid inside `foreverypart`.", rfc: RFC(5703, "7"), capability: "extracttext" },
|
||||
convert: { sig: "convert <from-type> <to-type> <parameters: string-list>", doc: "Converts MIME parts from one media type to another.", rfc: RFC(6558, "3"), capability: "convert" },
|
||||
error: { sig: "error <message: string>", doc: "Aborts the script with an error. The implicit keep applies.", rfc: RFC(5463, "5"), capability: "ihave" },
|
||||
let: { sig: "let <variable: string> <expression: string>", doc: "inbuxa extension. Evaluates an expression and stores the result, for example `let \"n\" \"len(header.subject) * 2\"`.", capability: "vnd.inbuxa.expressions" },
|
||||
};
|
||||
|
||||
export const TESTS = {
|
||||
address: { sig: "address [<address-part>] [<comparator>] [<match-type>] [:index <n> [:last]] <headers> <keys>", doc: "Compares addresses in header fields such as From or To. Address parts: `:all`, `:localpart`, `:domain`, `:user`, `:detail`.", rfc: RFC(5228, "5.1") },
|
||||
allof: { sig: "allof (<tests>)", doc: "True when every test in the list is true.", rfc: RFC(5228, "5.2") },
|
||||
anyof: { sig: "anyof (<tests>)", doc: "True when at least one test in the list is true.", rfc: RFC(5228, "5.3") },
|
||||
envelope: { sig: "envelope [<address-part>] [<comparator>] [<match-type>] <parts: from|to> <keys>", doc: "Compares the SMTP envelope. Set the envelope below the message.", rfc: RFC(5228, "5.4"), capability: "envelope" },
|
||||
exists: { sig: "exists <headers: string-list>", doc: "True when every listed header field is present.", rfc: RFC(5228, "5.5") },
|
||||
false: { sig: "false", doc: "Always false.", rfc: RFC(5228, "5.6") },
|
||||
header: { sig: "header [:mime] [<comparator>] [<match-type>] [:index <n> [:last]] <headers> <keys>", doc: "Compares header field values. With `:mime` it looks at MIME part headers.", rfc: RFC(5228, "5.7") },
|
||||
not: { sig: "not <test>", doc: "Inverts a test.", rfc: RFC(5228, "5.8") },
|
||||
size: { sig: "size <:over / :under> <limit: number>", doc: "Compares the message size. Numbers accept K, M and G suffixes.", rfc: RFC(5228, "5.9") },
|
||||
true: { sig: "true", doc: "Always true.", rfc: RFC(5228, "5.10") },
|
||||
body: { sig: "body [<comparator>] [<match-type>] [:raw / :content <types> / :text] <keys>", doc: "Compares the message body.", rfc: RFC(5173, "4"), capability: "body" },
|
||||
currentdate: { sig: "currentdate [:zone <zone>] [<comparator>] [<match-type>] <date-part> <keys>", doc: "Compares the current date. Date parts include `year`, `month`, `day`, `date`, `hour`, `weekday`, `iso8601`. The clock can be fixed in Settings.", rfc: RFC(5260, "5"), capability: "date" },
|
||||
date: { sig: "date [:zone <zone> / :originalzone] [<comparator>] [<match-type>] <header> <date-part> <keys>", doc: "Compares a date taken from a header field.", rfc: RFC(5260, "4"), capability: "date" },
|
||||
duplicate: { sig: "duplicate [:handle <s>] [:header <s> / :uniqueid <s>] [:seconds <n>] [:last]", doc: "True when the same message (by Message-ID or the given id) was seen before. Use Run again to simulate a redelivery.", rfc: RFC(7352, "3"), capability: "duplicate" },
|
||||
environment: { sig: "environment [<comparator>] [<match-type>] <name: string> <keys>", doc: "Compares environment items such as `domain`, `host`, `phase` or `remote-ip`. Configure them in Settings.", rfc: RFC(5183, "4"), capability: "environment" },
|
||||
hasflag: { sig: "hasflag [<comparator>] [<match-type>] [<variables>] <flags>", doc: "Tests the flags set by the script so far.", rfc: RFC(5232, "4"), capability: "imap4flags" },
|
||||
ihave: { sig: "ihave <capabilities: string-list>", doc: "True when the listed extensions are available.", rfc: RFC(5463, "4"), capability: "ihave" },
|
||||
mailboxexists: { sig: "mailboxexists <mailboxes: string-list>", doc: "True when every mailbox exists. Mailboxes are listed in Settings.", rfc: RFC(5490, "3.1"), capability: "mailbox" },
|
||||
mailboxidexists: { sig: "mailboxidexists <ids: string-list>", doc: "True when every mailbox id exists.", rfc: RFC(9042, "4.2"), capability: "mailboxid" },
|
||||
metadata: { sig: "metadata [<comparator>] [<match-type>] <mailbox> <annotation> <keys>", doc: "Compares a mailbox annotation.", rfc: RFC(5490, "3.2"), capability: "mboxmetadata" },
|
||||
metadataexists: { sig: "metadataexists <mailbox> <annotations>", doc: "True when the mailbox has the annotations.", rfc: RFC(5490, "3.3"), capability: "mboxmetadata" },
|
||||
servermetadata: { sig: "servermetadata [<comparator>] [<match-type>] <annotation> <keys>", doc: "Compares a server annotation. Configure annotations in Settings.", rfc: RFC(5490, "3.4"), capability: "servermetadata" },
|
||||
servermetadataexists: { sig: "servermetadataexists <annotations>", doc: "True when the server has the annotations.", rfc: RFC(5490, "3.5"), capability: "servermetadata" },
|
||||
spamtest: { sig: "spamtest [:percent] [<comparator>] [<match-type>] <value: string>", doc: "Compares the spam score (0 to 10, or a percentage with `:percent`). Set the score in Settings.", rfc: RFC(5235, "3.2"), capability: "spamtest" },
|
||||
virustest: { sig: "virustest [<comparator>] [<match-type>] <value: string>", doc: "Compares the virus status (0 unknown, 1 clean, 5 infected). Set it in Settings.", rfc: RFC(5235, "3.3"), capability: "virustest" },
|
||||
specialuse_exists: { sig: "specialuse_exists [<mailbox>] <special-use-attrs>", doc: "True when a mailbox with the special-use attributes exists.", rfc: RFC(8579, "3"), capability: "special-use" },
|
||||
string: { sig: "string [<comparator>] [<match-type>] <source: string-list> <keys>", doc: "Compares arbitrary strings, usually containing variables.", rfc: RFC(5229, "5"), capability: "variables" },
|
||||
valid_notify_method: { sig: "valid_notify_method <uris: string-list>", doc: "True when the notification URIs are valid. Allowed schemes are configured in Settings.", rfc: RFC(5435, "5"), capability: "enotify" },
|
||||
notify_method_capability: { sig: "notify_method_capability [<comparator>] [<match-type>] <uri> <capability> <keys>", doc: "Queries a capability of a notification method.", rfc: RFC(5435, "6"), capability: "enotify" },
|
||||
valid_ext_list: { sig: "valid_ext_list <lists: string-list>", doc: "True when the external lists exist. Lists are configured in Settings.", rfc: RFC(6134, "2.6"), capability: "extlists" },
|
||||
eval: { sig: "eval <expression: string>", doc: "inbuxa extension. True when the expression evaluates to a non-zero, non-empty value.", capability: "vnd.inbuxa.expressions" },
|
||||
};
|
||||
|
||||
export const TAGS = {
|
||||
is: "Exact match.",
|
||||
contains: "Substring match.",
|
||||
matches: "Wildcard match: `*` matches any sequence, `?` one character. Captures go to `${1}`, `${2}`...",
|
||||
regex: "Regular expression match. Captures go to `${1}`, `${2}`... Requires \"regex\".",
|
||||
value: "Relational comparison: \"gt\", \"ge\", \"lt\", \"le\", \"eq\" or \"ne\". Requires \"relational\".",
|
||||
count: "Compares the number of values. Requires \"relational\".",
|
||||
list: "Matches against an external list, such as \"addrbook:default\". Requires \"extlists\".",
|
||||
comparator: "Sets the comparator: \"i;ascii-casemap\" (default), \"i;octet\" or \"i;ascii-numeric\".",
|
||||
all: "Compares the whole address.",
|
||||
localpart: "Compares the part before the @.",
|
||||
domain: "Compares the part after the @.",
|
||||
user: "Compares the user part of a subaddress (before +). Requires \"subaddress\".",
|
||||
detail: "Compares the detail part of a subaddress (after +). Requires \"subaddress\".",
|
||||
over: "True when the size is larger than the limit.",
|
||||
under: "True when the size is smaller than the limit.",
|
||||
copy: "Keeps the implicit keep in place. Requires \"copy\".",
|
||||
flags: "Flags to store with the message. Requires \"imap4flags\".",
|
||||
create: "Creates the mailbox if it does not exist. Requires \"mailbox\".",
|
||||
specialuse: "Selects the mailbox by its special use, such as \"\\\\Junk\". Requires \"special-use\".",
|
||||
mailboxid: "Selects the mailbox by id. Requires \"mailboxid\".",
|
||||
days: "Minimum days between replies to the same sender.",
|
||||
seconds: "Minimum seconds between replies or duplicates.",
|
||||
subject: "Subject of the generated message.",
|
||||
from: "From address of the generated message.",
|
||||
addresses: "Your own addresses, so the reply is not sent to yourself.",
|
||||
handle: "Name used to track replies or duplicates independently.",
|
||||
mime: "For tests: look at MIME part headers. For vacation and replace: the text is a full MIME entity.",
|
||||
anychild: "With `:mime`, look at every child part.",
|
||||
type: "With `:mime`, compare only the media type.",
|
||||
subtype: "With `:mime`, compare only the media subtype.",
|
||||
contenttype: "With `:mime`, compare type/subtype.",
|
||||
param: "With `:mime`, compare Content-Type parameters.",
|
||||
index: "Selects the nth occurrence of a header field. Requires \"index\".",
|
||||
last: "Counts `:index` from the end.",
|
||||
lower: "Lowercases the value.",
|
||||
upper: "Uppercases the value.",
|
||||
lowerfirst: "Lowercases the first character.",
|
||||
upperfirst: "Uppercases the first character.",
|
||||
quotewildcard: "Escapes `*`, `?` and `\\` for use in :matches.",
|
||||
quoteregex: "Escapes regex special characters.",
|
||||
encodeurl: "Percent-encodes the value.",
|
||||
length: "Replaces the value with its length.",
|
||||
percent: "Uses the percentage spam score. Requires \"spamtestplus\".",
|
||||
zone: "Time zone offset such as \"+0100\".",
|
||||
originalzone: "Keeps the time zone from the header.",
|
||||
raw: "Compares the raw body.",
|
||||
content: "Compares body parts of the given media types.",
|
||||
text: "Compares the decoded text body.",
|
||||
importance: "\"1\" high, \"2\" normal, \"3\" low.",
|
||||
message: "Notification text.",
|
||||
options: "Method-specific notification options.",
|
||||
personal: "Includes a personal script.",
|
||||
global: "Includes a global script.",
|
||||
once: "Includes the script only once.",
|
||||
optional: "Does not fail when the script is missing.",
|
||||
notify: "DSN notification: \"never\" or a list of \"success\", \"failure\", \"delay\". Requires \"redirect-dsn\".",
|
||||
ret: "DSN return of content: \"full\" or \"hdrs\". Requires \"redirect-dsn\".",
|
||||
bytimerelative: "Deliver-by time in seconds. Requires \"redirect-deliverby\".",
|
||||
bytimeabsolute: "Deliver-by date. Requires \"redirect-deliverby\".",
|
||||
bymode: "\"notify\" or \"return\" when the deliver-by time passes.",
|
||||
bytrace: "Requests deliver-by trace information.",
|
||||
uniqueid: "Custom id for the duplicate test.",
|
||||
header: "Header field used as the duplicate id.",
|
||||
first: "Maximum number of characters to extract.",
|
||||
name: "Names a foreverypart loop.",
|
||||
fcc: "Stores a copy of the generated message in a mailbox. Requires \"fcc\".",
|
||||
};
|
||||
|
||||
export const DATE_PARTS = ["year", "month", "day", "date", "julian", "hour", "minute", "second", "time", "iso8601", "std11", "zone", "weekday"];
|
||||
|
||||
export const ENVIRONMENT_ITEMS = ["domain", "host", "location", "name", "phase", "remote-host", "remote-ip", "version"];
|
||||
|
||||
export const FUNCTIONS = {
|
||||
trim: 1, trim_start: 1, trim_end: 1, len: 1, count: 1, is_empty: 1, is_number: 1, is_ascii: 1,
|
||||
to_lowercase: 1, to_uppercase: 1, is_email: 1, thread_name: 1, html_to_text: 1, is_uppercase: 1,
|
||||
is_lowercase: 1, has_digits: 1, count_spaces: 1, count_uppercase: 1, count_lowercase: 1,
|
||||
count_chars: 1, dedup: 1, lines: 1, is_ip_addr: 1, is_ipv4_addr: 1, is_ipv6_addr: 1, winnow: 1,
|
||||
sort: 2, email_part: 2, eq_ignore_case: 2, contains: 2, contains_ignore_case: 2, starts_with: 2,
|
||||
ends_with: 2, uri_part: 2, split: 2, rsplit: 2, split_once: 2, rsplit_once: 2, strip_prefix: 2,
|
||||
strip_suffix: 2, is_intersect: 2, substring: 3, split_n: 3, llm_prompt: 3,
|
||||
};
|
||||
|
||||
export function lookup(word) {
|
||||
const key = word.toLowerCase();
|
||||
if (CONTROL[key]) return { kind: "control", ...CONTROL[key] };
|
||||
if (ACTIONS[key]) return { kind: "action", ...ACTIONS[key] };
|
||||
if (TESTS[key]) return { kind: "test", ...TESTS[key] };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { ACTIONS, CONTROL, DATE_PARTS, ENVIRONMENT_ITEMS, FUNCTIONS, TAGS, TESTS, lookup } from "./sieve-docs.js";
|
||||
|
||||
export const LANGUAGE_ID = "sieve";
|
||||
|
||||
const CAPABILITY_ERROR = /Undeclared capability '([^']+)'/;
|
||||
|
||||
function monarch() {
|
||||
return {
|
||||
ignoreCase: true,
|
||||
defaultToken: "",
|
||||
keywords: [...Object.keys(CONTROL), ...Object.keys(ACTIONS), "foreach"],
|
||||
tests: Object.keys(TESTS),
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#.*$/, "comment"],
|
||||
[/\/\*/, "comment", "@comment"],
|
||||
[/text:/, { token: "string.heredoc", next: "@heredoc" }],
|
||||
[/"/, { token: "string.quote", next: "@string" }],
|
||||
[/:[A-Za-z_][A-Za-z0-9_-]*/, "attribute.name"],
|
||||
[/\d+[KMGkmg]?\b/, "number"],
|
||||
[/[A-Za-z_][A-Za-z0-9_.-]*/, { cases: { "@keywords": "keyword", "@tests": "type", "@default": "identifier" } }],
|
||||
[/[{}()[\]]/, "@brackets"],
|
||||
[/[;,]/, "delimiter"],
|
||||
[/\s+/, "white"],
|
||||
],
|
||||
comment: [
|
||||
[/[^/*]+/, "comment"],
|
||||
[/\*\//, "comment", "@pop"],
|
||||
[/[/*]/, "comment"],
|
||||
],
|
||||
string: [
|
||||
[/\$\{[^}"]*\}/, "variable"],
|
||||
[/[^"\\$]+/, "string"],
|
||||
[/\\./, "string.escape"],
|
||||
[/\$/, "string"],
|
||||
[/"/, { token: "string.quote", next: "@pop" }],
|
||||
],
|
||||
heredoc: [
|
||||
[/\.\s*$/, { token: "string.heredoc", next: "@pop" }],
|
||||
[/.*$/, "string"],
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const configuration = {
|
||||
comments: { lineComment: "#", blockComment: ["/*", "*/"] },
|
||||
brackets: [["{", "}"], ["[", "]"], ["(", ")"]],
|
||||
autoClosingPairs: [
|
||||
{ open: "{", close: "}" },
|
||||
{ open: "[", close: "]" },
|
||||
{ open: "(", close: ")" },
|
||||
{ open: '"', close: '"', notIn: ["string", "comment"] },
|
||||
{ open: "/*", close: " */", notIn: ["string"] },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: "{", close: "}" },
|
||||
{ open: "[", close: "]" },
|
||||
{ open: "(", close: ")" },
|
||||
{ open: '"', close: '"' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /\{[^}"]*$/,
|
||||
decreaseIndentPattern: /^\s*\}/,
|
||||
},
|
||||
folding: { markers: { start: /\{\s*$/, end: /^\s*\}/ } },
|
||||
wordPattern: /(-?\d*\.\d\w*)|([^\s`~!@#%^&*()=+[{\]}\\|;:'",<>/?]+)/g,
|
||||
};
|
||||
|
||||
function docMarkdown(word, entry) {
|
||||
const parts = [`\`\`\`sieve\n${entry.sig}\n\`\`\``, entry.doc];
|
||||
const links = [];
|
||||
if (entry.capability) links.push(`Requires \`"${entry.capability}"\``);
|
||||
if (entry.rfc) links.push(`[Specification](${entry.rfc})`);
|
||||
if (links.length) parts.push(links.join(" · "));
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
const isComment = (mode) => mode === "lineComment" || mode === "blockComment";
|
||||
|
||||
export class SieveScanner {
|
||||
constructor() {
|
||||
this.mode = "code";
|
||||
this.statement = "";
|
||||
this.variableOpen = false;
|
||||
}
|
||||
|
||||
feedLine(line, hasNewline = true) {
|
||||
const startMode = this.mode;
|
||||
let opens = 0;
|
||||
let closes = 0;
|
||||
|
||||
if (this.mode === "heredoc" && /^\.\s*$/.test(line)) {
|
||||
this.mode = "code";
|
||||
this.variableOpen = false;
|
||||
this.statement += line;
|
||||
} else {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
const start = i;
|
||||
const mode = this.mode;
|
||||
switch (this.mode) {
|
||||
case "code":
|
||||
if (ch === '"') {
|
||||
this.mode = "string";
|
||||
this.variableOpen = false;
|
||||
} else if (ch === "#") {
|
||||
this.mode = "lineComment";
|
||||
} else if (ch === "/" && line[i + 1] === "*") {
|
||||
this.mode = "blockComment";
|
||||
i++;
|
||||
} else if (ch === ";" || ch === "{" || ch === "}") {
|
||||
this.statement = "";
|
||||
if (ch === "{") opens++;
|
||||
if (ch === "}") closes++;
|
||||
continue;
|
||||
} else if ((ch === "t" || ch === "T") && line.slice(i, i + 5).toLowerCase() === "text:" && !/[A-Za-z0-9_]/.test(line[i - 1] || "")) {
|
||||
this.mode = "heredocHeader";
|
||||
i += 4;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
case "heredoc":
|
||||
if (ch === "\\" && this.mode === "string") {
|
||||
i++;
|
||||
} else if (ch === '"' && this.mode === "string") {
|
||||
this.mode = "code";
|
||||
this.variableOpen = false;
|
||||
} else if (ch === "$" && line[i + 1] === "{") {
|
||||
this.variableOpen = true;
|
||||
i++;
|
||||
} else if (ch === "}") {
|
||||
this.variableOpen = false;
|
||||
}
|
||||
break;
|
||||
case "blockComment":
|
||||
if (ch === "*" && line[i + 1] === "/") {
|
||||
this.mode = "code";
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (!isComment(mode) && !isComment(this.mode)) this.statement += line.slice(start, i + 1);
|
||||
}
|
||||
if (hasNewline) {
|
||||
if (!isComment(this.mode)) this.statement += "\n";
|
||||
if (this.mode === "lineComment") this.mode = "code";
|
||||
if (this.mode === "heredocHeader") this.mode = "heredoc";
|
||||
}
|
||||
}
|
||||
|
||||
return { startMode, opens, closes };
|
||||
}
|
||||
}
|
||||
|
||||
function context(model, position) {
|
||||
const before = model.getValueInRange({ startLineNumber: 1, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column });
|
||||
const lines = before.split("\n");
|
||||
const scanner = new SieveScanner();
|
||||
lines.forEach((line, index) => scanner.feedLine(line, index < lines.length - 1));
|
||||
const mode = isComment(scanner.mode) ? "comment" : scanner.mode;
|
||||
const statement = scanner.statement;
|
||||
const inString = mode === "string" || mode === "heredoc";
|
||||
const command = (statement.match(/^\s*([A-Za-z_]+)/) || [])[1]?.toLowerCase() || "";
|
||||
const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
return { statement, inString, inComment: mode === "comment", inVariable: inString && scanner.variableOpen, command, line };
|
||||
}
|
||||
|
||||
function variableNames(model) {
|
||||
const names = new Set();
|
||||
const pattern = /\b(?:set|let|global|extracttext)\s+(?::[A-Za-z]+\s+(?:\d+\s+)?)*(\[[^\]]*\]|"[^"]*")/gi;
|
||||
for (const match of model.getValue().matchAll(pattern)) {
|
||||
for (const name of match[1].matchAll(/"([^"]+)"/g)) names.add(name[1]);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function replaceRange(model, position) {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
return {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSieve(monaco, getSettings) {
|
||||
monaco.languages.register({ id: LANGUAGE_ID, extensions: [".sieve", ".siv"], aliases: ["Sieve"] });
|
||||
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monarch());
|
||||
monaco.languages.setLanguageConfiguration(LANGUAGE_ID, configuration);
|
||||
|
||||
const { CompletionItemKind: Kind, CompletionItemInsertTextRule: Rule } = monaco.languages;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: [":", '"', "$", "{"],
|
||||
provideCompletionItems(model, position, completionContext) {
|
||||
const ctx = context(model, position);
|
||||
const range = replaceRange(model, position);
|
||||
const settings = getSettings();
|
||||
const items = [];
|
||||
|
||||
if (ctx.inComment) return { suggestions: [] };
|
||||
if (ctx.inVariable) {
|
||||
for (const name of variableNames(model)) items.push({ label: name, kind: Kind.Variable, insertText: name, range });
|
||||
return { suggestions: items };
|
||||
}
|
||||
const trigger = completionContext?.triggerCharacter;
|
||||
if ((trigger === "$" || trigger === "{") || (trigger === '"' && !ctx.inString)) return { suggestions: [] };
|
||||
|
||||
if (ctx.inString) {
|
||||
if (ctx.command === "require" || /\bihave\b/.test(ctx.statement)) {
|
||||
for (const capability of settings.capabilities || []) {
|
||||
items.push({ label: capability, kind: Kind.Module, insertText: capability, range });
|
||||
}
|
||||
} else if (/^\s*(fileinto|mailboxexists)\b/.test(ctx.statement) && !/:(flags|specialuse|mailboxid)\s+"[^"]*$/.test(ctx.statement)) {
|
||||
for (const mailbox of settings.mailboxes || []) {
|
||||
items.push({ label: mailbox.name, kind: Kind.Folder, insertText: mailbox.name, detail: mailbox.specialUse?.join(" "), range });
|
||||
}
|
||||
} else if (/\benvironment\b/.test(ctx.statement)) {
|
||||
const names = new Set([...ENVIRONMENT_ITEMS, ...(settings.environment || []).map((item) => item.name)]);
|
||||
for (const name of names) items.push({ label: name, kind: Kind.Constant, insertText: name, range });
|
||||
} else if (/\b(currentdate|date)\b/.test(ctx.statement)) {
|
||||
for (const part of DATE_PARTS) items.push({ label: part, kind: Kind.EnumMember, insertText: part, range });
|
||||
} else if (/\b(let|eval|while)\b/.test(ctx.statement)) {
|
||||
for (const [name, arity] of Object.entries(FUNCTIONS)) {
|
||||
const args = Array.from({ length: arity }, (_, i) => `\${${i + 1}}`).join(", ");
|
||||
items.push({ label: name, kind: Kind.Function, detail: `${arity} argument${arity === 1 ? "" : "s"}`, insertText: `${name}(${args})`, insertTextRules: Rule.InsertAsSnippet, range });
|
||||
}
|
||||
for (const name of ["header.subject", "header.from.addr", "header.from.name", "header.to[*].addr[*]", "envelope.from", "envelope.to", "env.remote_ip"]) {
|
||||
items.push({ label: name, kind: Kind.Variable, insertText: name, range });
|
||||
}
|
||||
} else if (/\b(addflag|setflag|removeflag|hasflag)\b|:flags\s+/.test(ctx.statement)) {
|
||||
for (const flag of ["\\\\Seen", "\\\\Flagged", "\\\\Answered", "\\\\Deleted", "\\\\Draft", "$Junk", "$NotJunk"]) {
|
||||
items.push({ label: flag, kind: Kind.EnumMember, insertText: flag, range });
|
||||
}
|
||||
}
|
||||
return { suggestions: items };
|
||||
}
|
||||
|
||||
if (trigger === ":" && !/:[A-Za-z_-]*$/.test(ctx.line)) return { suggestions: [] };
|
||||
if (/:[A-Za-z_-]*$/.test(ctx.line)) {
|
||||
for (const [tag, doc] of Object.entries(TAGS)) {
|
||||
items.push({ label: `:${tag}`, kind: Kind.Property, insertText: tag, documentation: { value: doc }, range });
|
||||
}
|
||||
return { suggestions: items };
|
||||
}
|
||||
|
||||
const snippets = {
|
||||
if: "if ${1:header :contains \"Subject\" \"${2:text}\"} {\n\t$0\n}",
|
||||
require: "require [\"${1:fileinto}\"];",
|
||||
fileinto: "fileinto \"${1:INBOX}\";",
|
||||
redirect: "redirect \"${1:[email protected]}\";",
|
||||
vacation: "vacation :days ${1:7} :subject \"${2:Out of office}\" text:\n${3:I am away.}\n.\n;",
|
||||
foreverypart: "foreverypart {\n\t$0\n}",
|
||||
while: "while \"${1:i < 10}\" {\n\t$0\n}",
|
||||
set: "set \"${1:name}\" \"${2:value}\";",
|
||||
let: "let \"${1:name}\" \"${2:expression}\";",
|
||||
};
|
||||
for (const [group, kind] of [[CONTROL, Kind.Keyword], [ACTIONS, Kind.Function], [TESTS, Kind.Interface]]) {
|
||||
for (const [name, entry] of Object.entries(group)) {
|
||||
items.push({
|
||||
label: name,
|
||||
kind,
|
||||
detail: entry.sig,
|
||||
documentation: { value: docMarkdown(name, entry) },
|
||||
insertText: snippets[name] || name,
|
||||
insertTextRules: snippets[name] ? Rule.InsertAsSnippet : undefined,
|
||||
range,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { suggestions: items };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(model, position) {
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
|
||||
if (line[word.startColumn - 2] === ":") {
|
||||
const doc = TAGS[word.word.toLowerCase()];
|
||||
if (!doc) return null;
|
||||
return { range, contents: [{ value: `**:${word.word}**` }, { value: doc }] };
|
||||
}
|
||||
const token = monaco.editor.tokenize(line, LANGUAGE_ID)[0]?.filter((t) => t.offset <= word.startColumn - 1).pop();
|
||||
if (token && /string|comment/.test(token.type)) return null;
|
||||
const entry = lookup(word.word);
|
||||
if (!entry) return null;
|
||||
return { range, contents: [{ value: docMarkdown(word.word, entry) }] };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerCodeActionProvider(LANGUAGE_ID, {
|
||||
provideCodeActions(model, _range, ctx) {
|
||||
const actions = [];
|
||||
for (const marker of ctx.markers) {
|
||||
const match = CAPABILITY_ERROR.exec(marker.message);
|
||||
if (!match) continue;
|
||||
const edit = requireEdit(monaco, model, match[1]);
|
||||
if (!edit) continue;
|
||||
actions.push({
|
||||
title: `Add "${match[1]}" to require`,
|
||||
kind: "quickfix",
|
||||
diagnostics: [marker],
|
||||
isPreferred: true,
|
||||
edit: { edits: [{ resource: model.uri, textEdit: edit, versionId: model.getVersionId() }] },
|
||||
});
|
||||
}
|
||||
return { actions, dispose() {} };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerDocumentFormattingEditProvider(LANGUAGE_ID, {
|
||||
provideDocumentFormattingEdits(model, options) {
|
||||
const unit = options.insertSpaces ? " ".repeat(options.tabSize) : "\t";
|
||||
const scanner = new SieveScanner();
|
||||
let depth = 0;
|
||||
const lines = model.getLinesContent().map((raw) => {
|
||||
const trimmed = raw.trim();
|
||||
const leadingCloses = scanner.mode === "code" ? (trimmed.match(/^\}+/) || [""])[0].length : 0;
|
||||
const { startMode, opens, closes } = scanner.feedLine(raw);
|
||||
if (startMode !== "code") return raw;
|
||||
const indent = Math.max(0, depth - leadingCloses);
|
||||
depth = Math.max(0, depth + opens - closes);
|
||||
return trimmed === "" ? "" : unit.repeat(indent) + trimmed;
|
||||
});
|
||||
return [{ range: model.getFullModelRange(), text: lines.join(model.getEOL()) }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function requireEdit(monaco, model, capability) {
|
||||
const text = model.getValue();
|
||||
const quoted = `"${capability}"`;
|
||||
const listMatch = /require\s*\[([^\]]*)\]\s*;/i.exec(text);
|
||||
if (listMatch) {
|
||||
const closeOffset = listMatch.index + listMatch[0].lastIndexOf("]");
|
||||
const inner = listMatch[1].trim();
|
||||
const pos = model.getPositionAt(closeOffset);
|
||||
return { range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column), text: inner ? `, ${quoted}` : quoted };
|
||||
}
|
||||
const singleMatch = /require\s*("[^"]*")\s*;/i.exec(text);
|
||||
if (singleMatch) {
|
||||
const start = model.getPositionAt(singleMatch.index);
|
||||
const end = model.getPositionAt(singleMatch.index + singleMatch[0].length);
|
||||
return { range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), text: `require [${singleMatch[1]}, ${quoted}];` };
|
||||
}
|
||||
let line = 1;
|
||||
const lines = model.getLinesContent();
|
||||
while (line <= lines.length && /^\s*(#.*)?$/.test(lines[line - 1])) line++;
|
||||
return { range: new monaco.Range(line, 1, line, 1), text: `require [${quoted}];\n\n` };
|
||||
}
|
||||
|
||||
export function markersFor(monaco, model, diagnostics) {
|
||||
return diagnostics.map((diagnostic) => {
|
||||
const known = diagnostic.line > 0 && diagnostic.line <= model.getLineCount();
|
||||
const line = known ? diagnostic.line : 1;
|
||||
const maxColumn = model.getLineMaxColumn(line);
|
||||
const startColumn = known ? Math.min(Math.max(diagnostic.column, 1), maxColumn) : 1;
|
||||
return {
|
||||
severity: diagnostic.severity === "warning" ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error,
|
||||
message: known ? diagnostic.message : `${diagnostic.message} (position unknown)`,
|
||||
startLineNumber: line,
|
||||
endLineNumber: line,
|
||||
startColumn: startColumn === maxColumn ? Math.max(1, startColumn - 1) : startColumn,
|
||||
endColumn: maxColumn,
|
||||
source: "sieve",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
const DB_NAME = "inbuxa-sieve-playground";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "workspaces";
|
||||
const PREFS_KEY = "inbuxa-sieve.prefs";
|
||||
const FALLBACK_KEY = "inbuxa-sieve.workspaces";
|
||||
const WELCOME_KEY = "inbuxa-sieve.welcome-dismissed";
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!("indexedDB" in self)) {
|
||||
reject(new Error("IndexedDB unavailable"));
|
||||
return;
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore(STORE, { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function wrap(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
class IdbStore {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
tx(mode) {
|
||||
return this.db.transaction(STORE, mode).objectStore(STORE);
|
||||
}
|
||||
|
||||
list() {
|
||||
return wrap(this.tx("readonly").getAll());
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return wrap(this.tx("readonly").get(id));
|
||||
}
|
||||
|
||||
put(workspace) {
|
||||
return wrap(this.tx("readwrite").put(workspace));
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return wrap(this.tx("readwrite").delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryStore {
|
||||
constructor() {
|
||||
this.items = new Map();
|
||||
try {
|
||||
for (const ws of JSON.parse(localStorage.getItem(FALLBACK_KEY) || "[]")) {
|
||||
this.items.set(ws.id, ws);
|
||||
}
|
||||
} catch (_) {
|
||||
this.items.clear();
|
||||
}
|
||||
}
|
||||
|
||||
persist() {
|
||||
try {
|
||||
localStorage.setItem(FALLBACK_KEY, JSON.stringify([...this.items.values()]));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async list() {
|
||||
return [...this.items.values()];
|
||||
}
|
||||
|
||||
async get(id) {
|
||||
return this.items.get(id);
|
||||
}
|
||||
|
||||
async put(workspace) {
|
||||
this.items.set(workspace.id, workspace);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
this.items.delete(id);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openStore() {
|
||||
try {
|
||||
return new IdbStore(await openDb());
|
||||
} catch (_) {
|
||||
return new MemoryStore();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPrefs() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePrefs(prefs) {
|
||||
try {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...prefs }));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWelcomeDismissed() {
|
||||
try {
|
||||
return localStorage.getItem(WELCOME_KEY) !== null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissWelcome() {
|
||||
try {
|
||||
localStorage.setItem(WELCOME_KEY, new Date().toISOString());
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function newId() {
|
||||
if (self.crypto && crypto.randomUUID) return crypto.randomUUID();
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// inbuxa: the engine is the server's sieve-rs built for wasm32-wasip1 (see
|
||||
// build.sh), so this worker gives it the few WASI calls it makes. The clock
|
||||
// is the point: currentdate, and the Date headers of the replies a script
|
||||
// writes, read the playground's current time from it.
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let memory;
|
||||
let engine;
|
||||
let panicMessage = null;
|
||||
// Seconds since the epoch the clock reports, or null for the real clock.
|
||||
let clockSeconds = null;
|
||||
|
||||
const view = () => new DataView(memory.buffer);
|
||||
const bytes = (ptr, len) => new Uint8Array(memory.buffer, ptr, len);
|
||||
|
||||
const SUCCESS = 0;
|
||||
const CLOCK_MONOTONIC = 1;
|
||||
|
||||
const wasi = {
|
||||
clock_time_get(clockId, _precision, timePtr) {
|
||||
const ms =
|
||||
clockId === CLOCK_MONOTONIC ? performance.now() : clockSeconds !== null ? clockSeconds * 1000 : Date.now();
|
||||
view().setBigUint64(timePtr, BigInt(Math.round(ms)) * 1000000n, true);
|
||||
return SUCCESS;
|
||||
},
|
||||
random_get(ptr, len) {
|
||||
for (let offset = 0; offset < len; offset += 65536) {
|
||||
crypto.getRandomValues(bytes(ptr + offset, Math.min(65536, len - offset)));
|
||||
}
|
||||
return SUCCESS;
|
||||
},
|
||||
environ_sizes_get(countPtr, sizePtr) {
|
||||
view().setUint32(countPtr, 0, true);
|
||||
view().setUint32(sizePtr, 0, true);
|
||||
return SUCCESS;
|
||||
},
|
||||
environ_get() {
|
||||
return SUCCESS;
|
||||
},
|
||||
fd_write(_fd, iovs, iovsLen, writtenPtr) {
|
||||
const v = view();
|
||||
let written = 0;
|
||||
let text = "";
|
||||
for (let i = 0; i < iovsLen; i++) {
|
||||
const ptr = v.getUint32(iovs + i * 8, true);
|
||||
const len = v.getUint32(iovs + i * 8 + 4, true);
|
||||
text += decoder.decode(bytes(ptr, len));
|
||||
written += len;
|
||||
}
|
||||
v.setUint32(writtenPtr, written, true);
|
||||
if (text.trim()) console.warn(text.trim());
|
||||
return SUCCESS;
|
||||
},
|
||||
proc_exit(code) {
|
||||
throw new WebAssembly.RuntimeError(`The engine exited with code ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const playground = {
|
||||
panicked(ptr, len) {
|
||||
panicMessage = decoder.decode(bytes(ptr, len).slice());
|
||||
},
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const url = new URL("./sieve_playground.wasm", import.meta.url);
|
||||
const imports = { wasi_snapshot_preview1: wasi, playground };
|
||||
let instance;
|
||||
try {
|
||||
({ instance } = await WebAssembly.instantiateStreaming(fetch(url), imports));
|
||||
} catch {
|
||||
// Served without application/wasm: compile from the bytes instead.
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} loading the engine`);
|
||||
({ instance } = await WebAssembly.instantiate(await response.arrayBuffer(), imports));
|
||||
}
|
||||
engine = instance.exports;
|
||||
memory = engine.memory;
|
||||
engine.playground_init();
|
||||
}
|
||||
|
||||
function put(data) {
|
||||
const ptr = engine.playground_alloc(data.length);
|
||||
bytes(ptr, data.length).set(data);
|
||||
return [ptr, data.length];
|
||||
}
|
||||
|
||||
function call(op, payload) {
|
||||
const [opPtr, opLen] = put(encoder.encode(op));
|
||||
const [payloadPtr, payloadLen] = put(encoder.encode(payload === undefined ? "null" : JSON.stringify(payload)));
|
||||
const packed = BigInt.asUintN(64, engine.playground_call(opPtr, opLen, payloadPtr, payloadLen));
|
||||
const ptr = Number(packed >> 32n);
|
||||
const len = Number(packed & 0xffffffffn);
|
||||
const text = decoder.decode(bytes(ptr, len).slice());
|
||||
engine.playground_free(ptr, len);
|
||||
const reply = JSON.parse(text);
|
||||
if ("error" in reply) throw new Error(reply.error);
|
||||
return reply.ok;
|
||||
}
|
||||
|
||||
const ops = {
|
||||
version: () => call("version"),
|
||||
capabilities: () => call("capabilities"),
|
||||
defaults: () => call("defaults"),
|
||||
compile: (request) => call("compile", request),
|
||||
run: (request) => {
|
||||
const currentTime = request?.settings?.currentTime;
|
||||
clockSeconds = Number.isFinite(currentTime) ? currentTime : null;
|
||||
try {
|
||||
return call("run", request);
|
||||
} finally {
|
||||
clockSeconds = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ready = load().then(
|
||||
() => {
|
||||
self.postMessage({ type: "ready" });
|
||||
return true;
|
||||
},
|
||||
(err) => {
|
||||
self.postMessage({ type: "init-error", error: String(err && err.message ? err.message : err) });
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const { id, op, payload } = event.data;
|
||||
if (!(await ready)) return;
|
||||
try {
|
||||
const fn = ops[op];
|
||||
if (!fn) throw new Error(`Unknown operation ${op}`);
|
||||
const result = fn(payload);
|
||||
self.postMessage({ id, result });
|
||||
} catch (err) {
|
||||
const fatal = err instanceof WebAssembly.RuntimeError;
|
||||
const message = fatal && panicMessage ? panicMessage : String(err && err.message ? err.message : err);
|
||||
self.postMessage({ id, error: message, fatal });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// Copied unchanged from the server's functions/misc.rs and unicode.rs, whose
|
||||
// other functions need crates the playground does not build. The tests in
|
||||
// mod.rs compare each one with the server's.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use sieve::{Context, runtime::Variable};
|
||||
|
||||
pub fn fn_is_empty<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
match &v[0] {
|
||||
Variable::String(s) => s.is_empty(),
|
||||
Variable::Integer(_) | Variable::Float(_) => false,
|
||||
Variable::Array(a) => a.is_empty(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn fn_is_number<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
|
||||
}
|
||||
|
||||
pub fn fn_is_ip_addr<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
v[0].to_string().parse::<std::net::IpAddr>().is_ok().into()
|
||||
}
|
||||
|
||||
pub fn fn_is_ipv4_addr<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
v[0].to_string()
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| matches!(ip, IpAddr::V4(_)))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn fn_is_ipv6_addr<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
v[0].to_string()
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| matches!(ip, IpAddr::V6(_)))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn fn_is_ascii<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
match &v[0] {
|
||||
Variable::String(s) => s.is_ascii(),
|
||||
Variable::Integer(_) | Variable::Float(_) => true,
|
||||
Variable::Array(a) => a.iter().all(|v| match v {
|
||||
Variable::String(s) => s.is_ascii(),
|
||||
_ => true,
|
||||
}),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// The expression functions an account script can call, compiled from the
|
||||
// server's own source so the playground cannot drift from it: the modules
|
||||
// below are the server's files, included by path. misc.rs and unicode.rs
|
||||
// also hold functions that need crates only the server builds, so the few
|
||||
// this set uses are copied into `copied`, and a test fails if a copy, the
|
||||
// list or ApplyString stops matching the server's.
|
||||
|
||||
#[path = "../../../../crates/common/src/scripts/functions/array.rs"]
|
||||
#[allow(dead_code)] // the trusted-only functions
|
||||
mod array;
|
||||
mod copied;
|
||||
#[path = "../../../../crates/common/src/scripts/functions/email.rs"]
|
||||
#[allow(dead_code)] // the trusted-only functions
|
||||
mod email;
|
||||
#[path = "../../../../crates/common/src/scripts/functions/header.rs"]
|
||||
#[allow(dead_code)] // the trusted-only functions
|
||||
mod header;
|
||||
#[path = "../../../../crates/common/src/scripts/functions/text.rs"]
|
||||
#[allow(dead_code)] // the trusted-only functions
|
||||
mod text;
|
||||
#[path = "../../../../crates/common/src/scripts/functions/url.rs"]
|
||||
#[allow(dead_code)] // the trusted-only functions
|
||||
mod url;
|
||||
|
||||
use sieve::{FunctionMap, runtime::Variable};
|
||||
|
||||
use self::{array::*, copied::*, email::*, header::*, text::*, url::*};
|
||||
|
||||
/// The server's plugin id and arity for llm_prompt
|
||||
/// (`crates/common/src/scripts/plugins`). The playground answers it as a
|
||||
/// server without a model does.
|
||||
pub const LLM_PROMPT_ID: u32 = 12;
|
||||
|
||||
pub fn register() -> FunctionMap {
|
||||
let mut functions = register_functions_untrusted();
|
||||
functions.set_external_function("llm_prompt", LLM_PROMPT_ID, 3);
|
||||
functions
|
||||
}
|
||||
|
||||
pub fn register_functions_untrusted() -> FunctionMap {
|
||||
FunctionMap::new()
|
||||
.with_function("trim", fn_trim)
|
||||
.with_function("trim_start", fn_trim_start)
|
||||
.with_function("trim_end", fn_trim_end)
|
||||
.with_function("len", fn_len)
|
||||
.with_function("count", fn_count)
|
||||
.with_function("is_empty", fn_is_empty)
|
||||
.with_function("is_number", fn_is_number)
|
||||
.with_function("is_ascii", fn_is_ascii)
|
||||
.with_function("to_lowercase", fn_to_lowercase)
|
||||
.with_function("to_uppercase", fn_to_uppercase)
|
||||
.with_function("is_email", fn_is_email)
|
||||
.with_function("thread_name", fn_thread_name)
|
||||
.with_function("html_to_text", fn_html_to_text)
|
||||
.with_function("is_uppercase", fn_is_uppercase)
|
||||
.with_function("is_lowercase", fn_is_lowercase)
|
||||
.with_function("has_digits", fn_has_digits)
|
||||
.with_function("count_spaces", fn_count_spaces)
|
||||
.with_function("count_uppercase", fn_count_uppercase)
|
||||
.with_function("count_lowercase", fn_count_lowercase)
|
||||
.with_function("count_chars", fn_count_chars)
|
||||
.with_function("dedup", fn_dedup)
|
||||
.with_function("lines", fn_lines)
|
||||
.with_function("is_ip_addr", fn_is_ip_addr)
|
||||
.with_function("is_ipv4_addr", fn_is_ipv4_addr)
|
||||
.with_function("is_ipv6_addr", fn_is_ipv6_addr)
|
||||
.with_function("winnow", fn_winnow)
|
||||
.with_function_args("sort", fn_sort, 2)
|
||||
.with_function_args("email_part", fn_email_part, 2)
|
||||
.with_function_args("eq_ignore_case", fn_eq_ignore_case, 2)
|
||||
.with_function_args("contains", fn_contains, 2)
|
||||
.with_function_args("contains_ignore_case", fn_contains_ignore_case, 2)
|
||||
.with_function_args("starts_with", fn_starts_with, 2)
|
||||
.with_function_args("ends_with", fn_ends_with, 2)
|
||||
.with_function_args("uri_part", fn_uri_part, 2)
|
||||
.with_function_args("substring", fn_substring, 3)
|
||||
.with_function_args("split", fn_split, 2)
|
||||
.with_function_args("rsplit", fn_rsplit, 2)
|
||||
.with_function_args("split_once", fn_split_once, 2)
|
||||
.with_function_args("rsplit_once", fn_rsplit_once, 2)
|
||||
.with_function_args("split_n", fn_split_n, 3)
|
||||
.with_function_args("strip_prefix", fn_strip_prefix, 2)
|
||||
.with_function_args("strip_suffix", fn_strip_suffix, 2)
|
||||
.with_function_args("is_intersect", fn_is_intersect, 2)
|
||||
}
|
||||
|
||||
pub trait ApplyString<'x> {
|
||||
fn transform(&self, f: impl Fn(&'_ str) -> Variable) -> Variable;
|
||||
}
|
||||
|
||||
impl ApplyString<'_> for Variable {
|
||||
fn transform(&self, f: impl Fn(&'_ str) -> Variable) -> Variable {
|
||||
match self {
|
||||
Variable::String(s) => f(s),
|
||||
Variable::Array(list) => list
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Variable::String(s) => f(s),
|
||||
v => f(v.to_string().as_ref()),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
v => f(v.to_string().as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
const SERVER_MOD: &str = include_str!("../../../../crates/common/src/scripts/functions/mod.rs");
|
||||
const SERVER_MISC: &str = include_str!("../../../../crates/common/src/scripts/functions/misc.rs");
|
||||
const SERVER_UNICODE: &str =
|
||||
include_str!("../../../../crates/common/src/scripts/functions/unicode.rs");
|
||||
const SERVER_PLUGINS: &str = include_str!("../../../../crates/common/src/scripts/plugins/mod.rs");
|
||||
const SERVER_LLM: &str =
|
||||
include_str!("../../../../crates/common/src/scripts/plugins/llm_prompt.rs");
|
||||
const OURS_MOD: &str = include_str!("mod.rs");
|
||||
const OURS_COPIED: &str = include_str!("copied.rs");
|
||||
|
||||
/// The item that starts with `start`, through its closing brace at
|
||||
/// column 0.
|
||||
fn item<'a>(source: &'a str, start: &str) -> &'a str {
|
||||
let from = source
|
||||
.find(start)
|
||||
.unwrap_or_else(|| panic!("{start} not found"));
|
||||
let len = source[from..].find("\n}\n").expect("item end") + 2;
|
||||
&source[from..from + len]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_list_matches_the_server() {
|
||||
let start = "pub fn register_functions_untrusted()";
|
||||
assert_eq!(item(OURS_MOD, start), item(SERVER_MOD, start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_string_matches_the_server() {
|
||||
for start in ["pub trait ApplyString", "impl ApplyString"] {
|
||||
assert_eq!(item(OURS_MOD, start), item(SERVER_MOD, start));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_functions_match_the_server() {
|
||||
for (name, server) in [
|
||||
("fn_is_empty", SERVER_MISC),
|
||||
("fn_is_number", SERVER_MISC),
|
||||
("fn_is_ip_addr", SERVER_MISC),
|
||||
("fn_is_ipv4_addr", SERVER_MISC),
|
||||
("fn_is_ipv6_addr", SERVER_MISC),
|
||||
("fn_is_ascii", SERVER_UNICODE),
|
||||
] {
|
||||
let start = format!("pub fn {name}<");
|
||||
assert_eq!(item(OURS_COPIED, &start), item(server, &start), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_prompt_matches_the_server() {
|
||||
assert!(SERVER_PLUGINS.contains(&format!(
|
||||
"llm_prompt::register({}, &mut self);",
|
||||
super::LLM_PROMPT_ID
|
||||
)));
|
||||
assert!(SERVER_LLM.contains(r#"set_external_function("llm_prompt", plugin_id, 3)"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_the_server_functions() {
|
||||
use sieve::Compiler;
|
||||
let script = Compiler::new()
|
||||
.register_functions(&mut super::register())
|
||||
.compile(
|
||||
br#"require ["vnd.inbuxa.expressions", "variables"];
|
||||
let "a" "trim(' x ')";
|
||||
let "b" "substring('hello', 1, 3)";
|
||||
let "c" "split_n('a,b,c', ',', 1)";
|
||||
let "d" "uri_part('https://example.org/', 'host')";
|
||||
let "e" "is_intersect(['a'], 'a')";
|
||||
let "f" "llm_prompt('model', 'prompt', 0.5)";
|
||||
"#,
|
||||
);
|
||||
assert!(script.is_ok(), "{script:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// inbuxa: the server's sieve-rs (0.7) hands the host one Event at a time and
|
||||
// takes an Input back, where the playground this came from implemented a
|
||||
// Handler trait; the answers and the events recorded are the same.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use mail_parser::MessageParser;
|
||||
use sieve::{
|
||||
Envelope, Event as SieveEvent, Importance, Input, Mailbox, MatchAs, Recipient,
|
||||
compiler::grammar::actions::action_redirect::{ByMode, ByTime, Notify, NotifyItem, Ret},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
output::{Detail, Event, OutputMessage},
|
||||
settings::Settings,
|
||||
};
|
||||
|
||||
/// What a message the script created is, read from its Auto-Submitted
|
||||
/// header: 0.7 does not say whether a SendMessage is a redirect.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Created {
|
||||
Vacation,
|
||||
Notification,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub struct Recorder<'s> {
|
||||
settings: &'s Settings,
|
||||
seen_ids: &'s [String],
|
||||
created: Vec<(usize, Created)>,
|
||||
pub new_ids: BTreeSet<String>,
|
||||
pub events: Vec<Event>,
|
||||
pub messages: Vec<OutputMessage>,
|
||||
pub after_error: bool,
|
||||
}
|
||||
|
||||
impl<'s> Recorder<'s> {
|
||||
pub fn new(settings: &'s Settings, seen_ids: &'s [String]) -> Self {
|
||||
Recorder {
|
||||
settings,
|
||||
seen_ids,
|
||||
created: Vec::new(),
|
||||
new_ids: BTreeSet::new(),
|
||||
events: Vec::new(),
|
||||
messages: Vec::new(),
|
||||
after_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_mailbox(&self, name: &str, special_use: &[String]) -> bool {
|
||||
self.settings.mailboxes.iter().any(|mailbox| {
|
||||
(mailbox.name == name
|
||||
|| (name.eq_ignore_ascii_case("INBOX")
|
||||
&& mailbox.name.eq_ignore_ascii_case("INBOX")))
|
||||
&& special_use.iter().all(|wanted| {
|
||||
mailbox
|
||||
.special_use
|
||||
.iter()
|
||||
.any(|have| have.eq_ignore_ascii_case(wanted))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn push(
|
||||
&mut self,
|
||||
kind: &'static str,
|
||||
summary: String,
|
||||
detail: Vec<Detail>,
|
||||
message_id: usize,
|
||||
) {
|
||||
self.events.push(Event {
|
||||
kind,
|
||||
summary,
|
||||
detail,
|
||||
message_id,
|
||||
is_final: matches!(kind, "keep" | "discard" | "reject"),
|
||||
after_error: self.after_error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Answers every event but IncludeScript, which needs the compiled
|
||||
/// include tabs and is answered by the caller.
|
||||
pub fn answer(&mut self, event: SieveEvent) -> Input {
|
||||
match event {
|
||||
SieveEvent::IncludeScript { .. } => Input::False,
|
||||
SieveEvent::MailboxExists {
|
||||
mailboxes,
|
||||
special_use,
|
||||
} => {
|
||||
let exists = if mailboxes.is_empty() {
|
||||
special_use.iter().all(|wanted| {
|
||||
self.settings.mailboxes.iter().any(|mailbox| {
|
||||
mailbox
|
||||
.special_use
|
||||
.iter()
|
||||
.any(|have| have.eq_ignore_ascii_case(wanted))
|
||||
})
|
||||
})
|
||||
} else {
|
||||
mailboxes.iter().all(|mailbox| match mailbox {
|
||||
Mailbox::Name(name) => self.has_mailbox(name, &special_use),
|
||||
Mailbox::Id(_) => false,
|
||||
})
|
||||
};
|
||||
exists.into()
|
||||
}
|
||||
SieveEvent::ListContains {
|
||||
lists,
|
||||
values,
|
||||
match_as,
|
||||
} => self
|
||||
.settings
|
||||
.lists
|
||||
.iter()
|
||||
.filter(|list| lists.contains(&list.name))
|
||||
.flat_map(|list| list.values.iter())
|
||||
.any(|entry| {
|
||||
values.iter().any(|value| match match_as {
|
||||
MatchAs::Octet => entry == value,
|
||||
MatchAs::Lowercase => entry.eq_ignore_ascii_case(value),
|
||||
MatchAs::Number => {
|
||||
match (entry.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.into(),
|
||||
SieveEvent::DuplicateId { id, .. } => {
|
||||
let seen = self.seen_ids.iter().any(|seen| *seen == id);
|
||||
if !seen {
|
||||
self.new_ids.insert(id);
|
||||
}
|
||||
seen.into()
|
||||
}
|
||||
SieveEvent::Function { .. } => {
|
||||
// The only external function a user script can call is
|
||||
// llm_prompt, which needs the server's model. The server
|
||||
// answers false when it has none.
|
||||
self.push(
|
||||
"function",
|
||||
"llm_prompt is not run here".into(),
|
||||
vec![Detail::new("result", "false, as on a server without a model")],
|
||||
0,
|
||||
);
|
||||
Input::FncResult(false.into())
|
||||
}
|
||||
SieveEvent::Keep { flags, message_id } => {
|
||||
self.push(
|
||||
"keep",
|
||||
"Keep the message in INBOX".into(),
|
||||
flags_detail(&flags),
|
||||
message_id,
|
||||
);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::Discard => {
|
||||
self.push(
|
||||
"discard",
|
||||
"Discard the message silently".into(),
|
||||
Vec::new(),
|
||||
0,
|
||||
);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::Reject { extended, reason } => {
|
||||
let mut detail = vec![Detail::new("reason", reason)];
|
||||
if extended {
|
||||
detail.push(Detail::new("type", "ereject (SMTP level)"));
|
||||
}
|
||||
self.push("reject", "Reject the message".into(), detail, 0);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::FileInto {
|
||||
folder,
|
||||
flags,
|
||||
mailbox_id,
|
||||
special_use,
|
||||
create,
|
||||
message_id,
|
||||
} => {
|
||||
let mut detail = flags_detail(&flags);
|
||||
detail.extend(mailbox_id.map(|id| Detail::new("mailbox id", id)));
|
||||
detail
|
||||
.extend(special_use.map(|special_use| Detail::new("special use", special_use)));
|
||||
if create {
|
||||
detail.push(Detail::new("create", "if missing"));
|
||||
}
|
||||
if !self.has_mailbox(&folder, &[]) && !create {
|
||||
detail.push(Detail::new("warning", "mailbox not in settings"));
|
||||
}
|
||||
self.push(
|
||||
"fileinto",
|
||||
format!("File into {folder}"),
|
||||
detail,
|
||||
message_id,
|
||||
);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::SendMessage {
|
||||
recipient,
|
||||
notify,
|
||||
return_of_content,
|
||||
by_time,
|
||||
message_id,
|
||||
} => {
|
||||
self.send_message(recipient, notify, return_of_content, by_time, message_id);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::Notify {
|
||||
from,
|
||||
importance,
|
||||
options,
|
||||
message,
|
||||
method,
|
||||
} => {
|
||||
let mut detail = Vec::new();
|
||||
detail.extend(from.map(|from| Detail::new("from", from)));
|
||||
match importance {
|
||||
Importance::High => detail.push(Detail::new("importance", "high")),
|
||||
Importance::Low => detail.push(Detail::new("importance", "low")),
|
||||
Importance::Normal => (),
|
||||
}
|
||||
if !message.is_empty() {
|
||||
detail.push(Detail::new("message", message));
|
||||
}
|
||||
if !options.is_empty() {
|
||||
detail.push(Detail::new("options", options.join(", ")));
|
||||
}
|
||||
self.push("notify", format!("Notify {method}"), detail, 0);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::SetEnvelope { envelope, value } => {
|
||||
let field = match envelope {
|
||||
Envelope::From => "from",
|
||||
Envelope::To => "to",
|
||||
Envelope::ByTimeAbsolute => "bytimeabsolute",
|
||||
Envelope::ByTimeRelative => "bytimerelative",
|
||||
Envelope::ByMode => "bymode",
|
||||
Envelope::ByTrace => "bytrace",
|
||||
Envelope::Notify => "notify",
|
||||
Envelope::Orcpt => "orcpt",
|
||||
Envelope::Ret => "ret",
|
||||
Envelope::Envid => "envid",
|
||||
};
|
||||
self.push(
|
||||
"envelope",
|
||||
format!("Set envelope {field} to {value}"),
|
||||
Vec::new(),
|
||||
0,
|
||||
);
|
||||
Input::True
|
||||
}
|
||||
SieveEvent::CreatedMessage {
|
||||
message_id,
|
||||
message,
|
||||
} => {
|
||||
self.created.push((message_id, created_kind(&message)));
|
||||
self.push(
|
||||
"created",
|
||||
format!("Generated message #{message_id}"),
|
||||
vec![Detail::new("size", format!("{} bytes", message.len()))],
|
||||
message_id,
|
||||
);
|
||||
self.messages
|
||||
.push(OutputMessage::parse(message_id, &message));
|
||||
Input::True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(
|
||||
&mut self,
|
||||
recipient: Recipient,
|
||||
notify: Notify,
|
||||
return_of_content: Ret,
|
||||
by_time: ByTime<i64>,
|
||||
message_id: usize,
|
||||
) {
|
||||
let to = match &recipient {
|
||||
Recipient::Address(address) => address.clone(),
|
||||
Recipient::List(list) => format!("list {list}"),
|
||||
Recipient::Group(group) => group.join(", "),
|
||||
};
|
||||
let created = self
|
||||
.created
|
||||
.iter()
|
||||
.find(|(id, _)| *id == message_id)
|
||||
.map(|(_, kind)| *kind);
|
||||
let (kind, summary) = match created {
|
||||
Some(Created::Vacation) => ("vacation", format!("Send a vacation reply to {to}")),
|
||||
Some(Created::Notification) => {
|
||||
("notification", format!("Send a notification to {to}"))
|
||||
}
|
||||
Some(Created::Other) | None => ("redirect", format!("Redirect to {to}")),
|
||||
};
|
||||
let mut detail = Vec::new();
|
||||
if let Recipient::Group(group) = &recipient
|
||||
&& group.len() > 1
|
||||
{
|
||||
detail.push(Detail::new("recipients", group.len().to_string()));
|
||||
}
|
||||
match notify {
|
||||
Notify::Never if kind == "redirect" => detail.push(Detail::new("dsn notify", "never")),
|
||||
Notify::Never => (),
|
||||
Notify::Items(items) => detail.push(Detail::new(
|
||||
"dsn notify",
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
NotifyItem::Success => "success",
|
||||
NotifyItem::Failure => "failure",
|
||||
NotifyItem::Delay => "delay",
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)),
|
||||
Notify::Default => (),
|
||||
}
|
||||
match return_of_content {
|
||||
Ret::Full => detail.push(Detail::new("dsn ret", "full")),
|
||||
Ret::Hdrs => detail.push(Detail::new("dsn ret", "headers")),
|
||||
Ret::Default => (),
|
||||
}
|
||||
match by_time {
|
||||
ByTime::Relative {
|
||||
rlimit,
|
||||
mode,
|
||||
trace,
|
||||
} => {
|
||||
detail.push(Detail::new("deliver within", format!("{rlimit}s")));
|
||||
push_by_mode(&mut detail, mode, trace);
|
||||
}
|
||||
ByTime::Absolute {
|
||||
alimit,
|
||||
mode,
|
||||
trace,
|
||||
} => {
|
||||
detail.push(Detail::new("deliver by", alimit.to_string()));
|
||||
push_by_mode(&mut detail, mode, trace);
|
||||
}
|
||||
ByTime::None => (),
|
||||
}
|
||||
self.push(kind, summary, detail, message_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn created_kind(message: &[u8]) -> Created {
|
||||
let auto_submitted = MessageParser::new()
|
||||
.parse_headers(message)
|
||||
.and_then(|message| {
|
||||
message
|
||||
.header_raw("Auto-Submitted")
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
});
|
||||
match auto_submitted.as_deref() {
|
||||
Some(value) if value.starts_with("auto-replied") => Created::Vacation,
|
||||
Some(value) if value.starts_with("auto-notified") => Created::Notification,
|
||||
_ => Created::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_detail(flags: &[String]) -> Vec<Detail> {
|
||||
if flags.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![Detail::new("flags", flags.join(" "))]
|
||||
}
|
||||
}
|
||||
|
||||
fn push_by_mode(detail: &mut Vec<Detail>, mode: ByMode, trace: bool) {
|
||||
match mode {
|
||||
ByMode::Notify => detail.push(Detail::new("by mode", "notify")),
|
||||
ByMode::Return => detail.push(Detail::new("by mode", "return")),
|
||||
ByMode::Default => (),
|
||||
}
|
||||
if trace {
|
||||
detail.push(Detail::new("by trace", "yes"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
// inbuxa: built for wasm32-wasip1, not with wasm-bindgen: the server's
|
||||
// sieve-rs and mail-builder read the clock, which only WASI gives a browser
|
||||
// module (the worker's shim answers it, with the playground's current
|
||||
// time). Calls go through one export, JSON in and JSON out.
|
||||
|
||||
// The server's function modules import http's Uri as hyper's.
|
||||
extern crate http as hyper;
|
||||
|
||||
pub mod functions;
|
||||
pub mod handler;
|
||||
pub mod output;
|
||||
pub mod run;
|
||||
pub mod settings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{run::Request, settings::Settings};
|
||||
|
||||
pub fn version() -> &'static str {
|
||||
env!("SIEVE_VERSION")
|
||||
}
|
||||
|
||||
pub fn capabilities() -> Vec<String> {
|
||||
settings::all_capabilities()
|
||||
.map(|capability| capability.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Runs one operation: `version`, `capabilities`, `defaults`, `compile` or
|
||||
/// `run`, the last two taking a Request.
|
||||
pub fn call(op: &str, payload: &[u8]) -> Result<Value, String> {
|
||||
fn to_value(value: impl Serialize) -> Result<Value, String> {
|
||||
serde_json::to_value(value).map_err(|err| err.to_string())
|
||||
}
|
||||
fn request(payload: &[u8]) -> Result<Request, String> {
|
||||
serde_json::from_slice(payload).map_err(|err| format!("Invalid request: {err}"))
|
||||
}
|
||||
match op {
|
||||
"version" => to_value(version()),
|
||||
"capabilities" => to_value(capabilities()),
|
||||
"defaults" => to_value(Settings::default()),
|
||||
"compile" => to_value(request(payload)?.compile()),
|
||||
"run" => to_value(request(payload)?.run()),
|
||||
op => Err(format!("Unknown operation {op}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
mod abi {
|
||||
use serde_json::json;
|
||||
|
||||
#[link(wasm_import_module = "playground")]
|
||||
unsafe extern "C" {
|
||||
/// Hands the host a panic's message before the module traps.
|
||||
fn panicked(ptr: *const u8, len: usize);
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn playground_init() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let message = info.to_string();
|
||||
unsafe { panicked(message.as_ptr(), message.len()) };
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn playground_alloc(len: usize) -> *mut u8 {
|
||||
Box::into_raw(vec![0u8; len].into_boxed_slice()).cast()
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// `ptr` and `len` come from playground_alloc or playground_call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn playground_free(ptr: *mut u8, len: usize) {
|
||||
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
|
||||
}
|
||||
|
||||
/// Takes the operation name and the JSON payload, each from
|
||||
/// playground_alloc, frees both, and returns `{"ok": ...}` or
|
||||
/// `{"error": "..."}` as (pointer << 32 | length), for the host to free.
|
||||
///
|
||||
/// # Safety
|
||||
/// The pointers and lengths come from playground_alloc.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn playground_call(
|
||||
op_ptr: *mut u8,
|
||||
op_len: usize,
|
||||
payload_ptr: *mut u8,
|
||||
payload_len: usize,
|
||||
) -> u64 {
|
||||
let op = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(op_ptr, op_len)) };
|
||||
let payload = unsafe {
|
||||
Box::from_raw(std::ptr::slice_from_raw_parts_mut(
|
||||
payload_ptr,
|
||||
payload_len,
|
||||
))
|
||||
};
|
||||
let reply = match super::call(&String::from_utf8_lossy(&op), &payload) {
|
||||
Ok(value) => json!({ "ok": value }),
|
||||
Err(error) => json!({ "error": error }),
|
||||
};
|
||||
let out = serde_json::to_vec(&reply)
|
||||
.unwrap_or_default()
|
||||
.into_boxed_slice();
|
||||
let len = out.len() as u64;
|
||||
let ptr = Box::into_raw(out).cast::<u8>() as usize as u64;
|
||||
(ptr << 32) | len
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use mail_parser::{Addr, Address, MessageParser, MimeHeaders};
|
||||
use serde::Serialize;
|
||||
use sieve::{compiler::CompileError, runtime::RuntimeError};
|
||||
|
||||
use crate::settings::KeyValue;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompileOutput {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunOutput {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub error: Option<Diagnostic>,
|
||||
pub events: Vec<Event>,
|
||||
pub messages: Vec<OutputMessage>,
|
||||
pub message_changed: bool,
|
||||
pub global_variables: Vec<KeyValue>,
|
||||
pub duplicate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Diagnostic {
|
||||
pub script: usize,
|
||||
pub severity: Severity,
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Event {
|
||||
pub kind: &'static str,
|
||||
pub summary: String,
|
||||
pub detail: Vec<Detail>,
|
||||
pub message_id: usize,
|
||||
pub is_final: bool,
|
||||
pub after_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Detail {
|
||||
pub label: &'static str,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OutputMessage {
|
||||
pub id: usize,
|
||||
pub size: usize,
|
||||
pub raw: String,
|
||||
pub subject: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub date: String,
|
||||
pub headers: Vec<KeyValue>,
|
||||
pub text: Option<String>,
|
||||
pub html: Option<String>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
const POSITION_SUFFIX: &str = " at line ";
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn compile(script: usize, err: &CompileError) -> Self {
|
||||
let mut message = err.to_string();
|
||||
if let Some(pos) = message.rfind(POSITION_SUFFIX) {
|
||||
message.truncate(pos);
|
||||
}
|
||||
Diagnostic {
|
||||
script,
|
||||
severity: Severity::Error,
|
||||
line: err.line_num(),
|
||||
column: err.line_pos(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime(err: &RuntimeError) -> Self {
|
||||
let (line, column, message) = match err {
|
||||
RuntimeError::InvalidInstruction(invalid) => (
|
||||
invalid.line_num(),
|
||||
invalid.line_pos(),
|
||||
format!("Unknown or invalid instruction {:?}", invalid.name()),
|
||||
),
|
||||
// inbuxa: 0.7 prints nothing for this one
|
||||
RuntimeError::TooManyIncludes => {
|
||||
(0, 0, "Too many nested includes".to_string())
|
||||
}
|
||||
err => (0, 0, err.to_string()),
|
||||
};
|
||||
Diagnostic {
|
||||
script: 0,
|
||||
severity: Severity::Warning,
|
||||
line,
|
||||
column,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Detail {
|
||||
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
|
||||
Detail {
|
||||
label,
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputMessage {
|
||||
pub fn parse(id: usize, bytes: &[u8]) -> Self {
|
||||
let raw = String::from_utf8_lossy(bytes).into_owned();
|
||||
let Some(message) = MessageParser::new().parse(bytes) else {
|
||||
return OutputMessage {
|
||||
id,
|
||||
size: bytes.len(),
|
||||
raw,
|
||||
subject: String::new(),
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
date: String::new(),
|
||||
headers: Vec::new(),
|
||||
text: None,
|
||||
html: None,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
let raw_bytes = message.raw_message();
|
||||
let headers = message
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|header| KeyValue {
|
||||
name: header.name.as_str().to_string(),
|
||||
value: raw_bytes
|
||||
.get(header.offset_start as usize..header.offset_end as usize)
|
||||
.map(|value| {
|
||||
String::from_utf8_lossy(value)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let html = message
|
||||
.html_body
|
||||
.iter()
|
||||
.any(|part| {
|
||||
message
|
||||
.part(*part)
|
||||
.is_some_and(|part| part.is_content_type("text", "html"))
|
||||
})
|
||||
.then(|| message.body_html(0).map(|html| html.into_owned()))
|
||||
.flatten();
|
||||
|
||||
OutputMessage {
|
||||
id,
|
||||
size: bytes.len(),
|
||||
subject: message.subject().unwrap_or_default().to_string(),
|
||||
from: message.from().map(format_address).unwrap_or_default(),
|
||||
to: message.to().map(format_address).unwrap_or_default(),
|
||||
date: message
|
||||
.date()
|
||||
.map(|date| date.to_rfc822())
|
||||
.unwrap_or_default(),
|
||||
headers,
|
||||
text: message.body_text(0).map(|text| text.into_owned()),
|
||||
html,
|
||||
attachments: message
|
||||
.attachments()
|
||||
.map(|part| Attachment {
|
||||
name: part.attachment_name().unwrap_or("unnamed").to_string(),
|
||||
content_type: part
|
||||
.content_type()
|
||||
.map(|ct| match ct.subtype() {
|
||||
Some(subtype) => format!("{}/{subtype}", ct.ctype()),
|
||||
None => ct.ctype().to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
size: part.len(),
|
||||
})
|
||||
.collect(),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_address(address: &Address<'_>) -> String {
|
||||
address
|
||||
.iter()
|
||||
.map(|Addr { name, address }| match (name, address) {
|
||||
(Some(name), Some(address)) => format!("{name} <{address}>"),
|
||||
(None, Some(address)) => address.to_string(),
|
||||
(Some(name), None) => name.to_string(),
|
||||
(None, None) => String::new(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use std::{borrow::Cow, sync::Arc};
|
||||
|
||||
use serde::Deserialize;
|
||||
use sieve::{Compiler, Event, Input, Script, Sieve, runtime::RuntimeError};
|
||||
|
||||
use crate::{
|
||||
functions,
|
||||
handler::Recorder,
|
||||
output::{CompileOutput, Diagnostic, RunOutput},
|
||||
settings::{KeyValue, Settings},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Request {
|
||||
pub scripts: Vec<ScriptSource>,
|
||||
pub message: String,
|
||||
pub settings: Settings,
|
||||
pub seen_ids: Vec<String>,
|
||||
pub now: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ScriptSource {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
fn compile_all(&self, compiler: &Compiler) -> (Vec<Sieve>, Vec<Diagnostic>) {
|
||||
let mut compiled = Vec::with_capacity(self.scripts.len());
|
||||
let mut diagnostics = Vec::new();
|
||||
for (index, script) in self.scripts.iter().enumerate() {
|
||||
match compiler.compile(to_crlf(&script.source).as_bytes()) {
|
||||
Ok(sieve) => compiled.push(sieve),
|
||||
Err(err) => diagnostics.push(Diagnostic::compile(index, &err)),
|
||||
}
|
||||
}
|
||||
(compiled, diagnostics)
|
||||
}
|
||||
|
||||
pub fn compile(&self) -> CompileOutput {
|
||||
let compiler = self.settings.compiler(&mut functions::register());
|
||||
CompileOutput {
|
||||
diagnostics: self.compile_all(&compiler).1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&self) -> RunOutput {
|
||||
let mut functions = functions::register();
|
||||
let compiler = self.settings.compiler(&mut functions);
|
||||
let (compiled, diagnostics) = self.compile_all(&compiler);
|
||||
if !diagnostics.is_empty() {
|
||||
return RunOutput {
|
||||
diagnostics,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
let mut compiled = compiled.into_iter().map(Arc::new);
|
||||
let Some(main) = compiled.next() else {
|
||||
return RunOutput::default();
|
||||
};
|
||||
// inbuxa: includes are answered from the other tabs, by name, for
|
||||
// :personal and :global alike.
|
||||
let includes: Vec<(&str, Arc<Sieve>)> = self
|
||||
.scripts
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|script| script.name.trim())
|
||||
.zip(compiled)
|
||||
.collect();
|
||||
|
||||
let runtime = self.settings.runtime(&mut functions);
|
||||
let raw = to_crlf(&self.message);
|
||||
let mut ctx = runtime.filter(raw.as_bytes());
|
||||
self.settings.apply(&mut ctx);
|
||||
|
||||
let main_name = self
|
||||
.scripts
|
||||
.first()
|
||||
.map_or("", |script| script.name.trim())
|
||||
.to_string();
|
||||
let mut input = Input::script(main_name, main);
|
||||
let mut recorder = Recorder::new(&self.settings, &self.seen_ids);
|
||||
let mut error = None;
|
||||
// inbuxa: as the server does, a runtime error is reported and the
|
||||
// script carries on.
|
||||
while let Some(result) = ctx.run(input) {
|
||||
input = match result {
|
||||
Ok(Event::IncludeScript { name, .. }) => {
|
||||
let wanted = match &name {
|
||||
Script::Personal(name) | Script::Global(name) => name.as_str(),
|
||||
};
|
||||
match includes.iter().find(|(tab, _)| *tab == wanted) {
|
||||
Some((_, script)) => Input::script(name.clone(), script.clone()),
|
||||
None => Input::False,
|
||||
}
|
||||
}
|
||||
Ok(event) => recorder.answer(event),
|
||||
Err(err) => {
|
||||
if error.is_none() {
|
||||
error = Some(self.runtime_diagnostic(&err));
|
||||
}
|
||||
recorder.after_error = true;
|
||||
Input::True
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mut global_variables: Vec<KeyValue> = ctx
|
||||
.global_variable_names()
|
||||
.filter_map(|name| {
|
||||
ctx.global_variable(name).map(|value| KeyValue {
|
||||
name: name.to_string(),
|
||||
value: value.to_string().into_owned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
global_variables.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let error_free = error.is_none();
|
||||
RunOutput {
|
||||
diagnostics,
|
||||
error,
|
||||
message_changed: ctx.has_message_changed(),
|
||||
global_variables,
|
||||
events: recorder.events,
|
||||
messages: recorder.messages,
|
||||
duplicate_ids: if error_free {
|
||||
let mut ids = self.seen_ids.clone();
|
||||
ids.extend(recorder.new_ids);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
} else {
|
||||
self.seen_ids.clone()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_diagnostic(&self, err: &RuntimeError) -> Diagnostic {
|
||||
let mut diagnostic = Diagnostic::runtime(err);
|
||||
if let RuntimeError::InvalidInstruction(invalid) = err {
|
||||
let line_num = invalid.line_num();
|
||||
let name = invalid.name().to_ascii_lowercase();
|
||||
let contains_instruction = |source: &str| {
|
||||
source
|
||||
.lines()
|
||||
.nth(line_num.saturating_sub(1))
|
||||
.is_some_and(|line| line.to_ascii_lowercase().contains(&name))
|
||||
};
|
||||
match self
|
||||
.scripts
|
||||
.iter()
|
||||
.position(|script| contains_instruction(&script.source))
|
||||
{
|
||||
Some(index) => diagnostic.script = index,
|
||||
None => {
|
||||
diagnostic.line = 0;
|
||||
diagnostic.column = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_crlf(text: &str) -> Cow<'_, str> {
|
||||
let bytes = text.as_bytes();
|
||||
let needs_fix = bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(pos, &b)| b == b'\n' && (pos == 0 || bytes.get(pos - 1) != Some(&b'\r')));
|
||||
if !needs_fix {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let mut out = String::with_capacity(text.len() + text.len() / 16);
|
||||
let mut prev = '\0';
|
||||
for ch in text.chars() {
|
||||
if ch == '\n' && prev != '\r' {
|
||||
out.push('\r');
|
||||
}
|
||||
out.push(ch);
|
||||
prev = ch;
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sieve::{
|
||||
Compiler, Context, Envelope, FunctionMap, Metadata, Runtime,
|
||||
compiler::grammar::{Capability, Comparator},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub capabilities: Vec<String>,
|
||||
pub no_capability_check: bool,
|
||||
|
||||
pub envelope_from: String,
|
||||
pub envelope_to: Vec<String>,
|
||||
pub envelope_id: String,
|
||||
pub user_address: String,
|
||||
pub user_full_name: String,
|
||||
pub local_hostname: String,
|
||||
pub current_time: Option<i64>,
|
||||
|
||||
pub mailboxes: Vec<MailboxSetting>,
|
||||
pub lists: Vec<ListSetting>,
|
||||
pub environment: Vec<KeyValue>,
|
||||
pub global_variables: Vec<KeyValue>,
|
||||
pub metadata: Vec<MetadataSetting>,
|
||||
pub protected_headers: Vec<String>,
|
||||
pub valid_notification_uris: Vec<String>,
|
||||
|
||||
pub spam_score: u32,
|
||||
pub virus_score: u32,
|
||||
|
||||
pub vacation_default_subject: String,
|
||||
pub vacation_subject_prefix: String,
|
||||
pub vacation_use_orig_rcpt: bool,
|
||||
pub default_vacation_expiry: u64,
|
||||
pub default_duplicate_expiry: u64,
|
||||
|
||||
pub cpu_limit: usize,
|
||||
pub max_redirects: usize,
|
||||
pub max_out_messages: usize,
|
||||
pub max_nested_includes: usize,
|
||||
pub max_variable_size: usize,
|
||||
pub max_header_size: usize,
|
||||
pub max_received_headers: usize,
|
||||
|
||||
pub max_script_size: usize,
|
||||
pub max_string_size: usize,
|
||||
pub max_variable_name_size: usize,
|
||||
pub max_nested_blocks: usize,
|
||||
pub max_nested_tests: usize,
|
||||
pub max_nested_foreverypart: usize,
|
||||
pub max_match_variables: usize,
|
||||
pub max_local_variables: usize,
|
||||
pub max_includes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MailboxSetting {
|
||||
pub name: String,
|
||||
pub special_use: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ListSetting {
|
||||
pub name: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct KeyValue {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MetadataSetting {
|
||||
pub mailbox: String,
|
||||
pub annotation: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
const KIB: usize = 1024;
|
||||
const MIB: usize = 1024 * KIB;
|
||||
const DAY: u64 = 86400;
|
||||
|
||||
pub fn all_capabilities() -> impl Iterator<Item = Capability> {
|
||||
Capability::all()
|
||||
.iter()
|
||||
.filter(|capability| !matches!(capability, Capability::Comparator(Comparator::Elbonia)))
|
||||
.cloned()
|
||||
.chain([Capability::While, Capability::Expressions])
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
capabilities: all_capabilities().map(|c| c.to_string()).collect(),
|
||||
no_capability_check: false,
|
||||
|
||||
envelope_from: String::new(),
|
||||
envelope_to: Vec::new(),
|
||||
envelope_id: String::new(),
|
||||
user_address: "[email protected]".into(),
|
||||
user_full_name: "Jane Doe".into(),
|
||||
local_hostname: "mx.example.org".into(),
|
||||
current_time: None,
|
||||
|
||||
mailboxes: [
|
||||
("INBOX", None),
|
||||
("Drafts", Some("\\Drafts")),
|
||||
("Sent", Some("\\Sent")),
|
||||
("Junk", Some("\\Junk")),
|
||||
("Trash", Some("\\Trash")),
|
||||
("Archive", Some("\\Archive")),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, special_use)| MailboxSetting {
|
||||
name: name.into(),
|
||||
special_use: special_use.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
.collect(),
|
||||
lists: vec![ListSetting {
|
||||
name: "addrbook:default".into(),
|
||||
values: vec!["[email protected]".into(), "[email protected]".into()],
|
||||
}],
|
||||
// inbuxa: what the server sets for account scripts, plus the
|
||||
// names a script may test that it would get from a message.
|
||||
environment: [
|
||||
("name", "inbuxa Server"),
|
||||
("location", "MS"),
|
||||
("phase", "during"),
|
||||
("domain", "example.org"),
|
||||
("host", "mx.example.org"),
|
||||
("remote-host", "mail.example.com"),
|
||||
("remote-ip", "192.0.2.25"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, value)| KeyValue {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
})
|
||||
.collect(),
|
||||
global_variables: Vec::new(),
|
||||
metadata: vec![MetadataSetting {
|
||||
mailbox: String::new(),
|
||||
annotation: "/private/vendor/vendor.inbuxa/playground".into(),
|
||||
value: "enabled".into(),
|
||||
}],
|
||||
protected_headers: vec![
|
||||
"Original-Subject".into(),
|
||||
"Original-From".into(),
|
||||
"Received".into(),
|
||||
"Auto-Submitted".into(),
|
||||
],
|
||||
valid_notification_uris: vec!["mailto".into(), "xmpp".into(), "tel".into()],
|
||||
|
||||
spam_score: 0,
|
||||
virus_score: 0,
|
||||
|
||||
vacation_default_subject: "Automated reply".into(),
|
||||
vacation_subject_prefix: "Auto: ".into(),
|
||||
vacation_use_orig_rcpt: false,
|
||||
default_vacation_expiry: 7 * DAY,
|
||||
default_duplicate_expiry: 7 * DAY,
|
||||
|
||||
cpu_limit: 100_000,
|
||||
max_redirects: 10,
|
||||
max_out_messages: 20,
|
||||
max_nested_includes: 5,
|
||||
max_variable_size: 64 * KIB,
|
||||
max_header_size: 8 * KIB,
|
||||
max_received_headers: 50,
|
||||
|
||||
max_script_size: MIB,
|
||||
max_string_size: 64 * KIB,
|
||||
max_variable_name_size: 64,
|
||||
max_nested_blocks: 32,
|
||||
max_nested_tests: 32,
|
||||
max_nested_foreverypart: 5,
|
||||
max_match_variables: 63,
|
||||
max_local_variables: 256,
|
||||
max_includes: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn compiler(&self, functions: &mut FunctionMap) -> Compiler {
|
||||
Compiler::new()
|
||||
.with_max_script_size(self.max_script_size)
|
||||
.with_max_string_size(self.max_string_size)
|
||||
.with_max_variable_name_size(self.max_variable_name_size)
|
||||
.with_max_nested_blocks(self.max_nested_blocks)
|
||||
.with_max_nested_tests(self.max_nested_tests)
|
||||
.with_max_nested_foreverypart(self.max_nested_foreverypart)
|
||||
.with_max_match_variables(self.max_match_variables)
|
||||
.with_max_local_variables(self.max_local_variables)
|
||||
.with_max_header_size(self.max_header_size)
|
||||
.with_max_includes(self.max_includes)
|
||||
.with_no_capability_check(self.no_capability_check)
|
||||
.register_functions(functions)
|
||||
}
|
||||
|
||||
pub fn runtime(&self, functions: &mut FunctionMap) -> Runtime {
|
||||
let mut runtime = Runtime::new()
|
||||
.without_capabilities(all_capabilities())
|
||||
.with_functions(functions)
|
||||
.with_cpu_limit(self.cpu_limit)
|
||||
.with_max_redirects(self.max_redirects)
|
||||
.with_max_out_messages(self.max_out_messages)
|
||||
.with_max_nested_includes(self.max_nested_includes)
|
||||
.with_max_variable_size(self.max_variable_size)
|
||||
.with_max_header_size(self.max_header_size)
|
||||
.with_max_received_headers(self.max_received_headers)
|
||||
.with_local_hostname(self.local_hostname.clone())
|
||||
.with_vacation_default_subject(self.vacation_default_subject.clone())
|
||||
.with_vacation_subject_prefix(self.vacation_subject_prefix.clone())
|
||||
.with_vacation_use_orig_rcpt(self.vacation_use_orig_rcpt)
|
||||
.with_default_vacation_expiry(self.default_vacation_expiry)
|
||||
.with_default_duplicate_expiry(self.default_duplicate_expiry)
|
||||
.with_valid_notification_uris(self.valid_notification_uris.iter().cloned())
|
||||
.with_valid_ext_lists(self.lists.iter().map(|list| list.name.clone()));
|
||||
|
||||
for capability in &self.capabilities {
|
||||
runtime.set_capability(capability.as_str());
|
||||
}
|
||||
for header in &self.protected_headers {
|
||||
runtime.set_protected_header(header.clone());
|
||||
}
|
||||
for KeyValue { name, value } in &self.environment {
|
||||
runtime.set_env_variable(name.clone(), value.clone());
|
||||
}
|
||||
for entry in &self.metadata {
|
||||
let key = if entry.mailbox.is_empty() {
|
||||
Metadata::Server {
|
||||
annotation: entry.annotation.clone(),
|
||||
}
|
||||
} else {
|
||||
Metadata::Mailbox {
|
||||
name: entry.mailbox.clone(),
|
||||
annotation: entry.annotation.clone(),
|
||||
}
|
||||
};
|
||||
runtime.set_medatata(key, entry.value.clone());
|
||||
}
|
||||
runtime
|
||||
}
|
||||
|
||||
// inbuxa: the current time is the clock the WebAssembly host gives the
|
||||
// module (`current_time`, else now): 0.7 reads it when the run starts.
|
||||
pub fn apply<'x>(&'x self, ctx: &mut Context<'x>) {
|
||||
if !self.envelope_from.is_empty() {
|
||||
ctx.set_envelope(Envelope::From, self.envelope_from.as_str());
|
||||
} else if let Some(sender) = ctx
|
||||
.message()
|
||||
.parts
|
||||
.first()
|
||||
.and_then(|_| ctx.message().return_address())
|
||||
.map(str::to_string)
|
||||
{
|
||||
ctx.set_envelope(Envelope::From, sender);
|
||||
}
|
||||
let mut recipients = self
|
||||
.envelope_to
|
||||
.iter()
|
||||
.filter(|to| !to.is_empty())
|
||||
.peekable();
|
||||
if recipients.peek().is_none() {
|
||||
ctx.set_envelope(Envelope::To, self.user_address.as_str());
|
||||
}
|
||||
for to in recipients {
|
||||
ctx.set_envelope(Envelope::To, to.as_str());
|
||||
}
|
||||
if !self.envelope_id.is_empty() {
|
||||
ctx.set_envelope(Envelope::Envid, self.envelope_id.as_str());
|
||||
}
|
||||
if !self.user_address.is_empty() {
|
||||
ctx.set_user_address(self.user_address.as_str());
|
||||
}
|
||||
if !self.user_full_name.is_empty() {
|
||||
ctx.set_user_full_name(&self.user_full_name);
|
||||
}
|
||||
for KeyValue { name, value } in &self.global_variables {
|
||||
ctx.set_global_variable(name.clone(), value.as_str());
|
||||
}
|
||||
ctx.set_spam_status(self.spam_score);
|
||||
ctx.set_virus_status(self.virus_score);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
output::RunOutput,
|
||||
run::{Request, ScriptSource},
|
||||
settings::Settings,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Sample {
|
||||
id: String,
|
||||
scripts: Vec<SampleFile>,
|
||||
messages: Vec<SampleFile>,
|
||||
#[serde(default)]
|
||||
settings: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SampleFile {
|
||||
name: String,
|
||||
file: String,
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_789_210_800;
|
||||
|
||||
fn samples_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("site/samples")
|
||||
}
|
||||
|
||||
fn load_request(sample: &Sample) -> Request {
|
||||
let dir = samples_dir();
|
||||
let read = |file: &str| fs::read_to_string(dir.join(file)).expect("sample file");
|
||||
let mut settings = serde_json::to_value(Settings::default()).expect("settings");
|
||||
if let Some(object) = settings.as_object_mut() {
|
||||
object.extend(sample.settings.clone());
|
||||
}
|
||||
Request {
|
||||
scripts: sample
|
||||
.scripts
|
||||
.iter()
|
||||
.map(|script| ScriptSource {
|
||||
name: script.name.clone(),
|
||||
source: read(&script.file),
|
||||
})
|
||||
.collect(),
|
||||
message: read(&sample.messages[0].file),
|
||||
settings: serde_json::from_value(settings).expect("settings"),
|
||||
seen_ids: Vec::new(),
|
||||
now: NOW,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(output: &RunOutput) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for event in &output.events {
|
||||
let detail = event
|
||||
.detail
|
||||
.iter()
|
||||
.map(|d| format!("{}={}", d.label, d.value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
lines.push(format!(
|
||||
" [{}#{}] {} {}",
|
||||
event.kind, event.message_id, event.summary, detail
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn samples() -> Vec<Sample> {
|
||||
serde_json::from_str(&fs::read_to_string(samples_dir().join("index.json")).expect("index"))
|
||||
.expect("index.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_run_cleanly() {
|
||||
for sample in samples() {
|
||||
let output = load_request(&sample).run();
|
||||
println!("{}:\n{}", sample.id, describe(&output));
|
||||
assert!(
|
||||
output.diagnostics.is_empty(),
|
||||
"{}: {:?}",
|
||||
sample.id,
|
||||
output.diagnostics
|
||||
);
|
||||
assert!(output.error.is_none(), "{}: {:?}", sample.id, output.error);
|
||||
assert!(!output.events.is_empty(), "{}", sample.id);
|
||||
}
|
||||
}
|
||||
|
||||
const STANDARD_CAPABILITIES: &[&str] = &[
|
||||
"body",
|
||||
"comparator-i;ascii-casemap",
|
||||
"comparator-i;ascii-numeric",
|
||||
"comparator-i;octet",
|
||||
"convert",
|
||||
"copy",
|
||||
"date",
|
||||
"duplicate",
|
||||
"editheader",
|
||||
"enclose",
|
||||
"encoded-character",
|
||||
"enotify",
|
||||
"envelope",
|
||||
"envelope-deliverby",
|
||||
"envelope-dsn",
|
||||
"environment",
|
||||
"ereject",
|
||||
"extlists",
|
||||
"extracttext",
|
||||
"fcc",
|
||||
"fileinto",
|
||||
"foreverypart",
|
||||
"ihave",
|
||||
"imap4flags",
|
||||
"imapsieve",
|
||||
"include",
|
||||
"index",
|
||||
"mailbox",
|
||||
"mailboxid",
|
||||
"mboxmetadata",
|
||||
"mime",
|
||||
"redirect-deliverby",
|
||||
"redirect-dsn",
|
||||
"regex",
|
||||
"reject",
|
||||
"relational",
|
||||
"replace",
|
||||
"servermetadata",
|
||||
"special-use",
|
||||
"spamtest",
|
||||
"spamtestplus",
|
||||
"subaddress",
|
||||
"vacation",
|
||||
"vacation-seconds",
|
||||
"variables",
|
||||
"virustest",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn samples_use_standard_capabilities() {
|
||||
let settings = Settings {
|
||||
capabilities: STANDARD_CAPABILITIES
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for sample in samples()
|
||||
.into_iter()
|
||||
.filter(|sample| sample.id != "expressions")
|
||||
{
|
||||
let output = Request {
|
||||
settings: settings.clone(),
|
||||
..load_request(&sample)
|
||||
}
|
||||
.run();
|
||||
assert!(
|
||||
output.diagnostics.is_empty() && output.error.is_none(),
|
||||
"{} needs a vendor-specific capability: {:?} {:?}",
|
||||
sample.id,
|
||||
output.diagnostics,
|
||||
output.error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_expand_their_variables() {
|
||||
let expectations = [
|
||||
("spam", "X-Spam-Score: 60%"),
|
||||
(
|
||||
"forward",
|
||||
"Urgent mail from Billing <[email protected]>",
|
||||
),
|
||||
("vacation", "Subject: Out of office: Lunch next week?"),
|
||||
("welcome", "Hi Alice Martin,"),
|
||||
];
|
||||
let samples = samples();
|
||||
for (id, expected) in expectations {
|
||||
let sample = samples
|
||||
.iter()
|
||||
.find(|sample| sample.id == id)
|
||||
.expect("sample exists");
|
||||
let output = load_request(sample).run();
|
||||
assert!(
|
||||
output
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.raw.contains(expected)),
|
||||
"{id}: no generated message contains {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_sample_shows_features() {
|
||||
let sample = samples()
|
||||
.into_iter()
|
||||
.find(|sample| sample.id == "welcome")
|
||||
.expect("welcome sample");
|
||||
let request = load_request(&sample);
|
||||
let output = request.run();
|
||||
let kinds: Vec<_> = output.events.iter().map(|event| event.kind).collect();
|
||||
assert!(kinds.contains(&"vacation"), "{kinds:?}");
|
||||
assert!(kinds.contains(&"keep"), "{kinds:?}");
|
||||
assert!(output.message_changed);
|
||||
let modified = output
|
||||
.messages
|
||||
.iter()
|
||||
.find(|message| message.raw.contains("X-Sieve-Filter"))
|
||||
.expect("modified message");
|
||||
assert!(modified.raw.contains("Filtered on its way in by a Sieve script."));
|
||||
assert!(modified.raw.contains("q3-report.pdf"));
|
||||
let reply = output
|
||||
.messages
|
||||
.iter()
|
||||
.find(|message| message.subject.starts_with("Away:"))
|
||||
.expect("vacation reply");
|
||||
assert_eq!(reply.subject, "Away: Q3 report and team offsite");
|
||||
assert!(reply.raw.contains("Hi Alice Martin,"));
|
||||
|
||||
let second = Request {
|
||||
seen_ids: output.duplicate_ids.clone(),
|
||||
..request
|
||||
}
|
||||
.run();
|
||||
assert_eq!(
|
||||
second
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.kind)
|
||||
.collect::<Vec<_>>(),
|
||||
["discard"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_errors_have_positions() {
|
||||
let request = Request {
|
||||
scripts: vec![ScriptSource {
|
||||
name: "main".into(),
|
||||
source: "require \"fileinto\";\n\nif true {\n fileinto \"x\" \n}\n".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let output = request.compile();
|
||||
assert_eq!(output.diagnostics.len(), 1);
|
||||
assert_eq!(output.diagnostics[0].line, 5);
|
||||
|
||||
let request = Request {
|
||||
scripts: vec![ScriptSource {
|
||||
name: "main".into(),
|
||||
source: "fileinto \"x\";".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let output = request.compile();
|
||||
assert!(
|
||||
output.diagnostics[0]
|
||||
.message
|
||||
.contains("Undeclared capability")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_only_sees_previous_runs() {
|
||||
let script = "require [\"duplicate\", \"imap4flags\"];\n\
|
||||
if duplicate :uniqueid \"abc\" { addflag \"first-dup\"; } else { addflag \"first-new\"; }\n\
|
||||
if duplicate :uniqueid \"abc\" { addflag \"second-dup\"; } else { addflag \"second-new\"; }\n";
|
||||
let request = Request {
|
||||
scripts: vec![ScriptSource {
|
||||
name: "main".into(),
|
||||
source: script.into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let first = request.run();
|
||||
let flags = |output: &RunOutput| {
|
||||
output
|
||||
.events
|
||||
.iter()
|
||||
.flat_map(|event| event.detail.iter().map(|detail| detail.value.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(flags(&first), ["first-new second-new"]);
|
||||
assert_eq!(first.duplicate_ids, ["abc"]);
|
||||
let second = Request {
|
||||
seen_ids: first.duplicate_ids,
|
||||
..request
|
||||
}
|
||||
.run();
|
||||
assert_eq!(flags(&second), ["first-dup second-dup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_runs_do_not_record_duplicates() {
|
||||
let request = Request {
|
||||
scripts: vec![ScriptSource {
|
||||
name: "main".into(),
|
||||
source: "require [\"duplicate\", \"vnd.inbuxa.while\"];\nif duplicate :uniqueid \"zz\" { }\nwhile \"true\" { }\n".into(),
|
||||
}],
|
||||
settings: Settings {
|
||||
cpu_limit: 100,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let output = request.run();
|
||||
assert!(output.error.is_some());
|
||||
assert!(output.duplicate_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_errors_point_at_the_failing_include() {
|
||||
let request = Request {
|
||||
scripts: vec![
|
||||
ScriptSource {
|
||||
name: "main".into(),
|
||||
source: "require \"include\";\ninclude \"common\";\n".into(),
|
||||
},
|
||||
ScriptSource {
|
||||
name: "common".into(),
|
||||
source: "require \"ihave\";\nkeep;\nfrobnicate;\n".into(),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let error = request.run().error.expect("runtime error");
|
||||
assert_eq!((error.script, error.line), (1, 3), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_errors_keep_the_message() {
|
||||
let request = Request {
|
||||
scripts: vec![ScriptSource {
|
||||
name: "main".into(),
|
||||
source: "require \"vnd.inbuxa.while\";\nwhile \"true\" { }\n".into(),
|
||||
}],
|
||||
settings: Settings {
|
||||
cpu_limit: 100,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let output = request.run();
|
||||
assert!(output.error.is_some());
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == "keep" && event.after_error)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user