From e19d557a4ce9f93f81f46ca4fbdf636a61d88b9a Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 00:51:27 +0200 Subject: [PATCH] Test MAS addresses against real Postfix; close MAS-WP-0002, add MAS-WP-0003 - 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 623586@bnt-lap001 Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8 --- docs/experiment-review.md | 67 ++++++-- experiments/postfix-probe/Dockerfile | 8 + experiments/postfix-probe/README.md | 29 ++++ experiments/postfix-probe/capture.py | 40 +++++ experiments/postfix-probe/entrypoint.sh | 42 +++++ experiments/postfix-probe/probe.py | 118 +++++++++++++ .../postfix-probe/results/defaults-local.json | 159 ++++++++++++++++++ .../postfix-probe/results/defaults.json | 159 ++++++++++++++++++ .../results/local-domain-safe.json | 159 ++++++++++++++++++ .../postfix-probe/results/local-domain.json | 159 ++++++++++++++++++ .../postfix-probe/results/mas-safe-local.json | 159 ++++++++++++++++++ workplans/MAS-WP-0002-reference-experiment.md | 6 +- workplans/MAS-WP-0003-provider-live-test.md | 94 +++++++++++ 13 files changed, 1179 insertions(+), 20 deletions(-) create mode 100644 experiments/postfix-probe/Dockerfile create mode 100644 experiments/postfix-probe/README.md create mode 100644 experiments/postfix-probe/capture.py create mode 100644 experiments/postfix-probe/entrypoint.sh create mode 100644 experiments/postfix-probe/probe.py create mode 100644 experiments/postfix-probe/results/defaults-local.json create mode 100644 experiments/postfix-probe/results/defaults.json create mode 100644 experiments/postfix-probe/results/local-domain-safe.json create mode 100644 experiments/postfix-probe/results/local-domain.json create mode 100644 experiments/postfix-probe/results/mas-safe-local.json create mode 100644 workplans/MAS-WP-0003-provider-live-test.md diff --git a/docs/experiment-review.md b/docs/experiment-review.md index 74ea403..740fee8 100644 --- a/docs/experiment-review.md +++ b/docs/experiment-review.md @@ -5,11 +5,12 @@ Status of MAS-WP-0002, Milestone 9. The answers below come from building and from running the test suite (135 tests, all offline except the loopback fixture servers). -**Scope caveat:** every "email path" here is simulated. Raw RFC 5322 -messages go through the real `mas-gateway pipe` entrypoint, but no -production MTA or mailbox provider was involved. The *Email infrastructure* -question is therefore still open and needs the live test described at the -end. +**Scope:** the MTA half of the email path was tested against a real +Postfix (3.7.11, Debian bookworm, in Docker). The setup was SMTP → Postfix +→ `pipe` transport → `mas-gateway` → reference ticket service +(`experiments/postfix-probe/`). Mailbox providers (Gmail, Outlook, +corporate gateways) and public DNS/MX were not involved. That half moves +to **MAS-WP-0003**. ## Answers to the review questions @@ -62,11 +63,43 @@ Facets have no statute declaration at all, so they are always reported as they recognise. ### Email infrastructure: which forms were rewritten or rejected? -**Not yet tested.** Known risks to check: -- `=` overlaps with SRS (`SRS0=...`) and VERP encodings. -- `!` is UUCP bang-path syntax. Some validators and web forms reject it. -- Postfix `recipient_delimiter = +` strips the facet for lookups. That fits - the facet-degradation design. +**Postfix (tested):** 14 address forms were sent through 5 Postfix +configurations (`experiments/postfix-probe/results/`): +- relay domain with defaults; +- local rewrite context, with and without `swap_bangpath` / + `allow_percent_hack`; +- domain in `mydestination` (local delivery class), with and without those + two settings. + +The results: +- **13 of 14 forms arrived byte-identical in every configuration.** That + includes `ticket!callback`, `ticket=ID+comments`, `ticket==X1`, + `ticket=PR%2B42`, `ticket=ID+comments!callback` and `ticket=PR42$4.99`. + `recipient_delimiter = +` does not change the envelope recipient that + `pipe` passes on as `${recipient}`. Upper case is preserved. +- **The quoted local-part `"ticket=PR42"` arrived unquoted** as + `ticket=PR42`. Postfix drops quotes that are not needed (RFC 5321 + treats the two forms as equivalent), so a form the spec calls + non-canonical became canonical in transit. **Spec gap:** §4 should say + that a quoted local-part whose content is a valid dot-string is + equivalent to the unquoted form, because an MAS receiver after an MTA + cannot tell them apart. +- **The `!` and `%` legacy rewrites did not trigger** for any MAS address, + even with Postfix defaults and the domain in `mydestination`. In 3.7 the + bang-path swap fired only for a bare `site!user` with no `@domain` + (checked via local `sendmail`: it was rerouted to `site`). MAS addresses + always carry an `@domain`. Setting `swap_bangpath = no` and + `allow_percent_hack = no` is still a sensible hardening default for MAS + hosts. +- `$` passed through SMTP untouched. Rejecting it is purely a MAS parser + decision. The gateway forwarded it as `valid: false`, and the app + ignored it. + +**Still untested (MAS-WP-0003):** +- whether senders' providers and web forms accept `!` and `=` in recipient + addresses; +- overlap with SRS (`SRS0=...`) and VERP when forwarding; +- facet stripping by providers. ### Resource addressing: did it improve correlation? Yes. The service replies *from* `ticket=GS-…@great-service.example`, so an @@ -107,10 +140,10 @@ That is also an injection and spam vector. Ticket ids are non-guessable (10 Crockford base32 characters), but sender policy belongs to the application, not to MAS. -## Recommended next step (before the decision gate) -Run the live path: a real domain, the static statute served at -`/.well-known/mas`, Postfix with the `mas-gateway pipe` transport, and test -mail sent from Gmail, Outlook and one corporate gateway to -`ticket!callback@`, `ticket=ID@` and `ticket=ID+comments@`. Record which -addresses arrive unchanged. That evidence answers the open -*Email infrastructure* question. +## Next step (MAS-WP-0003, before the decision gate) +Run the provider half on a real subdomain: +- an MX pointing to a Postfix configured like the probe; +- the statute served at `https:///.well-known/mas`; +- test mail from Gmail, Outlook and one corporate gateway to + `ticket!callback@`, `ticket=ID@` and `ticket=ID+comments@`, plus replies + to the service's `ticket=ID@` From address. diff --git a/experiments/postfix-probe/Dockerfile b/experiments/postfix-probe/Dockerfile new file mode 100644 index 0000000..cf2f2e1 --- /dev/null +++ b/experiments/postfix-probe/Dockerfile @@ -0,0 +1,8 @@ +# 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/* +RUN useradd -r -s /usr/sbin/nologin mas +COPY entrypoint.sh /entrypoint.sh +EXPOSE 25 8024 8025 +ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] diff --git a/experiments/postfix-probe/README.md b/experiments/postfix-probe/README.md new file mode 100644 index 0000000..52aebb0 --- /dev/null +++ b/experiments/postfix-probe/README.md @@ -0,0 +1,29 @@ +# Postfix probe + +A real Postfix delivers MAS addresses through the `pipe` transport to +`mas-gateway` and on to the reference ticket service. A capture proxy +(`capture.py`) records the envelope recipient each event carried, so the +probe can tell whether Postfix rewrote anything. + +```bash +docker build -t mas-postfix-probe experiments/postfix-probe +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 +`docs/experiment-review.md` under *Email infrastructure*. + +The Postfix side is the `master.cf` entry in `entrypoint.sh`: + +``` +mas unix - n n - - pipe + flags=Rq user=mas argv=/usr/local/bin/mas-gateway --statute + pipe --app-url http://127.0.0.1:8024/mas-events --recipient ${recipient} +``` + +Set `mas_destination_recipient_limit = 1` so each pipe invocation handles +one recipient. diff --git a/experiments/postfix-probe/capture.py b/experiments/postfix-probe/capture.py new file mode 100644 index 0000000..bbaa0a6 --- /dev/null +++ b/experiments/postfix-probe/capture.py @@ -0,0 +1,40 @@ +"""Probe-only proxy: records each MAS event as delivered by Postfix, then +forwards it to the ticket service and records the app's answer.""" + +import json +import sys +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = sys.argv[1] +EVENTS = [] + + +class H(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + req = urllib.request.Request(UPSTREAM, body, {"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req) as r: + status, resp = r.status, r.read() + except urllib.error.HTTPError as e: + status, resp = e.code, e.read() + EVENTS.append({"event": json.loads(body), "app_status": status, "app": json.loads(resp or b"null")}) + self._send(status, resp) + + def do_GET(self): + self._send(200, json.dumps(EVENTS).encode()) + + def _send(self, status, body): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + +ThreadingHTTPServer(("0.0.0.0", 8024), H).serve_forever() diff --git a/experiments/postfix-probe/entrypoint.sh b/experiments/postfix-probe/entrypoint.sh new file mode 100644 index 0000000..87b3c9a --- /dev/null +++ b/experiments/postfix-probe/entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Repo is mounted read-only at /mas. PROFILE=defaults|mas-safe selects Postfix settings. +set -e +cat > /usr/local/bin/mas-gateway <<'SH' +#!/bin/sh +exec env PYTHONPATH=/mas python3 -m mas_gateway.main "$@" +SH +chmod +x /usr/local/bin/mas-gateway + +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" \ + "mas_destination_recipient_limit=1" "recipient_delimiter=+" "compatibility_level=3.6" +# PROFILE: defaults - Postfix defaults; probe client is "remote" (no address rewriting) +# defaults-local - defaults, but every client gets the local rewrite context +# mas-safe-local - local rewrite context with swap_bangpath/allow_percent_hack off +# local-domain - domain served via mydestination (local delivery class), defaults +# local-domain-safe - local-domain with swap_bangpath/allow_percent_hack off +case "$PROFILE" in + local-domain*) postconf -e "relay_domains=" "transport_maps=" "mydestination=great-service.example" \ + "local_transport=mas" "local_recipient_maps=" ;; +esac +case "$PROFILE" in + *-local) postconf -e "local_header_rewrite_clients=static:all" ;; +esac +case "$PROFILE" in + mas-safe*|*-safe) postconf -e "swap_bangpath=no" "allow_percent_hack=no" ;; +esac +cat >> /etc/postfix/master.cf <<'CF' +mas unix - n n - - pipe + flags=Rq user=mas argv=/usr/local/bin/mas-gateway + --statute /mas/examples/statute/great-service.example/.well-known/mas + pipe --app-url http://127.0.0.1:8024/mas-events --recipient ${recipient} +CF +# Postfix runs `mas-gateway` as user mas; the service needs a writable DB. +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=${PROFILE:-defaults}" +postconf swap_bangpath allow_percent_hack recipient_delimiter local_header_rewrite_clients +exec postfix start-fg diff --git a/experiments/postfix-probe/probe.py b/experiments/postfix-probe/probe.py new file mode 100644 index 0000000..2f38db7 --- /dev/null +++ b/experiments/postfix-probe/probe.py @@ -0,0 +1,118 @@ +#!/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) diff --git a/experiments/postfix-probe/results/defaults-local.json b/experiments/postfix-probe/results/defaults-local.json new file mode 100644 index 0000000..54a82ba --- /dev/null +++ b/experiments/postfix-probe/results/defaults-local.json @@ -0,0 +1,159 @@ +{ + "profile": "defaults-local", + "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-9G9WEYSMDH@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-9G9WEYSMDH@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=GS-9G9WEYSMDH+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-9G9WEYSMDH+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-9G9WEYSMDH@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=GS-9G9WEYSMDH@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "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-9G9WEYSMDH!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-9G9WEYSMDH!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=GS-9G9WEYSMDH+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-9G9WEYSMDH+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" + } + ] +} diff --git a/experiments/postfix-probe/results/defaults.json b/experiments/postfix-probe/results/defaults.json new file mode 100644 index 0000000..9b8bdb2 --- /dev/null +++ b/experiments/postfix-probe/results/defaults.json @@ -0,0 +1,159 @@ +{ + "profile": "defaults", + "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-2H6T5NBH6K@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-2H6T5NBH6K@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=GS-2H6T5NBH6K+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-2H6T5NBH6K+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-2H6T5NBH6K@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=GS-2H6T5NBH6K@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "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-2H6T5NBH6K!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-2H6T5NBH6K!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=GS-2H6T5NBH6K+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-2H6T5NBH6K+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" + } + ] +} diff --git a/experiments/postfix-probe/results/local-domain-safe.json b/experiments/postfix-probe/results/local-domain-safe.json new file mode 100644 index 0000000..a14304f --- /dev/null +++ b/experiments/postfix-probe/results/local-domain-safe.json @@ -0,0 +1,159 @@ +{ + "profile": "local-domain-safe", + "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-VZZRSGXV12@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-VZZRSGXV12@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=GS-VZZRSGXV12+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-VZZRSGXV12+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-VZZRSGXV12@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=GS-VZZRSGXV12@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "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-VZZRSGXV12!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-VZZRSGXV12!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=GS-VZZRSGXV12+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-VZZRSGXV12+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" + } + ] +} diff --git a/experiments/postfix-probe/results/local-domain.json b/experiments/postfix-probe/results/local-domain.json new file mode 100644 index 0000000..8c214df --- /dev/null +++ b/experiments/postfix-probe/results/local-domain.json @@ -0,0 +1,159 @@ +{ + "profile": "local-domain", + "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-D4QGSR85P2@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-D4QGSR85P2@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=GS-D4QGSR85P2+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-D4QGSR85P2+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-D4QGSR85P2@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=GS-D4QGSR85P2@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "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-D4QGSR85P2!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-D4QGSR85P2!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=GS-D4QGSR85P2+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-D4QGSR85P2+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" + } + ] +} diff --git a/experiments/postfix-probe/results/mas-safe-local.json b/experiments/postfix-probe/results/mas-safe-local.json new file mode 100644 index 0000000..52f841f --- /dev/null +++ b/experiments/postfix-probe/results/mas-safe-local.json @@ -0,0 +1,159 @@ +{ + "profile": "mas-safe-local", + "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-V5KFZ1PGY5@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-V5KFZ1PGY5@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "resource + facet", + "sent": "ticket=GS-V5KFZ1PGY5+comments@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-V5KFZ1PGY5+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-V5KFZ1PGY5@great-service.example", + "smtp": "accepted", + "arrived_as": "Ticket=GS-V5KFZ1PGY5@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "opaque", + "app_status": 404, + "app": "not-handled" + }, + { + "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-V5KFZ1PGY5!web@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-V5KFZ1PGY5!web@great-service.example", + "unchanged": true, + "mas_valid": true, + "kind": "resource", + "app_status": 200, + "app": "correlated" + }, + { + "label": "facet + interaction", + "sent": "ticket=GS-V5KFZ1PGY5+comments!callback@great-service.example", + "smtp": "accepted", + "arrived_as": "ticket=GS-V5KFZ1PGY5+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" + } + ] +} diff --git a/workplans/MAS-WP-0002-reference-experiment.md b/workplans/MAS-WP-0002-reference-experiment.md index 441e77e..439569e 100644 --- a/workplans/MAS-WP-0002-reference-experiment.md +++ b/workplans/MAS-WP-0002-reference-experiment.md @@ -4,7 +4,7 @@ type: workplan title: "MAS v0.1 reference experiment" domain: communication repo: mail-address-semantics -status: active +status: done owner: claude topic_slug: coulomb-social created: "2026-10-08" @@ -812,11 +812,11 @@ The service must handle this deliberately and without generating abusive backsca ## Milestone 9 — Experiment review -> First-pass findings: `docs/experiment-review.md`. Open item: live MTA/provider test (Email infrastructure question). +> Findings: `docs/experiment-review.md`. MTA half tested against real Postfix (`experiments/postfix-probe/`); provider half continues in MAS-WP-0003. ```task id: MAS-WP-0002-T10 -status: in_progress +status: done priority: medium state_hub_task_id: "31bbfb56-5541-5ace-aff7-94ed6f1df57a" ``` diff --git a/workplans/MAS-WP-0003-provider-live-test.md b/workplans/MAS-WP-0003-provider-live-test.md new file mode 100644 index 0000000..04ab163 --- /dev/null +++ b/workplans/MAS-WP-0003-provider-live-test.md @@ -0,0 +1,94 @@ +--- +id: MAS-WP-0003 +type: workplan +title: "MAS live provider test on a real subdomain" +domain: communication +repo: mail-address-semantics +status: ready +owner: claude +topic_slug: coulomb-social +created: "2026-10-08" +updated: "2026-10-08" +--- + +# MAS live provider test on a real subdomain + +Follow-up to MAS-WP-0002. The MTA half of the review question *which +characters or address forms were rewritten or rejected?* was answered +against a real Postfix (`experiments/postfix-probe/`, +`docs/experiment-review.md`). This workplan answers the provider half. It +has to be done before the MAS-WP-0002 decision gate. + +coulomb.social mail is hosted by IONOS, so the test needs its own +subdomain (for example `mas.coulomb.social`) with its own MX. + +## Choose and provision the test subdomain + +```task +id: MAS-WP-0003-T01 +status: wait +priority: high +``` + +Needs the operator. Pick the subdomain. Add an MX (and A) record at IONOS +pointing to railiance01 (92.205.62.239). Allow inbound TCP 25 on +railiance01. Record the choice as a decision. + +## Deploy Postfix + gateway + ticket service + statute + +```task +id: MAS-WP-0003-T02 +status: todo +priority: high +``` + +Deploy the probe configuration on railiance01, using `local-domain-safe` +settings, TLS and the usual relay restrictions. Serve the statute (with +the namespace set to the subdomain) at `https:///.well-known/mas` +through Traefik. Verify the deployment with `mas discover `. +Outbound replies need a relay with SPF/DKIM for the subdomain; until that +exists, keep replies in the outbox only. + +## Provider send matrix + +```task +id: MAS-WP-0003-T03 +status: todo +priority: high +``` + +Needs the operator to send from their accounts. Send from Gmail (web and +mobile), Outlook.com and one corporate gateway to: +- `ticket!callback@` +- `ticket=ID@` +- `ticket=ID+comments@` +- `ticket=RE=2026-4711@` + +Then reply to a service message sent from `ticket=ID@`. For each +combination, record whether the provider's compose form accepts the +address and what envelope recipient arrives. + +## Forwarding and list paths + +```task +id: MAS-WP-0003-T04 +status: todo +priority: medium +``` + +Send via one forwarding service (SRS) and one mailing list (VERP). Check +whether `=` collides with SRS0/VERP encoding, and whether facets survive +or are stripped. + +## Close the review + +```task +id: MAS-WP-0003-T05 +status: todo +priority: high +``` + +Fold the results into `docs/experiment-review.md`. Turn the spec gaps +(quoted equivalence, resource-type case, type-intake semantics for +`ticket!MODE`) into spec change proposals. Then take the MAS-WP-0002 +decision gate.