.. This file is part of PEPSI. Copyright (C) 2026 Pepsi contributors PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. ================== pepsi-stage-milter ================== *Run a message past a sendmail/Postfix milter mail filter.* 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: :manpage:`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. 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 :doc:`pepsi-stage-discard` rather than a :doc:`pepsi-stage-bounce` if that matters. Filters that only *annotate* — a signer, or a scorer that adds a header for a later :doc:`pepsi-stage-if` to branch on — are unaffected. 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. Placement ========= Inbound, place it after :doc:`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 :doc:`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 :ref:`the scan ` in :manpage:`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 :doc:`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. 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. Configuration ============= ``[stage-]``: ``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 :manpage:`pepsi-stage-milter(1)`. 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. See also ======== :doc:`pepsi-stage-if`, :doc:`pepsi-stage-discard`, :doc:`pepsi-stage-bounce`, :doc:`pepsi-stage-dkim-sign`, :doc:`../features`, :manpage:`pepsi-stage-milter(1)`.