2026-10-08 00:24:43 +02:00
|
|
|
"""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
|
2026-10-18 18:16:59 +02:00
|
|
|
from mas_core.parser import MasSyntaxError, parse_received_address
|
2026-10-08 00:24:43 +02:00
|
|
|
from mas_core.policy import resolve
|
|
|
|
|
from mas_core.statute import build_statute
|
|
|
|
|
|
|
|
|
|
EVENT_VERSION = "0.1"
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 22:40:30 +02:00
|
|
|
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:
|
2026-10-08 00:24:43 +02:00
|
|
|
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,
|
2026-10-18 22:40:30 +02:00
|
|
|
"authentication": authentication_from_headers(msg.items(), trusted_authserv_id),
|
2026-10-08 00:24:43 +02:00
|
|
|
"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:
|
2026-10-18 18:16:59 +02:00
|
|
|
addr = parse_received_address(recipient)
|
2026-10-08 00:24:43 +02:00
|
|
|
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,
|
2026-10-18 22:24:28 +02:00
|
|
|
"base": sem.parsed.base,
|
|
|
|
|
"target": str(sem.parsed.target),
|
|
|
|
|
"target_syntax": sem.parsed.target.kind + ("-alternate" if "resource" in sem.stand_ins else ""),
|
2026-10-08 00:24:43 +02:00
|
|
|
"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]
|