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:
parent
bdf200a600
commit
8de8fddf37
10 changed files with 371 additions and 101 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue