feat(email): the report, invites, resets and test sends go through the outbox; only it talks SMTP (#5508)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m11s
CI / unit (push) Successful in 1m26s
CI / publish (push) Skipped

#5508 "Every other email through the outbox: weekly report, invites,
password resets, and the test buttons" (milestone 501).

- outbox.send_each queues one email per reader (claimed, so the sender
  doesn't take it too) and tries them at once, for a page that answers
  with what happened; outbox.said words the result. attempt takes a cfg
  for a test of the form's unsaved settings and returns the settled rows.
- Weekly report (scheduled and Send now) and invites: queued, tried at
  once; one the server couldn't take yet is retried for an hour. An invite
  that didn't go shows its link with the reason, as before.
- Password reset: queued in the reset link's own transaction; the sender
  delivers it on its next tick.
- Settings -> Notifications test email and a rule's Send test: queued and
  recorded with retry=False, tried once (System 156 ruling).
- Removed mail.send, notifications._send_email and _smtp_send_sync;
  dispatch_notifications is webhook only. A guard asserts only the outbox
  (and smtp_open itself) opens an SMTP connection, with a self-test.

Also fixes run 8941 (#5507's integration test): its Also-send-to
addresses were "@test", which is_real_address rightly drops; they are
example.com now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:05:01 -04:00
co-authored by Claude Opus 5.5
parent 5237ffc663
commit 9a3e7ddba2
19 changed files with 277 additions and 148 deletions
+12 -9
View File
@@ -266,9 +266,9 @@ async def active_count(db) -> int:
def notify_targets(smtp: dict, webhook: dict, accounts: int = 0) -> list[str]:
"""Where an alert's notifications go, in words, by the same tests
core.notifications.dispatch_notifications sends by: email when a server
is set and `accounts` (that get alerts) or Also send to addresses exist,
a webhook when its URL is. Empty: nobody (#5315, milestone 495)."""
alerts are sent by: email when a server is set and `accounts` (that get
alerts) or Also send to addresses exist (core.outbox.hold_alert), a
webhook when its URL is (core.notifications.dispatch_notifications). Empty: nobody (#5315, milestone 495)."""
from urllib.parse import urlsplit
out = []
recipients = smtp.get("recipients") or []
@@ -752,7 +752,8 @@ async def send_test(app, rule_id: str) -> dict:
failed; lines say what happened to each channel. LookupError for no rule.
"""
from urllib.parse import urlsplit
from steward.core.mail import recipients
from steward.core import outbox
from steward.core.mail import alert_email, recipients
from steward.core.notifications import dispatch_notifications
smtp, webhook = app.config.get("SMTP", {}), app.config.get("WEBHOOK", {})
async with app.db_sessionmaker() as db:
@@ -761,12 +762,14 @@ async def send_test(app, rule_id: str) -> dict:
raise LookupError(rule_id)
alert = await test_alert(db, rule)
to = await recipients(db, "alerts", smtp) if smtp.get("host") else []
results = await dispatch_notifications(smtp, webhook, alert, to)
results = await dispatch_notifications(webhook, alert)
lines = []
if email := results.get("email"):
who = ", ".join(r.address for r in to)
lines.append(f"Email sent to {who}." if email["sent"]
else f"Email to {who} failed: {email['error']}")
if to:
# Queued and recorded like any email, tried once, not retried.
ok, said = outbox.said(await outbox.send_each(
app, "test", to, lambda r: alert_email(smtp, alert, r), retry=False))
results["email"] = {"sent": ok, "error": None if ok else said}
lines.append(f"Email: {said}")
if hook := results.get("webhook"):
host = urlsplit(webhook["url"]).hostname or webhook["url"]
lines.append(f"Webhook to {host} delivered." if hook["sent"]