DATA stage sieve scripts are executed alongside MTA Milter instead of after

Issue Description

I am trying to set up an Antivirus filtering pipeline. Each mail we receive are passed to ClamAV through MTA Filters at DATA stage, then a sieve script should alter the message if the latter is infected.

Expected Behavior

I have written this script to test the detection:

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

let "subject" "header.subject";
deleteheader "Subject";

if exists ["X-Quarantine"] {
  addheader "Subject" "INFECTED - ${subject}";
} else {
  addheader "Subject" "CLEAN - ${subject}";
}

And for an infected message, the INFECTED prefix should appear

Actual Behavior

I only receive CLEAN-prefixed messages, because the condition is never met. It seems that the header is not present in the evaluated message.

Even more: the X-Quarantine header is lost when the message is finally received (I can see it when there is no sieve script or if no headers are deleted).

Reproduction Steps

  1. Set up a MTA Filter on DATA stage which adds a X-Quarantine header
  2. Apply the provided sieve script on DATA stage htrough the trusted interpreter
  3. Send a message with the EICAR test file attached
  4. Receive a CLEAN message without any X-Quarantine header
  5. Disable the sieve script
  6. Send the same message
  7. Receive a message with a X-Quarantine header

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

FoundationDB

Blob Storage

S3-compatible

Search Engine

Elasticsearch

Directory Backend

OIDC

Additional Context

More or less related to Quarantine folder

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

This confirms that Milter runs before Sieve (not alongside it), so the behavior you’re seeing is actually the intended pipeline order, though something seems off with header persistence. Let me check further.

Based on the source code (crates/smtp/src/inbound/data.rs), what you’re seeing is a real gap in how Milter and Sieve interact at the DATA stage, not a configuration mistake on your part.

Milter filters do run before the Sieve script, as documented, but the message that gets parsed and handed to the Sieve interpreter (parsed_message / auth_message) is built from the original raw message received over SMTP, before any Milter modifications are applied. Milter’s header/body changes are tracked separately as a set of Modifications and only merged into the final outgoing bytes at the end of the DATA handler. That’s why your exists ["X-Quarantine"] test never matches: the header ClamAV’s Milter added is invisible to Sieve.

It gets worse when your script performs an edit (your deleteheader/addheader pair): that triggers Sieve’s own message replacement, which is built from the pre-Milter message. That replacement then overwrites the Milter-produced modifications rather than being layered on top of them, which is why the X-Quarantine header disappears entirely from the final delivered message once a Sieve edit is applied.

So functionally, right now Milter-added headers are not visible to, nor preserved through, an editing Sieve script at the DATA stage, contrary to what the “Milter runs first, then Sieve” ordering in the docs implies. As a workaround, consider having ClamAV signal infection via an MTA Hook instead of a Milter (MTA Hooks run after Sieve and their modifications are merged after Sieve edits), or have the Sieve script itself call ClamAV directly via vnd.stalwart.execute/a shell check rather than relying on a header set by a separate Milter step.

Reference: 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.

This has been fixed in v0.16.24 (yet to be released).