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
|
|
@ -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
114
tests/test_outbound.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue