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
113 lines
5.3 KiB
Markdown
113 lines
5.3 KiB
Markdown
# 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`.
|
|
|
|
```sh
|
|
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`.
|