"""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]