mail-address-semantics/examples/ticket_service/ticket_service.py
tegwick cca58d41fb MAS-WP-0005-T12: gateway reads trusted A-R verdict; ticket service replies only on DMARC pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
2026-10-18 22:40:30 +02:00

256 lines
12 KiB
Python

#!/usr/bin/env python3
"""Reference ticket service for the MAS v0.1 experiment.
Consumes normalized MAS events from `mas-gateway` (POST /mas-events) and
proves the canonical lifecycle:
A create ticket!callback@DOMAIN -> new ticket, reply from ticket=ID@DOMAIN
(any address the statute declares with "intake_for": "ticket")
B correlate ticket=ID[+facet]@DOMAIN -> message attached to ID; subject ignored
C closed ticket=ID@DOMAIN (closed) -> archived, ticket not mutated, one notice
D unknown ticket=NOPE@DOMAIN -> quarantined, no reply (no backscatter)
Ticket ids match case-insensitively (`ticket=gs-id@` finds `GS-ID`), because
forwarders and mailing lists lower-case local-parts in practice.
Resource addresses are correlation identifiers, not credentials: knowing
one lets you send mail to the ticket, nothing more. Admin endpoints here are
unauthenticated and meant for local experiments only.
"""
from __future__ import annotations
import argparse
import json
import secrets
import smtplib
import sqlite3
import threading
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Optional
from mas_core.generator import resource_local_part
RESOURCE_TYPE = "ticket"
INTAKE_TARGET = "ticket"
_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" # Crockford base32, no i l o u
def default_ticket_id() -> str:
"""Non-guessable-preferred, per the great-service statute.
Lower case, so the address a customer sees is the one forwarders and
mailing lists deliver (spec 6.2, SCP-7).
"""
return "gs-" + "".join(secrets.choice(_ALPHABET) for _ in range(10))
SCHEMA = """
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, domain TEXT NOT NULL, status TEXT NOT NULL,
requester TEXT, subject TEXT, requested_interaction TEXT, created_seq INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, disposition TEXT NOT NULL,
recipient TEXT, facet TEXT, sender TEXT, subject TEXT, message_id TEXT, body TEXT
);
CREATE TABLE IF NOT EXISTS outbox (
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, mail_from TEXT, mail_to TEXT,
subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0
);
"""
class TicketService:
def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id,
smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True,
max_sent: Optional[int] = None, require_dmarc_pass: bool = False):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.executescript(SCHEMA)
self.lock = threading.Lock()
self.new_id = id_factory
self.smtp = smtp
self.closed_notice = closed_notice
self.max_sent = max_sent # lifetime cap on mail actually sent via smtp
# spec 14 item 10: auto-reply only to authenticated senders
self.require_dmarc_pass = require_dmarc_pass
# -- event handling -------------------------------------------------------------
def handle(self, event: dict) -> tuple[int, dict]:
mas, msg = event.get("mas", {}), event.get("message", {})
with self.lock, self.db:
if not mas.get("valid"):
return 422, {"status": "not-mas", "action": "ignored"}
if not mas.get("namespace_confirmed"):
# No validated statute: we refuse to act on unconfirmed semantics.
return 422, {"status": "namespace-unconfirmed", "action": "ignored",
"discovery": mas.get("discovery")}
if mas.get("kind") == "resource" and mas.get("resource_type") == RESOURCE_TYPE:
return self._to_resource(event, mas, msg)
if mas.get("intake_for") == RESOURCE_TYPE: # declared by the statute (SCP-3)
return self._create(event, mas, msg)
return 404, {"status": "not-handled", "action": "ignored"}
def _create(self, event, mas, msg):
interaction = mas.get("interaction")
failures = {f["code"] for f in mas.get("failures", [])}
honored = interaction if interaction and not failures else None
tid = self.new_id()
domain = mas["domain"]
self.db.execute("INSERT INTO tickets VALUES (?,?,?,?,?,?,NULL)",
(tid, domain, "open", msg.get("from"), msg.get("subject"), honored))
self._store(tid, "attached", event, mas, msg)
address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{domain}"
lines = [f"Your request is now ticket {tid}.",
f"Reply to {address} to add to it; no reference in the subject is needed."]
if honored:
lines.append(f"You asked to continue via '{honored}'. We will try to honour that preference.")
elif interaction:
lines.append(f"The requested continuation mode '{interaction}' is not available "
f"({', '.join(sorted(failures))}); we will continue by email.")
self._reply(tid, address, msg, msg.get("subject") or "Your request", "\n".join(lines))
return 201, {"status": "created", "ticket_id": tid, "resource_address": address,
"requested_interaction": honored}
def _to_resource(self, event, mas, msg):
# Ids match case-insensitively: forwarders and list servers lower-case
# local-parts (SCP-7). From here on use the id as issued.
row = self.db.execute("SELECT * FROM tickets WHERE id=? COLLATE NOCASE AND domain=?",
(mas["resource_id"], mas["domain"])).fetchone()
if row is None:
self._store(None, "quarantined-unknown", event, mas, msg)
return 202, {"status": "unknown-resource", "action": "quarantined, no reply"}
tid = row["id"]
if row["status"] == "closed":
self._store(tid, "archived-after-close", event, mas, msg)
notified = False
if self.closed_notice and msg.get("from") and msg.get("from") == row["requester"]:
address = f"{resource_local_part(RESOURCE_TYPE, tid)}@{row['domain']}"
notified = self._reply(tid, address, msg, msg.get("subject") or "Ticket closed",
f"Ticket {tid} is closed. Your message was archived and did "
f"not reopen it. Write to {INTAKE_TARGET}@{row['domain']} "
f"for a new request.")
return 200, {"status": "closed", "ticket_id": tid, "action": "archived",
"mutated": False, "notified": notified}
self._store(tid, "attached", event, mas, msg)
return 200, {"status": "correlated", "ticket_id": tid, "facet": mas.get("facet")}
def _store(self, tid, disposition, event, mas, msg):
self.db.execute(
"INSERT INTO messages (ticket_id, disposition, recipient, facet, sender, subject, message_id, body)"
" VALUES (?,?,?,?,?,?,?,?)",
(tid, disposition, event.get("recipient"), mas.get("facet"), msg.get("from"),
msg.get("subject"), msg.get("message_id"), msg.get("text")))
def _reply(self, tid, mail_from, msg, subject, body) -> bool:
to = msg.get("from")
auto = (msg.get("auto_submitted") or "no").lower() != "no"
bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk")
if not to or auto or bulk:
return False # never auto-reply to automated mail
if self.require_dmarc_pass and (msg.get("authentication") or {}).get("dmarc") != "pass":
return False # unauthenticated sender: keep the ticket, send nothing
if not subject.lower().startswith("re:"):
subject = "Re: " + subject
self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)"
" VALUES (?,?,?,?,?,?)", (tid, mail_from, to, subject, body, msg.get("message_id")))
seq = self.db.execute("SELECT last_insert_rowid()").fetchone()[0]
if self.smtp and not self.send_cap_reached():
self._send(mail_from, to, subject, body, msg.get("message_id"))
self.db.execute("UPDATE outbox SET sent=1 WHERE seq=?", (seq,))
return True
def send_cap_reached(self) -> bool:
"""Over the cap, replies stay in the outbox unsent."""
if self.max_sent is None:
return False
return self.db.execute("SELECT COUNT(*) FROM outbox WHERE sent=1").fetchone()[0] >= self.max_sent
def _send(self, mail_from, to, subject, body, in_reply_to):
m = EmailMessage()
m["From"], m["To"], m["Subject"] = mail_from, to, subject
m["Auto-Submitted"] = "auto-replied"
if in_reply_to:
m["In-Reply-To"] = m["References"] = in_reply_to
m.set_content(body)
with smtplib.SMTP(*self.smtp) as s:
s.send_message(m)
# -- admin / inspection ----------------------------------------------------------
def close(self, tid: str) -> bool:
with self.lock, self.db:
return self.db.execute("UPDATE tickets SET status='closed' WHERE id=? COLLATE NOCASE",
(tid,)).rowcount == 1
def ticket(self, tid: str) -> Optional[dict]:
row = self.db.execute("SELECT * FROM tickets WHERE id=? COLLATE NOCASE", (tid,)).fetchone()
if row is None:
return None
msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq",
(row["id"],)).fetchall()
return {**dict(row), "messages": [dict(m) for m in msgs]}
def outbox(self) -> list[dict]:
return [dict(r) for r in self.db.execute("SELECT * FROM outbox ORDER BY seq")]
def quarantine(self) -> list[dict]:
return [dict(r) for r in self.db.execute(
"SELECT * FROM messages WHERE disposition='quarantined-unknown' ORDER BY seq")]
def make_server(service: TicketService, host="127.0.0.1", port=8025) -> ThreadingHTTPServer:
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
if self.path == "/mas-events":
try:
event = json.loads(body.decode())
except (UnicodeDecodeError, json.JSONDecodeError):
return self._json(400, {"error": "invalid JSON"})
return self._json(*service.handle(event))
parts = self.path.strip("/").split("/")
if len(parts) == 3 and parts[0] == "tickets" and parts[2] == "close":
return self._json(200 if service.close(parts[1]) else 404, {"ticket_id": parts[1]})
self._json(404, {"error": "not found"})
def do_GET(self):
parts = self.path.strip("/").split("/")
if len(parts) == 2 and parts[0] == "tickets":
t = service.ticket(parts[1])
return self._json(200 if t else 404, t or {"error": "not found"})
if parts == ["outbox"]:
return self._json(200, service.outbox())
self._json(404, {"error": "not found"})
def _json(self, status, obj):
out = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
def log_message(self, *a):
pass
return ThreadingHTTPServer((host, port), Handler)
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", default="tickets.sqlite3")
ap.add_argument("--listen", default="127.0.0.1:8025")
ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay")
ap.add_argument("--require-dmarc-pass", action="store_true",
help="auto-reply only when the event says the sender passed DMARC")
ap.add_argument("--max-sent", type=int, metavar="N",
help="stop sending once N replies have been sent in total (kept in the db)")
a = ap.parse_args()
smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None
h, p = a.listen.rsplit(":", 1)
print(f"ticket service on http://{a.listen}/mas-events")
make_server(TicketService(a.db, smtp=smtp, max_sent=a.max_sent, require_dmarc_pass=a.require_dmarc_pass), h, int(p)).serve_forever()