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
This commit is contained in:
tegwick 2026-10-19 09:25:03 +02:00
parent 082d4c39eb
commit d766d61e7f
12 changed files with 1029 additions and 98 deletions

View file

@ -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

View file

@ -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 <<CONF
Mode v
Socket inet:8891@127.0.0.1
AuthservID $DOMAIN
PeerList /etc/opendkim-peers
UserID opendkim
PidFile /run/opendkim/opendkim.pid
Syslog no
CONF
cat > /etc/opendmarc.conf <<CONF
Socket inet:8893@127.0.0.1
AuthservID $DOMAIN
TrustedAuthservIDs $DOMAIN
SPFSelfValidate true
SPFIgnoreResults true
RejectFailures false
PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
UserID opendmarc
PidFile /run/opendmarc/opendmarc.pid
Syslog false
CONF
# Milters in the foreground: daemonized, OpenDKIM died on its first
# connection in this container.
opendkim -f -x /etc/opendkim.conf &
opendmarc -f -c /etc/opendmarc.conf &
printf '/^Authentication-Results:[[:space:]]*%s([[:space:];]|$)/ IGNORE\n' "$DOMAIN_RE" \
> /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 <<CF
mas unix - n n - - pipe
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
--statute $STATUTE_DIR/.well-known/mas --trusted-authserv-id $DOMAIN
pipe --app-url http://127.0.0.1:8024/mas-events --recipient \${recipient}
CF
mkdir -p /var/lib/mas && chown mas /var/lib/mas
# Replies are only recorded in the outbox unless a relay is configured above.

View file

@ -1,7 +1,7 @@
# Real Postfix -> 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

View file

@ -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`:

View file

@ -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" \

View file

@ -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:

View file

@ -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 <x@other.example>: 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 <x!y@other.example>: 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 <a@other.example@great-service.example>: 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 <a@great-service.example@other.example>: 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 <x%y@other.example>: Recipient address rejected: Access denied')}",
"arrived_as": null,
"unchanged": false,
"relay_refused": true
}
]
}

View file

@ -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 <statute> \
--trusted-authserv-id D pipe --app-url <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`).

View file

@ -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 <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`.

122
integrations/postfix/install.sh Executable file
View file

@ -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 <<CF
mas unix - n n - - pipe
flags=Rq user=mas argv=$GATEWAY
--statute $STATUTE --trusted-authserv-id $DOMAIN
pipe --app-url $APP_URL --recipient \${recipient}
CF
fi
# 4. Hygiene for a mail endpoint that only receives.
postconf -e "disable_vrfy_command=yes" "smtpd_helo_required=yes" "smtpd_recipient_limit=20"
# 5. Outbound. Only loopback may send (mynetworks), so this is the app's
# replies. Postfix's own bounces (null sender) are discarded rather than
# relayed, so the app's send cap bounds everything sent through the relay.
if [ -n "$RELAY" ]; then
: "${RELAY_CREDS:?--relay needs --relay-credentials}"
printf '%s %s:%s\n' "$RELAY" "$(cat "$RELAY_CREDS/username")" \
"$(cat "$RELAY_CREDS/password")" > /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 <<CONF
Mode v
Socket inet:8891@127.0.0.1
AuthservID $DOMAIN
PeerList /etc/opendkim-peers
UserID opendkim
PidFile /run/opendkim/opendkim.pid
Syslog no
CONF
cat > /etc/opendmarc.conf <<CONF
Socket inet:8893@127.0.0.1
AuthservID $DOMAIN
TrustedAuthservIDs $DOMAIN
SPFSelfValidate true
SPFIgnoreResults true
RejectFailures false
PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat
UserID opendmarc
PidFile /run/opendmarc/opendmarc.pid
Syslog false
CONF
printf '/^Authentication-Results:[[:space:]]*%s([[:space:];]|$)/ IGNORE\n' "$DOMAIN_RE" \
> /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"

View file

@ -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

View file

@ -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.