mail-address-semantics/mas_gateway/core.py
tegwick cca58d41fb MAS-WP-0005-T12: gateway reads trusted A-R verdict; ticket service replies only on DMARC pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
2026-10-18 22:40:30 +02:00

174 lines
6.9 KiB
Python

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