Skip to content

Expressions

Standard Sieve can compare strings and test headers, but it cannot compute values. A script cannot count the links in a message, extract the domain of an address, or look up whether that domain has an MX record. The vnd.stalwart.expressions extension adds these abilities. It brings arithmetic, logical and comparison operations and a library of functions to Sieve, so that a script can derive values from the message and its environment, and branch on conditions the base language cannot express.

The syntax is not new. Expressions use the same syntax as the configuration expressions of Stalwart, with the differences noted under Functions, so an administrator who writes one already knows the other.

Expressions are evaluated by the eval test and by the let and while instructions. A script that uses them declares vnd.stalwart.expressions in its require statement. A script that uses while also declares vnd.stalwart.while.

Expressions support three kinds of operator. Their precedence is given in Operators:

KindOperators
Arithmetic+ (addition), - (subtraction), * (multiplication), / (division)
Logical&& (AND), || (OR), ^ (XOR), ! (NOT)
Comparison== (equal), != (not equal), >, <, >=, <=

Comparisons between different types follow a rule that can surprise. When a comparison mixes a string and a number, the string is converted to a number, and a string that is not a number counts as 0. '10' > 9 is therefore true, and so is 'abc' > -1. Compare numbers with numbers and strings with strings to avoid such results.

The + operator is overloaded, which means that its meaning depends on its operands. When either operand is a string, it performs concatenation. When both are numbers, it performs addition. When either operand is an array, it concatenates arrays or appends the scalar. For example, 'user-' + env.remote_ip builds a key from a literal and a variable, and ['Sender', envelope.from] can be joined into a flat list with ['Sender'] + [envelope.from].

Literals follow familiar conventions. String literals are delimited by single or double quotes. Integer and floating-point literals follow the usual decimal form. Array literals use square brackets: [1, 2, 3], ['[email protected]', '[email protected]'].

Array elements are accessed with square-bracket indexing, starting at zero. The result of a function call that returns an array can be indexed directly, as in the following lines, which split an address into its local part and domain:

let "parts" "split_once(envelope.from, '@')";
let "local" "parts[0]";
let "domain" "parts[1]";

Try this script in Sievepad

The most common mistake with expressions concerns quoting. The value assigned by let, and the argument of the eval test, is a Sieve string whose contents are parsed as an expression. A bare word inside that string is therefore read as a variable or function name. A script such as let "prompt" "You are an assistant"; fails to compile with Invalid variable or function name "You".

Literal text has to carry its own quotes inside the Sieve string, as in the following line:

let "prompt" "'You are an assistant'";

Try this script in Sievepad

Text spanning several lines uses the Sieve multi-line form, which opens with text: and closes with a line containing a single period. The quoted expression string spans the whole block. There is no triple-quote form, so the following layout is the way to write a multi-line literal:

let "prompt" text:
"First line of the prompt.
Second line of the prompt."
.
;

Try this script in Sievepad

Two quoting layers are therefore involved: the Sieve string, and the expression string inside it. The delimiters are chosen as follows:

  • A text: block is taken verbatim, with no backslash processing, so the quote character that delimits the expression string cannot appear inside it. Use "..." when the text contains apostrophes and '...' when it contains double quotes. A backslash does keep the string from ending early, but it remains part of the value: 'the email\'s subject' evaluates to the email\'s subject.
  • A Sieve quoted string translates \n, \r, and \t before the expression is parsed, so a short multi-line value fits on one line: let "prompt" "'First line.\nSecond line.'"; assigns two lines. A quote of the other kind is written directly, and \\ produces a single backslash.
  • A line consisting of a single period closes a text: block. To carry such a line inside the text, double the period, and the interpreter removes the added one.

Most of the power of expressions comes from their functions. These range from string and array manipulation to DNS, SQL and HTTP operations, which only the trusted interpreter may call. The reference documents each function, its signature, and whether it is restricted to the trusted interpreter.

A few parts of the configuration expression language are missing from Sieve. The system() and metric() forms are not available in Sieve expressions, and neither is the matches() function. Regular expressions are tested in Sieve with the :regex match type of the regex extension instead.

The eval test brings expressions into Sieve’s if statements. It evaluates the expression in its argument, and is true when the result is true, a non-zero number, or a non-empty string or array:

if eval "expression_here" {
# Actions performed when the expression evaluates to true
}

Every name in the expression has to resolve to an environment or envelope variable, to a variable assigned earlier in the script, or to a function. An unknown name is a compile-time error, so a misspelt variable is caught before the script ever runs. The following system script, run at the DATA stage, rejects a message whose body consists mostly of links:

require ["variables", "vnd.stalwart.expressions", "reject"];
let "words" "count(tokenize(body.to_text, 'words'))";
let "urls" "count(tokenize(body.to_text, 'url_strict'))";
if eval "words > 0 && (urls * 100) / words > 25" {
reject "550 5.7.1 Your message is mostly links.";
stop;
}

The let instruction evaluates an expression and assigns the result to a variable. The following line stores the domain of the envelope sender, in lower case:

let "from_domain" "to_lowercase(email_part(envelope.from, 'domain'))";

Try this script in Sievepad

Breaking a computation into named steps keeps scripts readable. Intermediate values computed with let can then be tested with if eval. The following system script rejects senders whose domain publishes no MX record:

let "from_domain" "to_lowercase(email_part(envelope.from, 'domain'))";
let "mx_count" "count(dns_query(from_domain, 'mx'))";
if eval "mx_count == 0" {
reject "550 5.7.1 Your domain has no MX records.";
}

Sieve has no loops over arbitrary data, which keeps user scripts safe. System scripts sometimes need one, and the while instruction provides it. It is available only to system scripts, and executes a block repeatedly while its condition is true.

A loop whose condition never becomes false does not hang the server. It runs until the script reaches the interpreter’s maxCpuCycles limit, at which point the script is aborted. The following loop adds ten headers:

let "i" "10";
while "i > 0" {
addheader :last "X-Header-${i}" "Counter is ${i}";
let "i" "i - 1";
}

Try this script in Sievepad

Two instructions change the flow of a loop. The break instruction exits the loop and resumes execution after it:

let "i" "10";
while "true" {
let "i" "i - 1";
if eval "i == 0" {
break;
}
addheader :last "X-Header-${i}" "Counter is ${i}";
}

Try this script in Sievepad

The continue instruction skips the rest of the current iteration and starts the next one:

let "i" "10";
while "i > 0" {
let "i" "i - 1";
if eval "contains([1, 3, 5, 7, 9], i)" {
continue;
}
addheader :last "X-Header-${i}" "Counter is ${i}";
}

Try this script in Sievepad