#!/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)