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
256 lines
12 KiB
Python
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()
|