85.1.36. pepsi-stage-milter

run a message past a sendmail/Postfix milter mail filter

Manual section:

1

85.1.36.1.1. Name

pepsi-stage-milter - the milter-client stage of the Pepsi pipeline.

85.1.36.1.2. Synopsis

pepsi-stage-milter [GLOBAL-OPTIONS] worker

85.1.36.1.3. Description

pepsi-stage-milter is a stage program run by pepsi-dispatch(1) as a persistent worker reading message ids on standard input. For each message it opens a connection to a milter — the mail-filter protocol introduced by sendmail and adopted by Postfix — replays the message as a synthetic SMTP session, applies whatever modifications the filter asks for, and routes the message according to the filter’s verdict.

The stage is a milter client and nothing else. The filter itself is an existing long-lived daemon listening on SOCKET, exactly as it is under sendmail and Postfix: its own package, its own systemd unit, its own user account. Pepsi does not start it, does not stop it and does not sandbox it — confining a daemon is that daemon’s own unit’s job, and on Debian (opendkim, clamav-milter, rspamd, spamass-milter, milter-greylist) it is already done there. Consequently this stage carries no privilege of its own: no setuid bit, no setgid bit, no helper.

85.1.36.1.4. Post-queue filtering

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 sendmail’s or Postfix’s.

Under sendmail and Postfix, milter callbacks run inside the SMTP session, before the server has accepted responsibility for the message. That 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(1) has already answered 250. A milter 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 a Postfix installation still filters correctly, but at the price of backscatter that a pre-queue rejection avoids.

Deployments that cannot afford that should point REJECT_STAGE at a pepsi-stage-discard(1) rather than at a pepsi-stage-bounce(1), so a rejected message is dropped silently. Filters that only annotate — a DKIM signer, a spam scorer that adds a header for a later pepsi-stage-if(1) to branch on — are unaffected.

85.1.36.1.5. Placement

On the inbound path, place the stage after pepsi-stage-decrypt(1), so the filter sees plaintext rather than a PGP or S/MIME blob.

On the submission path, place it before pepsi-stage-dkim-sign(1), so our own signature covers whatever the filter changed.

A milter that rewrites the body necessarily breaks the originator’s DKIM body hash and this deployment’s own ARC AMS, because both are computed over bytes that no longer exist. That is unavoidable — it is the same caveat pepsi-stage-vacation(1) carries for its Subject: tag — and it is harmless on a local-delivery branch, where nothing downstream re-verifies. On a branch that relays the message onward, prefer a filter that only adds headers.

85.1.36.1.6. The protocol conversation

Each message is one connection, closed with SMFIC_QUIT afterwards. The session is reconstructed from what pepsi-ingress(1) recorded under state.origin (see pepsi.state(7)): the connecting address and its PTR name, the HELO/EHLO name, the TLS version and cipher, the listener the message arrived on, and the BODY=/SMTPUTF8 parameters the client declared on MAIL FROM.

A locally injected message — a bounce, an auto-reply, a submission through pepsi-sendmail(1) — has no remote peer. Rather than skipping the connect phase, which would leave {client_addr} undefined and make a filter’s local-mail special-casing fire unpredictably, a loopback session is synthesised (127.0.0.1, localhost), which is what sendmail presents for local submission.

85.1.36.1.6.1. Option negotiation

The stage offers protocol version PROTOCOL_VERSION (6 by default, as in Postfix), the modification actions ALLOW_ACTIONS permits, and every SMFIP_* protocol option it can honour. The filter negotiates the version down from there, but not below 2: a milter offering less than that is refused and the message takes the ON_FAILURE path. Two further rules govern the milter’s answer.

An action the milter requires but this stage did not offer is a configuration error. It is reported — naming the missing flag — and the message takes the ON_FAILURE path. Postfix ignores such a request silently; silently ignoring it is how an operator discovers six months later that a signing milter never signed anything.

A protocol option this stage does not implement is refused. Claiming one and then not honouring it would be a protocol violation on our side; the SMFIP_MDS_256K/SMFIP_MDS_1M larger-chunk options are the case in point, and are never offered.

The SMFIF_SETSYMLIST bit is not governed by ALLOW_ACTIONS and needs no grant. It authorises no change to the message — it only lets the filter ask for a narrower set of macros than would otherwise be sent — so gating it alongside “may rewrite the envelope” would break ordinary filters for no security benefit. When a milter names its own macro list for a phase, that list is used instead of the corresponding MACROS_* option.

85.1.36.1.6.2. Macros

The default macro sets are Postfix’s — milter_connect_macros, milter_helo_macros, milter_mail_macros, milter_rcpt_macros, milter_data_macros, milter_end_of_header_macros and milter_end_of_data_macros of the Postfix 3.x stable releases, entry for entry (connect: j {daemon_name} {daemon_addr} v _) — so a filter’s own documentation applies unchanged. {daemon_addr} (and its synonym {if_addr}) is the local IP address the client connected to, as pepsi-ingress(1) recorded it; a session that arrived over a UNIX socket has none. {if_name} is also answered, with the server’s host name, when a list names it. A macro whose value this deployment does not know is not sent at all, which is how libmilter distinguishes “no TLS” from “TLS with an empty cipher”; {cipher_bits}, {cert_subject} and {cert_issuer} are in the default list but never known here, because pepsi-ingress(1) does not record them, so a filter sees them exactly as it would from a Postfix session that had none.

The RFC 4954 authentication macros come from what pepsi-ingress(1) recorded about the session (state.origin.auth_*, see pepsi.state(7)):

{auth_type}

The SASL mechanism that succeeded — PLAIN or LOGIN. It is passed through exactly as it was recorded; the upper-casing is pepsi-ingress(1)’s, which canonicalises the mechanism name while parsing AUTH (RFC 4954 §4), not this stage’s. Deliberately unset for the authenticated paths that are not SASL: a UNIX-socket peer, a pinned TLS client certificate, a MYNETWORKS address. The macro is defined as the SASL mechanism, and a filter comparing it against a list of real mechanisms would be misled by a token that is not one.

{auth_authen}

The authentication identity: the SASL authcid, or the login a UNIX peer’s uid resolved to. A peercred session therefore has an identity with no {auth_type} — the honest description of a session the kernel authenticated rather than SASL. mynetworks and client-cert name nobody, so both macros stay unset there.

{auth_author}

The authorization identity — whom the client asked to act as, falling back to whom it authenticated as, which is sendmail’s rule. It differs from {auth_authen} only when a SASL PLAIN exchange supplied a distinct authzid.

{auth_ssf}

The SASL security layer’s strength, which for both mechanisms Pepsi implements is exactly 0: neither negotiates a layer of its own, and the confidentiality comes from the TLS underneath — which {cipher} describes. A mechanism that does carry a layer would leave this unset rather than understate it as 0.

85.1.36.1.7. Verdicts

milter verdict

where the message goes

SMFIR_CONTINUE

NEXT_STAGE

SMFIR_ACCEPT

ACCEPT_STAGE, defaulting to NEXT_STAGE

SMFIR_REJECT

REJECT_STAGE, defaulting to the section’s BOUNCE_STAGE

SMFIR_TEMPFAIL

paused for retry; the section’s BOUNCE_STAGE once MAX_LIFETIME has passed, or failed without one

SMFIR_DISCARD

the row is deleted, silently

SMFIR_QUARANTINE

QUARANTINE_STAGE; deleted when that is unset

An SMFIR_REPLYCODE is treated as a tempfail when its code begins with 4 and as a reject otherwise — the same rule SMTP itself uses. Its code, RFC 3463 enhanced status and text are recorded separately under state.bounce, so a pepsi-stage-bounce(1) report can quote the filter’s own words and set the DSN Status: field from its enhanced status.

With neither REJECT_STAGE nor the section’s BOUNCE_STAGE set, a rejected message has nowhere to go: the row is left failed rather than quietly advanced, so pepsi-failure-bouncer(1) or the operator decides.

A tempfail that has run out of MAX_LIFETIME — the filter kept deferring the message, or with ON_FAILURE = tempfail could not be reached for that long — is not sent to REJECT_STAGE. It is no verdict on the message, and REJECT_STAGE is often a stage that drops rejected mail to avoid backscatter (the setup wizard’s discard-rejected), which would lose mail because a filter was down. It goes to the section’s BOUNCE_STAGE with a bounce state, as an MTA bounces a message it could not hand on in time, and with no BOUNCE_STAGE the message is left failed, where pepsi-status shows it and pepsi-failure-bouncer(1) bounces it.

ACCEPT_STAGE exists because Postfix applies a list of milters, where ACCEPT means “skip the remaining ones for this message”. One milter per stage chained by NEXT_STAGE has no such notion, so without this option ACCEPT and CONTINUE would be indistinguishable. Point it past the rest of a filter chain to restore the distinction; leave it unset and the two verdicts are the same thing.

Quarantine is not a verdict of its own. A sendmail milter quarantines and then still returns an accept, so a quarantine request decides the route regardless of the verdict that follows it. Pepsi has no quarantine store — only a route — so with no QUARANTINE_STAGE configured the message is discarded.

85.1.36.1.7.1. Per-recipient rejection

When the filter answers SMFIC_RCPT with a reject (5xx), that recipient is peeled onto its own sibling row at REJECT_STAGE, sliced to itself, and the rest of the message carries on — the same fan-out pepsi-stage-relay-to-lmtp(1) uses. state.dsn.rcpt is sliced in step with rcpt_to on every resulting row. If every recipient is rejected, the source row is deleted, in the same statement that creates the siblings, and only the siblings remain. Each sibling’s token names its recipient rather than its position, so a retried pass (after the source row was reduced) cannot collide with one an earlier pass created. With neither REJECT_STAGE nor BOUNCE_STAGE set there is nowhere to peel them to, so the recipients are kept and the fact is logged as a warning: dropping them would lose mail and ignoring the filter silently is the failure mode this stage refuses.

A tempfail (SMFIR_TEMPFAIL, or a 4xx reply code) for one recipient is not a rejection. That recipient’s copy is peeled onto a sibling row that waits paused at this stage, with state.attempts and state.last_error, and is filtered again after the retry back-off, from the bytes it arrived with; the other recipients carry on. The copy keeps the message’s arrival time, so its MAX_LIFETIME counts from then; once that is up it goes to BOUNCE_STAGE, or is left failed when there is none — like a whole-message tempfail, never to REJECT_STAGE. A filter that defers every recipient has deferred the message, which is paused as a whole.

This happens for any filter, whatever it negotiated. In particular it does not depend on SMFIP_RCPT_REJ, which asks for something else entirely: that flag is a filter asking to be told about the recipients the MTA itself already rejected. Pepsi runs post-queue and rejects no recipient of its own during the replay, so there is never such a recipient to report.

85.1.36.1.8. Modifications

The end-of-message modifications a filter may request are governed by ALLOW_ACTIONS. SMFIR_ADDHEADER appends to the end of the block; SMFIR_INSHEADER inserts at the 0-based absolute position it names, and SMFIR_CHGHEADER replaces the 1-based n-th field of that name (an empty value deletes it, and an index past the last occurrence appends a new field, as sendmail and Postfix both do). The two indices count different things, which is the one part of this protocol that is easy to misread. SMFIR_REPLBODY replaces the body in full. SMFIR_ADDRCPT/SMFIR_ADDRCPT_PAR/SMFIR_DELRCPT rewrite the envelope recipients, with state.dsn.rcpt rebuilt to match (a recipient the filter added inherits the first original recipient’s NOTIFY and gets an RFC 3461 §5.2.7 ORCPT naming itself); SMFIR_CHGFROM rewrites the envelope sender.

A header value supplied by a filter is normalised before it is stored: line endings become CRLF and every continuation line is forced to begin with whitespace. Without that, a value containing a newline followed by Bcc: somebody would be re-rendered as a second header field — header injection by a component that is only supposed to be annotating the message. An invalid field name is refused outright rather than escaped.

A replacement body is normalised the same way: the SMFIR_REPLBODY chunks are joined and every bare LF becomes a CRLF, because RFC 5321 §2.3.8 terminates message lines with CRLF and §4.1.1.4 forbids transmitting a bare CR or LF. A filter that reads the body as text and writes it back with \n line endings therefore does not produce a message the next hop must guess at.

The denormalised from_header and subject columns are refreshed from the rewritten block, so a filter that tags a subject does not leave pepsi-queue(1) and pepsi-stage-check-whitelist(1) reading the old one.

A deferred message keeps the bytes it arrived with: modifications from a pass that ended in SMFIR_TEMPFAIL are discarded, because the message is filtered again from scratch on the next attempt and applying them now would tag it once per retry.

85.1.36.1.9. Configuration

Options live in the stage’s own [stage-<name>] section (PROGRAM = pepsi-stage-milter) and are documented in pepsi.conf(5): SOCKET, MILTER_NAME, ACCEPT_STAGE, REJECT_STAGE, QUARANTINE_STAGE, ON_FAILURE, ALLOW_ACTIONS, PROTOCOL_VERSION, the CONNECT_TIMEOUT/COMMAND_TIMEOUT/CONTENT_TIMEOUT timeouts, the shared retry options, and the seven MACROS_* lists.

NEXT_STAGE is mandatory: SMFIR_CONTINUE is the default verdict and hands the message on there, and ACCEPT_STAGE covers only SMFIR_ACCEPT, so neither it nor the reject/quarantine targets can stand in for it. pepsi-setup(1) refuses a section without it, and checks that ACCEPT_STAGE, REJECT_STAGE and QUARANTINE_STAGE all name existing stages.

85.1.36.1.10. State

Inputs: state.origin (the recorded SMTP session, used to build the connect/helo/mail phases and their macros) and state.dsn (kept parallel to any recipient rewrite).

Outputs: state.milter — the filter’s name (MILTER_NAME), the verdict, the protocol version in force, the elapsed time, the list of modifications applied, and, when present, the reply code, the quarantine reason and the number of recipients rejected. On the ON_FAILURE path the record is the short form instead: the stage’s own label, the verdict failed and the error. With ON_FAILURE = reject that error stays in the record and the journal: the bounce the sender receives only says that the filter could not be consulted. Nothing reads the record; it exists so pepsi-queue(1)

can show why a message was tagged and pepsi-stage-if(1) can branch on it. A rejected message additionally carries state.bounce. The state layout is described in pepsi.state(7).

Transitions: advance to NEXT_STAGE, ACCEPT_STAGE or QUARANTINE_STAGE; reroute to REJECT_STAGE, or to BOUNCE_STAGE when a tempfail has expired; pause for retry; finish (delete). A per-recipient rejection or tempfail additionally creates sibling rows (a tempfailed recipient’s row is created paused at this stage).

85.1.36.1.11. Commands

worker

Run as a persistent pepsi-dispatch(1) worker, reading message ids on standard input.

85.1.36.1.12. Global Options

-c FILE, –config FILE

Read the configuration from FILE instead of searching the default locations.

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity (default info).

-v, –verbose

Show log messages from all sources.

-h, –help; -V, –version

Print a usage summary / the version and exit.

85.1.36.1.13. Exit Status

0

The message was processed and routed.

1

An error occurred (message not found or not running, a misconfigured stage — e.g. an unparsable SOCKET — or a database error). Nothing the filter does is an error: neither a milter that could not be reached nor one that sent an unusable modification (an invalid header field name, a NUL in a value), nor one that ran away — more than 1024 modifications, more than 64 MiB of replacement body, or SMFIR_PROGRESS extending a reply timeout more than tenfold. All of these take the ON_FAILURE path; the reason is written to the log.

85.1.36.1.14. Examples

Sign outbound mail with opendkim before Pepsi’s own DKIM stage:

[stage-opendkim]
PROGRAM = pepsi-stage-milter
SOCKET = unix:/run/opendkim/opendkim.sock
NEXT_STAGE = dkim-sign

Score inbound mail with rspamd over TCP, bouncing what it rejects and letting it rewrite the body when it wraps a spam message:

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
ALLOW_ACTIONS = addhdrs chghdrs chgbody quarantine
QUARANTINE_STAGE = quarantine
REJECT_STAGE = bounce
NEXT_STAGE = deliver
BOUNCE_STAGE = bounce

The same filter, in a deployment that will not emit backscatter: rejected mail is dropped rather than bounced:

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
REJECT_STAGE = drop
NEXT_STAGE = deliver

[stage-drop]
PROGRAM = pepsi-stage-discard
DISPOSITION = failure
BOUNCE = no

Two filters in a chain, where the first one accepting skips the second:

[stage-greylist]
PROGRAM = pepsi-stage-milter
SOCKET = unix:/run/milter-greylist/milter-greylist.sock
NEXT_STAGE = spam-filter
ACCEPT_STAGE = deliver
REJECT_STAGE = bounce

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
NEXT_STAGE = deliver
REJECT_STAGE = bounce

85.1.36.1.15. See Also

pepsi-dispatch(1), pepsi-stage-if(1), pepsi-stage-bounce(1), pepsi-stage-discard(1), pepsi-stage-dkim-sign(1), pepsi-stage-decrypt(1), pepsi-queue(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7)

85.1.36.1.16. Bugs

Report bugs to the Pepsi issue tracker.