mail-address-semantics/mas_gateway/core.py
tegwick 59815701c1 Implement MAS v0.1 reference experiment (MAS-WP-0002 M0-M8, M9 first pass)
- mas_core: P2-maximal parser with required_profile, generator, statute
  schema + semantic lint + defaults-only inheritance, exact-host discovery
  with typed failures and same-origin redirects, pure policy resolver
- mas_conformance: vendored spec fixtures (drift-tested) + property and
  statute cases
- mas CLI: parse / explain / discover / lint-statute / test
- mas-gateway: generic mail -> MAS event -> HTTP (MTA pipe + webhook)
- examples: static great-service.example statute, reference ticket service
- docs/experiment-review.md: first-pass findings; live MTA test still open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 623586@bnt-lap001
Assistant-Session: 10b30928-5b3b-4c11-9934-5ecca07367d8
2026-10-08 00:24:43 +02:00

135 lines
5.1 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_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]