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 —
PLAINorLOGIN. It is passed through exactly as it was recorded; the upper-casing is pepsi-ingress(1)’s, which canonicalises the mechanism name while parsingAUTH(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, aMYNETWORKSaddress. 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
peercredsession therefore has an identity with no{auth_type}— the honest description of a session the kernel authenticated rather than SASL.mynetworksandclient-certname 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 SASLPLAINexchange 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 as0.
85.1.36.1.7. Verdicts¶
milter verdict |
where the message goes |
|---|---|
|
NEXT_STAGE |
|
ACCEPT_STAGE, defaulting to NEXT_STAGE |
|
REJECT_STAGE, defaulting to the section’s
|
|
paused for retry; the section’s |
|
the row is deleted, silently |
|
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, orSMFIR_PROGRESSextending 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.