Personal JMAP Sieve script never invoked during delivery

Your question

Personal JMAP Sieve script never invoked during delivery, even after correctly wiring MtaStageData → SieveSystemScript → include :personal

Version: Stalwart v0.16.19 (Docker)

Summary

Per-account Sieve scripts uploaded and activated via SieveScript/set (JMAP for Sieve, RFC 9661) never execute during real mail delivery — not for external unauthenticated SMTP, not for authenticated local submission. I followed the documented bridge mechanism (MtaStageData.script → a SieveSystemScript containing include :personal) exactly, verified every step via read-back, restarted the server, enabled full TRACE logging, and still see zero Sieve-related log events of any kind during delivery.

What I configured

  1. Uploaded a personal script for a test account via SieveScript/set (blob upload + create + onSuccessActivateScript). Confirmed active via SieveScript/get:

    {"id":"e","name":"test-personal","isActive":true}
    

    Contents (deliberately simple/unambiguous for testing):

    require ["fileinto", "special-use"];
    fileinto :specialuse "\\Trash" "Trash";
    
  2. Created a server-level trusted script via x:SieveSystemScript/set:

    {"name":"personal-include","isActive":true,
     "contents":"require [\"include\"];\ninclude :personal :optional \"test-personal\";"}
    
  3. Pointed the DATA stage at it via x:MtaStageData/set:

    {"update":{"singleton":{"script":{"else":"'personal-include'"}}}}
    

    (Note: the else value must be a Sieve string-literal — single-quoted inside the JSON string — not a bare identifier. An unquoted script name fails with "Invalid variable or constant". This isn’t obvious from the docs and might be worth calling out explicitly on the MtaStageData reference page.)

  4. Restarted the server so the config takes effect (confirmed via x:MtaStageData/get and x:SieveSystemScript/get that everything persisted correctly both before and after restart).

Test

Sent a real message via unauthenticated external SMTP (MAIL FROM/RCPT TO/DATA on port 25, mimicking a genuine external sender) to the test account. Also tested with an authenticated local submission between two accounts on the same server, to rule out anything specific to the unauthenticated/external path.

Expected: message lands in Trash (the unconditional action in the test script).

Actual: message is delivered normally (message-ingest.ham or message-ingest.spam, depending on Stalwart’s own classifier’s independent decision) into Inbox or Junk — the personal script’s fileinto never applies.

Verified with full tracing

Set the Stdout tracer to level: trace via x:Tracer/set, restarted (again required for the level change to take live effect), sent the test message again. Captured tens of thousands of log lines in the surrounding window — grepped for any event key matching (*sieve*) (Stalwart’s own dotted event-naming convention, e.g. smtp.mail-from, delivery.dsn-success): zero matches. No Sieve-related event of any kind fires — not an error, not a skip notice, nothing.

Things I’ve ruled out

  • Config not persisting — confirmed correct via /get at every step.
  • Config not reloaded — confirmed via two full container restarts.
  • include :personal "" (empty string) as a “use whatever’s active” wildcard — per RFC 6609 this looks up a script literally named "", so I switched to referencing the exact script name directly; no change in behavior.
  • Log level filtering the events out — confirmed at full trace level with no output filters (events: {}, eventsPolicy: exclude, i.e. nothing excluded).
  • Stalwart’s own built-in spam classifier interfering — reproduced with a message that classifier accepts as ham (authenticated local submission) and one it flags as spam (SPF/DMARC-failing external test); identical result either way — the personal script’s action never applies in either case.

Question

Is there an additional requirement beyond MtaStageData.script + include :personal to get a personal/JMAP-managed Sieve script to actually run at delivery time? Or is this a genuine regression/gap in 0.16.19’s DATA-stage → personal-script bridge?

Happy to provide full JMAP request/response logs or a minimal Docker Compose repro if useful.

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

Let’s look at the scripts.rs test file for how include :personal is tested, to see requirements.

Personal JMAP Sieve scripts do not need (and cannot) be reached through MtaStageData + a SieveSystemScript include :personal. That bridge is documented (and coded) only for trusted-script-to-trusted-script includes: the trusted interpreter that runs MtaStageData/MtaStageMail/etc. scripts resolves every include target by looking it up among the account‑independent SieveSystemScript records only. It has no code path that resolves include :personal, so when your SieveSystemScript runs include :personal :optional "test-personal" at the DATA stage, the trusted runtime simply fails to find a trusted script by that name, treats it as an optional miss, and silently continues — which is exactly the “zero Sieve events” behavior you observed. This isn’t a bug so much as a mismatch of contexts: include :personal is only meaningful when it’s evaluated from within the personal/JMAP Sieve execution context itself, not from a script invoked at the SMTP DATA stage.

Personal scripts stored via SieveScript/set and activated (isActive: true) are invoked automatically and directly at final local delivery — Stalwart checks for an account’s active Sieve script and runs it as part of normal message ingestion, with no MtaStageData wiring required at all. So the fix is to drop the MtaStageData/SieveSystemScript bridge entirely; simply having your test-personal script active on the recipient account should be sufficient for it to run at delivery.

If it still doesn’t fire once you remove the bridge, that would point to something else (e.g. the account lookup for the recipient address, or another script/action bypassing normal delivery) worth investigating separately with tracing focused on message-ingest events rather than sieve events, since email_ingest/sieve_script_ingest don’t always emit a distinct sieve.* event name.

Reference: Trusted Interpreter | Stalwart (see “Scripts” section: “Trusted scripts can also import each other using the include command”).

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

Fixed in v0.16.21 (yet to be released).