MAS-WP-0003-T06: lifetime cap on sent replies (300), discard Postfix bounces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
This commit is contained in:
tegwick 2026-10-18 13:32:32 +02:00
parent 8559b70dbd
commit d4585eb542
5 changed files with 54 additions and 5 deletions

View file

@ -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

View file

@ -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}

View file

@ -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()

View file

@ -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")

View file

@ -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