Sieve playground: the server's interpreter in the browser #181

Merged
jcoffey-dev merged 1 commits from sieve-playground into main 2026-10-09 08:33:56 +00:00
45 changed files with 7990 additions and 0 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
+687
View File
@@ -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"
+43
View File
@@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
#
# Modified by Coffey Labs in 2026 for INBUXA.
[package]
name = "sieve-playground"
description = "WebAssembly build of the server's Sieve interpreter, for the Sieve playground in inbuxa Admin"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-only"
publish = false
# Its own workspace: the server's never builds it, and it builds for
# wasm32-wasip1 (see build.sh).
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# The server's interpreter exactly: the vendored copy the root Cargo.toml
# patches in, renamed extensions included.
sieve-rs = { path = "../../vendor/sieve-rs" }
mail-parser = { version = "0.11", features = ["full_encoding"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# What the server's function modules (src/functions) use; hyper's Uri is
# http's, under the name the modules import it by.
http = "1"
idna = "1.1"
whatlang = "0.18"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[patch.crates-io]
gethostname = { path = "shim/gethostname" }
+62
View File
@@ -0,0 +1,62 @@
<!--
SPDX-FileCopyrightText: 2026 Coffey Labs LLC
SPDX-License-Identifier: AGPL-3.0-only
-->
# Sieve playground
A browser-only playground for Sieve scripts, shipped inside inbuxa Admin: the
**Debug** button on a Sieve script opens it there with a copy of the script.
It compiles and runs scripts with this repository's own Sieve interpreter,
compiled to WebAssembly, so a script behaves as it does on the server: the
same sieve-rs (`vendor/sieve-rs`, with the `vnd.inbuxa.*` extensions) and the
same expression functions an account script can call. Scripts, messages and
settings stay in the browser.
Ported from the playground in the upstream sieve-rs repository's `web/`
directory (commit `549a2d57f7`), which targets sieve-rs 1.x.
## Layout
- `src/lib.rs`: the module's one export, `playground_call`, JSON in and out:
`version`, `capabilities`, `defaults`, `compile` and `run`.
- `src/run.rs`: compiles the main script and the include tabs and drives the
interpreter's event loop the way the server's delivery does, carrying on
after a runtime error.
- `src/handler.rs`: answers the interpreter's events from the settings and
records the actions.
- `src/functions/`: the server's own function modules
(`crates/common/src/scripts/functions`), included by path; tests fail if the
playground's list stops matching the server's account-script list.
- `src/settings.rs`: compiler and runtime options. The defaults are
permissive; the server's limits for account scripts are lower.
- `site/`: the static page and its ES modules. `worker.js` runs the module off
the main thread and gives it the WASI calls it makes.
- `shim/gethostname`: stands in for a crate with no WASI build.
## Why WASI
sieve-rs 0.7 and mail-builder read the clock with `SystemTime::now()`, which
panics on `wasm32-unknown-unknown`. Built for `wasm32-wasip1`, they ask the
host for the time instead, and `worker.js` answers with the playground's
current-time setting, so `currentdate` and the `Date` of a vacation reply
follow it. The module needs six WASI calls, all in `worker.js`.
0.7 does not count the instructions a run executes, and has no memory limit,
so the playground shows neither.
## Build
```sh
./build.sh # target/site
./build.sh --admin ../inbuxa-admin # inbuxa-admin/sieve-playground
```
It runs the tests, builds the module and stages it with `site/`. It needs the
`wasm32-wasip1` Rust target. inbuxa Admin's build copies the staged directory,
with Monaco from its own `node_modules`, into a directory named after a hash
of its contents, so an Admin release never serves a stale copy.
When `vendor/sieve-rs` or the server's function modules change, rebuild with
`--admin` and commit the result in inbuxa-admin.
+34
View File
@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use std::fs;
fn main() {
// The vendored crate the server builds, so the playground names the
// interpreter version the server runs.
println!("cargo:rerun-if-changed=../../vendor/sieve-rs/Cargo.toml");
let manifest = fs::read_to_string("../../vendor/sieve-rs/Cargo.toml").unwrap_or_default();
let mut version = "unknown".to_string();
let mut in_package = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if in_package
&& line.starts_with("version")
&& let Some(value) = line.split('=').nth(1)
{
version = value.trim().trim_matches('"').to_string();
break;
}
}
println!("cargo:rustc-env=SIEVE_VERSION={version}");
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
#
# Builds the Sieve playground: the server's Sieve interpreter for
# wasm32-wasip1, beside the static site in site/. Monaco is not included:
# inbuxa Admin's build adds it from its own node_modules.
#
# ./build.sh stage into target/site
# ./build.sh --admin <dir> stage into <dir>/sieve-playground, an
# inbuxa-admin checkout, with SOURCE.md naming
# the commit it was built from
set -euo pipefail
cd "$(dirname "$0")"
out="target/site"
admin=""
if [ "${1:-}" = "--admin" ]; then
admin="${2:?--admin needs the inbuxa-admin directory}"
[ -f "$admin/package.json" ] || { echo "$admin is not an inbuxa-admin checkout." >&2; exit 1; }
out="$admin/sieve-playground"
elif [ -n "${1:-}" ]; then
echo "Usage: $0 [--admin <inbuxa-admin directory>]" >&2
exit 1
fi
cargo test --quiet
cargo build --release --target wasm32-wasip1
rm -rf "$out"
mkdir -p "$out"
cp -r site/. "$out/"
cp target/wasm32-wasip1/release/sieve_playground.wasm "$out/"
if [ -n "$admin" ]; then
commit="$(git rev-parse HEAD)"
dirty=""
git diff --quiet HEAD -- . ../../vendor/sieve-rs ../../crates/common/src/scripts || dirty=" (with uncommitted changes)"
version="$(sed -n 's/^version = "\(.*\)"/\1/p' ../../vendor/sieve-rs/Cargo.toml | head -1)"
cat > "$out/SOURCE.md" <<EOF
# Sieve playground
Generated: do not edit. This directory is built by
\`tools/sieve-playground/build.sh --admin\` in inbuxa-server, from commit
\`$commit\`$dirty, with the server's vendored sieve-rs $version. Change the
source there, rebuild, and commit the result here.
The WebAssembly module is that commit's \`tools/sieve-playground\` crate, built
for \`wasm32-wasip1\`; its source, and the site's, are in that directory under
AGPL-3.0-only.
EOF
fi
echo "Built the Sieve playground into $out ($(du -h "$out/sieve_playground.wasm" | cut -f1) of WebAssembly)."
@@ -0,0 +1,13 @@
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
# Stands in for gethostname, which mail-builder (through sieve-rs) builds by
# default and which has no WASI implementation. The playground has no host
# name: generated messages take local_hostname from the settings.
[package]
name = "gethostname"
version = "1.1.0"
edition = "2024"
license = "AGPL-3.0-only"
publish = false
@@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
use std::ffi::OsString;
pub fn gethostname() -> OsString {
OsString::from("localhost")
}
File diff suppressed because it is too large. Load diff
+113
View File
@@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
export const EML_ID = "eml";
class EmlState {
constructor(inHeaders, boundaries) {
this.inHeaders = inHeaders;
this.boundaries = boundaries;
}
clone() {
return new EmlState(this.inHeaders, this.boundaries.slice());
}
equals(other) {
return (
other instanceof EmlState &&
other.inHeaders === this.inHeaders &&
other.boundaries.length === this.boundaries.length &&
other.boundaries.every((b, i) => b === this.boundaries[i])
);
}
}
const HEADER = /^([A-Za-z0-9!#$%&'*+.^_`|~-]+)(\s*:)/;
const BOUNDARY_PARAM = /boundary\s*=\s*"?([^";\s]+)"?/i;
export function registerEml(monaco) {
monaco.languages.register({ id: EML_ID, extensions: [".eml"], aliases: ["Email"] });
monaco.languages.setTokensProvider(EML_ID, {
getInitialState: () => new EmlState(true, []),
tokenize(line, state) {
const next = state.clone();
const tokens = [];
if (next.boundaries.length) {
const trimmed = line.trimEnd();
const hit = next.boundaries.find((b) => trimmed === `--${b}` || trimmed === `--${b}--`);
if (hit) {
tokens.push({ startIndex: 0, scopes: "keyword.boundary" });
if (trimmed.endsWith("--") && trimmed === `--${hit}--`) {
next.boundaries = next.boundaries.filter((b) => b !== hit);
next.inHeaders = false;
} else {
next.inHeaders = true;
}
return { tokens, endState: next };
}
}
if (next.inHeaders) {
if (line.trim() === "") {
next.inHeaders = false;
return { tokens: [{ startIndex: 0, scopes: "" }], endState: next };
}
const boundary = BOUNDARY_PARAM.exec(line);
if (boundary && !next.boundaries.includes(boundary[1])) next.boundaries.push(boundary[1]);
const header = HEADER.exec(line);
if (header) {
tokens.push({ startIndex: 0, scopes: "attribute.name.header" });
tokens.push({ startIndex: header[1].length, scopes: "delimiter" });
tokens.push({ startIndex: header[0].length, scopes: "string.header" });
} else if (/^\s/.test(line)) {
tokens.push({ startIndex: 0, scopes: "string.header" });
} else {
tokens.push({ startIndex: 0, scopes: "invalid" });
}
return { tokens, endState: next };
}
tokens.push({ startIndex: 0, scopes: /^>/.test(line) ? "comment.quote" : "" });
return { tokens, endState: next };
},
});
}
export function defineThemes(monaco) {
const rules = (dark) => [
{ token: "keyword", foreground: dark ? "c792ea" : "7a3eb1", fontStyle: "bold" },
{ token: "type", foreground: dark ? "82aaff" : "1f5fbf" },
{ token: "attribute.name", foreground: dark ? "f78c6c" : "b35300" },
{ token: "variable", foreground: dark ? "ffcb6b" : "a0680a", fontStyle: "bold" },
{ token: "string", foreground: dark ? "c3e88d" : "2f7d32" },
{ token: "string.heredoc", foreground: dark ? "89ddff" : "0a7f94", fontStyle: "bold" },
{ token: "string.escape", foreground: dark ? "89ddff" : "0a7f94" },
{ token: "number", foreground: dark ? "f78c6c" : "b35300" },
{ token: "comment", foreground: dark ? "6a7390" : "7d8595", fontStyle: "italic" },
{ token: "attribute.name.header", foreground: dark ? "82aaff" : "1f5fbf", fontStyle: "bold" },
{ token: "string.header", foreground: dark ? "d7dae0" : "3a3f4b" },
{ token: "keyword.boundary", foreground: dark ? "c792ea" : "7a3eb1" },
{ token: "comment.quote", foreground: dark ? "6a7390" : "7d8595" },
{ token: "invalid", foreground: dark ? "ff7b82" : "c6313a" },
];
monaco.editor.defineTheme("playground-light", {
base: "vs",
inherit: true,
rules: rules(false),
colors: { "editor.background": "#ffffff", "editorGutter.background": "#ffffff" },
});
monaco.editor.defineTheme("playground-dark", {
base: "vs-dark",
inherit: true,
rules: rules(true),
colors: { "editor.background": "#1b1d24", "editorGutter.background": "#1b1d24" },
});
}
+144
View File
@@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
const RUN_TIMEOUT_MS = 8000;
const MAX_RESTARTS = 3;
const RESTART_WINDOW_MS = 60000;
const BASE_BACKOFF_MS = 500;
export class EngineUnavailable extends Error {}
export class Engine {
constructor() {
this.pending = new Map();
this.nextId = 1;
this.listeners = new Set();
this.restarts = [];
this.failed = null;
this.spawn();
}
onStatus(listener) {
this.listeners.add(listener);
}
emit(state, detail) {
for (const listener of this.listeners) listener(state, detail);
}
spawn() {
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker = worker;
this.ready = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.ready.catch(() => {});
worker.onmessage = (event) => {
if (worker !== this.worker) return;
const data = event.data;
if (data.type === "ready") {
this.resolveReady();
} else if (data.type === "init-error") {
this.crash(`Engine failed to load: ${data.error}`, { loadFailure: true });
} else {
this.settle(data);
}
};
worker.onerror = (event) => {
event.preventDefault();
if (worker === this.worker) {
this.crash(`Engine failed to load: ${event.message || "the worker script could not be loaded"}`, { loadFailure: true });
}
};
}
crash(reason, { loadFailure = false } = {}) {
this.worker.terminate();
this.rejectReady(new EngineUnavailable(reason));
for (const { reject, timer } of this.pending.values()) {
clearTimeout(timer);
reject(new EngineUnavailable(reason));
}
this.pending.clear();
let delay = 0;
if (loadFailure) {
const now = Date.now();
this.restarts = this.restarts.filter((time) => now - time < RESTART_WINDOW_MS);
if (this.restarts.length >= MAX_RESTARTS) {
this.failed = reason;
this.emit("failed", reason);
return;
}
this.restarts.push(now);
delay = BASE_BACKOFF_MS * 2 ** (this.restarts.length - 1);
}
this.emit("restarting", reason);
const failedReady = this.ready;
this.ready = new Promise((resolve, reject) => {
setTimeout(() => {
this.spawn();
this.ready.then(resolve, reject);
}, delay);
});
this.ready.catch(() => {});
failedReady.catch(() => {});
}
settle({ id, result, error, fatal }) {
const entry = this.pending.get(id);
if (!entry) return;
this.pending.delete(id);
clearTimeout(entry.timer);
if (error) {
entry.reject(new Error(error));
if (fatal) this.crash(`The engine crashed: ${error}`);
} else {
entry.resolve(result);
}
}
async call(op, payload, timeout) {
if (this.failed) throw new EngineUnavailable(this.failed);
await this.ready;
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = timeout
? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`The script did not finish within ${timeout / 1000} seconds and was stopped.`));
this.crash("timeout");
}, timeout)
: null;
this.pending.set(id, { resolve, reject, timer });
this.worker.postMessage({ id, op, payload });
});
}
version() {
return this.call("version");
}
capabilities() {
return this.call("capabilities");
}
defaults() {
return this.call("defaults");
}
compile(request) {
return this.call("compile", request, RUN_TIMEOUT_MS);
}
run(request) {
return this.call("run", request, RUN_TIMEOUT_MS);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f5bd5"/><path d="M7 9h18l-6.5 7.5v6L13.5 25v-8.5z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 175 B

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