mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition table, message fixtures, statute) vendored into mas_modes. EmailMode implements the master role over the reference ticket service (MasterStore): handoff, negotiation by email, statuses in the thread, timeouts, resume by email, principle 10 shared via mas_modes.mailer. 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
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
"""Wire the email side, the stubs and the gateway together on loopback HTTP.
|
|
|
|
Used by the tests and by the contract flow runner. Mail enters through the
|
|
real gateway (`build_event` from raw RFC 822, with trusted
|
|
Authentication-Results), so the whole path is exercised. Time is a fake
|
|
clock that only moves on `advance()`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from mas_gateway.core import Resolver, gateway, message_meta_from_rfc822
|
|
|
|
from .contract import serve
|
|
from .email_mode import EmailMode, RoleConfig
|
|
from .stubs.callback import CallbackStub
|
|
from .stubs.master import StubMaster
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CUSTOMER = "customer@example.net"
|
|
|
|
|
|
def reference_ticket_service():
|
|
"""The reference ticket service (examples/), the master-role store."""
|
|
path = str(ROOT / "examples/ticket_service")
|
|
if path not in sys.path:
|
|
sys.path.append(path)
|
|
from ticket_service import TicketService
|
|
return TicketService
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self, t: float = 1_000_000.0):
|
|
self.t = t
|
|
|
|
def __call__(self) -> float:
|
|
return self.t
|
|
|
|
|
|
class Harness:
|
|
"""roles: resource type -> {"role": "master"|"slave", ...RoleConfig fields}.
|
|
The strings "{callback}" and "{master}" in them are replaced by the stub URLs."""
|
|
|
|
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
|
|
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None):
|
|
self.resolver = Resolver.with_statute_files([statute_file])
|
|
[self.domain] = self.resolver.pinned
|
|
self.clock = FakeClock()
|
|
self.callback = CallbackStub(default=scenario)
|
|
self._servers = []
|
|
cb_url = self._serve(self.callback.server()) + "/handoffs"
|
|
self.master = StubMaster(auto_assign=auto_assign)
|
|
master_url = self._serve(self.master.server()) + "/events"
|
|
|
|
def fill(v):
|
|
if isinstance(v, str):
|
|
return v.replace("{callback}", cb_url).replace("{master}", master_url)
|
|
if isinstance(v, dict):
|
|
return {k: fill(x) for k, x in v.items()}
|
|
return v
|
|
configs = {t: RoleConfig(**fill(c)) for t, c in roles.items()}
|
|
self.tickets = None
|
|
if any(c.role == "master" for c in configs.values()):
|
|
kw = {"id_factory": (lambda it=iter(ticket_ids): next(it))} if ticket_ids else {}
|
|
self.tickets = reference_ticket_service()(require_dmarc_pass=True, max_sent=max_sent, **kw)
|
|
self.email = EmailMode(configs, self.tickets, statutes=dict(self.resolver.pinned),
|
|
clock=self.clock, max_sent=max_sent)
|
|
self.email.base_url = self._serve(self.email.server())
|
|
self.master.email_url = self.email.base_url + "/master"
|
|
self.app_url = self.email.base_url + "/mas-events"
|
|
|
|
def _serve(self, server):
|
|
self._servers.append(server)
|
|
return serve(server)
|
|
|
|
def close(self):
|
|
for s in self._servers:
|
|
s.shutdown()
|
|
|
|
# -- driving -------------------------------------------------------------------
|
|
|
|
def mail(self, local_part: str, text: str = "", subject: str = "Help needed",
|
|
sender: str = CUSTOMER, dmarc: Optional[str] = "pass", headers: str = ""):
|
|
to = f"{local_part}@{self.domain}"
|
|
ar = (f"Authentication-Results: {self.domain}; dmarc={dmarc} header.from={sender.split('@')[1]}\r\n"
|
|
if dmarc else "")
|
|
raw = (f"{ar}From: <{sender}>\r\nTo: {to}\r\nSubject: {subject}\r\n"
|
|
f"Message-ID: <{abs(hash((to, subject, text, self.clock.t)))}@example.net>\r\n"
|
|
f"{headers}\r\n{text}\r\n").encode()
|
|
meta = message_meta_from_rfc822(raw, trusted_authserv_id=self.domain)
|
|
[d] = gateway([to], meta, self.resolver, self.app_url)
|
|
return d
|
|
|
|
def advance(self, seconds: float):
|
|
self.clock.t += seconds
|
|
return self.email.tick()
|
|
|
|
def outbox(self) -> list[dict]:
|
|
"""Every mail email decided to send, in either role."""
|
|
mails = self.email.mailer.outbox()
|
|
if self.tickets is not None:
|
|
mails = self.tickets.outbox() + mails
|
|
return mails
|
|
|
|
def thread(self, ticket_id: str) -> list[str]:
|
|
"""Mode entries recorded in a master-role ticket thread."""
|
|
t = self.tickets.ticket(ticket_id)
|
|
return [m["body"] for m in t["messages"] if m["disposition"].startswith("mode-")]
|
|
|
|
def statuses(self, handoff_id: str) -> list[str]:
|
|
return [m["status"] for m in self.callback.handoffs[handoff_id].sent]
|