"""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_received_address from mas_core.policy import resolve from mas_core.statute import build_statute EVENT_VERSION = "0.1" def authentication_from_headers(headers: list[tuple[str, str]], trusted_authserv_id: Optional[str]) -> Optional[dict]: """Sender-authentication verdict from our own MTA's Authentication-Results. Only A-R headers whose authserv-id is `trusted_authserv_id` count, and only those above the first Received header, i.e. added at our hop (RFC 8601 section 5). Anything a sender brought along sits below that line. The MTA also strips incoming A-R headers claiming our id; this is the second layer. """ if not trusted_authserv_id: return None want = trusted_authserv_id.lower() verdict: dict = {} for name, value in headers: lname = name.lower() if lname == "received": break if lname != "authentication-results": continue authserv, _, results = str(value).partition(";") ids = authserv.split() if not ids or ids[0].lower() != want: continue for clause in results.split(";"): words = clause.split() if not words or "=" not in words[0]: continue method, result = words[0].split("=", 1) method, result = method.lower(), result.lower() if method in ("dmarc", "spf", "dkim"): # several DKIM signatures: one pass is enough if not (method == "dkim" and verdict.get("dkim") == "pass"): verdict[method] = result for w in words[1:]: if method == "dmarc" and w.lower().startswith("header.from="): verdict["header_from"] = w.split("=", 1)[1] return verdict or None def message_meta_from_rfc822(raw: bytes, trusted_authserv_id: Optional[str] = None) -> 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, "authentication": authentication_from_headers(msg.items(), trusted_authserv_id), "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_received_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": sem.parsed.base, "target": str(sem.parsed.target), "target_syntax": sem.parsed.target.kind + ("-alternate" if "resource" in sem.stand_ins else ""), "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]