49. pepsi-stage-milter¶
Run a message past a sendmail/Postfix milter mail filter.
49.1. Role¶
pepsi-stage-milter speaks the milter protocol — the mail-filter interface
sendmail introduced and Postfix adopted — so the filters an operator already runs
work here unchanged: opendkim, rspamd, clamav-milter,
spamass-milter, milter-greylist. For each message the stage connects to
the filter’s socket, replays the message as an SMTP session, applies whatever
modifications the filter asks for, and routes the message by its verdict.
Reference: pepsi-stage-milter(1).
The stage is a client and nothing else. The filter is an existing long-lived daemon with its own package, its own systemd unit and its own user account, exactly as under sendmail and Postfix — Pepsi neither starts it nor confines it, because confining a daemon is that daemon’s unit’s job and on Debian it is already done there. The stage itself therefore carries no privilege: no setuid bit, no setgid bit, no helper.
49.2. Post-queue, and what that costs¶
Warning
A milter is designed as a pre-queue filter, and Pepsi runs it post-queue. This is the one place where Pepsi’s milter support is not equivalent to an MTA’s.
In an MTA the callbacks run inside the SMTP session, before the server has
accepted the message — which is what lets a milter answer 550 to a forged
sender and generate no bounce at all. Pepsi’s pipeline runs entirely after the
message is queued: by the time any stage sees a row, pepsi-ingress has
already said 250. A REJECT here therefore costs a bounce to whatever
address the envelope named, which for forged spam is an innocent third party.
A greylisting or DNSBL milter carried over from Postfix still filters correctly,
but at the price of backscatter it used to avoid. Point REJECT_STAGE at a
pepsi-stage-discard rather than a pepsi-stage-bounce if that
matters. Filters that only annotate — a signer, or a scorer that adds a header
for a later pepsi-stage-if to branch on — are unaffected.
49.3. Features¶
Both socket families:
SOCKETtakes sendmail’s ownS=grammar, which Postfix also accepts —unix:/path,inet:host:port,inet:port@host,inet6:[addr]:port— so a value lifted straight out of either configuration works unchanged.The session is reconstructed, not invented: the connect, helo and mail phases come from what
pepsi-ingressrecorded understate.origin— the peer address and its PTR name, theHELOname, the TLS version and cipher, the listener, theBODY=/SMTPUTF8parameters, and how the session authenticated (the RFC 4954{auth_type}/{auth_authen}/{auth_author}/{auth_ssf}macros). A locally injected message (a bounce, an auto-reply, apepsi-sendmailsubmission) is presented as a loopback session, which is what sendmail does for local submission.An action allow-list:
ALLOW_ACTIONSdecides which modifications the filter may make. The default (addhdrs chghdrs chgbody) covers every content filter; rewriting the envelope — adding or deleting recipients, changing the sender — has to be granted explicitly. With no sandbox around the filter this is the one blast-radius lever available.An unmet requirement is reported, not ignored: a filter that needs an action which was not offered is named in the log and the message takes the
ON_FAILUREpath. Postfix drops such a request silently, which is how an operator discovers six months later that a signing milter never signed anything.Postfix’s failure semantics:
ON_FAILUREmirrorsmilter_default_actionand defaults the same way (tempfail), as do the three timeouts (30 s / 30 s / 300 s), so a filter that is down delays mail rather than losing it or letting it through unfiltered.Per-recipient rejection fans out: a recipient the filter turns down during the
RCPTphase is peeled onto its own sibling row atREJECT_STAGEand the rest of the message carries on, withstate.dsn.rcptsliced in step withrcpt_toon every resulting row.Header injection is not possible: a header value containing a newline is re-folded rather than allowed to become a second field, and an invalid field name is refused outright.
49.4. Placement¶
Inbound, place it after pepsi-stage-decrypt so the filter sees plaintext, and before the whitelist and spam gates so they can branch on whatever it tagged. Outbound, place it just before pepsi-stage-dkim-sign so our own signature covers its changes.
pepsi-setup --wizard does this for you for the filters it can find. It scans
the host for seven known daemons, confirms each by completing a real option
negotiation with its socket, and emits one stage per accepted filter — ordered
by what the filter does, with ALLOW_ACTIONS set to exactly what that filter
asked for during the negotiation, and with rejects routed to a generated discard
stage rather than bounced. Filters whose job Pepsi already does (opendkim,
openarc, opendmarc, SPF daemons, postsrsd) are named and skipped.
See the scan in pepsi-setup(1).
A filter that rewrites the body necessarily breaks the originator’s DKIM body
hash and this deployment’s ARC AMS — the same unavoidable caveat
pepsi-stage-vacation carries for its subject tag. That is harmless on a
local-delivery branch; on a branch that relays the message onward, prefer a
filter that only adds headers.
49.5. Verdicts¶
|
|
|
|
|
|
|
paused for retry, then |
|
deleted, silently |
|
|
ACCEPT_STAGE exists because Postfix applies a list of milters, where accept
means “skip the remaining ones”. One filter per stage has no such notion, so
without the option accept and continue would be the same thing; point it past the
rest of a chain to restore the distinction.
49.6. Configuration¶
[stage-<name>]: PROGRAM = pepsi-stage-milter, SOCKET, the routing
targets ACCEPT_STAGE/REJECT_STAGE/QUARANTINE_STAGE, ON_FAILURE,
ALLOW_ACTIONS, PROTOCOL_VERSION, the three timeouts, the shared retry
options and the seven MACROS_* lists. pepsi-setup checks that every
routing target resolves to a real stage. See pepsi-stage-milter(1).
49.7. State¶
Inputs:
state.origin(the recorded session, replayed to the filter) andstate.dsn(kept parallel to any recipient rewrite).Outputs:
state.milter— the verdict, protocol version, elapsed time and the modifications applied, plus the filter’s reply code and quarantine reason when present. A rejected message also carriesstate.bounce, with the filter’s SMTP code, RFC 3463 enhanced status and text in their own fields.
49.8. See also¶
pepsi-stage-if, pepsi-stage-discard, pepsi-stage-bounce, pepsi-stage-dkim-sign, Supported Features, pepsi-stage-milter(1).