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
185 lines
7.9 KiB
Python
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)
|