mail-address-semantics/integrations/postfix
tegwick d766d61e7f MAS-WP-0004: Postfix integration profile, corpus probe in pytest, Exim notes
Profile in integrations/postfix (install.sh + README), now used by the
live deployment. Probe runs every conformance-corpus local-part and the
relay negatives against it; tests/test_postfix_profile.py runs it where
Docker is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 09:25:03 +02:00
..
install.sh MAS-WP-0004: Postfix integration profile, corpus probe in pytest, Exim notes 2026-10-19 09:25:03 +02:00
README.md MAS-WP-0004: Postfix integration profile, corpus probe in pytest, Exim notes 2026-10-19 09:25:03 +02:00

MAS Postfix integration profile

Configures an installed Postfix (tested: 3.7, Debian bookworm) to receive mail for one MAS domain and hand every recipient, unchanged, to mas-gateway. This is the setup that runs live on mas.coulomb.social (deploy/railiance/entrypoint.sh). It is tested by experiments/postfix-probe and tests/test_postfix_profile.py.

integrations/postfix/install.sh --domain great-service.example \
  --statute /srv/statute/.well-known/mas \
  --app-url http://127.0.0.1:8024/mas-events --auth
opendkim -f -x /etc/opendkim.conf &      # only with --auth
opendmarc -f -c /etc/opendmarc.conf &    # only with --auth
postfix start-fg

The script needs a mas system user and mas-gateway on the path (or --gateway CMD). It writes /etc/postfix/mas_rcpt.regexp and, with --auth, also /etc/postfix/ar_strip.regexp, /etc/opendkim.conf and /etc/opendmarc.conf.

Why the stock configuration fails

A default Postfix silently loses every P2 address (ticket!callback@D) (docs/experiment-review.md, Email infrastructure):

  • reject_unauth_destination treats a local-part that contains ! or % as sender-specified routing and refuses it, even for a local domain.
  • When relaying is allowed, swap_bangpath=yes rewrites a!b@D to b@a and allow_percent_hack=yes rewrites a%b@D to a@b. The mail then goes somewhere else, or bounces.
  • An access-map OK for such an address is ignored unless allow_untrusted_routing=yes.

main.cf

Setting Why
mydestination = D, relay_domains = D is the only domain received; nothing is relayed.
mynetworks = 127.0.0.0/8 Only loopback (your app's replies) may send out.
smtpd_relay_restrictions = permit_mynetworks, check_recipient_access regexp:/etc/postfix/mas_rcpt.regexp, reject Replaces reject_unauth_destination. The final reject refuses everything else, and smtpd will not start without a reject-class entry.
allow_untrusted_routing = yes Without it, the regexp result is ignored for !/% local-parts. It is safe only because relay_domains is empty and nothing rewrites such addresses.
swap_bangpath = no, allow_percent_hack = no Never rewrite MAS syntax.
local_transport = mas, local_recipient_maps = Every recipient of D goes to the MAS pipe, with no local-user lookup. The gateway and statute decide.
mas_destination_recipient_limit = 1 One recipient per gateway call, so ${recipient} is that recipient.
recipient_delimiter = + Postfix knows that facets exist. ${recipient} is unaffected.
disable_vrfy_command, smtpd_helo_required, smtpd_recipient_limit = 20 Hygiene for a receive-only endpoint.

mas_rcpt.regexp (dots in D escaped):

/@great-service\.example$/ permit

The match runs the permit restriction rather than returning OK.

master.cf

mas       unix  -       n       n       -       -       pipe
  flags=Rq user=mas argv=/usr/local/bin/mas-gateway
  --statute <statute> --trusted-authserv-id D
  pipe --app-url <url> --recipient ${recipient}

flags=q quotes the recipient for the shell-free argv. Postfix passes ${recipient} in RFC 5321 canonical form, which can differ from the bytes sent: needless quotes are dropped ("ticket=PR42" becomes ticket=PR42) and quotes are added around non-dot-atoms (.bernd becomes ".bernd"). Spec §3 treats both forms as equivalent, and a re-quoted form is never a valid MAS address.

Outbound (--relay HOST:PORT --relay-credentials DIR)

Only loopback may send out (mynetworks), so outbound mail is your app's replies. With --relay, they go through an authenticated submission relay (relayhost, smtp_sasl_*, smtp_tls_security_level = secure); DIR holds the files username and password. Postfix's own bounces (null sender) are discarded rather than relayed (sender_dependent_default_transport_maps = inline:{<>=discard:}). Then the app's own send cap bounds everything sent through the relay. The reference ticket service has --max-sent. Without --relay, default_transport = error: makes outbound mail fail at once instead of queueing.

Sender authentication (--auth)

The gateway trusts Authentication-Results only for its own authserv-id (spec §14 item 10). The MTA does SPF/DKIM/DMARC, because these need DNS:

  • OpenDKIM in verify mode (Mode v) and OpenDMARC with SPFSelfValidate true both write A-R headers with authserv-id D. RejectFailures false and milter_default_action = accept mean they annotate only: they never reject, and mail is accepted if a milter is down. Loopback is in OpenDKIM's PeerList, so your own replies are skipped.
  • header_checks = regexp:/etc/postfix/ar_strip.regexp drops every incoming Authentication-Results: D ... header. Header checks do not see headers that milters add, so only the milters' own results survive, and a sender cannot forge a pass. OpenDKIM's RemoveARFrom did not remove them in testing.
  • Run both milters in the foreground (-f). Daemonized, OpenDKIM died on its first connection in a container.

Not covered here

  • The send cap itself is the application's job (ticket service --max-sent). The application must never reply to a null sender (spec §14).
  • TLS certificates, banner, size limits and chroot: deployment specifics, see deploy/railiance/entrypoint.sh.
  • Exim: see integrations/exim/NOTES.md.