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: SOCKET takes sendmail’s own S= 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-ingress recorded under state.origin — the peer address and its PTR name, the HELO name, the TLS version and cipher, the listener, the BODY=/SMTPUTF8 parameters, 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, a pepsi-sendmail submission) is presented as a loopback session, which is what sendmail does for local submission.

  • An action allow-list: ALLOW_ACTIONS decides 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_FAILURE path. 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_FAILURE mirrors milter_default_action and 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 RCPT phase is peeled onto its own sibling row at REJECT_STAGE and the rest of the message carries on, with state.dsn.rcpt sliced in step with rcpt_to on 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

CONTINUE

NEXT_STAGE

ACCEPT

ACCEPT_STAGE (default NEXT_STAGE)

REJECT

REJECT_STAGE (default BOUNCE_STAGE)

TEMPFAIL

paused for retry, then REJECT_STAGE

DISCARD

deleted, silently

QUARANTINE

QUARANTINE_STAGE; deleted when unset

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) and state.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 carries state.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).