Skip to content

Trusted interpreter

Some mail policies cannot be expressed with the settings of the SMTP server: rejecting a sender based on a custom lookup, adding a header that downstream systems rely on, or rewriting part of a message. System scripts fill that gap. Administrators install them to act on messages while the SMTP server receives them, and the trusted interpreter runs them.

Because only administrators write system scripts, the trusted interpreter gives them wide powers. System scripts can call every function of the function library, including DNS, SQL, HTTP and AI model calls. They cannot file messages into mailboxes or send automatic replies, which remain the job of user scripts. Two interpreters compares the two interpreters.

Each system script is a SieveSystemScript, found in the WebUI under Settings › Sieve › System Scripts. The object has a name, an optional description, the script text in contents, and an isActive flag.

Only active scripts are loaded. isActive is false by default, so a new script does nothing until it is activated, which allows a script to be prepared before it takes effect. Script names are case-insensitive and must be unique among the active scripts.

An active script still runs only when an SMTP stage selects it. The stage objects are MtaStageConnect, MtaStageEhlo, MtaStageMail, MtaStageRcpt and MtaStageData. Each has a script field, an expression that returns the name of the script to run for the current session, or false to run none.

Because the field is an expression, a stage can run different scripts depending on the listener, the client or the sender. Scripts can also load other active system scripts with the include command, so common logic can be kept in one place.

The following object defines a script that rejects clients whose EHLO domain is listed in the blocked-domains lookup list:

{
"name": "block-helo",
"description": "Reject blocklisted HELO domains",
"isActive": true,
"contents": "require [\"variables\", \"extlists\", \"reject\"];\n\nif string :list \"${env.helo_domain}\" \"blocked-domains\" {\n reject \"550 5.7.1 Your domain '${env.helo_domain}' has been blocklisted.\";\n}\n"
}

The EHLO stage then selects it with the following script value on MtaStageEhlo:

{
"script": {"else": "'block-helo'"}
}

Each script is compiled when it is saved. A script that does not compile is refused with an invalidProperties error, Failed to compile system Sieve script: <error>.

A script that compiled once can still stop compiling later, for example after a change to the interpreter settings. A stored script that no longer compiles when the settings are loaded is reported as a configuration error with the same text, and is not loaded.

Scripts cannot be loaded from files, so contents always holds the full text of the script.

A system script runs in the middle of an SMTP transaction, so its outcome is an SMTP reply. It decides what happens to the command or message being processed:

  • reject "<reply>" refuses the current command, or the message at the DATA stage. The text is sent as the SMTP reply when it starts with a three-digit code followed by a space, for example reject "550 5.7.1 Not accepted.";. Any other text is sent as 503 5.5.3 <text>, a “bad sequence of commands” reply that does not describe a policy rejection, so always start the text with a reply code. A reject at the connect stage closes the connection after the reply, while at the other stages the session continues.
  • discard at the DATA stage accepts the message with 250 2.0.0 Message queued for delivery. and drops it. At the other stages it has no effect.
  • Ending the script without one of these actions accepts the command. At the DATA stage, changes made by addheader, deleteheader, replace and the other editing extensions are applied to the queued message.
  • redirect and the notification and reply actions send new messages through the queue, up to maxOutMessages per run.

A runtime error, such as exceeding a limit, ends the script. A reject issued before the error still applies. Without one, the command is accepted, so a failing script does not block mail.

The trusted interpreter is configured on the SieveSystemInterpreter singleton, found in the WebUI under Settings › Sieve › System Interpreter.

System scripts can send messages of their own, for example with redirect or notify. These messages need a sender and, to be trusted by receivers, a DKIM signature. The following settings decide how they are addressed and signed. Each is an expression, and the first three are evaluated with the RCPT TO variables:

FieldDefaultUse
defaultFromName'Automated Message'Display name in the From header of generated messages.
defaultFromAddress'MAILER-DAEMON@' + system('domain')Address in the From header of generated messages.
defaultReturnPathNot setEnvelope sender of generated messages. While it is unset, they are sent with an empty envelope sender (<>), so no bounce is generated for them.
dkimSignDomainsystem('domain')Domain whose DKIM keys sign generated messages; every active key of that domain is used.

Two further settings complete the picture. messageIdHostname overrides the host name used in the Message-ID header of generated messages, which is the server host name when the field is not set. noCapabilityCheck allows system scripts to use extensions without declaring them in a require statement. It is enabled by default.

System scripts are trusted, but they can still contain mistakes, and a mistake in a script that runs for every message affects the whole server. The limits protect the server from scripts that loop or produce too much output:

FieldDefaultLimit
maxCpuCycles1048576Instructions a script may execute in one run.
maxVarSize52428800Size of a variable, in bytes.
maxNestedIncludes5Depth of nested include commands.
maxRedirects3redirect actions per run.
maxOutMessages5Messages a script may send per run.
maxReceivedHeaders50Received headers a message may carry before a script treats it as looping and sends nothing for it.

The following object body lowers the instruction limit and the number of messages a script may send:

{
"maxCpuCycles": 100000,
"maxOutMessages": 2
}

The trusted interpreter has no expiry setting for the duplicate extension, because the extension is not available to system scripts. The untrusted interpreter has one.

A system script runs unseen, inside SMTP sessions, so a script that seems to have no effect gives no direct sign of why. The cause is usually one of the following situations, each of which is recorded in the server log:

  • The script is not active. Inactive scripts are not loaded, and a stage that names one logs sieve.script-not-found.
  • The name returned by the stage’s script expression does not match the script’s name, or the expression is not set on the stage where the script is expected to run. The stage logs sieve.script-not-found with the name it looked for.
  • The script was created or changed after the settings were last reloaded. System scripts are loaded with the server settings, so a new or edited script takes effect only after a ReloadSettings action, as described in Applying changes.
  • The script no longer compiles with the current interpreter settings, and the configuration error Failed to compile system Sieve script was reported when the settings were loaded.
  • The script uses an extension the trusted interpreter does not provide, such as fileinto or vacation. The script fails at its require statement or, without one, at the action, which is refused with the event sieve.not-supported.
  • The script ran but failed, for example by exceeding maxCpuCycles or calling a lookup list that does not exist. The events sieve.runtime-error and sieve.list-not-found record the cause, and sieve.runtime-error is logged at the debug level.

When the script does run, its decisions are logged too. The actions a script takes are logged as sieve.action-accept, sieve.action-reject and sieve.action-discard at the debug level.