mail-address-semantics/examples/ticket_service/ticket_service.py
tegwick 59815701c1 Implement MAS v0.1 reference experiment (MAS-WP-0002 M0-M8, M9 first pass)
- mas_core: P2-maximal parser with required_profile, generator, statute
  schema + semantic lint + defaults-only inheritance, exact-host discovery
  with typed failures and same-origin redirects, pure policy resolver
- mas_conformance: vendored spec fixtures (drift-tested) + property and
  statute cases
- mas CLI: parse / explain / discover / lint-statute / test
- mas-gateway: generic mail -> MAS event -> HTTP (MTA pipe + webhook)
- examples: static great-service.example statute, reference ticket service
- docs/experiment-review.md: first-pass findings; live MTA test still open

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
2026-10-08 00:24:43 +02:00

226 lines
11 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
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)
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."""
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):
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
# -- 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("target_syntax") == "opaque" and mas.get("target") == INTAKE_TARGET:
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):
tid = mas["resource_id"]
row = self.db.execute("SELECT * FROM tickets WHERE id=? AND domain=?",
(tid, mas["domain"])).fetchone()
if row is None:
self._store(None, "quarantined-unknown", event, mas, msg)
return 202, {"status": "unknown-resource", "action": "quarantined, no reply"}
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 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")))
if self.smtp:
self._send(mail_from, to, subject, body, msg.get("message_id"))
return True
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=?", (tid,)).rowcount == 1
def ticket(self, tid: str) -> Optional[dict]:
row = self.db.execute("SELECT * FROM tickets WHERE id=?", (tid,)).fetchone()
if row is None:
return None
msgs = self.db.execute("SELECT * FROM messages WHERE ticket_id=? ORDER BY seq", (tid,)).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")
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), h, int(p)).serve_forever()