System sieve doesn't take effect

Issue Description

System Sieve script is not taking effect.

Expected Behavior

A sieve script is created to copy the incoming message to a specific user’s mail box, under specific conditions and stop processing further.

Actual Behavior

Script doesn’t take effect and the message is processed as it normally would in the absence of such script.

Reproduction Steps

The script:

require ["fileinto", "envelope", "variables", "copy"];

# Check if none of the legitimacy tests check
if allof (
    not header :contains "Authentication-Results" "spf=pass",
    not header :contains "Authentication-Results" "dkim=pass",
    not header :contains "Authentication-Results" "dmarc=pass",
    header :contains "X-Spam-Status" "Yes"
) {
    # Redirect it to another mailbox for further investigation
    redirect :copy "[email protected]";
    
    # Prevent mail reaching to "[email protected]"
    discard;
    stop;
}

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

Internal

Additional Context

Now I receive a spam message into “[email protected]” which has this headers:

--- Additional Headers ---
Delivered-To:  [email protected]
X-Spam-Status:  Yes
Received:  from [xxx.xxx.xxx.xxx] (unknown [xxx.xxx.xxx.xxx] (BG))
	by mail.example.com (Stalwart SMTP) with ESMTP id 492658B86048A00;
	Mon, 14 Sep 2026 06:39:40 +0000
Authentication-Results:  mail.example.com;
	spf=temperror (mail.example.com: temporary dns error validating postmaster@[xxx.xxx.xxx.xxx]) smtp.helo=[xxx.xxx.xxx.xxx];
	spf=fail (mail.example.com: domain of [email protected] does not designate xxx.xxx.xxx.xxx as permitted sender) [email protected];
	iprev=permerror (dns record not found) policy.iprev=xxx.xxx.xxx.xxx;
	dmarc=none [email protected] policy.dmarc=none
Received-SPF:  fail (mail.example.com: domain of [email protected] does not designate xxx.xxx.xxx.xxx as permitted sender)

As far as I can tell, these headers satisfy the conditions I wrote in the script. But nonetheless, message is not redirected to [email protected] but instead it is delivered to the spam folder of [email protected], which would be the expected way if there were no sieve scripts to intercept.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Found it. The problem is not in your Sieve logic itself but in when the headers your script is testing become available.

In Stalwart’s DATA stage pipeline, the Authentication-Results and X-Spam-Status headers you’re testing with header :contains are computed by the server but only written into an internal buffer that is prepended to the final message body after the trusted DATA-stage Sieve script runs. They are not merged back into the parsed message that the script inspects, so a header :contains "Authentication-Results" ... or header :contains "X-Spam-Status" ... test in a system/trusted script at the DATA stage will not see them, even though they appear in the delivered message afterward.

Instead, the trusted interpreter exposes the same information as environment variables, which are populated before the script runs and are the supported way to make these decisions:

  • env.spf.result
  • env.dkim.result / env.dkim.domains
  • env.dmarc.result / env.dmarc.policy
  • env.arc.result
  • env.spam.score and env.spam.is_spam (only available at the DATA stage when the spam filter is enabled)

Rewrite your condition using these variables instead of header tests, e.g.:

require ["fileinto", "envelope", "variables", "copy"];

if allof (
    not string :is "${env.spf.result}" "pass",
    not string :is "${env.dkim.result}" "pass",
    not string :is "${env.dmarc.result}" "pass",
    eval "env.spam.is_spam"
) {
    redirect :copy "[email protected]";
    discard;
    stop;
}

This will react to the actual verification results as the script runs, rather than to headers that don’t exist yet at that point.

Reference: Variables | Stalwart and DATA stage | Stalwart

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

I copied the script you suggested. But this didn’t change anything.

Here a new message after I updated the sieve script to use variables as suggested:

--- Additional Headers ---
Delivered-To:  [email protected]
X-Spam-Status:  Yes
Received:  from uploadboy.com (127.230.106.34.bc.googleusercontent.com [34.106.230.127] (AS396982 Google LLC, US))
	by mail.example.com (Stalwart SMTP) with ESMTP id 492B946CA679200;
	Mon, 14 Sep 2026 18:51:20 +0000
Authentication-Results:  mail.example.com;
	spf=permerror (mail.example.com: unable to verify SPF record for [email protected]) smtp.helo=uploadboy.com;
	spf=permerror (mail.example.com: unable to verify SPF record for [email protected]) [email protected];
	iprev=pass policy.iprev=34.106.230.127;
	dmarc=none header.from=uploadboy.com policy.dmarc=none
Received-SPF:  permerror (mail.example.com: unable to verify SPF record for [email protected])
	receiver=mail.example.com; client-ip=34.106.230.127; envelope-from="[email protected]"; helo=uploadboy.com;

This mail should have been redirected to the account designated in the script. But it got into the spam folder of the user, as in standard procedure without such script.

The script is fine syntactically; it just cannot match. At the DATA stage your script sees the message exactly as the client sent it. Authentication-Results, Received-SPF and the X-Spam-* headers are built into a separate buffer and only prepended when the message is queued, so a header test against any of them is always false. X-Spam-Status is worse: it is added at delivery time, after both the system and the user script have run, so no Sieve script can ever see it. Our spam filter docs point you at it for exactly this purpose and they are wrong; I will fix that page.

Use the environment variables instead. The spam filter and all the auth checks run before the DATA script, and their results are exposed as env.spam.is_spam (0 or 1), env.spam.score, env.spf.result, env.dkim.result, env.dmarc.result and env.iprev.result:

require ["variables", "vnd.stalwart.expressions", "envelope"];

if eval "env.spam.is_spam == 1 && env.spf.result != 'pass' && env.dkim.result != 'pass' && env.dmarc.result != 'pass'" {
    redirect "[email protected]";
    discard;
}

Drop the require “fileinto”; the trusted interpreter has no fileinto, and if you ever set noCapabilityCheck to false that require will abort the whole script silently.

If it still does nothing, the script record needs isActive set, and MtaStageData.script has to be an expression returning the script’s name; otherwise the script is stored but never invoked. Watch sieve.runtime-error, sieve.script-not-found and sieve.action-discard in live tracing to see which of those you are hitting.

One consequence to be aware of: a discard in a DATA-stage system script drops the message for every recipient of that transaction, not just the one you are targeting. If you want this per-user, it belongs in that user’s own Sieve script.

Script is active but it still doesn’t do anything. I created a mail box only for this purpose; no mail falls into it. All the qualifying messages go to the respective spam folders of the users.

I checked the logs, no errors or warnings there at the time of receiving the message. (Also none before or after as a matter of fact.)

“Active” only means the script gets compiled. Nothing calls it until the script field of the DATA stage (MtaStageData, under Settings › MTA › Session › DATA Stage) evaluates to its name, and that field defaults to false, which runs nothing and logs nothing. It has to be a quoted string, ‘my-script’ with the single quotes, using whatever name you gave the script. After saving the script or that field, run Reload settings under Management › Actions (or restart) and check it comes back without errors; until then the running server keeps the old configuration.

The empty logs don’t prove much, and I should have said so last time. sieve.runtime-error and the sieve.action-* events are all logged at debug level; only sieve.script-not-found is a warning. So seeing nothing at the default level points at that field being unset or not reloaded. Once it’s in place, turn on live tracing and send one message that should match: every message that goes through the script produces a sieve.action-accept or sieve.action-discard event, and a matching one also gives you queue.autogenerated-queued for the redirected copy.

If it still does nothing after that, post the script exactly as it is now and the value of that DATA stage field.