mail-address-semantics/mas_modes/email_mode.py
tegwick 71c17fb5f9 MAS-WP-0014 T01: independent JS email side for mode contract v0.1; MCP-1..6; flow a-008
impl/js/src/email.mjs (written from CONTRACT.md + FLOWS.md): both roles,
case store, handoffs, timeouts, one-cap outbox with holding, authorization.
Passes all 22 flows from the bundle; with the JS master all 12 slave
flows (no Python contract party). Writing it found six contract gaps,
folded into the draft as MCP-1..6 (acknowledgement, details convention,
resume names the outcome, opaque case key, error table, transfer
reporting); flows now check outcome tokens, not reference prose. A
mutation check found a missing flow: a-008 (per-type master check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 13:56:53 +02:00

492 lines
25 KiB
Python

"""The email side of the MAS mode contract, in both roles (CONTRACT.md section 3).
Takes normalized gateway events (POST /mas-events), mode statuses
(POST /mode-status) and, in the slave role, outside-master commands
(POST /master). The role is configured per resource type:
- master: the case lives in a `MasterStore` (the reference ticket service).
Email negotiates by mail, hands off, records every status in the thread,
owns the deadline (`tick()`) and resumes by mail when a handoff ends.
- slave: an outside master issues case ids, gets every inbound mail as an
event, and decides what email sends and where mode requests go.
Every peer is authenticated (CONTRACT.md section 7): requests in and out
are signed with pairwise keys, master commands are accepted only from the
master configured for the resource type, and mode statuses only at the
handoff's own tokenized URL, from the peer it was sent to.
Every outbound mail goes through one shared `Mailer` (principle 10 and the
send cap), whoever asked for it. Outbound contract messages are queued while
the lock is held and sent after it is released, so peers may call back at
once.
"""
from __future__ import annotations
import json
import re
import secrets
import sqlite3
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Callable, Optional, Protocol, Union
from mas_core.generator import resource_local_part
from mas_core.parser import MasSyntaxError, parse_address
from mas_core.policy import resolve
from mas_core.statute import Statute
from . import message_errors
from .contract import TERMINAL, Idempotent, allowed, json_server, message, post, refuse
from .mailer import Mailer
NOT_ENABLED = {"InteractionModeNotEnabled", "ProfileNotEnabled"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS handoffs (
id TEXT PRIMARY KEY, role TEXT, resource_type TEXT, case_address TEXT, case_key TEXT,
case_ref TEXT, mode TEXT, status TEXT, seq INTEGER DEFAULT 0, deadline REAL,
needs TEXT, participant TEXT, endpoint TEXT, relay_seq INTEGER DEFAULT 0,
peer TEXT, token TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS intakes (
ref TEXT PRIMARY KEY, resource_type TEXT, domain TEXT, message_ref TEXT,
requested_mode TEXT, resource_id TEXT
);
CREATE TABLE IF NOT EXISTS cases (
key TEXT PRIMARY KEY, address TEXT, resource_type TEXT, resource_id TEXT, policy TEXT
);
CREATE TABLE IF NOT EXISTS case_messages (
ref TEXT PRIMARY KEY, case_key TEXT, message TEXT, disposition TEXT
);
"""
class MasterStore(Protocol):
"""Where email-mastered cases live (implemented by the reference ticket service)."""
def handle(self, event: dict) -> tuple[int, dict]: ...
def open_case(self, response: dict) -> Optional[str]: ... # case ref, if open
def case_address(self, case_ref: str) -> str: ...
def record(self, case_ref: str, kind: str, text: str) -> None: ...
def reply(self, case_ref: str, msg: dict, subject: str, body: str) -> bool: ...
@dataclass
class RoleConfig:
role: str # "master" | "slave"
modes: dict = field(default_factory=dict) # master: mode -> {"url": URL, "peer": ID}
fields: tuple = ("phone", "time") # details read from "Key: value" lines
timeout: float = 3600.0 # master: handoff deadline (seconds)
master_url: Optional[str] = None # slave: outside master's event URL
master_peer: Optional[str] = None # slave: the only peer allowed to command
autonomous_send: bool = False # slave: default policy
transfers: Union[str, dict] = "block" # slave: "block" | {"webhook": URL, "peer": ID}
def extract_details(text: str, fields) -> dict:
"""`Phone: +49 ...` style lines, for the fields a mode asks for."""
out = {}
for line in (text or "").splitlines():
m = re.match(r"\s*([A-Za-z_-]+)\s*:\s*(.+?)\s*$", line)
if m and m.group(1).lower() in fields and m.group(1).lower() not in out:
out[m.group(1).lower()] = m.group(2)
return out
def _resume_text(mode: str, status: str, info: dict) -> str:
why = info.get("reason") or info.get("detail") or ""
texts = {
"completed": f"Your {mode} has taken place.",
"failed": f"Your {mode} did not succeed{f' ({why})' if why else ''}.",
"expired": f"Your {mode} request expired{f' ({why})' if why else ''}.",
"refused": f"We cannot continue by {mode} ({why}).",
"cancelled": f"Your {mode} was cancelled.",
"timed-out": f"We could not arrange your {mode} in time.",
}
outcome = f"Outcome: {status}" + (f" ({why})" if why else "")
return f"{texts[status]} We continue by email: reply to this message.\n{outcome}"
class EmailMode:
def __init__(self, roles: dict[str, RoleConfig], keys: dict[str, bytes],
store: Optional[MasterStore] = None, peer_id: str = "email",
event_peer: Optional[str] = None, db_path: str = ":memory:",
statutes: Optional[dict[str, Statute]] = None,
clock: Callable[[], float] = time.time, mailer: Optional[Mailer] = None):
"""keys: peer id -> key shared with that peer. event_peer: the gateway's
peer id when events must be signed; None accepts unsigned events from
loopback only. mailer: the deployment's one outbound path, shared with
the master store; by default a private in-memory one."""
if any(c.role == "master" for c in roles.values()) and store is None:
raise ValueError("a master role needs a MasterStore")
for t, c in roles.items():
peers = [m["peer"] for m in c.modes.values()] if c.role == "master" else [c.master_peer]
if isinstance(c.transfers, dict):
peers.append(c.transfers.get("peer"))
missing = [p for p in peers if p not in keys]
if missing:
raise ValueError(f"{t}: no key for peer(s) {missing}")
self.keys = keys
self.peer_id = peer_id
self.event_peer = event_peer
self.roles = roles
self.store = store
self.statutes = statutes or {}
self.clock = clock
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.executescript(SCHEMA)
self.mailer = mailer or Mailer()
self.lock = threading.RLock()
self.base_url = "" # set by serve(); status URLs are base_url/mode-status/TOKEN
self._queue: list = []
self._once = Idempotent()
# -- HTTP entry points ---------------------------------------------------------
def server(self, host="127.0.0.1", port=0):
routes = {"/mas-events": self.handle_event, "/mode-status/": self.mode_status,
"/master": self.master_command,
"/tick": lambda body, peer, rest: (200, {"ok": True, "timed_out": self.tick()})}
unsigned = {"/tick"} | ({"/mas-events"} if self.event_peer is None else set())
return json_server(routes, self.keys, frozenset(unsigned), host, port)
def _run(self, fn, *args):
with self.lock, self.db:
out = fn(*args)
self._flush()
return out
def _send(self, url, peer, msg, on_result=None):
self._queue.append((url, (self.peer_id, self.keys[peer]), msg, on_result))
def _flush(self):
while True:
with self.lock:
if not self._queue:
return
url, signer, msg, on_result = self._queue.pop(0)
status, body = post(url, msg, signer)
if on_result:
with self.lock, self.db:
on_result(status, body)
# -- inbound mail --------------------------------------------------------------
def handle_event(self, event: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.event_peer: # None: unsigned on loopback, when configured so
return refuse("wrong-peer", "events come from the gateway only", 403)
mas = event.get("mas", {})
rtype = mas.get("resource_type") if mas.get("kind") == "resource" else mas.get("intake_for")
cfg = self.roles.get(rtype) if mas.get("valid") and mas.get("namespace_confirmed") else None
if cfg is None:
return self.store.handle(event) if self.store else (404, {"status": "not-handled"})
if cfg.role == "master":
return self._run(self._master_event, event, rtype, cfg)
return self._run(self._slave_event, event, rtype, cfg)
def _case(self, address: str) -> dict:
try:
addr = parse_address(address)
except MasSyntaxError:
return {"address": address, "key": address.rsplit("@", 1)[0]}
sem = resolve(addr, self.statutes.get(addr.domain))
return {"address": address, "key": sem.equivalence_key}
def _open_handoff(self, case_key):
rows = self.db.execute("SELECT * FROM handoffs WHERE case_key=? ORDER BY rowid", (case_key,))
return next((r for r in rows if r["status"] not in TERMINAL), None)
def _master_event(self, event, rtype, cfg):
code, resp = self.store.handle(event)
ref = self.store.open_case(resp)
if ref is None:
return code, resp
mas, msg = event["mas"], event.get("message", {})
case = self._case(self.store.case_address(ref))
h = self._open_handoff(case["key"])
if h is not None:
if h["status"] == "negotiating":
details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]"))
if details:
self.store.record(ref, "mode-update", f"{h['mode']}: details {sorted(details)}")
self._send(h["endpoint"], h["peer"], message("handoff.update", case,
handoff_id=h["id"], details=details))
return code, {**resp, "handoff_id": h["id"]}
mode = mas.get("interaction")
if not mode:
return code, resp
failures = {f["code"] for f in mas.get("failures", [])}
endpoint = cfg.modes.get(mode) or {}
hid = self._new_handoff("master", rtype, case, ref, mode, msg, endpoint.get("url"),
endpoint.get("peer"), self.clock() + cfg.timeout)
if failures & NOT_ENABLED:
# The creation reply already says so; only an existing case gets a notice.
self._status(hid, "refused", {"reason": "mode-not-enabled"},
notify=resp.get("status") != "created")
elif mode not in cfg.modes:
self._status(hid, "refused", {"reason": "mode-unavailable"})
else:
self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg)
return code, {**resp, "handoff_id": hid}
def _new_handoff(self, role, rtype, case, ref, mode, msg, endpoint, peer, deadline):
hid = "h-" + uuid.uuid4().hex[:12]
self.db.execute("INSERT INTO handoffs (id, role, resource_type, case_address, case_key,"
" case_ref, mode, status, deadline, participant, endpoint, peer, token)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(hid, role, rtype, case["address"], case["key"], ref, mode, None,
deadline, json.dumps(msg), endpoint, peer, secrets.token_urlsafe(24)))
return hid
def _request(self, hid, case, mode, details, msg):
h = self._handoff(hid)
self._status(hid, "requested", {})
req = message("handoff.request", case, handoff_id=hid, mode=mode, details=details,
status_url=f"{self.base_url}/mode-status/{h['token']}",
participant={"address": msg.get("from")} if msg.get("from") else None)
def on_result(status, body):
if status is None or status >= 400:
self._status(hid, "refused", {"reason": "mode-unavailable",
"detail": f"mode endpoint answered {status}"})
self._send(h["endpoint"], h["peer"], req, on_result)
def _handoff(self, hid):
return self.db.execute("SELECT * FROM handoffs WHERE id=?", (hid,)).fetchone()
# -- statuses ------------------------------------------------------------------
def mode_status(self, msg: dict, peer: Optional[str] = None, token: str = "") -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._mode_status, m, peer, token))
def _mode_status(self, msg, peer, token):
h = self.db.execute("SELECT * FROM handoffs WHERE token=?", (token,)).fetchone()
if h is None:
return refuse("unknown-handoff", "no handoff at this status URL", 404)
if peer != h["peer"]:
return refuse("wrong-peer", f"handoff {h['id']} was sent to another peer", 403)
errors = message_errors(msg)
if errors or msg.get("type") != "handoff.status":
return refuse("schema", "; ".join(errors) or "expected handoff.status", 400)
if msg["handoff_id"] != h["id"] or msg["case"]["key"] != h["case_key"]:
return refuse("unknown-handoff", msg["handoff_id"], 404)
if msg["seq"] <= h["seq"]:
return 200, {"ok": True, "ignored": "stale"}
if msg["status"] == "timed-out":
return refuse("invalid-transition", "only the timeout owner records timed-out")
if not allowed(h["status"], msg["status"]):
return refuse("invalid-transition", f"{h['status']} -> {msg['status']}")
self.db.execute("UPDATE handoffs SET seq=? WHERE id=?", (msg["seq"], h["id"]))
info = {k: msg[k] for k in ("needs", "at", "outcome", "reason", "detail") if k in msg}
self._status(h["id"], msg["status"], info)
return 200, {"ok": True}
def _status(self, hid, status, info, notify=True):
"""Record a status and react to it, per role."""
h = self._handoff(hid)
self.db.execute("UPDATE handoffs SET status=?, needs=? WHERE id=?",
(status, json.dumps(info.get("needs")) if "needs" in info else h["needs"], hid))
case = {"address": h["case_address"], "key": h["case_key"]}
participant = json.loads(h["participant"])
if h["role"] == "master":
self.store.record(h["case_ref"], "mode-status",
f"{h['mode']}: {status}" + "".join(f" {k}={v}" for k, v in info.items()))
if status == "negotiating":
self._ask(h, participant, info["needs"], lambda subj, body:
self.store.reply(h["case_ref"], participant, subj, body))
elif status in TERMINAL and notify:
self.store.reply(h["case_ref"], participant, participant.get("subject") or "Your request",
_resume_text(h["mode"], status, info))
return
# slave: the master owns the flow; relay, and ask only when allowed to
cfg = self.roles[h["resource_type"]]
if status != "requested":
self._send(cfg.master_url, cfg.master_peer, message(
"handoff.status", case, handoff_id=hid, seq=self._relay_seq(hid), status=status, **info))
if status == "negotiating" and self._policy(h["case_key"])["autonomous_send"]:
self._ask(h, participant, info["needs"], lambda subj, body: self.mailer.reply(
h["case_key"], h["case_address"], participant, subj, body) is None)
def _relay_seq(self, hid):
# Statuses relayed to the master are numbered by email, per handoff.
self.db.execute("UPDATE handoffs SET relay_seq=relay_seq+1 WHERE id=?", (hid,))
return self._handoff(hid)["relay_seq"]
def _ask(self, h, participant, needs, send):
lines = [f"To arrange the {h['mode']}, please reply to this message with:"]
lines += [f"{n.capitalize()}: ..." for n in needs]
send(participant.get("subject") or "Your request", "\n".join(lines))
def tick(self) -> list[str]:
"""Master role: time out handoffs past their deadline and resume by email."""
with self.lock, self.db:
now = self.clock()
rows = self.db.execute("SELECT * FROM handoffs WHERE role='master' AND deadline<=?",
(now,)).fetchall()
out = []
for h in rows:
if h["status"] in TERMINAL or h["status"] is None:
continue
self._status(h["id"], "timed-out", {})
if h["endpoint"]:
self._send(h["endpoint"], h["peer"], message(
"handoff.cancel", {"address": h["case_address"], "key": h["case_key"]},
handoff_id=h["id"], reason="timed-out"))
out.append(h["id"])
self._flush()
return out
# -- slave role ----------------------------------------------------------------
def _store_message(self, case_key, msg, disposition):
ref = "m-" + uuid.uuid4().hex[:12]
self.db.execute("INSERT INTO case_messages VALUES (?,?,?,?)",
(ref, case_key, json.dumps(msg), disposition))
return ref
def _slave_event(self, event, rtype, cfg):
mas, msg = event["mas"], event.get("message", {})
if mas.get("kind") != "resource":
iref = "i-" + uuid.uuid4().hex[:12]
mref = self._store_message(None, msg, "intake")
self.db.execute("INSERT INTO intakes VALUES (?,?,?,?,?,NULL)",
(iref, rtype, mas["domain"], mref, mas.get("interaction")))
self._send(cfg.master_url, cfg.master_peer, message(
"case.intake", intake_ref=iref, resource_type=rtype, event=event,
requested_mode=mas.get("interaction")))
return 202, {"status": "intake-reported", "intake_ref": iref}
row = self.db.execute("SELECT * FROM cases WHERE key=?", (mas["equivalence_key"],)).fetchone()
if row is None:
self._store_message(None, msg, "quarantined-unknown")
return 202, {"status": "unknown-resource", "action": "quarantined, no reply"}
case = {"address": row["address"], "key": row["key"]}
mref = self._store_message(row["key"], msg, "attached")
self._send(cfg.master_url, cfg.master_peer,
message("case.message", case, event=event, message_ref=mref))
out = {"status": "reported", "case": case, "message_ref": mref}
h = self._open_handoff(row["key"])
if h is not None and h["status"] == "negotiating" and h["endpoint"]:
details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]"))
if details:
self._send(h["endpoint"], h["peer"], message("handoff.update", case,
handoff_id=h["id"], details=details))
elif h is None and mas.get("interaction"):
out["handoff_id"] = self._transfer(rtype, cfg, case, mas["interaction"], msg)
return 200, out
def _policy(self, case_key) -> dict:
row = self.db.execute("SELECT policy FROM cases WHERE key=?", (case_key,)).fetchone()
return json.loads(row["policy"])
def _transfer(self, rtype, cfg, case, mode, msg):
"""A !MODE request in the slave role: blocked, or sent to the primed webhook."""
transfers = self._policy(case["key"])["transfers"]
webhook = transfers if isinstance(transfers, dict) else {}
hid = self._new_handoff("slave", rtype, case, case["key"], mode, msg, webhook.get("webhook"),
webhook.get("peer"), None)
self._send(cfg.master_url, cfg.master_peer, message(
"handoff.requested", case, handoff_id=hid, mode=mode, forwarded=bool(webhook)))
if not webhook:
self._status(hid, "requested", {})
self._status(hid, "refused", {"reason": "blocked"})
else:
self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg)
return hid
def master_command(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._master_command, m, peer))
def _authorized(self, rtype, peer):
cfg = self.roles.get(rtype)
return cfg is not None and cfg.role == "slave" and peer is not None and peer == cfg.master_peer
def _unknown_webhook_peer(self, msg):
pol = msg.get("policy", msg)
transfers = pol.get("transfers")
if isinstance(transfers, dict) and transfers["peer"] not in self.keys:
return refuse("unknown-peer", transfers["peer"], 400)
return None
def _master_command(self, msg, peer):
if not any(self._authorized(t, peer) for t in self.roles):
return refuse("wrong-peer", f"{peer} is not a master here", 403)
errors = message_errors(msg)
if errors:
return refuse("schema", "; ".join(errors), 400)
t = msg["type"]
if t == "case.assign":
it = self.db.execute("SELECT resource_type FROM intakes WHERE ref=?",
(msg["intake_ref"],)).fetchone()
if it is not None and not self._authorized(it["resource_type"], peer):
return refuse("wrong-peer", f"{peer} is not the master for {it['resource_type']}", 403)
return self._unknown_webhook_peer(msg) or self._assign(msg)
row = self.db.execute("SELECT * FROM cases WHERE key=?", (msg["case"]["key"],)).fetchone()
if row is None:
return refuse("unknown-case", msg["case"]["key"], 404)
if not self._authorized(row["resource_type"], peer):
return refuse("wrong-peer", f"{peer} is not the master for {row['resource_type']}", 403)
if t == "case.policy":
bad = self._unknown_webhook_peer(msg)
if bad:
return bad
pol = {**self._policy(row["key"]),
**{k: msg[k] for k in ("autonomous_send", "transfers") if k in msg}}
self.db.execute("UPDATE cases SET policy=? WHERE key=?", (json.dumps(pol), row["key"]))
return 200, {"ok": True, "policy": pol}
if t == "mail.send":
m = self.db.execute("SELECT * FROM case_messages WHERE ref=? AND case_key=?",
(msg["in_reply_to"], row["key"])).fetchone()
if m is None:
return refuse("refused", msg["in_reply_to"], reason="unknown-message")
orig = json.loads(m["message"])
why, held = self.mailer.send(row["key"], row["address"], orig,
msg.get("subject") or orig.get("subject") or "Your request",
msg["body"])
if why is None:
return 200, {"ok": True, "sent": True}
return refuse("refused", why, reason=why, **({"held": held} if why == "send-cap-reached" else {}))
if t == "handoff.cancel":
h = self._handoff(msg["handoff_id"])
if h is None or h["case_key"] != row["key"]:
return refuse("unknown-handoff", msg["handoff_id"], 404)
if h["endpoint"] and h["status"] not in TERMINAL:
self._send(h["endpoint"], h["peer"], message(
"handoff.cancel", msg["case"], handoff_id=h["id"], reason=msg.get("reason")))
return 200, {"ok": True}
return refuse("unsupported", t, 400)
def _assign(self, msg):
it = self.db.execute("SELECT * FROM intakes WHERE ref=?", (msg["intake_ref"],)).fetchone()
if it is None:
return refuse("unknown-intake", msg["intake_ref"], 404)
if it["resource_id"] is not None:
return refuse("already-assigned", it["resource_id"])
try:
address = f"{resource_local_part(it['resource_type'], msg['resource_id'])}@{it['domain']}"
except MasSyntaxError as e:
return refuse("invalid-resource-id", str(e), 400)
case = self._case(address)
if self.db.execute("SELECT 1 FROM cases WHERE key=?", (case["key"],)).fetchone():
return refuse("case-exists", case["key"])
cfg = self.roles[it["resource_type"]]
pol = {"autonomous_send": cfg.autonomous_send, "transfers": cfg.transfers,
**msg.get("policy", {})}
self.db.execute("INSERT INTO cases VALUES (?,?,?,?,?)",
(case["key"], address, it["resource_type"], msg["resource_id"], json.dumps(pol)))
self.db.execute("UPDATE intakes SET resource_id=? WHERE ref=?", (msg["resource_id"], it["ref"]))
self.db.execute("UPDATE case_messages SET case_key=? WHERE ref=?", (case["key"], it["message_ref"]))
intake = json.loads(self.db.execute("SELECT message FROM case_messages WHERE ref=?",
(it["message_ref"],)).fetchone()["message"])
out = {"ok": True, "case": case, "message_ref": it["message_ref"]}
if pol["autonomous_send"]:
why = self.mailer.reply(case["key"], address, intake, intake.get("subject") or "Your request",
f"Your request is now case {msg['resource_id']}.\n"
f"Reply to {address} to add to it.")
out["acknowledged"] = why is None
if it["requested_mode"]:
out["handoff_id"] = self._transfer(it["resource_type"], cfg, case, it["requested_mode"], intake)
return 200, out