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
+50 -2
View File
@@ -13,10 +13,15 @@ STORAGE = {"total": 3 * 2**30, "rest": {"count": 0, "bytes": 0},
{"name": "docker_logs", "bytes": 512 * 2**20, "rows": None}]}
async def _page(app, ctx, storage=STORAGE, cleanup=None):
NO_EMAIL = {"rows": [], "more": 0, "sent": 0, "problems": []}
async def _page(app, ctx, storage=STORAGE, cleanup=None, email=NO_EMAIL):
from steward.core.outbox import KIND_LABELS
async with app.test_request_context("/settings/system/"):
session["user_role"] = "admin"
page = await render_template("settings/system.html", **ctx, storage=storage, cleanup=cleanup)
page = await render_template("settings/system.html", **ctx, storage=storage, cleanup=cleanup,
email=email, kind_labels=KIND_LABELS)
return " ".join(page.split())
@@ -109,3 +114,46 @@ async def test_the_last_cleanup_keeps_only_what_went(monkeypatch):
assert got["removed"] == [("docker_logs: past the age window", 7)]
stored = {}
assert await health.last_cleanup(None) is None
# ── Outgoing email (#5509) ────────────────────────────────────────────────────
def _mail(**kw):
from steward.models.outbox import EmailOutbox
base = dict(kind="alert", recipient="ann@example.com", state="pending", attempts=0,
created_at=AT, send_after=None, next_attempt_at=None, last_error=None)
return EmailOutbox(**{**base, **kw})
async def test_outgoing_email_lists_what_has_not_gone_failures_first(app, previews):
previews()
got = await health.process_health(None)
for r in got["rows"]:
r["spark"] = ""
email = {"sent": 1234, "more": 3, "problems": ["1 email failed in the last 7 days"], "rows": [
_mail(state="failed", kind="invite", recipient="bob@example.com", attempts=1,
last_error="550 no such user"),
_mail(state="pending", attempts=3, next_attempt_at=AT, last_error="451 greylisted"),
_mail(state="held", kind="alert", send_after=AT)]}
got["problems"] = email["problems"] + got["problems"]
page = await _page(app, got, email=email)
# The verdict leads with it; the section says each one's state and why.
assert '<p class="page-verdict"><span class="tone-warn">1 email failed in the last 7 days.' in page
assert "1,234 sent in the last 7 days." in page and "Nothing waiting" not in page
assert page.index("bob@example.com") < page.index("451 greylisted")
assert "Invite" in page and "550 no such user" in page
assert "retrying</span> <span class=\"tone-dim\">attempt 3, next" in page
assert "gathering</span> <span class=\"tone-dim\">sends" in page
assert "+3 more not yet sent" in page
assert 'title="<' not in page
assert page.index("Steward itself") < page.index("Outgoing email") < page.index("Storage")
async def test_nothing_waiting_says_so(app, previews):
previews()
got = await health.process_health(None)
for r in got["rows"]:
r["spark"] = ""
page = await _page(app, got, email={**NO_EMAIL, "sent": 2})
assert "Nothing waiting to send. 2 sent in the last 7 days." in page
assert "Steward is running normally." in page