Files
jcoffey-dev 136fc2540b
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m45s
Sieve playground: the server's interpreter in the browser
tools/sieve-playground builds the vendored sieve-rs 0.7.3 (vnd.inbuxa.*
names included) to WebAssembly, with a static page around it, for inbuxa
Admin's Debug button on a Sieve script: until now that button sent the
script to an outside site whose interpreter knows only upstream's
extension names, so any script that requires vnd.inbuxa.* failed to
compile there.

Ported from the playground in the upstream sieve-rs repository's web/
directory (commit 549a2d57f7), which targets sieve-rs 1.x:

- run.rs and handler.rs drive 0.7's event loop the way the server's
  delivery does, carrying on after a runtime error; llm_prompt compiles
  and answers false, as on a server without a model.
- The expression functions are the server's own modules, included by
  path; tests fail if the list, ApplyString or the few functions copied
  from misc.rs/unicode.rs stop matching crates/common.
- Built for wasm32-wasip1 rather than with wasm-bindgen: sieve-rs and
  mail-builder read the clock, which panics on wasm32-unknown-unknown.
  The page's worker answers the six WASI calls, giving the module the
  playground's current-time setting. shim/gethostname stands in for a
  crate with no WASI build. Nothing in vendor/ or crates/ changes.
- The page carries no upstream names or links; storage keys and the
  samples are renamed to match.

build.sh --admin <dir> stages the result into an inbuxa-admin checkout.
2026-10-09 01:13:53 -07:00

174 lines
17 KiB
JavaScript

/*
* 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;
}