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
114 lines
4.8 KiB
Python
114 lines
4.8 KiB
Python
"""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
|