mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition table, message fixtures, statute) vendored into mas_modes. EmailMode implements the master role over the reference ticket service (MasterStore): handoff, negotiation by email, statuses in the thread, timeouts, resume by email, principle 10 shared via mas_modes.mailer. 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
82 lines
3.2 KiB
Python
82 lines
3.2 KiB
Python
"""Outbound 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import smtplib
|
|
import sqlite3
|
|
from email.message import EmailMessage
|
|
from typing import 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
|
|
);
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
class Mailer:
|
|
"""Gated replies, recorded in an outbox; sent over SMTP when configured,
|
|
up to a lifetime cap counted in the outbox."""
|
|
|
|
def __init__(self, db: sqlite3.Connection, smtp: Optional[tuple[str, int]] = None,
|
|
max_sent: Optional[int] = None, require_dmarc_pass: bool = True):
|
|
self.db = db
|
|
self.db.executescript(OUTBOX)
|
|
self.smtp = smtp
|
|
self.max_sent = max_sent
|
|
self.require_dmarc_pass = require_dmarc_pass
|
|
|
|
def reply(self, case_ref: str, mail_from: str, msg: dict, subject: str,
|
|
body: str) -> Optional[str]:
|
|
"""Answer `msg` from `mail_from`. Returns None when accepted, else the reason."""
|
|
why = refusal(msg, self.require_dmarc_pass)
|
|
if why:
|
|
return why
|
|
if self.cap_reached():
|
|
return "send-cap-reached"
|
|
if not subject.lower().startswith("re:"):
|
|
subject = "Re: " + subject
|
|
cur = self.db.execute(
|
|
"INSERT INTO mode_outbox (case_ref, mail_from, mail_to, subject, body, in_reply_to)"
|
|
" VALUES (?,?,?,?,?,?)", (case_ref, mail_from, msg["from"], subject, body,
|
|
msg.get("message_id")))
|
|
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
|
|
|
|
def cap_reached(self) -> bool:
|
|
if self.max_sent is None:
|
|
return False
|
|
n = self.db.execute("SELECT COUNT(*) FROM mode_outbox WHERE sent=1").fetchone()[0]
|
|
return n >= self.max_sent
|
|
|
|
def outbox(self) -> list[dict]:
|
|
return [dict(r) for r in self.db.execute("SELECT * FROM mode_outbox ORDER BY seq")]
|
|
|
|
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)
|