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 |
||
|---|---|---|
| .. | ||
| install.sh | ||
| README.md | ||
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_destinationtreats a local-part that contains!or%as sender-specified routing and refuses it, even for a local domain.- When relaying is allowed,
swap_bangpath=yesrewritesa!b@Dtob@aandallow_percent_hack=yesrewritesa%b@Dtoa@b. The mail then goes somewhere else, or bounces. - An access-map
OKfor such an address is ignored unlessallow_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 withSPFSelfValidate trueboth write A-R headers with authserv-id D.RejectFailures falseandmilter_default_action = acceptmean they annotate only: they never reject, and mail is accepted if a milter is down. Loopback is in OpenDKIM'sPeerList, so your own replies are skipped. header_checks = regexp:/etc/postfix/ar_strip.regexpdrops every incomingAuthentication-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'sRemoveARFromdid 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.