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