mail-address-semantics/mas_gateway/core.py
tegwick bdf200a600 MAS-WP-0011 finished: peer authentication in mode contract v0.1
CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over
TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only
from the configured master; statuses only at the handoff's tokenized URL
from its peer; primed webhooks must name a known peer. mas_modes.signing,
signed contract.post, authenticating json_server, EmailMode/stubs/gateway
signing. conformance/auth.json vectors + 7 authorization flows.

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
2026-10-19 11:25:24 +02:00

182 lines
7.3 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,
signer: Optional[tuple[str, bytes]] = None) -> Delivery:
"""POST one event. `signer` (peer id, key) signs it as the mode contract
requires when the application is on another host (CONTRACT.md 7.2)."""
body = json.dumps(event).encode()
headers = {"Content-Type": "application/json"}
if signer is not None:
from mas_modes.signing import sign # only needed when signing
headers.update(sign(body, *signer))
req = urllib.request.Request(app_url, data=body, method="POST", headers=headers)
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,
signer: Optional[tuple[str, bytes]] = None) -> list[Delivery]:
return [post_event(build_event(r, message, resolver), app_url, signer=signer) for r in recipients]