feat(email): alert and weekly-report emails read as designed messages (#5335)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m49s
CI / publish (push) Successful in 1m13s

#5335 "Emails Steward sends read as designed messages: an HTML alert email
and weekly report, with a plain-text twin".

- core/mail.py renders each email as multipart/alternative from
  templates/email/: a table-and-inline-style HTML part and a plain-text
  twin carrying the same facts. Colours are the design system's light
  theme, resolved from theme.css; nothing is fetched when a mail opens.
- Alert email: state badge, a headline sentence ("Disk used on web01 is
  94%."), rule, resource, reading, the condition in words, and the time in
  the zone Notifications was saved in with UTC beside it; "View in
  Steward" when Steward's address is set. Subject "[Steward] Firing: <rule>
  — <resource> 94%". Alerts now carry their operator.
- Weekly report: monitors down, active alerts, the week's alert events,
  uptime and busiest Traefik routers as small tables with links. Sent from
  the configured From address; the "Generated … UTC" line and the stale
  repo footer are gone.
- condition_text() states a condition from a rule's parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:08:05 -04:00
co-authored by Claude Opus 5.5
parent 24700ee608
commit ec55faf737
18 changed files with 539 additions and 113 deletions
+10 -79
View File
@@ -7,20 +7,19 @@ from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from steward.alerts import service as alert_svc
from steward.core.formatting import zone
from steward.core.mail import message, report_email
from steward.core.settings import get_setting
from steward.models.alerts import AlertEvent
from steward.models.metrics import PluginMetric
from steward.models.monitors import Monitor, MonitorResult
logger = logging.getLogger(__name__)
_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
async def build_report_data(app) -> dict:
"""Collect all data for the weekly digest."""
now = datetime.now(timezone.utc)
@@ -55,12 +54,10 @@ async def build_report_data(app) -> dict:
active_rows = await alert_svc.active_alerts(db)
active_alerts = [
{
"state": alert_svc.alert_label(state).upper(),
"state": alert_svc.alert_label(state),
"name": rule.name,
"resource": rule.resource_name,
"metric": rule.metric_name,
"operator": rule.operator.value,
"threshold": rule.threshold,
"condition": alert_svc.rule_condition(rule),
}
for state, rule in active_rows
]
@@ -120,66 +117,6 @@ async def build_report_data(app) -> dict:
}
def _render_report_text(data: dict) -> str:
now = data["generated_at"]
lines: list[str] = []
lines.append("Steward — Weekly Report")
lines.append(f"Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}")
lines.append("")
# ── Hosts currently down ──────────────────────────────────────────────────
down = data["hosts_down"]
if down:
lines.append(f"⚠ {len(down)} monitor(s) currently DOWN: {', '.join(down)}")
lines.append("")
# ── Uptime summary ────────────────────────────────────────────────────────
lines.append("UPTIME SUMMARY (7-day)")
lines.append("─" * 40)
for entry in data["uptime_summary"]:
pct = entry["pct_7d"]
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
lines.append(f" {entry['name']:<32} {pct_str}")
if not data["uptime_summary"]:
lines.append(" (no monitors configured)")
lines.append("")
# ── Active alerts ─────────────────────────────────────────────────────────
alerts = data["active_alerts"]
lines.append(f"ACTIVE ALERTS ({len(alerts)})")
lines.append("─" * 40)
if alerts:
for a in alerts:
lines.append(
f" [{a['state']}] {a['name']} — {a['resource']}:"
f"{a['metric']} {a['operator']} {a['threshold']}"
)
else:
lines.append(" None — all clear.")
lines.append("")
# ── Alert events this week ────────────────────────────────────────────────
ev = data["alert_events"]
lines.append("ALERT EVENTS THIS WEEK")
lines.append("─" * 40)
lines.append(f" Fired: {ev['fired']}")
lines.append(f" Resolved: {ev['resolved']}")
lines.append("")
# ── Top Traefik routers ───────────────────────────────────────────────────
if data["top_routers"]:
lines.append("TOP TRAEFIK ROUTERS (avg req/s, last 24h)")
lines.append("─" * 40)
for r in data["top_routers"]:
lines.append(f" {r['name']:<40} {r['avg_rate']:.2f} req/s")
lines.append("")
lines.append("─" * 40)
lines.append("Steward — https://git.fabledsword.com/bvandeusen/Steward")
return "\n".join(lines)
async def send_report(app) -> tuple[bool, str]:
"""Build and email the weekly digest. Returns (success, message)."""
smtp_cfg = app.config.get("SMTP", {})
@@ -192,15 +129,10 @@ async def send_report(app) -> tuple[bool, str]:
logger.exception("Failed to build report data")
return False, f"Data collection failed: {exc}"
body = _render_report_text(data)
date_str = data["generated_at"].strftime("%Y-%m-%d")
subject = f"[Steward] Weekly Report — {date_str}"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = smtp_cfg.get("username", "steward@localhost")
msg["To"] = ", ".join(smtp_cfg["recipients"])
msg.set_content(body)
async with app.db_sessionmaker() as db:
tz = zone(await get_setting(db, "reports.schedule_tz"))
subject, text, html = report_email(smtp_cfg, data, tz)
msg = message(smtp_cfg, subject, text, html)
try:
loop = asyncio.get_running_loop()
@@ -231,8 +163,7 @@ def schedule_in(day: int, hour: int, saved_tz, view_tz, now: datetime | None = N
async def check_and_send(app) -> None:
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
from steward.core.formatting import zone
from steward.core.settings import get_setting, set_setting
from steward.core.settings import set_setting
async with app.db_sessionmaker() as db:
enabled = await get_setting(db, "reports.enabled")