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:
parent
8559b70dbd
commit
d4585eb542
5 changed files with 54 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue