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

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 09:25:03 +02:00

155 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Send MAS test addresses through a real Postfix and report what arrived.
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
with the one that was sent.
"""
import argparse
import json
import smtplib
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
D = "great-service.example"
# (label, local-part as sent in RCPT TO). "{ID}" is filled with the created ticket id.
CASES = [
("P2 intake", "ticket!callback"),
("resource", "ticket={ID}"),
("resource + facet", "ticket={ID}+comments"),
("extra '=' in id", "ticket==X1"),
("registry function", "support"),
("person-style", "bernd.worsch"),
("upper-case local-part", "Ticket={ID}"),
("percent data", "ticket=PR%2B42"),
("percent hack form", "ticket%other.example"),
("bang in resource", "ticket={ID}!web"),
("facet + interaction", "ticket={ID}+comments!callback"),
("reserved $", "ticket=PR42$4.99"),
("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 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}",
"-v", f"{ROOT}:/mas:ro", "-p", f"127.0.0.1:{port}:25",
"-p", f"127.0.0.1:{port + 1}:8024", "mas-postfix-probe"], check=True,
capture_output=True)
try:
for _ in range(60):
try:
smtplib.SMTP("127.0.0.1", port, timeout=2).quit()
break
except OSError:
time.sleep(0.5)
results, tid = [], None
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"
f"Message-ID: <{abs(hash(rcpt))}@example.net>\r\n\r\nprobe\r\n")
before = len(events(port))
try:
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, 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
mas = got["event"]["mas"] if got else {}
app = got["app"] if got else None
if label == "P2 intake" and app:
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:
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
def events(port):
with urllib.request.urlopen(f"http://127.0.0.1:{port + 1}/") as r:
return json.loads(r.read())
def wait_event(port, before, timeout=15):
end = time.time() + timeout
while time.time() < end:
ev = events(port)
if len(ev) > before:
return ev[-1]
time.sleep(0.3)
return None
if __name__ == "__main__":
ap = argparse.ArgumentParser()
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, a.corpus)
for r in results:
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.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:
Path(a.log).write_text(log)
sys.exit(0)