"""Application-neutral MAS mail gateway core. email recipient -> MAS parse -> statute/policy resolution -> normalized event The gateway knows nothing about tickets or any other resource type. It forwards every recipient, including ones that are not canonical MAS syntax (MAS never makes ordinary email depend on MAS), and lets the application decide what to do. """ from __future__ import annotations import json import urllib.error import urllib.request from dataclasses import dataclass, field from email import policy from email.message import EmailMessage from email.parser import BytesParser from email.utils import getaddresses from pathlib import Path from typing import Callable, Optional from mas_core.discovery import Discovered, DiscoveryCache, DiscoveryResult, discover from mas_core.parser import MasSyntaxError, parse_address from mas_core.policy import resolve from mas_core.statute import build_statute EVENT_VERSION = "0.1" def message_meta_from_rfc822(raw: bytes) -> dict: msg: EmailMessage = BytesParser(policy=policy.default).parsebytes(raw) body = msg.get_body(preferencelist=("plain",)) text = body.get_content() if body is not None else "" return { "from": _first_addr(msg.get("From")), "to": [a for _, a in getaddresses([str(v) for v in msg.get_all("To", [])])], "subject": str(msg.get("Subject", "")), "message_id": str(msg.get("Message-ID", "")) or None, "in_reply_to": str(msg.get("In-Reply-To", "")) or None, "references": str(msg.get("References", "")) or None, "date": str(msg.get("Date", "")) or None, "auto_submitted": str(msg.get("Auto-Submitted", "")) or None, "precedence": str(msg.get("Precedence", "")) or None, "text": text, } def _first_addr(value) -> Optional[str]: if not value: return None pairs = getaddresses([str(value)]) return pairs[0][1] if pairs else None @dataclass class Resolver: """Discovery (cached) plus optional pinned statutes from local configuration.""" pinned: dict = field(default_factory=dict) # host -> Statute cache: DiscoveryCache = field(default_factory=DiscoveryCache) discover_fn: Callable[..., DiscoveryResult] = discover local_registry: Optional[dict] = None @classmethod def with_statute_files(cls, files, **kw) -> "Resolver": pinned = {} for f in files or (): doc = json.loads(Path(f).read_text()) st = build_statute(doc, doc.get("namespace")) pinned[st.namespace] = st return cls(pinned=pinned, **kw) def statute_for(self, host: str) -> DiscoveryResult: if host in self.pinned: return Discovered(host, "pinned:local-configuration", self.pinned[host]) return self.discover_fn(host, cache=self.cache) def build_event(recipient: str, message: dict, resolver: Resolver) -> dict: try: addr = parse_address(recipient) except MasSyntaxError as e: mas = {"valid": False, "error": e.code, "detail": str(e)} return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message} disc = resolver.statute_for(addr.domain) statute = disc.statute if disc.ok else None sem = resolve(addr, statute, resolver.local_registry) mas = { "valid": True, "domain": addr.domain, "local_part": addr.local.raw, "base": addr.local.base, "target": str(addr.local.target), "target_syntax": addr.local.target.kind, "discovery": {"ok": True, "url": disc.url} if disc.ok else {"ok": False, "failure": disc.kind.value, "detail": disc.detail}, **{k: v for k, v in sem.to_dict().items() if k != "address"}, } mas["profile"] = mas.pop("required_profile") return {"mas_event_version": EVENT_VERSION, "recipient": recipient, "mas": mas, "message": message} @dataclass class Delivery: recipient: str status: Optional[int] outcome: str # "delivered" | "rejected-by-app" | "tempfail" response: Optional[object] = None def post_event(event: dict, app_url: str, timeout: float = 10.0) -> Delivery: req = urllib.request.Request(app_url, data=json.dumps(event).encode(), method="POST", headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return Delivery(event["recipient"], resp.status, "delivered", _json(resp.read())) except urllib.error.HTTPError as e: outcome = "tempfail" if e.code >= 500 else "rejected-by-app" return Delivery(event["recipient"], e.code, outcome, _json(e.read())) except (OSError, urllib.error.URLError) as e: return Delivery(event["recipient"], None, "tempfail", str(e)) def _json(b: bytes): try: return json.loads(b.decode() or "null") except (UnicodeDecodeError, json.JSONDecodeError): return b.decode(errors="replace") def gateway(recipients: list[str], message: dict, resolver: Resolver, app_url: str) -> list[Delivery]: return [post_event(build_event(r, message, resolver), app_url) for r in recipients]