mail-address-semantics/mas_modes/mailer.py
tegwick 8de8fddf37 MAS-WP-0012 finished: one outbound path and one send cap
mas_modes.mailer.Mailer is the only outbound path (own SQLite + lock),
shared by the ticket service and both EmailMode roles. Over the cap mail
is held and reported (send-cap-reached, held); held mail is purged after
email-held-duration-max (7d) and not stored beyond emails-held-storage-max
(100MB). The ticket service's legacy outbox is imported once so the live
count carries over (checked on a copy of the live DB: 4 sent). Entrypoint
passes the cap and both limits.

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 12:44:16 +02:00

185 lines
7.9 KiB
Python

"""The one outbound path for MAS mail, under principle 10 (CONTRACT.md section 6).
Every send is a reply to a recorded inbound message: the recipient is that
message's sender, and the message's own sender authentication decides
whether email may answer at all. All senders in a deployment (the ticket
service, both roles of the email side) share one Mailer, so one outbox and
one lifetime cap bound everything sent.
Over the cap a reply is *held*: recorded unsent, and reported as
`send-cap-reached`. Held mail is purged after `held_max_age` and is not
stored at all once held mail would exceed `held_max_bytes`.
"""
from __future__ import annotations
import re
import smtplib
import sqlite3
import threading
import time
from email.message import EmailMessage
from typing import Callable, Optional
OUTBOX = """
CREATE TABLE IF NOT EXISTS mode_outbox (
seq INTEGER PRIMARY KEY AUTOINCREMENT, case_ref TEXT, mail_from TEXT, mail_to TEXT,
subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0, held TEXT, created REAL
);
CREATE TABLE IF NOT EXISTS mailer_meta (key TEXT PRIMARY KEY, value TEXT);
"""
DEFAULT_HELD_MAX_AGE = 7 * 86400 # email-held-duration-max: 7d
DEFAULT_HELD_MAX_BYTES = 100 * 1024 ** 2 # emails-held-storage-max: 100MB
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
_SIZES = {"": 1, "b": 1, "kb": 1024, "mb": 1024 ** 2, "gb": 1024 ** 3}
def parse_duration(text: str) -> float:
"""'7d', '12h', '30m', '45s' or plain seconds."""
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhdw]?)\s*", str(text).lower())
if not m:
raise ValueError(f"not a duration: {text!r}")
return float(m.group(1)) * _UNITS[m.group(2) or "s"]
def parse_size(text: str) -> int:
"""'100MB', '512KB', '1GB' or plain bytes (binary units)."""
m = re.fullmatch(r"\s*(\d+)\s*([kmg]?b?)\s*", str(text).lower())
if not m:
raise ValueError(f"not a size: {text!r}")
return int(m.group(1)) * _SIZES[m.group(2)]
def refusal(msg: dict, require_dmarc_pass: bool = True) -> Optional[str]:
"""Why email must not answer `msg`, or None if it may."""
auto = (msg.get("auto_submitted") or "no").lower() != "no"
bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk")
if not msg.get("from") or auto or bulk:
return "automated-sender"
if require_dmarc_pass and (msg.get("authentication") or {}).get("dmarc") != "pass":
return "unauthenticated-sender"
return None
def _size(*parts) -> int:
return sum(len((p or "").encode()) for p in parts)
class Mailer:
"""Gated replies in one outbox (its own SQLite file and lock); sent over
SMTP when configured, up to a lifetime cap counted in the outbox.
Without SMTP, replies are only recorded (and never count)."""
def __init__(self, path: str = ":memory:", smtp: Optional[tuple[str, int]] = None,
max_sent: Optional[int] = None, require_dmarc_pass: bool = True,
held_max_age: float = DEFAULT_HELD_MAX_AGE,
held_max_bytes: int = DEFAULT_HELD_MAX_BYTES,
clock: Callable[[], float] = time.time):
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.executescript(OUTBOX)
self.lock = threading.RLock()
self.smtp = smtp
self.max_sent = max_sent
self.require_dmarc_pass = require_dmarc_pass
self.held_max_age = held_max_age
self.held_max_bytes = held_max_bytes
self.clock = clock
def reply(self, case_ref: str, mail_from: str, msg: dict, subject: str,
body: str) -> Optional[str]:
"""Answer `msg` from `mail_from`. None when sent (or recorded, without
SMTP); else the reason it was not sent."""
return self.send(case_ref, mail_from, msg, subject, body)[0]
def send(self, case_ref: str, mail_from: str, msg: dict, subject: str,
body: str) -> tuple[Optional[str], bool]:
"""Like reply(), plus whether the mail was held: over the cap it is
recorded unsent if held storage allows."""
why = refusal(msg, self.require_dmarc_pass)
if why:
return why, False
if not subject.lower().startswith("re:"):
subject = "Re: " + subject
row = (case_ref, mail_from, msg["from"], subject, body, msg.get("message_id"))
with self.lock, self.db:
self._purge()
if self.smtp and self._cap_reached():
return "send-cap-reached", self._hold(row)
cur = self.db.execute(
"INSERT INTO mode_outbox (case_ref, mail_from, mail_to, subject, body, in_reply_to,"
" created) VALUES (?,?,?,?,?,?,?)", (*row, self.clock()))
if self.smtp:
self._send(mail_from, msg["from"], subject, body, msg.get("message_id"))
self.db.execute("UPDATE mode_outbox SET sent=1 WHERE seq=?", (cur.lastrowid,))
return None, False
def _hold(self, row) -> bool:
if self.held_bytes() + _size(*row) > self.held_max_bytes:
return False
self.db.execute("INSERT INTO mode_outbox (case_ref, mail_from, mail_to, subject, body,"
" in_reply_to, held, created) VALUES (?,?,?,?,?,?,?,?)",
(*row, "send-cap-reached", self.clock()))
return True
def _cap_reached(self) -> bool:
if self.max_sent is None:
return False
return self.sent_count() >= self.max_sent
def cap_reached(self) -> bool:
with self.lock:
return bool(self.smtp) and self._cap_reached()
def sent_count(self) -> int:
return self.db.execute("SELECT COUNT(*) FROM mode_outbox WHERE sent=1").fetchone()[0]
def held_bytes(self) -> int:
row = self.db.execute(
"SELECT COALESCE(SUM(LENGTH(CAST(COALESCE(case_ref,'') || COALESCE(mail_from,'') ||"
" COALESCE(mail_to,'') || COALESCE(subject,'') || COALESCE(body,'') ||"
" COALESCE(in_reply_to,'') AS BLOB))), 0) FROM mode_outbox WHERE held IS NOT NULL").fetchone()
return row[0]
def purge_held(self) -> int:
"""Delete held mail older than held_max_age. Returns how many."""
with self.lock, self.db:
return self._purge()
def _purge(self) -> int:
cur = self.db.execute("DELETE FROM mode_outbox WHERE held IS NOT NULL AND created < ?",
(self.clock() - self.held_max_age,))
return cur.rowcount
def outbox(self) -> list[dict]:
with self.lock:
return [dict(r) for r in self.db.execute("SELECT * FROM mode_outbox ORDER BY seq")]
def import_legacy(self, source: str, rows: list[dict]) -> int:
"""Copy an older outbox once (marker `source`), so mail sent before keeps
counting against the cap. Rows need mail_from, mail_to, subject, body,
in_reply_to, sent and a case reference under `case_ref`."""
with self.lock, self.db:
key = f"imported:{source}"
if self.db.execute("SELECT 1 FROM mailer_meta WHERE key=?", (key,)).fetchone():
return 0
for r in rows:
self.db.execute(
"INSERT INTO mode_outbox (case_ref, mail_from, mail_to, subject, body, in_reply_to,"
" sent, created) VALUES (?,?,?,?,?,?,?,?)",
(r["case_ref"], r["mail_from"], r["mail_to"], r["subject"], r["body"],
r["in_reply_to"], r["sent"], self.clock()))
self.db.execute("INSERT INTO mailer_meta VALUES (?, ?)", (key, str(len(rows))))
return len(rows)
def _send(self, mail_from, to, subject, body, in_reply_to):
m = EmailMessage()
m["From"], m["To"], m["Subject"] = mail_from, to, subject
m["Auto-Submitted"] = "auto-replied"
if in_reply_to:
m["In-Reply-To"] = m["References"] = in_reply_to
m.set_content(body)
with smtplib.SMTP(*self.smtp) as s:
s.send_message(m)