"""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)