The Debug button on a Sieve script sent a copy to an outside site, whose interpreter knows only upstream's extension names: a script that requires vnd.inbuxa.expressions or vnd.inbuxa.while failed to compile there, and the button took admins to another project's site. The playground now ships inside the console and runs the server's own interpreter. sieve-playground/ is built by inbuxa-server's tools/sieve-playground (SOURCE.md names the commit); sieve-playground.ts copies it, with Monaco 0.52.2 from node_modules, into sieve-playground/<content hash>/ (the server caches every file but the console's index.html for good) and gives the console its path. The script stays on the console's origin, so the warning dialog before opening it is gone. Strings (en.json): sievepad.failed reworded, sievepad.debugHint added, the three warning-dialog strings removed. webui.zip grows from 1.3 MB to 3.4 MB: 0.9 MB of compressed WebAssembly, the rest Monaco's editor core.
174 lines
17 KiB
JavaScript
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;
|
|
}
|