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
This commit is contained in:
tegwick 2026-10-18 22:40:30 +02:00
parent ab3346d187
commit cca58d41fb
6 changed files with 128 additions and 13 deletions

View file

@ -23,6 +23,7 @@ import argparse
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Optional
from urllib.parse import parse_qs, urlsplit
from .core import Resolver, gateway, message_meta_from_rfc822
@ -33,26 +34,30 @@ MAX_BODY = 25 * 1024 * 1024
def cmd_pipe(args, resolver) -> int:
raw = sys.stdin.buffer.read()
deliveries = gateway(args.recipient, message_meta_from_rfc822(raw), resolver, args.app_url)
meta = message_meta_from_rfc822(raw, args.trusted_authserv_id)
deliveries = gateway(args.recipient, meta, resolver, args.app_url)
for d in deliveries:
print(json.dumps(d.__dict__), file=sys.stderr)
return EX_TEMPFAIL if any(d.outcome == "tempfail" for d in deliveries) else 0
def webhook_message(body: bytes, ctype: str, query: dict) -> tuple[list[str], dict]:
def webhook_message(body: bytes, ctype: str, query: dict,
trusted_authserv_id: Optional[str] = None) -> tuple[list[str], dict]:
if ctype.startswith("message/rfc822"):
return query.get("recipient", []), message_meta_from_rfc822(body)
return query.get("recipient", []), message_meta_from_rfc822(body, trusted_authserv_id)
data = json.loads(body.decode())
recipients = data.pop("recipients", None) or [data.pop("recipient")]
meta = {"from": data.get("from"), "to": data.get("to", recipients),
"subject": data.get("subject", ""), "message_id": data.get("message_id"),
"in_reply_to": data.get("in_reply_to"), "references": data.get("references"),
"date": data.get("date"), "auto_submitted": data.get("auto_submitted"),
"precedence": data.get("precedence"), "text": data.get("text", "")}
"precedence": data.get("precedence"), "text": data.get("text", ""),
"authentication": data.get("authentication")} # as verified by the inbound provider
return recipients, meta
def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> ThreadingHTTPServer:
def make_webhook_server(listen: str, app_url: str, resolver: Resolver,
trusted_authserv_id: Optional[str] = None) -> ThreadingHTTPServer:
host, port = listen.rsplit(":", 1)
class Handler(BaseHTTPRequestHandler):
@ -63,7 +68,8 @@ def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> Thread
body = self.rfile.read(length)
try:
recipients, meta = webhook_message(body, self.headers.get("Content-Type", ""),
parse_qs(urlsplit(self.path).query))
parse_qs(urlsplit(self.path).query),
trusted_authserv_id)
except (ValueError, KeyError) as e:
return self._reply(400, {"error": f"bad inbound payload: {e}"})
if not recipients:
@ -92,6 +98,9 @@ def main(argv=None) -> int:
ap.add_argument("--statute", action="append", metavar="FILE",
help="pin a statute from local configuration (keyed by its namespace)")
ap.add_argument("--registry", metavar="FILE", help="local registry JSON")
ap.add_argument("--trusted-authserv-id", metavar="ID",
help="read sender authentication only from Authentication-Results with this id "
"(our own MTA); without it, no verdict is reported")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("pipe")
p.add_argument("--recipient", action="append", required=True)
@ -104,7 +113,7 @@ def main(argv=None) -> int:
resolver = Resolver.with_statute_files(args.statute, local_registry=registry)
if args.cmd == "pipe":
return cmd_pipe(args, resolver)
srv = make_webhook_server(args.listen, args.app_url, resolver)
srv = make_webhook_server(args.listen, args.app_url, resolver, args.trusted_authserv_id)
print(f"mas-gateway webhook on http://{args.listen} -> {args.app_url}", file=sys.stderr)
srv.serve_forever()
return 0