majorwiki/05-troubleshooting/networking/postfix-header-checks-vs-milter-headers.md
MajorLinux 662741e7ad troubleshooting: Postfix header_checks can't act on milter-added headers
Document the majormail spam-routing failure (2026-06-06): a cleanup
header_checks REDIRECT keyed on the milter-added X-Spam-Flag never fired for
real inbound mail (only locally-injected), so spam kept reaching the inbox.
Fix is to route in Sieve at delivery (after the milter), with a redirect +
loop guard. Includes the 'local-injection tests lie' warning.
2026-06-06 10:38:04 -04:00

4.1 KiB

title domain category tags status created updated
Postfix header_checks Can't Act on Milter-Added Headers (Use Sieve) troubleshooting networking
postfix
milter
header_checks
spamassassin
spamass-milter
dovecot
sieve
spam
published 2026-06-06 2026-06-06

Postfix header_checks Can't Act on Milter-Added Headers (Use Sieve)

A Postfix header_checks rule that keys on a header added by a milter (e.g. X-Spam-Flag: YES from spamass-milter/rspamd/opendkim) appears correct, is wired up, and even fires for test mail — yet silently does nothing for real inbound mail. The cause: header_checks run in the cleanup daemon and do not reliably see headers a milter adds, so a rule like:

/^X-Spam-Flag:[[:space:]]+YES/   REDIRECT junk@example.com

never matches genuine inbound spam, even though the delivered message clearly contains X-Spam-Flag: YES.

Hit on majormail (2026-06-06): spam-routing REDIRECT had been dead since it was deployed — spam kept reaching the inbox.

Why

Milter header modifications and header_checks happen at different stages of cleanup, and header_checks evaluate the message as received from the network, before the milter's header additions are folded in. So for an smtpd_milter-tagged message, the flag header is not visible to header_checks at the time they run.

Confusingly, locally-injected test mail can fire the rule (timing/origin differences) — so a quick swaks/smtplib test to localhost:25 "passes" while real inbound mail silently slips through. Don't trust a local-injection test for this; verify against real inbound mail (or with the method below).

How to confirm

# A delivered message that SHOULD have matched — but wasn't acted on:
grep -iE '^(X-Spam-Flag|Delivered-To|Subject):' /var/vmail/<dom>/<user>/.Junk/cur/<msg>
#   X-Spam-Flag: YES   Delivered-To: <user>@…   (i.e. NOT redirected)

# Is the spam scanner an smtpd milter? (then header_checks can't see its headers)
postconf smtpd_milters
# smtpd_milters = … unix:/run/spamass-milter/spamass-milter.sock

# maillog: the header_checks REDIRECT never appears for real inbound spam,
# only (if at all) for locally-submitted mail ("redirect: … from local").
grep -i 'redirect:' /var/log/maillog

Fix — act at delivery time, in Sieve

Dovecot Sieve runs at LMTP delivery, after the milter, so it reliably sees milter-added headers. Do the routing there instead of in header_checks. To keep spam out of the real mailbox entirely (so a push client like Spark never sees it), redirect to a dedicated account rather than fileinto Junk:

require ["envelope"];
if header :contains "X-Spam-Flag" "YES" {
    # Loop guard: a global before-script also runs for junk@'s own delivery.
    if envelope :is "to" "junk@example.com" {
        keep;
        stop;
    }
    redirect "junk@example.com";
    stop;
}

On majormail this is the global before-script roles/majormail/templates/spam-to-junk.sieve.j2 (MajorAnsible 07dab90); redirect cancels the implicit keep so the real mailbox stays clean (INBOX and Junk). Verify deterministically with sieve-test -u <user> -r <recipient> <script> <msg.eml> — it prints the resulting actions.

Key Notes

  • Don't use header_checks for milter-added headers. Options that do see them: Sieve at delivery (simplest), or running the scanner as a re-injecting content_filter (the re-injected message has the flag as a real header). spamass-milter cannot rewrite the envelope recipient itself.
  • redirect re-injects via the MTA — if a global before-script does the redirect, it also runs for the destination mailbox's delivery; guard with an envelope :is "to" check or you get a mail loop.
  • Local-injection tests lie here. A localhost:25 test may fire a header_checks rule that real inbound mail never triggers.