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
+8 -2
View File
@@ -630,7 +630,10 @@ async def _reports_page(settings: dict, flash):
async def system():
"""Steward's own health (#5467): its process metrics now and over the day,
each one an alert rule can watch (source "Steward itself"); then its
tables' sizes and what the last hourly cleanup removed (#5469)."""
tables' sizes and what the last hourly cleanup removed (#5469); and
the email not yet sent (#5509), whose failures and slow retries join the
page's verdict."""
from steward.core import outbox
from steward.core.health import last_cleanup, process_health, table_sizes
from steward.core.status import sparkline_svg
async with current_app.db_sessionmaker() as db:
@@ -638,11 +641,14 @@ async def system():
# Table sizes and the last cleanup (#5469): Postgres's catalog and
# what the cleanup kept, never a scan of the tables themselves.
storage, cleanup = await table_sizes(db), await last_cleanup(db)
email = await outbox.outgoing(db)
health["problems"] = email["problems"] + health["problems"]
for row in health["rows"]:
# From zero to the row's own peak, with a floor (UI & Design ruling 8).
row["spark"] = sparkline_svg(row["series"], width=120, height=20,
stroke="currentColor", floor=1.0) if len(row["series"]) > 1 else ""
return await render_template("settings/system.html", **health, storage=storage, cleanup=cleanup)
return await render_template("settings/system.html", **health, storage=storage, cleanup=cleanup,
email=email, kind_labels=outbox.KIND_LABELS)
@settings_bp.get("/reports/")