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

@ -102,15 +102,17 @@ def test_lower_cased_id_correlates(env):
assert svc.outbox()[-1]["mail_from"] == f"ticket=GS-41872@{D}" # replies use the issued id
def test_send_cap_keeps_replies_in_outbox(env):
def test_send_cap_holds_replies(env):
svc, send, *_ = env
sent = []
svc.smtp, svc.max_sent = ("127.0.0.1", 25), 2
svc._send = lambda *a: sent.append(a)
svc.mailer.smtp, svc.mailer.max_sent = ("127.0.0.1", 25), 2
svc.mailer._send = lambda *a: sent.append(a)
for n in range(3):
send(f"ticket!callback@{D}", f"request {n}")
assert len(sent) == 2 and svc.send_cap_reached()
assert [r["sent"] for r in svc.outbox()] == [1, 1, 0] # third recorded, not sent
out = svc.outbox()
assert [(r["sent"], r["held"]) for r in out] == [(1, None), (1, None), (0, "send-cap-reached")]
assert out[2]["ticket_id"] == out[2]["case_ref"]
def test_only_declared_intake_creates(env):
@ -232,18 +234,18 @@ def test_forged_or_foreign_authentication_is_ignored():
def test_reply_only_to_authenticated_sender(env):
svc, send, *_ = env
svc.require_dmarc_pass = True
svc.mailer.require_dmarc_pass = True
sent = []
svc.smtp, svc._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
svc.mailer.smtp, svc.mailer._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
send(f"ticket!callback@{D}", "spoofed") # no verdict
assert sent == [] and svc.outbox() == [] and svc.ticket("GS-41872") is not None
def test_authenticated_sender_gets_reply(env):
svc, _, _, resolver = env
svc.require_dmarc_pass = True
svc.mailer.require_dmarc_pass = True
sent = []
svc.smtp, svc._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
svc.mailer.smtp, svc.mailer._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
event = build_event(f"ticket!callback@{D}", _meta(AR_OURS + RECEIVED), resolver)
status, body = svc.handle(event)
assert status == 201 and len(sent) == 1 and sent[0][1] == CUSTOMER

114
tests/test_outbound.py Normal file
View file

@ -0,0 +1,114 @@
"""One outbound path and one send cap (MAS-WP-0012)."""
import sqlite3
import pytest
from mas_modes.harness import ROOT, Harness, reference_ticket_service
from mas_modes.mailer import Mailer, parse_duration, parse_size
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
ENTRYPOINT = ROOT / "deploy/railiance/entrypoint.sh"
OK = {"from": "c@example.net", "authentication": {"dmarc": "pass"}, "subject": "s", "message_id": "<m@x>"}
def mailer(**kw):
m = Mailer(smtp=("sink", 0), **kw)
m.sent = []
m._send = lambda *mail: m.sent.append(mail)
return m
def test_both_roles_share_one_cap():
h = Harness(STATUTE, {"ticket": {"role": "master"}, "case": {"role": "slave"}}, max_sent=3)
try:
for n in range(2):
assert h.mail("ticket", f"ticket {n}").status == 201 # 2 creation replies
h.mail("case", "case intake")
[(_, body)] = h.master.pump()
code, resp = h.master.send(body["case"], body["message_ref"], "on it")
assert (code, resp["sent"]) == (200, True) # third send: the cap
code, resp = h.master.send(body["case"], body["message_ref"], "again")
assert code == 409 and resp["reason"] == "send-cap-reached" and resp["held"] is True
assert h.mail("ticket", "ticket 3").status == 201 # ticket created, reply held
assert len(h.sent) == 3 # exactly N, across both roles
held = [m for m in h.mailer.outbox() if m["held"]]
assert [m["body"] for m in held][0] == "again" and len(held) == 2
finally:
h.close()
def test_held_mail_is_purged_after_max_age():
clock = [0.0]
m = mailer(max_sent=0, held_max_age=parse_duration("7d"), clock=lambda: clock[0])
assert m.send("t1", "a@d", OK, "s", "held") == ("send-cap-reached", True)
clock[0] += 7 * 86400 - 1
assert m.purge_held() == 0
clock[0] += 2
assert m.purge_held() == 1 and m.outbox() == []
m.send("t1", "a@d", OK, "s", "new") # purging also runs on every send
clock[0] += 8 * 86400
m.send("t1", "a@d", OK, "s", "newer")
assert [r["body"] for r in m.outbox()] == ["newer"]
def test_held_storage_limit():
m = mailer(max_sent=0, held_max_bytes=parse_size("1KB"))
assert m.send("t", "a@d", OK, "s", "x" * 600)[1] is True
assert m.send("t", "a@d", OK, "s", "y" * 600) == ("send-cap-reached", False) # would exceed 1 KB
assert len(m.outbox()) == 1 and m.held_bytes() < 1024
def test_sent_mail_is_never_purged():
clock = [0.0]
m = mailer(max_sent=1, clock=lambda: clock[0])
assert m.reply("t", "a@d", OK, "s", "sent") is None
clock[0] += 365 * 86400
m.purge_held()
assert [(r["body"], r["sent"]) for r in m.outbox()] == [("sent", 1)]
assert m.cap_reached()
def test_legacy_outbox_counts_once(tmp_path):
db = tmp_path / "tickets.sqlite3"
con = sqlite3.connect(db)
con.execute("CREATE TABLE 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)")
con.executemany("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to, sent)"
" VALUES (?,?,?,?,?,?,?)", [("gs-1", "t@d", "c@x", "s", "b", None, 1)] * 2
+ [("gs-2", "t@d", "c@x", "s", "b", None, 0)])
con.commit()
con.close()
out = tmp_path / "tickets-outbox.sqlite3"
TicketService = reference_ticket_service()
svc = TicketService(str(db), mailer=mailer(path=str(out), max_sent=3))
assert svc.mailer.sent_count() == 2 and len(svc.outbox()) == 3
svc = TicketService(str(db), mailer=mailer(path=str(out), max_sent=3)) # restart: no re-import
assert len(svc.outbox()) == 3
assert svc.mailer.reply("gs-1", "t@d", OK, "s", "third") is None
assert svc.send_cap_reached() # 2 legacy + 1 new = the cap of 3
@pytest.mark.parametrize("text, seconds", [("7d", 604800), ("12h", 43200), ("30m", 1800), ("45", 45)])
def test_parse_duration(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text, size", [("100MB", 100 * 1024 ** 2), ("512KB", 512 * 1024), ("1gb", 1024 ** 3),
("2048", 2048)])
def test_parse_size(text, size):
assert parse_size(text) == size
def test_bad_config_values():
with pytest.raises(ValueError):
parse_duration("seven days")
with pytest.raises(ValueError):
parse_size("100 MiB")
def test_entrypoint_passes_one_cap_and_hold_limits():
text = ENTRYPOINT.read_text()
assert "--max-sent ${MAS_MAX_SENT:-300}" in text
assert "--email-held-duration-max ${MAS_EMAIL_HELD_DURATION_MAX:-7d}" in text
assert "--emails-held-storage-max ${MAS_EMAILS_HELD_STORAGE_MAX:-100MB}" in text