diff --git a/deploy/railiance/Dockerfile b/deploy/railiance/Dockerfile index f5ace6d..503cd96 100644 --- a/deploy/railiance/Dockerfile +++ b/deploy/railiance/Dockerfile @@ -8,6 +8,7 @@ COPY mas_core /app/mas_core COPY mas_gateway /app/mas_gateway COPY examples /app/examples COPY experiments/postfix-probe/capture.py /app/capture.py +COPY integrations /app/integrations COPY deploy/railiance/entrypoint.sh /entrypoint.sh ENV PYTHONPATH=/app EXPOSE 25 8080 diff --git a/deploy/railiance/entrypoint.sh b/deploy/railiance/entrypoint.sh index df0fdaa..33f6025 100644 --- a/deploy/railiance/entrypoint.sh +++ b/deploy/railiance/entrypoint.sh @@ -15,28 +15,22 @@ exec env PYTHONPATH=/app python3 -m mas_gateway.main "\$@" SH chmod +x /usr/local/bin/mas-gateway -# Not an open relay: no relay_domains, mynetworks is loopback only, so the -# only accepted recipients are @$DOMAIN, all delivered to the mas pipe. -# Stock reject_unauth_destination refuses any local-part containing '!' or '%' -# (treated as sender-specified routing), which kills every P2 address, and an -# access-map result is ignored for such routed addresses unless -# allow_untrusted_routing=yes (safe here: relay_domains is empty). So relay control is an -# explicit regexp whose match runs the `permit` restriction (not OK) for -# anything ending in @$DOMAIN; everything else falls through to `reject` -# (Postfix refuses to start smtpd without a reject-class entry in the list). -# With swap_bangpath/percent_hack off and local_transport=mas, those -# addresses can only reach the mas pipe. -DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/\./\\./g') -printf '/@%s$/ permit\n' "$DOMAIN_RE" > /etc/postfix/mas_rcpt.regexp +# The MAS setup (relay control, no rewriting, mas pipe, outbound relay, sender +# authentication) is the integration profile; see integrations/postfix/. +# Port 25 out is blocked on railiance01, so replies go through an +# authenticated submission relay, and only when its credentials are mounted. +RELAY_ARGS="" +if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then + RELAY_ARGS="--relay $MAS_RELAY --relay-credentials /etc/mas-relay" +fi +/app/integrations/postfix/install.sh --domain "$DOMAIN" \ + --statute "$STATUTE_DIR/.well-known/mas" --app-url http://127.0.0.1:8024/mas-events --auth \ + $RELAY_ARGS + +# Deployment specifics. postconf -e \ - "maillog_file=/dev/stdout" "myhostname=$DOMAIN" "mydomain=$DOMAIN" \ - "mydestination=$DOMAIN" "relay_domains=" "mynetworks=127.0.0.0/8" \ - "smtpd_relay_restrictions=permit_mynetworks,check_recipient_access regexp:/etc/postfix/mas_rcpt.regexp,reject" \ - "local_transport=mas" "local_recipient_maps=" "mas_destination_recipient_limit=1" \ - "recipient_delimiter=+" "swap_bangpath=no" "allow_percent_hack=no" \ - "allow_untrusted_routing=yes" \ - "disable_vrfy_command=yes" "smtpd_helo_required=yes" "smtpd_recipient_limit=20" \ - "message_size_limit=10240000" "smtpd_banner=\$myhostname ESMTP MAS live test" \ + "maillog_file=/dev/stdout" "message_size_limit=10240000" \ + "smtpd_banner=\$myhostname ESMTP MAS live test" \ "smtpd_tls_security_level=may" \ "smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem" \ "smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key" \ @@ -46,74 +40,16 @@ postconf -e \ # container already isolates Postfix, so run the daemons unchrooted. postconf -F '*/*/chroot = n' -# Outbound replies (reply test). Port 25 out is blocked on railiance01, so -# replies go through an authenticated submission relay, and only when its -# credentials are mounted. Only loopback (the ticket service) may relay. -# Without a relay all non-local mail fails fast instead of queueing. +# The ticket service sends replies only with a relay, capped at MAS_MAX_SENT. SMTP_ARG="" if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then - printf '%s %s:%s\n' "$MAS_RELAY" "$(cat /etc/mas-relay/username)" \ - "$(cat /etc/mas-relay/password)" > /etc/postfix/sasl_passwd - chmod 600 /etc/postfix/sasl_passwd && postmap /etc/postfix/sasl_passwd - postconf -e "relayhost=$MAS_RELAY" "smtp_sasl_auth_enable=yes" \ - "smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd" \ - "smtp_sasl_security_options=noanonymous" "smtp_tls_security_level=secure" \ - "smtp_tls_CApath=/etc/ssl/certs" - # Postfix's own bounces (null sender) never use the relay, so the ticket - # service cap below really bounds everything sent through TEM. - postconf -e "sender_dependent_default_transport_maps=inline:{<>=discard:}" \ - "empty_address_default_transport_maps_lookup_key=<>" SMTP_ARG="--smtp 127.0.0.1:25 --max-sent ${MAS_MAX_SENT:-300} --require-dmarc-pass" -else - postconf -e "default_transport=error:5.7.1 outbound mail disabled" fi -# Sender authentication (spec 14 item 10). The MTA does SPF/DKIM/DMARC, since -# they need DNS; the gateway only reads Authentication-Results carrying our -# authserv-id. Postfix header_checks drop incoming A-R headers claiming that -# id; header_checks do not see milter-added headers, so only the milters' -# own results survive and a sender cannot forge a pass. (OpenDKIM's -# RemoveARFrom did not remove them in testing.) Annotate only: never reject, -# and accept mail if a milter is down. Loopback (the ticket service's -# replies) is skipped. Both milters run in the foreground: daemonized, -# OpenDKIM died on its first connection in this container. -mkdir -p /run/opendkim /run/opendmarc -chown opendkim:opendkim /run/opendkim && chown opendmarc:opendmarc /run/opendmarc -printf '127.0.0.1\n' > /etc/opendkim-peers -cat > /etc/opendkim.conf < /etc/opendmarc.conf < /etc/postfix/ar_strip.regexp -postconf -e "header_checks=regexp:/etc/postfix/ar_strip.regexp" "smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8893" "non_smtpd_milters=" \ - "milter_default_action=accept" "milter_protocol=6" - -cat >> /etc/postfix/master.cf < pipe transport -> mas-gateway -> reference ticket service. FROM debian:bookworm-slim RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - postfix python3 python3-jsonschema ca-certificates && rm -rf /var/lib/apt/lists/* + postfix opendkim opendmarc publicsuffix python3 python3-jsonschema ca-certificates && rm -rf /var/lib/apt/lists/* RUN useradd -r -s /usr/sbin/nologin mas COPY entrypoint.sh /entrypoint.sh EXPOSE 25 8024 8025 diff --git a/experiments/postfix-probe/README.md b/experiments/postfix-probe/README.md index 52aebb0..5a90cfc 100644 --- a/experiments/postfix-probe/README.md +++ b/experiments/postfix-probe/README.md @@ -7,14 +7,20 @@ probe can tell whether Postfix rewrote anything. ```bash docker build -t mas-postfix-probe experiments/postfix-probe +python3 experiments/postfix-probe/probe.py --profile mas-profile --corpus # the shipped profile python3 experiments/postfix-probe/probe.py --profile defaults # relay domain python3 experiments/postfix-probe/probe.py --profile local-domain # domain in mydestination python3 experiments/postfix-probe/probe.py --profile local-domain-safe # + swap_bangpath/percent_hack off ``` -The other profiles are `defaults-local` and `mas-safe-local`. The repo is -mounted read-only. In `results/` a `=` mark means the address arrived -unchanged and `!` means it was rewritten. Findings are in +`mas-profile` runs the shipped integration profile +(`integrations/postfix/`) with `--corpus` (every conformance-corpus +local-part), plus relay attempts that must be refused. `pytest` runs it as +`tests/test_postfix_profile.py` when Docker is available. The other +experiment profiles are `defaults-local` and `mas-safe-local`. The repo is +mounted read-only. In the output, `=` means the address arrived unchanged, +`~` means only its RFC 5321 quoting changed, `!` means it was rewritten, and +`R` means a relay attempt was refused. Findings are in `docs/experiment-review.md` under *Email infrastructure*. The Postfix side is the `master.cf` entry in `entrypoint.sh`: diff --git a/experiments/postfix-probe/entrypoint.sh b/experiments/postfix-probe/entrypoint.sh index 87b3c9a..9c6bc11 100644 --- a/experiments/postfix-probe/entrypoint.sh +++ b/experiments/postfix-probe/entrypoint.sh @@ -7,6 +7,24 @@ exec env PYTHONPATH=/mas python3 -m mas_gateway.main "$@" SH chmod +x /usr/local/bin/mas-gateway +# mas-profile: the shipped integration profile (integrations/postfix), as +# deployed live. Relaying is loopback-only, so the probe client is an ordinary +# outside sender. The other profiles below are the MAS-WP-0002 experiments. +if [ "$PROFILE" = "mas-profile" ]; then + postconf -e "maillog_file=/dev/stdout" "compatibility_level=3.6" + sh /mas/integrations/postfix/install.sh --domain great-service.example \ + --statute /mas/examples/statute/great-service.example/.well-known/mas \ + --app-url http://127.0.0.1:8024/mas-events --auth + opendkim -f -x /etc/opendkim.conf & + opendmarc -f -c /etc/opendmarc.conf & + mkdir -p /var/lib/tickets && chown mas /var/lib/tickets + su -s /bin/sh mas -c "PYTHONPATH=/mas python3 /mas/examples/ticket_service/ticket_service.py \ + --db /var/lib/tickets/t.sqlite3 --listen 0.0.0.0:8025" & + python3 /mas/experiments/postfix-probe/capture.py http://127.0.0.1:8025/mas-events & + echo "PROFILE=mas-profile" + exec postfix start-fg +fi + postconf -e "maillog_file=/dev/stdout" "myhostname=mx.probe.test" "mydestination=" \ "relay_domains=great-service.example" "transport_maps=inline:{great-service.example=mas:}" \ "mynetworks=0.0.0.0/0" "smtpd_relay_restrictions=permit_mynetworks,reject" \ diff --git a/experiments/postfix-probe/probe.py b/experiments/postfix-probe/probe.py index 2f38db7..01775d1 100644 --- a/experiments/postfix-probe/probe.py +++ b/experiments/postfix-probe/probe.py @@ -4,6 +4,7 @@ python experiments/postfix-probe/probe.py --profile defaults python experiments/postfix-probe/probe.py --profile defaults-local python experiments/postfix-probe/probe.py --profile mas-safe-local + python experiments/postfix-probe/probe.py --profile mas-profile --corpus Starts the mas-postfix-probe container, sends one message per address via SMTP, then compares the envelope recipient Postfix handed to mas-gateway @@ -38,9 +39,33 @@ CASES = [ ("quoted local-part", '"ticket=PR42"'), ("unknown resource", "ticket=DOES-NOT-EXIST"), ] +# Relay attempts: the MAS profile must refuse all of them (not an open relay). +RELAY_NEGATIVES = ["x@other.example", "x!y@other.example", "a@other.example@" + D, + "a@" + D + "@other.example", "x%y@other.example"] -def run(profile, port): + + +def unquoted(addr): + """Strip RFC 5321 quoting. Postfix hands ${recipient} over in canonical + form: needless quotes dropped, quotes added around non-dot-atoms.""" + local, _, dom = (addr or "").rpartition("@") + if len(local) > 1 and local[0] == local[-1] == '"': + local = local[1:-1] + return f"{local}@{dom}" + + +def corpus_cases(): + """Every distinct local-part of the shared conformance corpus.""" + seen, out = set(), [] + for c in json.loads((ROOT / "mas_conformance/addresses.json").read_text()): + if c["local_part"] not in seen: + seen.add(c["local_part"]) + out.append((f"corpus {c['id']}", c["local_part"])) + return out + + +def run(profile, port, corpus=False): name = f"mas-probe-{profile}" subprocess.run(["docker", "rm", "-f", name], capture_output=True) subprocess.run(["docker", "run", "-d", "--name", name, "-e", f"PROFILE={profile}", @@ -55,7 +80,7 @@ def run(profile, port): except OSError: time.sleep(0.5) results, tid = [], None - for label, lp in CASES: + for label, lp in CASES + (corpus_cases() if corpus else []): rcpt_lp = lp.replace("{ID}", tid or "MISSING") rcpt = f"{rcpt_lp}@{D}" msg = (f"From: probe@example.net\r\nTo: {rcpt}\r\nSubject: {label}\r\n" @@ -65,7 +90,7 @@ def run(profile, port): with smtplib.SMTP("127.0.0.1", port, timeout=10) as s: refused = s.sendmail("probe@example.net", [rcpt], msg) smtp = "accepted" if not refused else f"refused {refused}" - except smtplib.SMTPException as e: + except (smtplib.SMTPException, UnicodeEncodeError) as e: smtp = f"rejected: {e}" got = wait_event(port, before) if smtp == "accepted" else None arrived = got["event"]["recipient"] if got else None @@ -75,9 +100,19 @@ def run(profile, port): tid = app.get("ticket_id") results.append({"label": label, "sent": rcpt, "smtp": smtp, "arrived_as": arrived, "unchanged": arrived == rcpt, + "equivalent": arrived is not None and unquoted(arrived) == unquoted(rcpt), "mas_valid": mas.get("valid"), "kind": mas.get("kind"), "app_status": got["app_status"] if got else None, "app": (app or {}).get("status") if isinstance(app, dict) else app}) + for rcpt in RELAY_NEGATIVES: + try: + with smtplib.SMTP("127.0.0.1", port, timeout=10) as s: + refused = s.sendmail("probe@example.net", [rcpt], f"Subject: relay\r\n\r\nx\r\n") + smtp = "accepted" if not refused else f"refused {refused}" + except smtplib.SMTPException as e: + smtp = f"rejected: {e}" + results.append({"label": "relay attempt", "sent": rcpt, "smtp": smtp, "arrived_as": None, + "unchanged": False, "relay_refused": smtp != "accepted"}) logs = subprocess.run(["docker", "logs", name], capture_output=True, text=True) return results, logs.stdout + logs.stderr finally: @@ -101,16 +136,18 @@ def wait_event(port, before, timeout=15): if __name__ == "__main__": ap = argparse.ArgumentParser() - ap.add_argument("--profile", choices=["defaults", "defaults-local", "mas-safe-local", "local-domain", "local-domain-safe"], default="defaults") + ap.add_argument("--profile", choices=["mas-profile", "defaults", "defaults-local", "mas-safe-local", + "local-domain", "local-domain-safe"], default="mas-profile") ap.add_argument("--port", type=int, default=2525) + ap.add_argument("--corpus", action="store_true", help="also send every conformance-corpus local-part") ap.add_argument("--json", metavar="FILE") ap.add_argument("--log", metavar="FILE") a = ap.parse_args() - results, log = run(a.profile, a.port) + results, log = run(a.profile, a.port, a.corpus) for r in results: - mark = "=" if r["unchanged"] else "!" + mark = ("R" if r["relay_refused"] else "X") if "relay_refused" in r else ("=" if r["unchanged"] else "~" if r["equivalent"] else "!") print(f"{mark} {r['label']:<22} {r['sent']:<52} -> {r['arrived_as'] or r['smtp']}" - f" [{r['kind']}/{r['app']}]") + f" [{r.get('kind')}/{r.get('app')}]") if a.json: Path(a.json).write_text(json.dumps({"profile": a.profile, "results": results}, indent=2) + "\n") if a.log: diff --git a/experiments/postfix-probe/results/mas-profile.json b/experiments/postfix-probe/results/mas-profile.json new file mode 100644 index 0000000..505122f --- /dev/null +++ b/experiments/postfix-probe/results/mas-profile.json @@ -0,0 +1,573 @@ +{ + "profile": "mas-profile", + "results": [ + { + "label": "P2 intake", + "sent": "ticket!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket!callback@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "function", + "app_status": 201, + "app": "created" + }, + { + "label": "resource", + "sent": "ticket=gs-aktbdkka1q@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=gs-aktbdkka1q@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=gs-aktbdkka1q+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=gs-aktbdkka1q+comments@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "extra '=' in id", + "sent": "ticket==X1@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket==X1@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "registry function", + "sent": "support@great-service.example", + "smtp": "accepted", + "arrived_as": "support@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "function", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "person-style", + "sent": "bernd.worsch@great-service.example", + "smtp": "accepted", + "arrived_as": "bernd.worsch@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "upper-case local-part", + "sent": "Ticket=gs-aktbdkka1q@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=gs-aktbdkka1q@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "percent data", + "sent": "ticket=PR%2B42@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR%2B42@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "percent hack form", + "sent": "ticket%other.example@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket%other.example@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "bang in resource", + "sent": "ticket=gs-aktbdkka1q!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=gs-aktbdkka1q!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=gs-aktbdkka1q+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=gs-aktbdkka1q+comments!callback@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "reserved $", + "sent": "ticket=PR42$4.99@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42$4.99@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "quoted local-part", + "sent": "\"ticket=PR42\"@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42@great-service.example", + "unchanged": false, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "unknown resource", + "sent": "ticket=DOES-NOT-EXIST@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=DOES-NOT-EXIST@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-001", + "sent": "bernd.worsch@great-service.example", + "smtp": "accepted", + "arrived_as": "bernd.worsch@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-002", + "sent": "anne-marie.schmidt@great-service.example", + "smtp": "accepted", + "arrived_as": "anne-marie.schmidt@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-003", + "sent": "support@great-service.example", + "smtp": "accepted", + "arrived_as": "support@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "function", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-004", + "sent": "invoice-reviewer_backup@great-service.example", + "smtp": "accepted", + "arrived_as": "invoice-reviewer_backup@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-005", + "sent": "happy-helper.v2@great-service.example", + "smtp": "accepted", + "arrived_as": "happy-helper.v2@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-010", + "sent": "ticket=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-011", + "sent": "invoice=RE=2026-4711@great-service.example", + "smtp": "accepted", + "arrived_as": "invoice=RE=2026-4711@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-012", + "sent": "ticket=PR42+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+comments@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-013", + "sent": "bernd.worsch+github@great-service.example", + "smtp": "accepted", + "arrived_as": "bernd.worsch+github@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "corpus p1-020", + "sent": "=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "=PR42@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-021", + "sent": "ticket=@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-022", + "sent": "ticket=PR42+@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-023", + "sent": "ticket=PR42+comments+attachments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+comments+attachments@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-024", + "sent": "ticket=PR42$4.99@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42$4.99@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-025", + "sent": "ticket=PR42^1h@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42^1h@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-026", + "sent": ".bernd@great-service.example", + "smtp": "accepted", + "arrived_as": "\".bernd\"@great-service.example", + "unchanged": false, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-027", + "sent": "bernd.@great-service.example", + "smtp": "accepted", + "arrived_as": "\"bernd.\"@great-service.example", + "unchanged": false, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-028", + "sent": "bernd..worsch@great-service.example", + "smtp": "accepted", + "arrived_as": "\"bernd..worsch\"@great-service.example", + "unchanged": false, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-029", + "sent": "ticket=PR.42@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR.42@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-030", + "sent": "ticket=PR%2B42@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR%2B42@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p2-001", + "sent": "ticket!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket!callback@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "function", + "app_status": 201, + "app": "created" + }, + { + "label": "corpus p2-002", + "sent": "ticket=PR42!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p2-003", + "sent": "ticket=PR42+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+comments!callback@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p2-010", + "sent": "ticket!@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket!@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p2-011", + "sent": "ticket!callback!chat@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket!callback!chat@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p2-012", + "sent": "ticket!callback+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket!callback+comments@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p2-013", + "sent": "ticket=PR42+comments!callback$4.99@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+comments!callback$4.99@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p2-014", + "sent": "ticket=PR42+comments!call.back@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42+comments!call.back@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-040", + "sent": "\"ticket=PR42\"@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=PR42@great-service.example", + "unchanged": false, + "mas_valid": true, + "kind": "resource", + "app_status": 202, + "app": "unknown-resource" + }, + { + "label": "corpus p1-041", + "sent": "t\u00efcket=PR42@great-service.example", + "smtp": "rejected: 'ascii' codec can't encode character '\\xef' in position 30: ordinal not in range(128)", + "arrived_as": null, + "unchanged": false, + "mas_valid": null, + "kind": null, + "app_status": null, + "app": null + }, + { + "label": "corpus p1-042", + "sent": "Ticket=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=PR42@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-043", + "sent": "my--type=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "my--type=PR42@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-044", + "sent": "type-=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "type-=PR42@great-service.example", + "unchanged": true, + "mas_valid": false, + "kind": null, + "app_status": 422, + "app": "not-mas" + }, + { + "label": "corpus p1-045", + "sent": "support-case_v2=PR42@great-service.example", + "smtp": "accepted", + "arrived_as": "support-case_v2=PR42@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "label": "relay attempt", + "sent": "x@other.example", + "smtp": "rejected: {'x@other.example': (554, b'5.7.1 : Recipient address rejected: Access denied')}", + "arrived_as": null, + "unchanged": false, + "relay_refused": true + }, + { + "label": "relay attempt", + "sent": "x!y@other.example", + "smtp": "rejected: {'x!y@other.example': (554, b'5.7.1 : Recipient address rejected: Access denied')}", + "arrived_as": null, + "unchanged": false, + "relay_refused": true + }, + { + "label": "relay attempt", + "sent": "a@other.example@great-service.example", + "smtp": "rejected: {'a@other.example@great-service.example': (554, b'5.7.1 : Recipient address rejected: Access denied')}", + "arrived_as": null, + "unchanged": false, + "relay_refused": true + }, + { + "label": "relay attempt", + "sent": "a@great-service.example@other.example", + "smtp": "rejected: {'a@great-service.example@other.example': (554, b'5.7.1 : Recipient address rejected: Access denied')}", + "arrived_as": null, + "unchanged": false, + "relay_refused": true + }, + { + "label": "relay attempt", + "sent": "x%y@other.example", + "smtp": "rejected: {'x%y@other.example': (554, b'5.7.1 : Recipient address rejected: Access denied')}", + "arrived_as": null, + "unchanged": false, + "relay_refused": true + } + ] +} diff --git a/integrations/exim/NOTES.md b/integrations/exim/NOTES.md new file mode 100644 index 0000000..7ad0df8 --- /dev/null +++ b/integrations/exim/NOTES.md @@ -0,0 +1,51 @@ +# Exim notes (MAS-WP-0004 T03) + +No Exim profile is shipped, because there is no demand yet. These notes say +what one would need. The ACL finding was checked against the stock config +of Debian bookworm `exim4-daemon-light` (Exim 4.96, 2026-10-19). The router +and transport points come from the Exim documentation and are untested. + +## Pitfalls + +- **Stock ACL rejects every P2 address.** `acl_check_rcpt` denies local + domains' local-parts that match `CHECK_RCPT_LOCAL_LOCALPARTS`, which + defaults to `^[.] : ^.*[@%!/|`#&?]`. So `ticket!callback@D` and + `ticket=PR%2B42@D` get `550 restricted characters in address`. This is the + same failure class as Postfix's `reject_unauth_destination`. Upstream + Exim's example `configure` has the same rule (`^[.] : ^.*[@%!/|]`). +- **Percent hack** is off by default (`percent_hack_domains` unset), so `%` + is not rewritten once the ACL lets it through. Exim has no bang-path + rewriting. +- **Case.** Routers lower-case `$local_part` unless `caseful_local_part = + true`, which would hide `Ticket=PR42` (invalid in MAS) as `ticket=PR42`. +- **Suffixes.** A router with `local_part_suffix = +*` strips facets before + the transport sees them. Do not set it on the MAS router. + +## Settings a profile would need + +``` +# main section: drop % and ! from the local-domain restriction +CHECK_RCPT_LOCAL_LOCALPARTS = ^[.] : ^.*[@/|`#&?] + +# router, first for the MAS domain +mas: + driver = accept + domains = D + caseful_local_part = true + transport = mas_pipe + +# transport: one recipient per call, envelope recipient as received +mas_pipe: + driver = pipe + batch_max = 1 + user = mas + command = /usr/local/bin/mas-gateway --statute \ + --trusted-authserv-id D pipe --app-url \ + --recipient ${quote:$local_part}@$domain +``` + +Keep `CHECK_RCPT_REMOTE_LOCALPARTS` as it is: relaying stays closed, and only +D is a local domain. Sender authentication would use Exim's built-in +`dkim`/`spf`/`dmarc` ACL conditions with `add_header = +Authentication-Results: D; ...`. Incoming A-R headers for D must be removed +first (`headers_remove`). diff --git a/integrations/postfix/README.md b/integrations/postfix/README.md new file mode 100644 index 0000000..882a3fb --- /dev/null +++ b/integrations/postfix/README.md @@ -0,0 +1,113 @@ +# 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 --trusted-authserv-id D + pipe --app-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`. diff --git a/integrations/postfix/install.sh b/integrations/postfix/install.sh new file mode 100755 index 0000000..6af547b --- /dev/null +++ b/integrations/postfix/install.sh @@ -0,0 +1,122 @@ +#!/bin/sh +# MAS Postfix integration profile (MAS-WP-0004). Configures an installed +# Postfix to receive mail for one MAS domain and hand every recipient to +# mas-gateway. See README.md for why each setting is needed. +# +# install.sh --domain D --statute FILE --app-url URL [--gateway CMD] [--auth] +# [--relay HOST:PORT --relay-credentials DIR] +# +# --relay sends outbound mail (the app's replies) through an authenticated +# submission relay; DIR holds the files username and password. Without it +# all outbound mail fails fast. +# --auth adds sender authentication (OpenDKIM verify + OpenDMARC milters, +# annotate-only) and strips forged Authentication-Results for D. The caller +# starts the milters (opendkim -f -x /etc/opendkim.conf, opendmarc -f -c +# /etc/opendmarc.conf) and Postfix. +set -e + +GATEWAY=/usr/local/bin/mas-gateway +AUTH=no +while [ $# -gt 0 ]; do + case "$1" in + --domain) DOMAIN=$2; shift 2 ;; + --statute) STATUTE=$2; shift 2 ;; + --app-url) APP_URL=$2; shift 2 ;; + --gateway) GATEWAY=$2; shift 2 ;; + --auth) AUTH=yes; shift ;; + --relay) RELAY=$2; shift 2 ;; + --relay-credentials) RELAY_CREDS=$2; shift 2 ;; + *) echo "install.sh: unknown argument $1" >&2; exit 64 ;; + esac +done +: "${DOMAIN:?--domain is required}" "${STATUTE:?--statute is required}" "${APP_URL:?--app-url is required}" +DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/\./\\./g') + +# 1. Relay control. Stock reject_unauth_destination refuses every local-part +# containing '!' or '%' (sender-specified routing), so it cannot be used. +# An explicit regexp permits only @DOMAIN; everything else hits the final +# 'reject' (smtpd will not start without a reject-class entry). +# allow_untrusted_routing=yes is needed, otherwise Postfix ignores the +# regexp result for '!'/'%' local-parts. It is safe here because +# relay_domains is empty and nothing rewrites such addresses (2.). +printf '/@%s$/ permit\n' "$DOMAIN_RE" > /etc/postfix/mas_rcpt.regexp +postconf -e \ + "myhostname=$DOMAIN" "mydomain=$DOMAIN" "mydestination=$DOMAIN" "relay_domains=" \ + "mynetworks=127.0.0.0/8" \ + "smtpd_relay_restrictions=permit_mynetworks,check_recipient_access regexp:/etc/postfix/mas_rcpt.regexp,reject" \ + "allow_untrusted_routing=yes" + +# 2. Never rewrite MAS syntax: no bang-path swap, no percent hack. +postconf -e "swap_bangpath=no" "allow_percent_hack=no" + +# 3. Every recipient of DOMAIN goes to the mas pipe, one recipient per call, +# as the envelope recipient Postfix received. recipient_delimiter=+ does +# not change ${recipient}; it only lets Postfix know facets exist. +postconf -e "local_transport=mas" "local_recipient_maps=" \ + "mas_destination_recipient_limit=1" "recipient_delimiter=+" +if ! grep -q '^mas[[:space:]]' /etc/postfix/master.cf; then + cat >> /etc/postfix/master.cf < /etc/postfix/sasl_passwd + chmod 600 /etc/postfix/sasl_passwd && postmap /etc/postfix/sasl_passwd + postconf -e "relayhost=$RELAY" "smtp_sasl_auth_enable=yes" \ + "smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd" \ + "smtp_sasl_security_options=noanonymous" "smtp_tls_security_level=secure" \ + "smtp_tls_CApath=/etc/ssl/certs" \ + "sender_dependent_default_transport_maps=inline:{<>=discard:}" \ + "empty_address_default_transport_maps_lookup_key=<>" +else + postconf -e "default_transport=error:5.7.1 outbound mail disabled" +fi + +[ "$AUTH" = yes ] || exit 0 + +# 6. Sender authentication (spec 14 item 10): annotate only, never reject, +# accept if a milter is down. Our own replies (loopback) are skipped. +# header_checks drop incoming A-R headers claiming DOMAIN; they do not +# apply to milter-added headers, so only the milters' results survive. +# (OpenDKIM's RemoveARFrom did not remove them in testing.) +mkdir -p /run/opendkim /run/opendmarc +chown opendkim:opendkim /run/opendkim && chown opendmarc:opendmarc /run/opendmarc +printf '127.0.0.1\n' > /etc/opendkim-peers +cat > /etc/opendkim.conf < /etc/opendmarc.conf < /etc/postfix/ar_strip.regexp +postconf -e "header_checks=regexp:/etc/postfix/ar_strip.regexp" \ + "smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8893" "non_smtpd_milters=" \ + "milter_default_action=accept" "milter_protocol=6" diff --git a/tests/test_postfix_profile.py b/tests/test_postfix_profile.py new file mode 100644 index 0000000..0761133 --- /dev/null +++ b/tests/test_postfix_profile.py @@ -0,0 +1,62 @@ +"""The Postfix integration profile against a real Postfix (MAS-WP-0004 T02). + +Runs only where Docker is available; builds experiments/postfix-probe. +""" + +import importlib.util +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PROBE_DIR = ROOT / "experiments/postfix-probe" + + +def _docker_ok(): + if not shutil.which("docker"): + return False + return subprocess.run(["docker", "info"], capture_output=True).returncode == 0 + + +pytestmark = pytest.mark.skipif(not _docker_ok(), reason="Docker not available") + + +@pytest.fixture(scope="module") +def probe_results(): + build = subprocess.run(["docker", "build", "-q", "-t", "mas-postfix-probe", str(PROBE_DIR)], + capture_output=True, text=True) + if build.returncode != 0: + pytest.skip(f"cannot build probe image: {build.stderr[-300:]}") + spec = importlib.util.spec_from_file_location("postfix_probe", PROBE_DIR / "probe.py") + probe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(probe) + results, _ = probe.run("mas-profile", 2625, corpus=True) + return probe, results + + +def test_never_rewritten(probe_results): + # Delivered as sent up to RFC 5321 quoting, or refused at SMTP; never altered. + _, results = probe_results + for r in results: + if "relay_refused" in r or r["smtp"] != "accepted": + continue + assert r["equivalent"], r + if not r["unchanged"] and r["arrived_as"].startswith('"'): + assert r["mas_valid"] is False, r # quoting added only to non-MAS forms + + +def test_p2_forms_delivered(probe_results): + _, results = probe_results + p2 = [r for r in results if "!" in r["sent"].split("@")[0] and "relay_refused" not in r] + assert p2 + for r in p2: + assert r["smtp"] == "accepted" and r["unchanged"], r + + +def test_not_an_open_relay(probe_results): + probe, results = probe_results + relay = [r for r in results if "relay_refused" in r] + assert [r["sent"] for r in relay] == probe.RELAY_NEGATIVES + assert all(r["relay_refused"] for r in relay), relay diff --git a/workplans/MAS-WP-0004-postfix-integration-profile.md b/workplans/MAS-WP-0004-postfix-integration-profile.md index 652338f..40b1fbd 100644 --- a/workplans/MAS-WP-0004-postfix-integration-profile.md +++ b/workplans/MAS-WP-0004-postfix-integration-profile.md @@ -4,11 +4,11 @@ type: workplan title: "MAS Postfix integration profile" domain: communication repo: mail-address-semantics -status: proposed +status: finished owner: claude topic_slug: coulomb-social created: "2026-10-18" -updated: "2026-10-18" +updated: "2026-10-19" origin: residual origin_ref: MAS-WP-0003 state_hub_workstream_id: "bf5eebc0-3b97-5c78-9c3a-58834258744e" @@ -27,7 +27,7 @@ integration profile. ```task id: MAS-WP-0004-T01 -status: todo +status: done priority: high state_hub_task_id: "e1d1c1fb-b34e-5ea3-98e5-10556b80a83b" ``` @@ -50,7 +50,7 @@ Explain each setting and say why the stock config fails. ```task id: MAS-WP-0004-T02 -status: todo +status: done priority: high state_hub_task_id: "dcdf491a-3be8-566e-8111-c496a7970b4b" ``` @@ -64,7 +64,7 @@ suite where Docker is available. ```task id: MAS-WP-0004-T03 -status: todo +status: done priority: low state_hub_task_id: "a3ddf4f1-6b21-5ee7-8dae-4926fcf4181e" ``` @@ -72,3 +72,15 @@ state_hub_task_id: "a3ddf4f1-6b21-5ee7-8dae-4926fcf4181e" Check whether Exim has equivalent pitfalls for `!`/`%` local-parts and record the needed settings. Build an Exim profile only if there is demand. + +## Outcome + +- Profile: `integrations/postfix/install.sh` and `README.md`. The live + deployment (`deploy/railiance/entrypoint.sh`) now uses it. +- Probe: `probe.py --profile mas-profile --corpus`, run in pytest as + `tests/test_postfix_profile.py` where Docker is available. Every corpus + address arrives unchanged up to RFC 5321 quoting: Postfix adds quotes + around non-dot-atoms (`.bernd` becomes `".bernd"`), and those are still + invalid MAS. All relay negatives get 554. +- Exim: `integrations/exim/NOTES.md`. The stock ACL rejects `!`/`%` for + local domains. No profile is built, because there is no demand yet.