feat(email): delivery you can see: each alert's email in history, outgoing email on Settings -> System (#5509)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m1s

#5509 "Delivery you can see: each alert's email state in history, and
outgoing email on Settings -> System" (milestone 501).

- Alerts -> History: an alert's email reads from the outbox (#5506):
  "gathering" with when it sends, "sending", "retrying" with the attempt,
  next try and the server's last answer, "sent", or "failed" naming each
  reader it didn't reach and why (outbox.delivery / email_states). Events
  from before the outbox keep what they recorded. The Notified column
  shows once an alert has a queued email.
- Settings -> System gets "Outgoing email": every email of the last 7
  days not yet sent, failures first, then the longest retrying, with
  recipient, kind, state and last error, and a count of what was sent.
  Failures and anything still retrying after 5 minutes lead the page's
  verdict (outbox.outgoing).
- get_alert_history (MCP) adds `email`: the same delivery state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:12:40 -04:00
co-authored by Claude Opus 5.5
parent 249d22e41f
commit defad2e98b
14 changed files with 375 additions and 26 deletions
+78
View File
@@ -356,3 +356,81 @@ def said(rows: list[EmailOutbox]) -> tuple[bool, str]:
([f"Not sent yet to {'; '.join(later)}; Steward will try again for up to an hour."]
if later else [])
return bool(rows) and not failed and not later, " ".join(parts)
# ── What the pages show (#5509) ───────────────────────────────────────────────
KIND_LABELS = {"alert": "Alert", "report": "Weekly report", "invite": "Invite",
"reset": "Password reset", "test": "Test"}
# Retrying longer than this is worth a line in Settings → System's verdict.
SLOW_S = 300
SHOWN_DAYS = 7
def delivery(rows: list[EmailOutbox]) -> dict:
"""One alert event's email, over every reader's copy, as history says
it: {"state", "at", "attempts", "error"}. Gathering while any copy is
held (at: when it sends); sending/retrying while any is still due (at:
the next try); failed when any reader wasn't reached (error names each);
else sent (at: the last acceptance)."""
if held := [r for r in rows if r.state == "held"]:
return {"state": "held", "at": min(r.send_after for r in held), "attempts": 0, "error": None}
if due := [r for r in rows if r.state in ("pending", "sending")]:
worst = max(due, key=lambda r: r.attempts)
return {"state": "retrying" if worst.attempts else "sending",
"at": min((r.next_attempt_at for r in due if r.next_attempt_at), default=None),
"attempts": worst.attempts, "error": worst.last_error}
if failed := [r for r in rows if r.state == "failed"]:
return {"state": "failed", "at": max(r.first_attempt_at or r.created_at for r in failed),
"attempts": max(r.attempts for r in failed),
"error": "; ".join(f"{r.recipient}: {r.last_error}" for r in failed)}
return {"state": "sent", "at": max((r.sent_at for r in rows if r.sent_at), default=None),
"attempts": max(r.attempts for r in rows), "error": None}
async def email_states(db, event_ids: list[str]) -> dict[str, dict]:
"""{event id: delivery(...)} for the alert events whose email went
through the outbox; an event with none (no reader, or from before it)
is absent."""
if not event_ids:
return {}
link = email_outbox_events.c
by: dict[str, list] = {}
for event_id, row in (await db.execute(
select(link.event_id, EmailOutbox).join(EmailOutbox, EmailOutbox.id == link.outbox_id)
.where(link.event_id.in_(event_ids)))).all():
by.setdefault(event_id, []).append(row)
return {eid: delivery(rows) for eid, rows in by.items()}
async def outgoing(db, now: datetime | None = None, limit: int = 20) -> dict:
"""Settings → System's "Outgoing email": every email of the last
SHOWN_DAYS not yet sent (failed first, then retrying longest, then
sending and gathering), how many were sent, and the problems for the
page's verdict. {"rows": [EmailOutbox], "more": n, "sent": n,
"problems": [str]}."""
from sqlalchemy import case, func
now = now or datetime.now(timezone.utc)
since = now - timedelta(days=SHOWN_DAYS)
open_states = ("failed", "pending", "sending", "held")
order = case({"failed": 0, "pending": 1, "sending": 2, "held": 3}, value=EmailOutbox.state)
base = select(EmailOutbox).where(EmailOutbox.created_at >= since,
EmailOutbox.state.in_(open_states))
rows = list((await db.execute(
base.order_by(order, EmailOutbox.first_attempt_at.asc().nulls_last(),
EmailOutbox.created_at.desc()).limit(limit))).scalars())
counts = dict((await db.execute(
select(EmailOutbox.state, func.count()).where(EmailOutbox.created_at >= since)
.group_by(EmailOutbox.state))).all())
slow = (await db.execute(select(func.count()).select_from(EmailOutbox).where(
EmailOutbox.state.in_(("pending", "sending")),
EmailOutbox.first_attempt_at < now - timedelta(seconds=SLOW_S)))).scalar_one()
problems = []
if failed := counts.get("failed", 0):
problems.append(f"{failed} email{'s' if failed != 1 else ''} failed in the last {SHOWN_DAYS} days")
if slow:
problems.append(f"{slow} email{'s' if slow != 1 else ''} still not accepted by the mail "
f"server after {SLOW_S // 60} minutes of trying")
shown = sum(counts.get(s, 0) for s in open_states)
return {"rows": rows, "more": max(shown - len(rows), 0), "sent": counts.get("sent", 0),
"problems": problems}