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
135 lines
5.2 KiB
Python
135 lines
5.2 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 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_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]
|