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
This commit is contained in:
tegwick 2026-10-19 12:44:16 +02:00
parent bdf200a600
commit 8de8fddf37
10 changed files with 371 additions and 101 deletions

View file

@ -23,15 +23,14 @@ from __future__ import annotations
import argparse
import json
import secrets
import smtplib
import sqlite3
import threading
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Callable, Optional
from mas_core.generator import resource_local_part
from mas_modes.mailer import refusal
from mas_modes.mailer import Mailer, parse_duration, parse_size
RESOURCE_TYPE = "ticket"
INTAKE_TARGET = "ticket"
@ -56,27 +55,37 @@ CREATE TABLE IF NOT EXISTS messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, disposition TEXT NOT NULL,
recipient TEXT, facet TEXT, sender TEXT, subject TEXT, message_id TEXT, body TEXT
);
CREATE TABLE IF NOT EXISTS outbox (
seq INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id TEXT, mail_from TEXT, mail_to TEXT,
subject TEXT, body TEXT, in_reply_to TEXT, sent INTEGER DEFAULT 0
);
"""
# Before the shared Mailer (MAS-WP-0012) replies lived in this table. It is
# read once at start so mail sent before keeps counting against the cap.
LEGACY_OUTBOX = "outbox"
class TicketService:
def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id,
smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True,
max_sent: Optional[int] = None, require_dmarc_pass: bool = False):
max_sent: Optional[int] = None, require_dmarc_pass: bool = False,
mailer: Optional[Mailer] = None):
"""Replies go through `mailer`, the deployment's one outbound path (principle
10, one lifetime cap). Without one, the service makes its own from smtp,
max_sent and require_dmarc_pass."""
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.executescript(SCHEMA)
self.lock = threading.Lock()
self.new_id = id_factory
self.smtp = smtp
self.closed_notice = closed_notice
self.max_sent = max_sent # lifetime cap on mail actually sent via smtp
# spec 14 item 10: auto-reply only to authenticated senders
self.require_dmarc_pass = require_dmarc_pass
self.mailer = mailer or Mailer(smtp=smtp, max_sent=max_sent,
require_dmarc_pass=require_dmarc_pass)
self._import_legacy_outbox()
def _import_legacy_outbox(self):
exists = self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(LEGACY_OUTBOX,)).fetchone()
if exists:
rows = [{**dict(r), "case_ref": r["ticket_id"]}
for r in self.db.execute(f"SELECT * FROM {LEGACY_OUTBOX} ORDER BY seq")]
self.mailer.import_legacy(f"ticket-service:{LEGACY_OUTBOX}", rows)
# -- event handling -------------------------------------------------------------
@ -147,34 +156,12 @@ class TicketService:
msg.get("subject"), msg.get("message_id"), msg.get("text")))
def _reply(self, tid, mail_from, msg, subject, body) -> bool:
to = msg.get("from")
if refusal(msg, self.require_dmarc_pass):
return False # automated or unauthenticated sender: keep the ticket, send nothing
if not subject.lower().startswith("re:"):
subject = "Re: " + subject
self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)"
" VALUES (?,?,?,?,?,?)", (tid, mail_from, to, subject, body, msg.get("message_id")))
seq = self.db.execute("SELECT last_insert_rowid()").fetchone()[0]
if self.smtp and not self.send_cap_reached():
self._send(mail_from, to, subject, body, msg.get("message_id"))
self.db.execute("UPDATE outbox SET sent=1 WHERE seq=?", (seq,))
return True
"""True when the reply went out (or was recorded, without SMTP). False for
automated or unauthenticated senders, and over the cap (then it is held)."""
return self.mailer.reply(tid, mail_from, msg, subject, body) is None
def send_cap_reached(self) -> bool:
"""Over the cap, replies stay in the outbox unsent."""
if self.max_sent is None:
return False
return self.db.execute("SELECT COUNT(*) FROM outbox WHERE sent=1").fetchone()[0] >= self.max_sent
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)
return self.mailer.cap_reached()
# -- MasterStore (mas_modes.email_mode): email as master for tickets -------------
@ -212,7 +199,8 @@ class TicketService:
return {**dict(row), "messages": [dict(m) for m in msgs]}
def outbox(self) -> list[dict]:
return [dict(r) for r in self.db.execute("SELECT * FROM outbox ORDER BY seq")]
"""This service's replies in the shared outbox (`ticket_id` = case ref)."""
return [{**r, "ticket_id": r["case_ref"]} for r in self.mailer.outbox()]
def quarantine(self) -> list[dict]:
return [dict(r) for r in self.db.execute(
@ -265,9 +253,20 @@ if __name__ == "__main__":
ap.add_argument("--require-dmarc-pass", action="store_true",
help="auto-reply only when the event says the sender passed DMARC")
ap.add_argument("--max-sent", type=int, metavar="N",
help="stop sending once N replies have been sent in total (kept in the db)")
help="stop sending once N replies have been sent in total (kept in the outbox)")
ap.add_argument("--outbox-db", metavar="FILE",
help="the shared outbox (default: DB with '-outbox' added before the suffix)")
ap.add_argument("--email-held-duration-max", default="7d", metavar="DURATION",
help="purge mail held over the cap after this long (default 7d)")
ap.add_argument("--emails-held-storage-max", default="100MB", metavar="SIZE",
help="hold no more mail once held mail would exceed this (default 100MB)")
a = ap.parse_args()
smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None
outbox_db = a.outbox_db or (a.db if a.db == ":memory:" else
str(Path(a.db).with_name(Path(a.db).stem + "-outbox" + Path(a.db).suffix)))
mailer = Mailer(outbox_db, smtp, a.max_sent, a.require_dmarc_pass,
held_max_age=parse_duration(a.email_held_duration_max),
held_max_bytes=parse_size(a.emails_held_storage_max))
h, p = a.listen.rsplit(":", 1)
print(f"ticket service on http://{a.listen}/mas-events")
make_server(TicketService(a.db, smtp=smtp, max_sent=a.max_sent, require_dmarc_pass=a.require_dmarc_pass), h, int(p)).serve_forever()
print(f"ticket service on http://{a.listen}/mas-events (outbox {outbox_db})")
make_server(TicketService(a.db, mailer=mailer), h, int(p)).serve_forever()