- experiments/postfix-probe: Postfix 3.7 -> pipe -> mas-gateway -> ticket service in Docker; 14 address forms x 5 configurations. 13/14 arrive byte-identical everywhere; quoted "ticket=PR42" is unquoted in transit. Legacy bang/percent rewrites never fire for MAS addresses. - docs/experiment-review.md: Email infrastructure section with results - MAS-WP-0003: provider-side live test on a real subdomain (needs DNS/MX) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 623586@bnt-lap001 Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8
118 lines
4.7 KiB
Python
118 lines
4.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
|
|
|
|
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"),
|
|
]
|
|
|
|
|
|
def run(profile, port):
|
|
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:
|
|
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 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,
|
|
"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})
|
|
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=["defaults", "defaults-local", "mas-safe-local", "local-domain", "local-domain-safe"], default="defaults")
|
|
ap.add_argument("--port", type=int, default=2525)
|
|
ap.add_argument("--json", metavar="FILE")
|
|
ap.add_argument("--log", metavar="FILE")
|
|
a = ap.parse_args()
|
|
results, log = run(a.profile, a.port)
|
|
for r in results:
|
|
mark = "=" if r["unchanged"] else "!"
|
|
print(f"{mark} {r['label']:<22} {r['sent']:<52} -> {r['arrived_as'] or r['smtp']}"
|
|
f" [{r['kind']}/{r['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)
|