From d4585eb542b70fab3a63e2681717d68f8f92695c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 13:32:32 +0200 Subject: [PATCH] MAS-WP-0003-T06: lifetime cap on sent replies (300), discard Postfix bounces Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 252470@bnt-lap001 Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241 --- deploy/railiance/entrypoint.sh | 6 +++++- deploy/railiance/manifests.yaml | 5 ++++- examples/ticket_service/ticket_service.py | 18 +++++++++++++++--- tests/test_end_to_end.py | 11 +++++++++++ workplans/MAS-WP-0003-provider-live-test.md | 19 +++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/deploy/railiance/entrypoint.sh b/deploy/railiance/entrypoint.sh index 2717d76..390968e 100644 --- a/deploy/railiance/entrypoint.sh +++ b/deploy/railiance/entrypoint.sh @@ -59,7 +59,11 @@ if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then "smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd" \ "smtp_sasl_security_options=noanonymous" "smtp_tls_security_level=secure" \ "smtp_tls_CApath=/etc/ssl/certs" - SMTP_ARG="--smtp 127.0.0.1:25" + # Postfix's own bounces (null sender) never use the relay, so the ticket + # service cap below really bounds everything sent through TEM. + postconf -e "sender_dependent_default_transport_maps=inline:{<>=discard:}" \ + "empty_address_default_transport_maps_lookup_key=<>" + SMTP_ARG="--smtp 127.0.0.1:25 --max-sent ${MAS_MAX_SENT:-300}" else postconf -e "default_transport=error:5.7.1 outbound mail disabled" fi diff --git a/deploy/railiance/manifests.yaml b/deploy/railiance/manifests.yaml index 3c31c69..a6d0019 100644 --- a/deploy/railiance/manifests.yaml +++ b/deploy/railiance/manifests.yaml @@ -80,7 +80,10 @@ spec: args: [mx] # Outbound replies via Scaleway TEM, enabled only when Secret mas-relay # (keys username, password) exists. Port 25 out is blocked on the node. - env: [{name: MAS_RELAY, value: "[smtp.tem.scaleway.com]:587"}] + # MAS_MAX_SENT: lifetime cap on replies sent (counted in the ticket db). + env: + - {name: MAS_RELAY, value: "[smtp.tem.scaleway.com]:587"} + - {name: MAS_MAX_SENT, value: "300"} ports: [{name: smtp, containerPort: 25}] volumeMounts: - {name: evidence, mountPath: /var/lib/mas} diff --git a/examples/ticket_service/ticket_service.py b/examples/ticket_service/ticket_service.py index 5d79ead..6dfc5f5 100644 --- a/examples/ticket_service/ticket_service.py +++ b/examples/ticket_service/ticket_service.py @@ -56,7 +56,8 @@ CREATE TABLE IF NOT EXISTS 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): + smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True, + max_sent: Optional[int] = None): self.db = sqlite3.connect(db_path, check_same_thread=False) self.db.row_factory = sqlite3.Row self.db.executescript(SCHEMA) @@ -64,6 +65,7 @@ class TicketService: 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 # -- event handling ------------------------------------------------------------- @@ -141,10 +143,18 @@ class TicketService: 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"))) - if self.smtp: + 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 + 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 @@ -219,8 +229,10 @@ if __name__ == "__main__": ap.add_argument("--db", default="tickets.sqlite3") ap.add_argument("--listen", default="127.0.0.1:8025") ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay") + ap.add_argument("--max-sent", type=int, metavar="N", + help="stop sending once N replies have been sent in total (kept in the db)") a = ap.parse_args() smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None h, p = a.listen.rsplit(":", 1) print(f"ticket service on http://{a.listen}/mas-events") - make_server(TicketService(a.db, smtp=smtp), h, int(p)).serve_forever() + make_server(TicketService(a.db, smtp=smtp, max_sent=a.max_sent), h, int(p)).serve_forever() diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index cacf1d8..9e05a49 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -83,6 +83,17 @@ def test_canonical_lifecycle(env): assert len(svc.outbox()) == before and len(svc.quarantine()) == 1 +def test_send_cap_keeps_replies_in_outbox(env): + svc, send, *_ = env + sent = [] + svc.smtp, svc.max_sent = ("127.0.0.1", 25), 2 + svc._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 + + def test_no_auto_reply_to_automated_mail(env): svc, send, *_ = env send(f"ticket@{D}", "out of office", extra="Auto-Submitted: auto-replied\r\n") diff --git a/workplans/MAS-WP-0003-provider-live-test.md b/workplans/MAS-WP-0003-provider-live-test.md index 0d3f515..937e68e 100644 --- a/workplans/MAS-WP-0003-provider-live-test.md +++ b/workplans/MAS-WP-0003-provider-live-test.md @@ -176,6 +176,25 @@ Send via one forwarding service (SRS) and one mailing list (VERP). Check whether `=` collides with SRS0/VERP encoding, and whether facets survive or are stripped. +## Cap total outbound mail + +```task +id: MAS-WP-0003-T06 +status: progress +priority: high +``` + +Operator request (2026-10-18): bound the total number of mails sent +through the TEM relay. The initial value is 300. `ticket_service.py +--max-sent N` counts replies actually sent (`outbox.sent=1`, which persists +in the ticket DB on the PVC). Once N is reached, further replies are kept in +the outbox unsent. The entrypoint passes `MAS_MAX_SENT` (manifest: 300) and +routes null-sender mail (Postfix bounces) to `discard:`, so nothing else can +use the relay outside the count. A unit test covers the cap. The container +was verified locally: bounces are discarded and replies go to TEM. +Remaining: deploy, and mark the one reply already sent (GS-85QJ8WC09P) as +counted. + ## Close the review ```task