diff --git a/tools/sieve-playground/.gitignore b/tools/sieve-playground/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/tools/sieve-playground/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tools/sieve-playground/Cargo.lock b/tools/sieve-playground/Cargo.lock new file mode 100644 index 00000000..921f2874 --- /dev/null +++ b/tools/sieve-playground/Cargo.lock @@ -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" diff --git a/tools/sieve-playground/Cargo.toml b/tools/sieve-playground/Cargo.toml new file mode 100644 index 00000000..6e392c04 --- /dev/null +++ b/tools/sieve-playground/Cargo.toml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# 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" } diff --git a/tools/sieve-playground/README.md b/tools/sieve-playground/README.md new file mode 100644 index 00000000..6f2790a3 --- /dev/null +++ b/tools/sieve-playground/README.md @@ -0,0 +1,62 @@ + + +# 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. diff --git a/tools/sieve-playground/build.rs b/tools/sieve-playground/build.rs new file mode 100644 index 00000000..f8afddf0 --- /dev/null +++ b/tools/sieve-playground/build.rs @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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}"); +} diff --git a/tools/sieve-playground/build.sh b/tools/sieve-playground/build.sh new file mode 100755 index 00000000..78c47f79 --- /dev/null +++ b/tools/sieve-playground/build.sh @@ -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 stage into /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 ]" >&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" < OsString { + OsString::from("localhost") +} diff --git a/tools/sieve-playground/site/app.js b/tools/sieve-playground/site/app.js new file mode 100644 index 00000000..2eae2738 --- /dev/null +++ b/tools/sieve-playground/site/app.js @@ -0,0 +1,1018 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +import { Engine } from "./engine.js"; +import { EML_ID, defineThemes, registerEml } from "./eml-language.js"; +import { ResultView, h } from "./result.js"; +import { SettingsDrawer, normalizeSettings } from "./settings.js"; +import { LONG_LINK, clearShareHash, decodeSharedWorkspace, encodeShareUrl, hasSharedWorkspace, portable, validatePortable } from "./share.js"; +import { LANGUAGE_ID, markersFor, registerSieve } from "./sieve-language.js"; +import { dismissWelcome, isWelcomeDismissed, loadPrefs, newId, openStore, savePrefs } from "./store.js"; + +const $ = (selector) => document.querySelector(selector); +const COMPILE_DELAY = 300; +const SAVE_DELAY = 400; +const NARROW = window.matchMedia("(max-width: 900px)"); +const DARK = window.matchMedia("(prefers-color-scheme: dark)"); +const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); + +const BLANK_SCRIPT = `require ["fileinto", "imap4flags"]; + +# Write your Sieve script here. Press Run to test it. +# Everything runs in your browser: nothing is sent to or stored on a server. +if header :contains "Subject" "hello" { + addflag "\\\\Flagged"; + fileinto "INBOX"; +} +`; + +const BLANK_MESSAGE = `From: Sender +To: Jane Doe +Subject: Hello from the Sieve playground +Date: Mon, 14 Sep 2026 10:00:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +Hello! This is a test message. +`; + +const state = { + monaco: null, + engine: null, + store: null, + prefs: loadPrefs(), + defaults: null, + capabilities: [], + samples: [], + workspace: null, + workspaces: [], + compileSeq: 0, + runSeq: 0, + diagnostics: [], + lastOutput: null, + lastRunMessage: "", + stale: true, + contentVersion: 0, +}; + +let scriptEditor; +let messageEditor; +let result; +let settingsDrawer; +let saveTimer; +let compileTimer; + +function toast(message) { + const el = $("#toast"); + el.textContent = message; + el.hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => (el.hidden = true), 2600); +} + +document.addEventListener("toast", (event) => toast(event.detail)); + +window.addEventListener("unhandledrejection", (event) => { + if (event.reason?.name === "Canceled" && event.reason?.message === "Canceled") event.preventDefault(); +}); + +function setStatus(id, text, stateName) { + const el = $(id); + el.textContent = text; + if (stateName) el.dataset.state = stateName; + else delete el.dataset.state; +} + +function theme() { + return DARK.matches ? "playground-dark" : "playground-light"; +} + +function settings() { + return state.workspace?.settings || state.defaults; +} + +function editorOptions(extra) { + return { + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + lineNumbersMinChars: 3, + scrollBeyondLastLine: false, + tabSize: 4, + insertSpaces: true, + fixedOverflowWidgets: true, + stickyScroll: { enabled: false }, + padding: { top: 8 }, + theme: theme(), + ...extra, + }; +} + +function modelFor(item, language) { + if (!item.model || item.model.isDisposed()) { + const model = state.monaco.editor.createModel(item.source, language); + Object.defineProperty(item, "model", { value: model, writable: true, enumerable: false, configurable: true }); + Object.defineProperty(item, "source", { + get: () => (model.isDisposed() ? "" : model.getValue()), + set: (value) => model.setValue(value), + enumerable: true, + configurable: true, + }); + model.onDidChangeContent(() => onContentChanged(language === LANGUAGE_ID)); + } + return item.model; +} + +function disposeModels(workspace) { + if (!workspace) return; + for (const item of [...workspace.scripts, ...workspace.messages]) item.model?.dispose(); +} + +function serializable(workspace) { + return { + id: workspace.id, + name: workspace.name, + created: workspace.created, + updated: workspace.updated, + scripts: workspace.scripts.map(({ name, source }) => ({ name, source })), + messages: workspace.messages.map(({ name, source }) => ({ name, source })), + activeScript: workspace.activeScript, + activeMessage: workspace.activeMessage, + settings: workspace.settings, + }; +} + +function makeWorkspace({ name, scripts, messages, settings: overrides }) { + const now = Date.now(); + return { + id: newId(), + name, + created: now, + updated: now, + scripts: scripts.length ? scripts : [{ name: "main", source: BLANK_SCRIPT }], + messages: messages.length ? messages : [{ name: "message.eml", source: BLANK_MESSAGE }], + activeScript: 0, + activeMessage: 0, + settings: normalizeSettings({ ...state.defaults, ...(overrides || {}) }, state.defaults), + }; +} + +function uniqueName(base) { + const names = new Set(state.workspaces.map((ws) => ws.name)); + if (!names.has(base)) return base; + let n = 2; + while (names.has(`${base} ${n}`)) n++; + return `${base} ${n}`; +} + +async function saveNow() { + clearTimeout(saveTimer); + saveTimer = null; + const ws = state.workspace; + if (!ws) return; + ws.updated = Date.now(); + const data = serializable(ws); + const index = state.workspaces.findIndex((item) => item.id === ws.id); + if (index >= 0) state.workspaces[index] = data; + else state.workspaces.push(data); + try { + await state.store.put(data); + } catch (err) { + toast(`Could not save: ${err.message}`); + } +} + +function saveSoon() { + clearTimeout(saveTimer); + saveTimer = setTimeout(saveNow, SAVE_DELAY); +} + +function onContentChanged(isScript) { + state.contentVersion++; + saveSoon(); + markStale(); + if (isScript) compileSoon(); +} + +function markStale() { + state.stale = true; + $("#run-button").classList.add("stale"); +} + +async function loadSamples() { + try { + const response = await fetch(new URL("./samples/index.json", import.meta.url)); + state.samples = await response.json(); + } catch (_) { + state.samples = []; + } +} + +async function sampleWorkspace(sample) { + const read = async (file) => { + const response = await fetch(new URL(`./samples/${file}`, import.meta.url)); + if (!response.ok) throw new Error(`Could not load ${file}`); + return response.text(); + }; + const scripts = await Promise.all(sample.scripts.map(async (s) => ({ name: s.name, source: await read(s.file) }))); + const messages = await Promise.all(sample.messages.map(async (m) => ({ name: m.name, source: await read(m.file) }))); + return makeWorkspace({ name: uniqueName(sample.title), scripts, messages, settings: sample.settings }); +} + +async function openWorkspace(data, { run = true } = {}) { + if (state.workspace) await saveNow(); + disposeModels(state.workspace); + const ws = { + ...data, + scripts: data.scripts.map((s) => ({ ...s })), + messages: data.messages.map((m) => ({ ...m })), + settings: normalizeSettings({ ...state.defaults, ...(data.settings || {}) }, state.defaults), + }; + ws.activeScript = Math.min(ws.activeScript || 0, ws.scripts.length - 1); + ws.activeMessage = Math.min(ws.activeMessage || 0, ws.messages.length - 1); + state.workspace = ws; + state.lastOutput = null; + state.prefs.lastWorkspace = ws.id; + savePrefs(state.prefs); + + scriptEditor.setModel(modelFor(ws.scripts[ws.activeScript], LANGUAGE_ID)); + messageEditor.setModel(modelFor(ws.messages[ws.activeMessage], EML_ID)); + renderScriptTabs(); + renderMessageTabs(); + renderWorkspaceMenu(); + renderEnvelope(); + result.renderEmpty(); + await saveNow(); + if (run) await runScript(); + else await compile(); +} + +function renderWorkspaceMenu() { + $("#workspace-name").textContent = state.workspace?.name || "Workspace"; + const listEl = $("#workspace-list"); + const sorted = [...state.workspaces].sort((a, b) => b.updated - a.updated); + listEl.replaceChildren( + ...sorted.map((ws) => + h("button", { type: "button", role: "menuitem", class: ws.id === state.workspace?.id ? "current" : "", onclick: async () => { closeMenus(); if (ws.id !== state.workspace.id) await openWorkspace(ws); } }, + ws.name, + h("span", { class: "item-sub" }, `${ws.scripts.length} script${ws.scripts.length === 1 ? "" : "s"} · edited ${relativeTime(ws.updated)}`), + ), + ), + ); +} + +function renderExamplesMenu() { + $("#examples-list").replaceChildren( + ...state.samples.map((sample) => + h("button", { type: "button", role: "menuitem", onclick: async () => { closeMenus(); try { await openWorkspace(await sampleWorkspace(sample)); toast(`Opened "${sample.title}"`); } catch (err) { toast(err.message); } } }, + sample.title, + h("span", { class: "item-sub" }, sample.description), + ), + ), + ); +} + +function relativeTime(ms) { + const minutes = Math.round((Date.now() - ms) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + return new Date(ms).toLocaleDateString(); +} + +function tabButton({ label, active, error, closable, kind, onSelect, onClose, onRename }) { + const button = h("button", { type: "button", role: "tab", class: `tab${active ? " active" : ""}`, "aria-selected": active ? "true" : "false", title: onRename ? "Double-click to rename" : undefined, onclick: onSelect, ondblclick: onRename }, + error ? h("span", { class: "dot", title: "Has errors" }) : null, + label, + kind ? h("span", { class: "tab-kind" }, kind) : null, + ); + if (closable) { + const close = h("span", { class: "tab-close", role: "button", title: "Remove", onclick: (event) => { event.stopPropagation(); onClose(); } }); + close.innerHTML = ''; + button.append(close); + } + return button; +} + +function renderScriptTabs() { + const ws = state.workspace; + const errors = new Set(state.diagnostics.map((d) => d.script)); + $("#script-tabs").replaceChildren( + ...ws.scripts.map((script, index) => + tabButton({ + label: script.name, + kind: index === 0 ? "main" : "include", + active: index === ws.activeScript, + error: errors.has(index), + closable: index > 0, + onSelect: () => selectScript(index), + onRename: index > 0 ? () => renameScript(index) : undefined, + onClose: () => removeScript(index), + }), + ), + ); +} + +function renderMessageTabs() { + const ws = state.workspace; + $("#message-tabs").replaceChildren( + ...ws.messages.map((message, index) => + tabButton({ + label: message.name, + active: index === ws.activeMessage, + closable: ws.messages.length > 1, + onSelect: () => selectMessage(index), + onRename: () => renameMessage(index), + onClose: () => removeMessage(index), + }), + ), + ); +} + +function selectScript(index) { + const ws = state.workspace; + ws.activeScript = index; + scriptEditor.setModel(modelFor(ws.scripts[index], LANGUAGE_ID)); + renderScriptTabs(); + renderProblems(); + saveSoon(); +} + +function selectMessage(index) { + const ws = state.workspace; + ws.activeMessage = index; + state.contentVersion++; + messageEditor.setModel(modelFor(ws.messages[index], EML_ID)); + renderMessageTabs(); + markStale(); + saveSoon(); +} + +async function addScript(name, source) { + const ws = state.workspace; + const chosen = name ?? (await promptText("New include script", "Scripts include it by this name, for example include \"common\".", nextName(ws.scripts.map((s) => s.name), "common"))); + if (!chosen) return; + ws.scripts.push({ name: chosen, source: source ?? 'require ["variables"];\n\n' }); + selectScript(ws.scripts.length - 1); + compileSoon(); +} + +async function addMessage(name, source) { + const ws = state.workspace; + ws.messages.push({ name: name || nextName(ws.messages.map((m) => m.name), "message.eml"), source: source ?? BLANK_MESSAGE }); + selectMessage(ws.messages.length - 1); +} + +function nextName(existing, base) { + if (!existing.includes(base)) return base; + const dot = base.lastIndexOf("."); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ""; + let n = 2; + while (existing.includes(`${stem}-${n}${ext}`)) n++; + return `${stem}-${n}${ext}`; +} + +async function renameScript(index) { + const ws = state.workspace; + const name = await promptText("Rename include script", "Other scripts include it by this name.", ws.scripts[index].name); + if (!name) return; + ws.scripts[index].name = name; + renderScriptTabs(); + saveSoon(); + compileSoon(); +} + +async function renameMessage(index) { + const ws = state.workspace; + const name = await promptText("Rename message", "", ws.messages[index].name); + if (!name) return; + ws.messages[index].name = name; + renderMessageTabs(); + saveSoon(); +} + +function removeScript(index) { + const ws = state.workspace; + if (index === 0) return; + if (ws.scripts[index].source.trim() && !confirm(`Remove the script "${ws.scripts[index].name}"?`)) return; + ws.scripts[index].model?.dispose(); + ws.scripts.splice(index, 1); + const active = ws.activeScript >= index ? ws.activeScript - 1 : ws.activeScript; + selectScript(Math.min(Math.max(active, 0), ws.scripts.length - 1)); + compileSoon(); +} + +function removeMessage(index) { + const ws = state.workspace; + if (ws.messages.length < 2) return; + if (!confirm(`Remove the message "${ws.messages[index].name}"?`)) return; + ws.messages[index].model?.dispose(); + ws.messages.splice(index, 1); + const active = ws.activeMessage >= index ? ws.activeMessage - 1 : ws.activeMessage; + selectMessage(Math.min(Math.max(active, 0), ws.messages.length - 1)); +} + +function renderEnvelope() { + const s = settings(); + $("#envelope-from").value = s.envelopeFrom || ""; + $("#envelope-from").placeholder = "taken from the message"; + $("#envelope-to").value = (s.envelopeTo || []).join(", "); + $("#envelope-to").placeholder = s.userAddress || "the user address"; + $("#envelope-summary").textContent = `${s.envelopeFrom || "sender from message"} → ${(s.envelopeTo || []).join(", ") || s.userAddress}`; +} + +function updateSettings(next) { + state.contentVersion++; + state.workspace.settings = normalizeSettings(next, state.defaults); + renderEnvelope(); + saveSoon(); + markStale(); + compileSoon(); +} + +function request(extra = {}) { + const ws = state.workspace; + return { + scripts: ws.scripts.map(({ name, source }) => ({ name, source })), + message: ws.messages[ws.activeMessage]?.source || "", + settings: ws.settings, + seenIds: [], + now: Math.floor(Date.now() / 1000), + ...extra, + }; +} + +function compileSoon() { + clearTimeout(compileTimer); + compileTimer = setTimeout(compile, COMPILE_DELAY); +} + +async function compile() { + clearTimeout(compileTimer); + const seq = ++state.compileSeq; + const ws = state.workspace; + let output; + try { + output = await state.engine.compile({ scripts: ws.scripts.map(({ name, source }) => ({ name, source })), settings: ws.settings }); + } catch (err) { + if (seq === state.compileSeq) setStatus("#compile-status", `Compiler unavailable: ${err.message}`, "error"); + return; + } + if (seq !== state.compileSeq || ws !== state.workspace) return; + applyDiagnostics(output.diagnostics); +} + +function applyDiagnostics(diagnostics) { + const ws = state.workspace; + state.diagnostics = diagnostics; + ws.scripts.forEach((script, index) => { + const model = modelFor(script, LANGUAGE_ID); + state.monaco.editor.setModelMarkers(model, "sieve", markersFor(state.monaco, model, diagnostics.filter((d) => d.script === index))); + }); + renderScriptTabs(); + renderProblems(); + if (diagnostics.length) { + setStatus("#compile-status", `${diagnostics.length} error${diagnostics.length === 1 ? "" : "s"}`, "error"); + } else { + setStatus("#compile-status", "No problems", "ready"); + } +} + +function renderProblems() { + const ws = state.workspace; + const el = $("#problems"); + const runtime = state.lastOutput?.error && state.lastOutput.error.line > 0 ? [state.lastOutput.error] : []; + const items = [...state.diagnostics, ...runtime]; + el.dataset.state = items.length ? "active" : "idle"; + el.replaceChildren( + ...items.map((d) => { + const scriptName = ws.scripts[d.script]?.name || "main"; + const where = d.line > 0 ? `${scriptName}:${d.line}:${Math.max(d.column, 1)}` : `${scriptName}`; + const capability = /Undeclared capability '([^']+)'/.exec(d.message); + return h("button", { type: "button", class: `problem ${d.severity}`, onclick: () => revealLine(d.script, d.line, d.column) }, + h("span", { class: "sev" }), + h("span", { class: "where" }, where), + h("span", {}, d.message, d.line > 0 ? "" : " (position unknown)"), + capability ? h("span", { class: "fix" }, "Quick fix available") : null, + ); + }), + ); +} + +function revealLine(scriptIndex, line, column) { + if (scriptIndex !== state.workspace.activeScript) selectScript(scriptIndex); + showPanel("script"); + const lineNumber = Math.max(1, line || 1); + scriptEditor.revealLineInCenter(lineNumber); + scriptEditor.setPosition({ lineNumber, column: Math.max(1, column || 1) }); + scriptEditor.focus(); +} + +async function runScript({ redeliver = false } = {}) { + if (!state.workspace) return; + const button = $("#run-button"); + button.disabled = true; + await saveNow(); + const ws = state.workspace; + const version = state.contentVersion; + const seq = ++state.runSeq; + setStatus("#engine-status", "Running…", "busy"); + const seenIds = redeliver && state.lastOutput ? state.lastOutput.duplicateIds : []; + const req = request({ seenIds }); + const started = performance.now(); + let output; + try { + output = await state.engine.run(req); + } catch (err) { + if (seq !== state.runSeq) return; + button.disabled = false; + if (ws !== state.workspace) return; + result.renderFailure(err.message); + setEngineStatus(); + return; + } + if (seq !== state.runSeq) return; + const elapsed = performance.now() - started; + button.disabled = false; + setEngineStatus(); + if (ws !== state.workspace) return; + if (version === state.contentVersion) { + button.classList.remove("stale"); + state.stale = false; + } + applyDiagnostics(output.diagnostics); + + ws.scripts.forEach((script, index) => { + const runtimeMarkers = output.error && output.error.line > 0 && output.error.script === index ? markersFor(state.monaco, modelFor(script, LANGUAGE_ID), [output.error]) : []; + state.monaco.editor.setModelMarkers(modelFor(script, LANGUAGE_ID), "sieve-runtime", runtimeMarkers); + }); + + if (output.diagnostics.length) { + state.lastOutput = null; + result.renderCompileErrors(output.diagnostics.length, () => revealLine(output.diagnostics[0].script, output.diagnostics[0].line, output.diagnostics[0].column)); + setStatus("#run-stats", ""); + return; + } + state.lastOutput = output; + state.lastRunMessage = req.message; + result.render(output); + renderProblems(); + selectResultView("actions"); + setStatus("#run-stats", `${elapsed < 1 ? "<1" : Math.round(elapsed)} ms${redeliver ? " · redelivery" : ""}`); + if (NARROW.matches) showPanel("result"); +} + +function selectResultView(view) { + for (const tab of document.querySelectorAll("#result-tabs .tab")) tab.classList.toggle("active", tab.dataset.view === view); + for (const pane of document.querySelectorAll(".result-view")) pane.classList.toggle("active", pane.dataset.view === view); + if (view === "messages") requestAnimationFrame(() => result.onShown()); + if (NARROW.matches) showPanel("result"); +} + +function showPanel(name) { + for (const button of document.querySelectorAll(".mobile-tabs button")) button.classList.toggle("active", button.dataset.panel === name); + for (const panel of document.querySelectorAll(".panel")) panel.classList.toggle("active", panel.dataset.panel === name); +} + +function closeMenus() { + for (const menu of document.querySelectorAll(".menu")) { + menu.querySelector(".menu-panel").hidden = true; + menu.querySelector(".menu-trigger").setAttribute("aria-expanded", "false"); + } +} + +function setupMenus() { + for (const menu of document.querySelectorAll(".menu")) { + const trigger = menu.querySelector(".menu-trigger"); + const panel = menu.querySelector(".menu-panel"); + trigger.addEventListener("click", (event) => { + event.stopPropagation(); + const open = panel.hidden; + closeMenus(); + if (open) { + if (menu.id === "workspace-menu") renderWorkspaceMenu(); + panel.hidden = false; + trigger.setAttribute("aria-expanded", "true"); + panel.querySelector("button")?.focus(); + } + }); + } + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMenus(); + }); + document.addEventListener( + "pointerdown", + (event) => { + if (!event.target.closest(".menu")) closeMenus(); + }, + true, + ); + document.addEventListener("click", async (event) => { + const target = event.target.closest("[data-action]"); + if (!target) return; + closeMenus(); + const action = ACTIONS[target.dataset.action]; + if (action) await action(); + }); +} + +const ACTIONS = { + "new-workspace": async () => { + const name = await promptText("New workspace", "A workspace holds scripts, test messages and settings.", uniqueName("Untitled")); + if (!name) return; + await openWorkspace(makeWorkspace({ name, scripts: [], messages: [] })); + }, + "duplicate-workspace": async () => { + const ws = state.workspace; + await openWorkspace(makeWorkspace({ name: uniqueName(`${ws.name} copy`), scripts: serializable(ws).scripts, messages: serializable(ws).messages, settings: ws.settings })); + toast("Workspace duplicated"); + }, + "rename-workspace": async () => { + const name = await promptText("Rename workspace", "", state.workspace.name); + if (!name) return; + state.workspace.name = name; + await saveNow(); + renderWorkspaceMenu(); + }, + "delete-workspace": async () => { + const ws = state.workspace; + if (!confirm(`Delete the workspace "${ws.name}"? This cannot be undone.`)) return; + await state.store.delete(ws.id); + state.workspaces = state.workspaces.filter((item) => item.id !== ws.id); + disposeModels(ws); + state.workspace = null; + const next = [...state.workspaces].sort((a, b) => b.updated - a.updated)[0]; + if (next) await openWorkspace(next); + else await openWorkspace(makeWorkspace({ name: uniqueName("Untitled"), scripts: [], messages: [] })); + toast("Workspace deleted"); + }, + "export-workspace": async () => { + $("#share-dialog").close(); + const data = portable(state.workspace, state.defaults); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const link = h("a", { href: URL.createObjectURL(blob), download: `${state.workspace.name.replace(/[^\w.-]+/g, "-")}.sieve-playground.json` }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + }, + "import-workspace": async () => { + $("#file-input").value = ""; + $("#file-input").click(); + }, +}; + +async function importFile(file) { + const text = await file.text(); + const lower = file.name.toLowerCase(); + if (lower.endsWith(".json") || /^\s*\{/.test(text)) { + try { + const data = validatePortable(JSON.parse(text)); + await openWorkspace(makeWorkspace({ ...data, name: uniqueName(data.name) })); + toast(`Imported "${data.name}"`); + } catch (err) { + toast(`Import failed: ${err.message}`); + } + } else if (lower.endsWith(".sieve") || lower.endsWith(".siv") || /^\s*(require|#|if\b)/.test(text)) { + const name = file.name.replace(/\.(sieve|siv|txt)$/i, ""); + const ws = state.workspace; + if (ws.scripts.length === 1 && ws.scripts[0].source === BLANK_SCRIPT) { + ws.scripts[0].model.setValue(text); + } else if (confirm(`Replace the main script with "${file.name}"? Choose Cancel to add it as an include script instead.`)) { + selectScript(0); + ws.scripts[0].model.setValue(text); + } else { + await addScript(name, text); + } + toast(`Loaded ${file.name}`); + } else { + await addMessage(file.name, text); + toast(`Added ${file.name} as a test message`); + } +} + +function setupFiles() { + $("#file-input").addEventListener("change", async (event) => { + for (const file of event.target.files) await importFile(file); + }); + let depth = 0; + window.addEventListener("dragenter", (event) => { + if (!event.dataTransfer?.types.includes("Files")) return; + depth++; + document.body.classList.add("dragging-file"); + }); + window.addEventListener("dragleave", () => { + depth = Math.max(0, depth - 1); + if (depth === 0) document.body.classList.remove("dragging-file"); + }); + window.addEventListener("dragover", (event) => { + if (event.dataTransfer?.types.includes("Files")) event.preventDefault(); + }); + window.addEventListener("drop", async (event) => { + if (!event.dataTransfer?.files.length) return; + event.preventDefault(); + depth = 0; + document.body.classList.remove("dragging-file"); + for (const file of event.dataTransfer.files) await importFile(file); + }); +} + +function setEngineStatus() { + if (state.engine.failed) setStatus("#engine-status", state.engine.failed, "error"); + else setStatus("#engine-status", "Engine ready", "ready"); +} + +function promptText(title, text, value) { + const dialog = $("#prompt-dialog"); + $("#prompt-title").textContent = title; + $("#prompt-text").textContent = text; + $("#prompt-text").hidden = !text; + const input = $("#prompt-input"); + input.value = value || ""; + return new Promise((resolve) => { + dialog.onclose = () => { + const name = input.value.trim(); + resolve(dialog.returnValue === "ok" && name ? name.slice(0, 80) : null); + }; + input.onkeydown = (event) => { + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault(); + dialog.close("ok"); + } + }; + dialog.returnValue = ""; + dialog.showModal(); + input.select(); + }); +} + +function setupShare() { + const dialog = $("#share-dialog"); + const includeMessages = $("#share-messages"); + const refresh = async () => { + try { + const url = await encodeShareUrl(portable(state.workspace, state.defaults, { includeMessages: includeMessages.checked })); + $("#share-url").value = url; + $("#share-privacy").hidden = !includeMessages.checked; + $("#share-size").textContent = url.length > LONG_LINK + ? `This link is ${(url.length / 1024).toFixed(1)} KB long. Some chat apps and email clients cut long links: consider sharing without messages or exporting to a file.` + : `Link length: ${url.length.toLocaleString()} characters.`; + } catch (err) { + $("#share-url").value = ""; + $("#share-size").textContent = `Could not create a link: ${err.message}`; + } + }; + includeMessages.addEventListener("change", refresh); + $("#share-button").addEventListener("click", async () => { + await saveNow(); + await refresh(); + dialog.showModal(); + $("#share-url").select(); + }); + $("#share-copy").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText($("#share-url").value); + $("#share-copy").textContent = "Copied"; + setTimeout(() => ($("#share-copy").textContent = "Copy link"), 1500); + } catch (_) { + $("#share-url").select(); + toast("Press Ctrl+C to copy the selected link"); + } + }); +} + +function setupGutters() { + const workbench = $("#workbench"); + let columns = Array.isArray(state.prefs.columns) && state.prefs.columns.length === 3 ? state.prefs.columns : [1.15, 0.95, 1]; + const apply = () => { + ["--col-a", "--col-b", "--col-c"].forEach((name, i) => workbench.style.setProperty(name, `minmax(220px, ${columns[i]}fr)`)); + }; + apply(); + for (const gutter of document.querySelectorAll(".gutter")) { + const index = Number(gutter.dataset.gutter); + const resize = (deltaFraction) => { + const total = columns[index] + columns[index + 1]; + const left = Math.min(Math.max(columns[index] + deltaFraction, total * 0.15), total * 0.85); + columns = columns.map((value, i) => (i === index ? left : i === index + 1 ? total - left : value)); + apply(); + }; + gutter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + gutter.setPointerCapture(event.pointerId); + gutter.classList.add("dragging"); + const sum = columns.reduce((a, b) => a + b, 0); + const width = workbench.clientWidth - 12; + let last = event.clientX; + const move = (e) => { + resize(((e.clientX - last) / width) * sum); + last = e.clientX; + }; + const up = () => { + gutter.classList.remove("dragging"); + gutter.removeEventListener("pointermove", move); + gutter.removeEventListener("pointerup", up); + state.prefs.columns = columns; + savePrefs(state.prefs); + }; + gutter.addEventListener("pointermove", move); + gutter.addEventListener("pointerup", up); + }); + gutter.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + resize((event.key === "ArrowLeft" ? -0.05 : 0.05) * columns.reduce((a, b) => a + b, 0)); + state.prefs.columns = columns; + savePrefs(state.prefs); + }); + gutter.addEventListener("dblclick", () => { + columns = [1.15, 0.95, 1]; + apply(); + state.prefs.columns = columns; + savePrefs(state.prefs); + }); + } +} + +function setupKeys() { + const runKey = state.monaco.KeyMod.CtrlCmd | state.monaco.KeyCode.Enter; + const saveKey = state.monaco.KeyMod.CtrlCmd | state.monaco.KeyCode.KeyS; + for (const editor of [scriptEditor, messageEditor]) { + editor.addAction({ id: "playground.run", label: "Playground: Run script", keybindings: [runKey], run: () => runScript() }); + editor.addAction({ id: "playground.redeliver", label: "Playground: Run again as a redelivery", run: () => runScript({ redeliver: true }) }); + editor.addAction({ id: "playground.save", label: "Playground: Save workspace", keybindings: [saveKey], run: async () => { await saveNow(); toast("Saved in this browser"); } }); + editor.addAction({ id: "playground.share", label: "Playground: Share workspace", run: () => $("#share-button").click() }); + editor.addAction({ id: "playground.settings", label: "Playground: Open settings", run: () => settingsDrawer.open() }); + } + scriptEditor.addAction({ id: "playground.format", label: "Playground: Format script", run: (editor) => editor.getAction("editor.action.formatDocument")?.run() }); + document.addEventListener("keydown", async (event) => { + const mod = IS_MAC ? event.metaKey : event.ctrlKey; + if (mod && event.key === "Enter") { + event.preventDefault(); + await runScript(); + } else if (mod && event.key.toLowerCase() === "s") { + event.preventDefault(); + await saveNow(); + toast("Saved in this browser"); + } + }); + $("#run-button").title = `Run (${IS_MAC ? "⌘" : "Ctrl"}+Enter)`; +} + +function setupEnvelope() { + $("#envelope-from").addEventListener("change", (event) => updateSettings({ ...settings(), envelopeFrom: event.target.value.trim() })); + $("#envelope-to").addEventListener("change", (event) => + updateSettings({ ...settings(), envelopeTo: event.target.value.split(",").map((s) => s.trim()).filter(Boolean) }), + ); +} + +function setupWelcome() { + const dialog = $("#welcome-dialog"); + $("#privacy-link").addEventListener("click", () => dialog.showModal()); + dialog.querySelector("button.primary").addEventListener("click", dismissWelcome); + dialog.addEventListener("cancel", dismissWelcome); + dialog.addEventListener("close", dismissWelcome); + if (!isWelcomeDismissed()) dialog.showModal(); +} + +async function initialWorkspace() { + if (hasSharedWorkspace()) { + try { + const data = await decodeSharedWorkspace(); + clearShareHash(); + const ws = makeWorkspace({ ...data, name: uniqueName(`${data.name} (shared)`) }); + toast("Opened a shared workspace. Your own workspaces are in the workspace menu."); + return ws; + } catch (err) { + clearShareHash(); + toast(`The shared link is invalid: ${err.message}`); + } + } + const last = state.workspaces.find((ws) => ws.id === state.prefs.lastWorkspace); + if (last) return last; + const recent = [...state.workspaces].sort((a, b) => b.updated - a.updated)[0]; + if (recent) return recent; + const welcome = state.samples.find((sample) => sample.id === "welcome"); + if (welcome) { + try { + return await sampleWorkspace(welcome); + } catch (_) { + return makeWorkspace({ name: "Untitled", scripts: [], messages: [] }); + } + } + return makeWorkspace({ name: "Untitled", scripts: [], messages: [] }); +} + +function clearReloadFlag() { + try { + sessionStorage.removeItem("inbuxa-sieve.reloaded"); + } catch (_) { + return; + } +} + +async function boot() { + clearReloadFlag(); + let monaco; + try { + monaco = await window.monacoReady; + } catch (err) { + setStatus("#engine-status", "Editor failed to load", "error"); + console.error(err); + return; + } + state.monaco = monaco; + defineThemes(monaco); + registerEml(monaco); + registerSieve(monaco, () => settings() || {}); + + scriptEditor = monaco.editor.create($("#script-editor"), editorOptions({ language: LANGUAGE_ID, ariaLabel: "Sieve script", wordBasedSuggestions: "off", "semanticHighlighting.enabled": false })); + messageEditor = monaco.editor.create($("#message-editor"), editorOptions({ language: EML_ID, wordWrap: "on", ariaLabel: "Test message" })); + + state.engine = new Engine(); + state.engine.onStatus((kind, detail) => { + if (kind === "failed") setStatus("#engine-status", detail, "error"); + else if (kind === "restarting" && detail !== "timeout") setStatus("#engine-status", "Restarting engine…", "busy"); + }); + + try { + const [version, defaults, capabilities, store] = await Promise.all([ + state.engine.version(), + state.engine.defaults(), + state.engine.capabilities(), + openStore(), + loadSamples(), + ]); + state.defaults = defaults; + state.capabilities = capabilities; + state.store = store; + $("#engine-version").textContent = `v${version}`; + setStatus("#engine-status", "Engine ready", "ready"); + } catch (err) { + setStatus("#engine-status", `Engine failed: ${err.message}`, "error"); + console.error(err); + return; + } + + state.workspaces = await state.store.list().catch(() => []); + + result = new ResultView({ + monaco, + root: $("#result-body"), + onRevealLine: revealLine, + onRunAgain: () => runScript({ redeliver: true }), + onSelectView: selectResultView, + getOriginal: () => state.lastRunMessage, + getTheme: theme, + }); + + settingsDrawer = new SettingsDrawer({ + drawer: $("#settings-drawer"), + backdrop: $("#settings-backdrop"), + body: $("#settings-body"), + resetButton: $("#settings-reset"), + jsonButton: $("#settings-json"), + defaults: state.defaults, + capabilities: state.capabilities, + get: () => state.workspace.settings, + set: updateSettings, + }); + + $("#run-button").addEventListener("click", () => runScript()); + $("#settings-button").addEventListener("click", () => settingsDrawer.open()); + $("#add-script").addEventListener("click", () => addScript()); + $("#add-message").addEventListener("click", () => addMessage()); + for (const tab of document.querySelectorAll("#result-tabs .tab")) tab.addEventListener("click", () => selectResultView(tab.dataset.view)); + for (const button of document.querySelectorAll(".mobile-tabs button")) button.addEventListener("click", () => showPanel(button.dataset.panel)); + DARK.addEventListener("change", () => monaco.editor.setTheme(theme())); + window.addEventListener("hashchange", async () => { + if (!hasSharedWorkspace()) return; + await openWorkspace(await initialWorkspace()); + }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden" && saveTimer) saveNow(); + }); + window.addEventListener("pagehide", () => { + if (saveTimer) saveNow(); + }); + + setupMenus(); + setupShare(); + setupFiles(); + setupGutters(); + setupKeys(); + setupEnvelope(); + setupWelcome(); + renderExamplesMenu(); + + await openWorkspace(await initialWorkspace()); +} + +boot(); diff --git a/tools/sieve-playground/site/eml-language.js b/tools/sieve-playground/site/eml-language.js new file mode 100644 index 00000000..b43f96ce --- /dev/null +++ b/tools/sieve-playground/site/eml-language.js @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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" }, + }); +} diff --git a/tools/sieve-playground/site/engine.js b/tools/sieve-playground/site/engine.js new file mode 100644 index 00000000..4fd3c2e1 --- /dev/null +++ b/tools/sieve-playground/site/engine.js @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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); + } +} diff --git a/tools/sieve-playground/site/favicon.svg b/tools/sieve-playground/site/favicon.svg new file mode 100644 index 00000000..95fabf87 --- /dev/null +++ b/tools/sieve-playground/site/favicon.svg @@ -0,0 +1 @@ + diff --git a/tools/sieve-playground/site/index.html b/tools/sieve-playground/site/index.html new file mode 100644 index 00000000..dd578f0e --- /dev/null +++ b/tools/sieve-playground/site/index.html @@ -0,0 +1,260 @@ + + + + + + + + Sieve playground · inbuxa Admin + + + + + + + + +
+ + + Sieve playground + + inbuxa Admin + +
+ + + + + + + + + +
+
+ + + +
+
+
+ 1Script +
+ +
+
+
+
+ + + +
+
+ 2Message +
+ +
+
+
Drop an .eml or .sieve file
+
+
+ + Envelope + + +
+ + + + +
+
+
+ + + +
+
+ 3Result +
+ + + +
+
+
+
+
+
+
+
+
+ +
+ Loading engine… + + + + + sieve-rs +
+ + + + + +
+

Sieve playground

+

+ Write and test Sieve email filters against real messages, with the same Sieve interpreter your + inbuxa server runs. +

+
    +
  • + + Everything runs in your browser. Scripts are compiled and executed locally with WebAssembly. +
  • +
  • + + Nothing is stored on a server. Your scripts, messages and settings are saved only in this browser. +
  • +
  • + + Sharing is up to you. A share link carries the workspace inside the link itself; nothing is uploaded. +
  • +
+
+ + +
+
+
+ + +
+

Share workspace

+

+ The workspace is compressed into the link itself. Nothing is uploaded: whoever opens the link gets their own copy. +

+ +

Messages may contain personal information. Check them before sharing.

+ +

+
+ + + +
+
+
+ + +
+

+

+ +
+ + + +
+
+
+ + + + + + + + + + + diff --git a/tools/sieve-playground/site/result.js b/tools/sieve-playground/site/result.js new file mode 100644 index 00000000..a2a10139 --- /dev/null +++ b/tools/sieve-playground/site/result.js @@ -0,0 +1,434 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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: '', + fileinto: '', + discard: '', + reject: '', + redirect: '', + vacation: '', + notification: '', + notify: '', + created: '', + envelope: '', + error: '', +}; + +function icon(kind) { + const span = h("span", { class: "event-icon", "aria-hidden": "true" }); + span.innerHTML = `${ICONS[kind] || ICONS.created}`; + 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 = ''; + 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" })); + } +} diff --git a/tools/sieve-playground/site/samples/expressions.eml b/tools/sieve-playground/site/samples/expressions.eml new file mode 100644 index 00000000..3a92104a --- /dev/null +++ b/tools/sieve-playground/site/samples/expressions.eml @@ -0,0 +1,9 @@ +From: Team Lead +To: jane@example.org, bob@example.net, carol@example.com +Cc: dave@example.org +Subject: MEETING MOVED TO FRIDAY +Date: Sat, 12 Sep 2026 09:00:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +The weekly meeting moves to Friday 10:00. diff --git a/tools/sieve-playground/site/samples/expressions.sieve b/tools/sieve-playground/site/samples/expressions.sieve new file mode 100644 index 00000000..dac8110c --- /dev/null +++ b/tools/sieve-playground/site/samples/expressions.sieve @@ -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"; +} diff --git a/tools/sieve-playground/site/samples/forward.eml b/tools/sieve-playground/site/samples/forward.eml new file mode 100644 index 00000000..7d4b61b2 --- /dev/null +++ b/tools/sieve-playground/site/samples/forward.eml @@ -0,0 +1,14 @@ +From: Billing +To: jane@example.org +Subject: Urgent: invoice 2026-0915 overdue +Importance: high +Date: Thu, 10 Sep 2026 08:00:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +Dear customer, + +Invoice 2026-0915 is now overdue. Please arrange payment at your +earliest convenience. + +Supplier Accounts diff --git a/tools/sieve-playground/site/samples/forward.sieve b/tools/sieve-playground/site/samples/forward.sieve new file mode 100644 index 00000000..32d8c67f --- /dev/null +++ b/tools/sieve-playground/site/samples/forward.sieve @@ -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" "accounting@example.org"; +} + +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:jane.mobile@example.org?subject=Urgent%20mail"; +} + +if header :matches "Subject" "*" { + deleteheader "Subject"; + addheader "Subject" "[Finance] ${1}"; +} + +fileinto :copy :create "Finance"; diff --git a/tools/sieve-playground/site/samples/include-common.sieve b/tools/sieve-playground/site/samples/include-common.sieve new file mode 100644 index 00000000..b40e085e --- /dev/null +++ b/tools/sieve-playground/site/samples/include-common.sieve @@ -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"; +} diff --git a/tools/sieve-playground/site/samples/include-main.sieve b/tools/sieve-playground/site/samples/include-main.sieve new file mode 100644 index 00000000..6cbc7a82 --- /dev/null +++ b/tools/sieve-playground/site/samples/include-main.sieve @@ -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}"; +} diff --git a/tools/sieve-playground/site/samples/include.eml b/tools/sieve-playground/site/samples/include.eml new file mode 100644 index 00000000..1714619e --- /dev/null +++ b/tools/sieve-playground/site/samples/include.eml @@ -0,0 +1,8 @@ +From: Ops Team +To: jane@example.org +Subject: [Apollo] Outage in the staging cluster +Date: Fri, 11 Sep 2026 14:30:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +The staging cluster is down since 14:10 UTC. Investigating. diff --git a/tools/sieve-playground/site/samples/index.json b/tools/sieve-playground/site/samples/index.json new file mode 100644 index 00000000..0e8ec29b --- /dev/null +++ b/tools/sieve-playground/site/samples/index.json @@ -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" }] + } +] diff --git a/tools/sieve-playground/site/samples/lists.eml b/tools/sieve-playground/site/samples/lists.eml new file mode 100644 index 00000000..80995599 --- /dev/null +++ b/tools/sieve-playground/site/samples/lists.eml @@ -0,0 +1,16 @@ +From: Ferris +To: rust-users@lists.rust-lang.example +Subject: [ANN] Release 1.99 is out +Date: Mon, 7 Sep 2026 17:02:11 +0000 +Message-ID: +In-Reply-To: +List-Id: Rust users +List-Unsubscribe: +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 diff --git a/tools/sieve-playground/site/samples/lists.sieve b/tools/sieve-playground/site/samples/lists.sieve new file mode 100644 index 00000000..94def2e0 --- /dev/null +++ b/tools/sieve-playground/site/samples/lists.sieve @@ -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 jane+shopping@example.org goes to "shopping". +if envelope :detail :matches "to" "*" { + if string :is "${1}" "" { + keep; + } else { + set :lower "folder" "${1}"; + fileinto :create "${folder}"; + } +} diff --git a/tools/sieve-playground/site/samples/spam.eml b/tools/sieve-playground/site/samples/spam.eml new file mode 100644 index 00000000..b0a0ef05 --- /dev/null +++ b/tools/sieve-playground/site/samples/spam.eml @@ -0,0 +1,8 @@ +From: "Prize Department" +To: jane@example.org +Subject: You have WON 1,000,000 dollars!!! +Date: Sun, 6 Sep 2026 03:12:45 +0000 +Message-ID: +Content-Type: text/html; charset="utf-8" + +

Congratulations!

Click here to claim your prize.

diff --git a/tools/sieve-playground/site/samples/spam.sieve b/tools/sieve-playground/site/samples/spam.sieve new file mode 100644 index 00000000..1d7fb1c9 --- /dev/null +++ b/tools/sieve-playground/site/samples/spam.sieve @@ -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"; +} diff --git a/tools/sieve-playground/site/samples/vacation.eml b/tools/sieve-playground/site/samples/vacation.eml new file mode 100644 index 00000000..c1abeeea --- /dev/null +++ b/tools/sieve-playground/site/samples/vacation.eml @@ -0,0 +1,10 @@ +From: Bob Smith +To: Jane Doe +Subject: Lunch next week? +Date: Wed, 9 Sep 2026 11:20:00 +0000 +Message-ID: +Content-Type: text/plain; charset="utf-8" + +Hi Jane, are you free for lunch next Tuesday? + +Bob diff --git a/tools/sieve-playground/site/samples/vacation.sieve b/tools/sieve-playground/site/samples/vacation.sieve new file mode 100644 index 00000000..20a604bd --- /dev/null +++ b/tools/sieve-playground/site/samples/vacation.sieve @@ -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 " + :addresses ["jane@example.org", "j.doe@example.org"] + :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 support@example.org. + +Best regards, +Jane +. +; + } + } +} diff --git a/tools/sieve-playground/site/samples/welcome.eml b/tools/sieve-playground/site/samples/welcome.eml new file mode 100644 index 00000000..70e7ff32 --- /dev/null +++ b/tools/sieve-playground/site/samples/welcome.eml @@ -0,0 +1,33 @@ +Return-Path: +Received: from mail.example.com (mail.example.com [192.0.2.25]) + by mx.example.org with ESMTPS id 4F2A1B3C + for ; Tue, 8 Sep 2026 09:41:07 +0000 +From: Alice Martin +To: Jane Doe +Subject: Q3 report and team offsite +Date: Tue, 8 Sep 2026 09:41:02 +0000 +Message-ID: +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-- diff --git a/tools/sieve-playground/site/samples/welcome.sieve b/tools/sieve-playground/site/samples/welcome.sieve new file mode 100644 index 00000000..a7188f2b --- /dev/null +++ b/tools/sieve-playground/site/samples/welcome.sieve @@ -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}"; +} diff --git a/tools/sieve-playground/site/settings.js b/tools/sieve-playground/site/settings.js new file mode 100644 index 00000000..951c954d --- /dev/null +++ b/tools/sieve-playground/site/settings.js @@ -0,0 +1,337 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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)"), + ]; + } +} diff --git a/tools/sieve-playground/site/share.js b/tools/sieve-playground/site/share.js new file mode 100644 index 00000000..b25aecea --- /dev/null +++ b/tools/sieve-playground/site/share.js @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 : {}, + }; +} diff --git a/tools/sieve-playground/site/sieve-docs.js b/tools/sieve-playground/site/sieve-docs.js new file mode 100644 index 00000000..0651bcaf --- /dev/null +++ b/tools/sieve-playground/site/sieve-docs.js @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 ", 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 ", doc: "Runs the block when the test is true. Can be followed by `elsif` and `else`.", rfc: RFC(5228, "3.1") }, + elsif: { sig: "elsif ", 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 ", 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] ', 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 ", doc: "Shares variables between the including and included scripts.", rfc: RFC(6609, "3.4"), capability: "include" }, + foreverypart: { sig: "foreverypart [:name ] ", 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 ]", doc: "Leaves the innermost (or the named) `foreverypart` or `while` loop.", rfc: RFC(5703, "3"), capability: "foreverypart" }, + while: { sig: "while ", 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 ]", 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 ] [:create] [:specialuse ] [:mailboxid ] ", 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 ] [:ret ] [:bytimerelative / :bytimeabsolute ] ", 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 ", doc: "Refuses the message and sends the reason back to the sender.", rfc: RFC(5429, "2.2"), capability: "reject" }, + ereject: { sig: "ereject ", doc: "Refuses the message at the SMTP level when possible.", rfc: RFC(5429, "2.1"), capability: "ereject" }, + vacation: { sig: "vacation [:days / :seconds ] [:subject ] [:from ] [:addresses ] [:mime] [:handle ] ", 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 ] [:importance <1|2|3>] [:options ] [:message ] ", 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] ", doc: "Stores a value in a variable. Use `${name}` to read it back inside strings.", rfc: RFC(5229, "4"), capability: "variables" }, + setflag: { sig: "setflag [] ", doc: "Replaces the flags that will be stored with the message.", rfc: RFC(5232, "3.1"), capability: "imap4flags" }, + addflag: { sig: "addflag [] ", 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 [] ", doc: "Removes flags from the set stored with the message.", rfc: RFC(5232, "3.3"), capability: "imap4flags" }, + addheader: { sig: "addheader [:last] ", doc: "Adds a header field, at the top unless `:last` is given.", rfc: RFC(5293, "4"), capability: "editheader" }, + deleteheader: { sig: 'deleteheader [:index [:last]] [] [] []', doc: "Removes header fields, optionally only those matching the values.", rfc: RFC(5293, "5"), capability: "editheader" }, + replace: { sig: "replace [:mime] [:subject ] [:from ] ", doc: "Replaces the message body, or the current part inside `foreverypart`.", rfc: RFC(5703, "5"), capability: "replace" }, + enclose: { sig: "enclose [:subject ] [:headers ] ", doc: "Wraps the original message as an attachment of a new message.", rfc: RFC(5703, "6"), capability: "enclose" }, + extracttext: { sig: "extracttext [:first ] [] ", 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 ", doc: "Converts MIME parts from one media type to another.", rfc: RFC(6558, "3"), capability: "convert" }, + error: { sig: "error ", doc: "Aborts the script with an error. The implicit keep applies.", rfc: RFC(5463, "5"), capability: "ihave" }, + let: { sig: "let ", 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 [] [] [] [:index [:last]] ", 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 ()", doc: "True when every test in the list is true.", rfc: RFC(5228, "5.2") }, + anyof: { sig: "anyof ()", doc: "True when at least one test in the list is true.", rfc: RFC(5228, "5.3") }, + envelope: { sig: "envelope [] [] [] ", doc: "Compares the SMTP envelope. Set the envelope below the message.", rfc: RFC(5228, "5.4"), capability: "envelope" }, + exists: { sig: "exists ", 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] [] [] [:index [:last]] ", doc: "Compares header field values. With `:mime` it looks at MIME part headers.", rfc: RFC(5228, "5.7") }, + not: { sig: "not ", doc: "Inverts a test.", rfc: RFC(5228, "5.8") }, + size: { sig: "size <:over / :under> ", 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 [] [] [:raw / :content / :text] ", doc: "Compares the message body.", rfc: RFC(5173, "4"), capability: "body" }, + currentdate: { sig: "currentdate [:zone ] [] [] ", 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 / :originalzone] [] []
", doc: "Compares a date taken from a header field.", rfc: RFC(5260, "4"), capability: "date" }, + duplicate: { sig: "duplicate [:handle ] [:header / :uniqueid ] [:seconds ] [: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 [] [] ", 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 [] [] [] ", doc: "Tests the flags set by the script so far.", rfc: RFC(5232, "4"), capability: "imap4flags" }, + ihave: { sig: "ihave ", doc: "True when the listed extensions are available.", rfc: RFC(5463, "4"), capability: "ihave" }, + mailboxexists: { sig: "mailboxexists ", doc: "True when every mailbox exists. Mailboxes are listed in Settings.", rfc: RFC(5490, "3.1"), capability: "mailbox" }, + mailboxidexists: { sig: "mailboxidexists ", doc: "True when every mailbox id exists.", rfc: RFC(9042, "4.2"), capability: "mailboxid" }, + metadata: { sig: "metadata [] [] ", doc: "Compares a mailbox annotation.", rfc: RFC(5490, "3.2"), capability: "mboxmetadata" }, + metadataexists: { sig: "metadataexists ", doc: "True when the mailbox has the annotations.", rfc: RFC(5490, "3.3"), capability: "mboxmetadata" }, + servermetadata: { sig: "servermetadata [] [] ", doc: "Compares a server annotation. Configure annotations in Settings.", rfc: RFC(5490, "3.4"), capability: "servermetadata" }, + servermetadataexists: { sig: "servermetadataexists ", doc: "True when the server has the annotations.", rfc: RFC(5490, "3.5"), capability: "servermetadata" }, + spamtest: { sig: "spamtest [:percent] [] [] ", 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 [] [] ", 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 [] ", doc: "True when a mailbox with the special-use attributes exists.", rfc: RFC(8579, "3"), capability: "special-use" }, + string: { sig: "string [] [] ", doc: "Compares arbitrary strings, usually containing variables.", rfc: RFC(5229, "5"), capability: "variables" }, + valid_notify_method: { sig: "valid_notify_method ", 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 [] [] ", doc: "Queries a capability of a notification method.", rfc: RFC(5435, "6"), capability: "enotify" }, + valid_ext_list: { sig: "valid_ext_list ", doc: "True when the external lists exist. Lists are configured in Settings.", rfc: RFC(6134, "2.6"), capability: "extlists" }, + eval: { sig: "eval ", 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; +} diff --git a/tools/sieve-playground/site/sieve-language.js b/tools/sieve-playground/site/sieve-language.js new file mode 100644 index 00000000..6141bc4a --- /dev/null +++ b/tools/sieve-playground/site/sieve-language.js @@ -0,0 +1,384 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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:user@example.org}\";", + 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", + }; + }); +} diff --git a/tools/sieve-playground/site/store.js b/tools/sieve-playground/site/store.js new file mode 100644 index 00000000..ea53358f --- /dev/null +++ b/tools/sieve-playground/site/store.js @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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)}`; +} diff --git a/tools/sieve-playground/site/style.css b/tools/sieve-playground/site/style.css new file mode 100644 index 00000000..2ffbc31d --- /dev/null +++ b/tools/sieve-playground/site/style.css @@ -0,0 +1,1506 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * Modified by Coffey Labs in 2026 for INBUXA. + */ + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --surface-3: #e6e9ee; + --border: #dde1e7; + --text: #1c1f26; + --text-2: #5a6272; + --text-3: #8a91a0; + --accent: #4f5bd5; + --accent-2: #3f4ac0; + --accent-soft: #e8eafc; + --ok: #1f8a4c; + --ok-soft: #e3f5ea; + --warn: #a86400; + --warn-soft: #fdf1dc; + --err: #c6313a; + --err-soft: #fbe6e7; + --shadow: 0 8px 28px rgba(20, 24, 40, 0.14); + --radius: 8px; + --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --topbar-h: 52px; + --statusbar-h: 26px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161b; + --surface: #1b1d24; + --surface-2: #22252e; + --surface-3: #2b2f3a; + --border: #2f3340; + --text: #e6e8ee; + --text-2: #a4abba; + --text-3: #737b8c; + --accent: #8b94ff; + --accent-2: #a3aaff; + --accent-soft: #2a2d4d; + --ok: #5fd08f; + --ok-soft: #1c3527; + --warn: #f0b458; + --warn-soft: #3a2e1a; + --err: #ff7b82; + --err-soft: #3d2023; + --shadow: 0 10px 32px rgba(0, 0, 0, 0.5); + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +a { + color: var(--accent); +} + +svg { + width: 16px; + height: 16px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +[hidden] { + display: none !important; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { + background: var(--surface-2); +} + +.btn.ghost { + border-color: transparent; + background: transparent; +} + +.btn.ghost:hover { + background: var(--surface-2); +} + +.btn.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; + font-weight: 600; +} + +.btn.primary:hover { + background: var(--accent-2); +} + +.btn.primary svg { + fill: currentColor; +} + +.btn.primary.stale { + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.btn:disabled { + opacity: 0.55; + cursor: default; +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; +} + +.icon-btn:hover { + background: var(--surface-3); + color: var(--text); +} + +.topbar { + display: flex; + align-items: center; + gap: 10px; + height: var(--topbar-h); + padding: 0 12px 0 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex: none; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + width: 22px; + height: 22px; + color: var(--accent); + stroke: none; +} + +.brand-mark path { + fill: currentColor; +} + +.brand-name { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.brand-by { + color: var(--text-3); + font-size: 12px; + text-decoration: none; + margin-top: 3px; +} + +.brand-by:hover { + color: var(--accent); +} + +.toolbar { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + margin-left: 12px; +} + +.toolbar-gap { + flex: 1; +} + +.menu { + position: relative; +} + +.menu-label { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} + +.chevron { + width: 12px; + height: 12px; + color: var(--text-3); +} + +.menu-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 50; + min-width: 230px; + max-height: 70vh; + overflow: auto; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.menu-panel.wide { + width: 340px; +} + +.menu-panel button { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: 5px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.menu-panel button:hover, +.menu-panel button:focus-visible { + background: var(--surface-2); + outline: none; +} + +.menu-panel button.current { + color: var(--accent); + font-weight: 600; +} + +.menu-panel .item-sub { + color: var(--text-3); + font-size: 12px; + font-weight: 400; +} + +.menu-section { + padding: 6px 10px 4px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.menu-sep { + height: 1px; + margin: 6px 4px; + background: var(--border); +} + +.mobile-tabs { + display: none; +} + +.workbench { + display: grid; + grid-template-columns: var(--col-a, 1fr) 6px var(--col-b, 1fr) 6px var(--col-c, 1fr); + flex: 1; + min-height: 0; + padding: 8px; + gap: 0; +} + +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 6px 0 10px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); + flex: none; +} + +.panel-title { + display: inline-flex; + align-items: center; + gap: 7px; + margin-right: 6px; + font-weight: 600; + white-space: nowrap; +} + +.step { + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +.tabs { + display: flex; + align-items: stretch; + gap: 2px; + height: 100%; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + flex: 1; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 6px 0; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; + white-space: nowrap; +} + +.tab:hover { + background: var(--surface-3); + color: var(--text); +} + +.tab.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 0 0 1px var(--border); +} + +.tab .tab-close { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 3px; + color: var(--text-3); + visibility: hidden; +} + +.tab:hover .tab-close, +.tab.active .tab-close { + visibility: visible; +} + +.tab .tab-close:hover { + background: var(--surface-3); + color: var(--text); +} + +.tab .tab-close svg { + width: 10px; + height: 10px; + stroke-width: 2; +} + +.tab .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--err); +} + +.tab .tab-kind { + color: var(--text-3); + font-size: 11px; +} + +.count:empty { + display: none; +} + +.count { + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-align: center; +} + +.gutter { + cursor: col-resize; + position: relative; +} + +.gutter::after { + content: ""; + position: absolute; + inset: 30% 2px; + border-radius: 2px; + background: transparent; + transition: background 0.15s; +} + +.gutter:hover::after, +.gutter:focus-visible::after, +.gutter.dragging::after { + background: var(--accent); + outline: none; +} + +.editor-host { + position: relative; + flex: 1; + min-height: 0; +} + +.drop-hint { + position: absolute; + inset: 12px; + z-index: 5; + display: none; + place-items: center; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent-soft) 85%, transparent); + color: var(--accent); + font-weight: 600; + pointer-events: none; +} + +body.dragging-file .drop-hint { + display: grid; +} + +.problems { + flex: none; + max-height: 30%; + overflow: auto; + border-top: 1px solid var(--border); + font-size: 13px; +} + +.problems[data-state="idle"], +.problems:empty { + display: none; +} + +.problem { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 6px 12px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.problem:hover { + background: var(--surface-2); +} + +.problem .sev { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--err); + transform: translateY(-1px); +} + +.problem.warning .sev { + background: var(--warn); +} + +.problem .where { + flex: none; + color: var(--text-3); + font-family: var(--mono); + font-size: 12px; +} + +.problem .fix { + margin-left: auto; + flex: none; + color: var(--accent); + font-size: 12px; +} + +.envelope { + flex: none; + border-top: 1px solid var(--border); + background: var(--surface-2); + font-size: 13px; +} + +.envelope summary { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px; + cursor: pointer; + list-style: none; + font-weight: 600; +} + +.envelope summary::-webkit-details-marker { + display: none; +} + +.envelope summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid var(--text-3); + border-bottom: 1.5px solid var(--text-3); + transform: rotate(-45deg); + transition: transform 0.15s; +} + +.envelope[open] summary::before { + transform: rotate(45deg); +} + +.envelope-summary { + overflow: hidden; + color: var(--text-3); + font-family: var(--mono); + font-size: 12px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.envelope-grid { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 6px 10px; + padding: 0 12px 10px; +} + +.envelope-grid label { + color: var(--text-2); + font-family: var(--mono); + font-size: 11px; +} + +input[type="text"], +input[type="number"], +input[type="datetime-local"], +select, +textarea { + width: 100%; + min-width: 0; + height: 30px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface); + font-family: var(--mono); + font-size: 12.5px; +} + +textarea { + height: auto; + min-height: 64px; + padding: 6px 8px; + resize: vertical; + line-height: 1.5; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + outline: 2px solid var(--accent-soft); +} + +.result-body { + position: relative; + flex: 1; + min-height: 0; +} + +.result-view { + position: absolute; + inset: 0; + display: none; + overflow: auto; +} + +.result-view.active { + display: block; +} + +.result-view[data-view="messages"].active { + display: flex; + flex-direction: column; +} + +.empty { + display: grid; + place-items: center; + height: 100%; + padding: 24px; + color: var(--text-3); + text-align: center; +} + +.empty strong { + display: block; + margin-bottom: 4px; + color: var(--text-2); + font-size: 15px; +} + +.empty kbd, +.kbd { + padding: 1px 5px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-2); + font-family: var(--mono); + font-size: 11px; +} + +.outcome { + margin: 12px; + padding: 12px 14px; + border-radius: var(--radius); + background: var(--accent-soft); +} + +.outcome-title { + margin-bottom: 6px; + color: var(--text-2); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.outcome-lines { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 14px; +} + +.outcome-lines b { + font-weight: 600; +} + +.outcome-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.outcome-actions .btn { + height: 28px; + font-size: 13px; +} + +.banner { + display: flex; + gap: 10px; + margin: 12px 12px 0; + padding: 10px 12px; + border-radius: var(--radius); + background: var(--err-soft); + color: var(--err); +} + +.banner.warn { + background: var(--warn-soft); + color: var(--warn); +} + +.banner button { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +.timeline { + position: relative; + margin: 0; + padding: 4px 12px 16px 12px; + list-style: none; +} + +.event { + position: relative; + display: grid; + grid-template-columns: 30px 1fr; + gap: 10px; + padding: 8px 0; +} + +.event::before { + content: ""; + position: absolute; + top: 38px; + bottom: -8px; + left: 14.5px; + width: 1px; + background: var(--border); +} + +.event:last-child::before { + display: none; +} + +.event-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-2); + z-index: 1; +} + +.event-card { + min-width: 0; + padding: 7px 10px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.event.final .event-card { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.event.muted .event-card { + border-style: dashed; + opacity: 0.85; +} + +.event-summary { + font-weight: 600; + overflow-wrap: anywhere; +} + +.event-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.chip { + display: inline-flex; + gap: 4px; + max-width: 100%; + padding: 1px 7px; + border-radius: 10px; + background: var(--surface-2); + font-size: 12px; + overflow-wrap: anywhere; +} + +.chip .k { + color: var(--text-3); +} + +.chip.tag { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} + +.chip.warn { + background: var(--warn-soft); + color: var(--warn); +} + +.chip-link { + border: 0; + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; + cursor: pointer; +} + +.chip-link:hover { + text-decoration: underline; +} + +.event[data-kind="keep"] .event-icon, +.event[data-kind="fileinto"] .event-icon { + background: var(--ok-soft); + color: var(--ok); +} + +.event[data-kind="discard"] .event-icon, +.event[data-kind="reject"] .event-icon { + background: var(--err-soft); + color: var(--err); +} + +.event[data-kind="redirect"] .event-icon, +.event[data-kind="vacation"] .event-icon, +.event[data-kind="notification"] .event-icon, +.event[data-kind="notify"] .event-icon { + background: var(--accent-soft); + color: var(--accent); +} + +.event[data-kind="created"] .event-icon, +.event[data-kind="envelope"] .event-icon { + background: var(--warn-soft); + color: var(--warn); +} + +.message-list { + display: flex; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex: none; +} + +.message-pill { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 150px; + max-width: 240px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + text-align: left; + cursor: pointer; +} + +.message-pill.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.message-pill .role { + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.message-pill .subj { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + flex: none; +} + +.segmented { + display: inline-flex; + padding: 2px; + border-radius: 6px; + background: var(--surface-2); +} + +.segmented button { + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-2); + cursor: pointer; +} + +.segmented button.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.segmented button:disabled { + opacity: 0.45; + cursor: default; +} + +.message-frame { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; +} + +.message-frame.code { + overflow: hidden; +} + +.viewer-host { + position: absolute; + inset: 0; +} + +.preview { + padding: 12px 14px; +} + +.headers-table { + width: 100%; + margin-bottom: 12px; + border-collapse: collapse; + font-size: 13px; +} + +.headers-table th { + width: 1%; + padding: 2px 12px 2px 0; + color: var(--text-3); + font-weight: 500; + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.headers-table td { + padding: 2px 0; + overflow-wrap: anywhere; +} + +.headers-table tr.changed th, +.headers-table tr.changed td { + color: var(--ok); +} + +.headers-toggle { + margin-bottom: 10px; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.body-text { + margin: 0; + padding: 12px; + border-radius: 6px; + background: var(--surface-2); + font-family: var(--mono); + font-size: 12.5px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.body-html { + width: 100%; + min-height: 320px; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; +} + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.attachment { + display: inline-flex; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; +} + +.attachment .muted { + color: var(--text-3); +} + +.vars { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.vars th, +.vars td { + padding: 6px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.vars th { + position: sticky; + top: 0; + background: var(--surface-2); + color: var(--text-2); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.vars td { + font-family: var(--mono); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.vars-note { + padding: 10px 12px; + color: var(--text-3); + font-size: 12px; +} + +.statusbar { + display: flex; + align-items: center; + gap: 14px; + height: var(--statusbar-h); + padding: 0 12px; + border-top: 1px solid var(--border); + background: var(--surface); + color: var(--text-2); + font-size: 12px; + flex: none; + white-space: nowrap; + overflow: hidden; +} + +.status-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; +} + +a.status-item:hover { + color: var(--accent); +} + +.status-item[data-state]::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-3); +} + +.status-item[data-state="ready"]::before { + background: var(--ok); +} + +.status-item[data-state="error"]::before { + background: var(--err); +} + +.status-item[data-state="busy"]::before { + background: var(--warn); +} + +.status-link { + padding: 0; + border: 0; + background: transparent; + font-size: inherit; + cursor: pointer; +} + +.status-link:hover { + color: var(--accent); +} + +.status-link svg { + width: 12px; + height: 12px; +} + +.welcome-points { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; + font-size: 13.5px; +} + +.welcome-points li { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.welcome-points svg { + margin-top: 2px; + color: var(--accent); +} + +.status-spacer { + flex: 1; +} + + +.drawer-backdrop { + position: fixed; + inset: 0; + z-index: 80; + background: rgba(10, 12, 20, 0.35); +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 90; + display: flex; + flex-direction: column; + width: min(520px, 100vw); + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.drawer-head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border); +} + +.drawer-head h2 { + margin: 0; + font-size: 17px; +} + +.drawer-sub { + color: var(--text-3); + font-size: 12px; +} + +.drawer-head .icon-btn { + margin-left: auto; +} + +.drawer-body { + flex: 1; + overflow: auto; + padding: 4px 16px 16px; +} + +.drawer-foot { + display: flex; + gap: 6px; + padding: 10px 16px; + border-top: 1px solid var(--border); +} + +.settings-group { + border-bottom: 1px solid var(--border); +} + +.settings-group summary { + padding: 12px 0; + cursor: pointer; + font-weight: 600; +} + +.settings-group summary .item-sub { + margin-left: 6px; + color: var(--text-3); + font-size: 12px; + font-weight: 400; +} + +.settings-fields { + display: grid; + gap: 10px; + padding-bottom: 14px; +} + +.field { + display: grid; + gap: 4px; +} + +.field > span { + color: var(--text-2); + font-size: 12.5px; +} + +.field small { + color: var(--text-3); + font-size: 11.5px; +} + +.field-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; +} + +.caps { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 12px; +} + +.caps .check { + font-family: var(--mono); + font-size: 12px; +} + +.caps-actions { + display: flex; + gap: 6px; +} + +.caps-actions button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.dialog { + width: min(560px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +.dialog::backdrop { + background: rgba(10, 12, 20, 0.4); +} + +.dialog form { + display: grid; + gap: 12px; + padding: 18px 20px; +} + +.dialog h2 { + margin: 0; + font-size: 17px; +} + +.dialog-text, +.dialog-note, +.dialog-warning { + margin: 0; + color: var(--text-2); + font-size: 13px; +} + +.dialog-note { + color: var(--text-3); +} + +.dialog-warning { + color: var(--warn); +} + +.share-row { + display: flex; + gap: 6px; +} + +.share-row input { + height: 32px; +} + +.dialog-foot { + display: flex; + gap: 6px; +} + +.toast { + position: fixed; + bottom: calc(var(--statusbar-h) + 14px); + left: 50%; + z-index: 100; + max-width: calc(100vw - 32px); + padding: 9px 14px; + border-radius: var(--radius); + background: var(--text); + color: var(--bg); + box-shadow: var(--shadow); + transform: translateX(-50%); + font-size: 13px; +} + +@media (max-width: 900px) { + .hide-narrow, + .brand-by { + display: none; + } + + .topbar { + padding: 0 8px 0 12px; + } + + .toolbar { + margin-left: 4px; + } + + .menu-label { + max-width: 110px; + } + + .mobile-tabs { + display: flex; + gap: 4px; + padding: 6px 8px 0; + flex: none; + } + + .mobile-tabs button { + flex: 1; + height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text-2); + cursor: pointer; + } + + .mobile-tabs button.active { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; + } + + .workbench { + display: flex; + padding: 6px 8px 8px; + } + + .gutter { + display: none; + } + + .panel { + display: none; + flex: 1; + } + + .panel.active { + display: flex; + } + + .engine-link { + display: none; + } + + .caps, + .field-row { + grid-template-columns: minmax(0, 1fr); + } + + .menu-panel.wide { + width: min(340px, calc(100vw - 24px)); + } +} + +@media (max-width: 520px) { + #compile-status, + #run-stats { + display: none; + } + + .brand-name, + .chevron { + display: none; + } + + .topbar { + gap: 4px; + } + + .toolbar { + gap: 0; + margin-left: 0; + } + + .menu-trigger, + .toolbar .btn.ghost { + padding: 0 7px; + } + + .menu-label { + max-width: 84px; + } + + #run-button { + padding: 0 10px; + } +} diff --git a/tools/sieve-playground/site/worker.js b/tools/sieve-playground/site/worker.js new file mode 100644 index 00000000..b4ced538 --- /dev/null +++ b/tools/sieve-playground/site/worker.js @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 }); + } +}; diff --git a/tools/sieve-playground/src/functions/copied.rs b/tools/sieve-playground/src/functions/copied.rs new file mode 100644 index 00000000..deebeb58 --- /dev/null +++ b/tools/sieve-playground/src/functions/copied.rs @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 { + 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 { + matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() +} + +pub fn fn_is_ip_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string().parse::().is_ok().into() +} + +pub fn fn_is_ipv4_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V4(_))) + .into() +} + +pub fn fn_is_ipv6_addr<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + v[0].to_string() + .parse::() + .is_ok_and(|ip| matches!(ip, IpAddr::V6(_))) + .into() +} + +pub fn fn_is_ascii<'x>(_: &'x Context<'x>, v: Vec) -> 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() +} diff --git a/tools/sieve-playground/src/functions/mod.rs b/tools/sieve-playground/src/functions/mod.rs new file mode 100644 index 00000000..904fac9e --- /dev/null +++ b/tools/sieve-playground/src/functions/mod.rs @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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::>() + .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:?}"); + } +} diff --git a/tools/sieve-playground/src/handler.rs b/tools/sieve-playground/src/handler.rs new file mode 100644 index 00000000..d75eb96c --- /dev/null +++ b/tools/sieve-playground/src/handler.rs @@ -0,0 +1,390 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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, + pub events: Vec, + pub messages: Vec, + 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, + 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::(), value.trim().parse::()) { + (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, + 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::>() + .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 { + if flags.is_empty() { + Vec::new() + } else { + vec![Detail::new("flags", flags.join(" "))] + } +} + +fn push_by_mode(detail: &mut Vec, 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")); + } +} diff --git a/tools/sieve-playground/src/lib.rs b/tools/sieve-playground/src/lib.rs new file mode 100644 index 00000000..ac6d4376 --- /dev/null +++ b/tools/sieve-playground/src/lib.rs @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 { + 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 { + fn to_value(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(|err| err.to_string()) + } + fn request(payload: &[u8]) -> Result { + 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::() as usize as u64; + (ptr << 32) | len + } +} diff --git a/tools/sieve-playground/src/output.rs b/tools/sieve-playground/src/output.rs new file mode 100644 index 00000000..a74fcb96 --- /dev/null +++ b/tools/sieve-playground/src/output.rs @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunOutput { + pub diagnostics: Vec, + pub error: Option, + pub events: Vec, + pub messages: Vec, + pub message_changed: bool, + pub global_variables: Vec, + pub duplicate_ids: Vec, +} + +#[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, + 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, + pub text: Option, + pub html: Option, + pub attachments: Vec, +} + +#[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) -> 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::>() + .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::>() + .join(", ") +} diff --git a/tools/sieve-playground/src/run.rs b/tools/sieve-playground/src/run.rs new file mode 100644 index 00000000..7c259979 --- /dev/null +++ b/tools/sieve-playground/src/run.rs @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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, + pub message: String, + pub settings: Settings, + pub seen_ids: Vec, + 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, Vec) { + 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)> = 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 = 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) +} diff --git a/tools/sieve-playground/src/settings.rs b/tools/sieve-playground/src/settings.rs new file mode 100644 index 00000000..4ce78efa --- /dev/null +++ b/tools/sieve-playground/src/settings.rs @@ -0,0 +1,302 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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, + pub no_capability_check: bool, + + pub envelope_from: String, + pub envelope_to: Vec, + pub envelope_id: String, + pub user_address: String, + pub user_full_name: String, + pub local_hostname: String, + pub current_time: Option, + + pub mailboxes: Vec, + pub lists: Vec, + pub environment: Vec, + pub global_variables: Vec, + pub metadata: Vec, + pub protected_headers: Vec, + pub valid_notification_uris: Vec, + + 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, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ListSetting { + pub name: String, + pub values: Vec, +} + +#[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 { + 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: "jane@example.org".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!["boss@example.org".into(), "friend@example.net".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); + } +} diff --git a/tools/sieve-playground/src/tests.rs b/tools/sieve-playground/src/tests.rs new file mode 100644 index 00000000..237006b7 --- /dev/null +++ b/tools/sieve-playground/src/tests.rs @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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, + messages: Vec, + #[serde(default)] + settings: serde_json::Map, +} + +#[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::>() + .join(" "); + lines.push(format!( + " [{}#{}] {} {}", + event.kind, event.message_id, event.summary, detail + )); + } + lines.join("\n") +} + +fn samples() -> Vec { + 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 ", + ), + ("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::>(), + ["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::>() + }; + 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) + ); +} +