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
+9 -3
View File
@@ -134,15 +134,21 @@ _BINARY = ("is_up", "is_healthy")
def rule_condition(rule: AlertRule) -> str:
"""What a rule fires on, in words: "CPU > 90%", "Fullest disk >= 85%",
or "is down" for an up/down metric rather than "is_up < 0.5"."""
return condition_text(rule.metric_name, rule.operator, rule.threshold)
def condition_text(metric: str, operator: AlertOperator | str, threshold: float) -> str:
"""rule_condition from a rule's parts, for an alert carried as a dict (an
email); the operator may be its value, ">=", rather than the enum."""
from steward.core.alerts import _is_breached
from steward.core.formatting import metric_label, metric_value
metric = rule.metric_name
operator = AlertOperator(operator)
if metric in _BINARY:
fires_on = [metric_value(metric, v) for v in (1.0, 0.0)
if _is_breached(v, rule.operator, rule.threshold)]
if _is_breached(v, operator, threshold)]
if len(fires_on) == 1:
return f"is {fires_on[0]}"
return f"{metric_label(metric)} {rule.operator.value} {metric_value(metric, rule.threshold)}"
return f"{metric_label(metric)} {operator.value} {metric_value(metric, threshold)}"
def alert_sentence(rule: AlertRule, label: str, value: float | None,
+1
View File
@@ -484,6 +484,7 @@ async def _dispatch_notification(
"state": notif_type.upper(),
"metric": rule.metric_name,
"value": value,
"operator": rule.operator.value,
"threshold": rule.threshold,
"resource": rule.resource_name,
"source_module": rule.source_module,
+2
View File
@@ -185,6 +185,8 @@ STATES: dict[str, tuple[str, str]] = {
"stale": ("warn", "Stale"),
"no_data": ("warn", "No data"),
"acknowledged": ("info", "Acknowledged"),
"data_resumed": ("ok", "Reporting again"),
"test": ("info", "Test"),
"inactive": ("dim", "Inactive"),
"paused": ("dim", "Paused"),
"unknown": ("dim", "Unknown"),
+148
View File
@@ -0,0 +1,148 @@
"""Emails Steward sends (#5335): an alert and the weekly report.
Each is multipart/alternative, a plain-text part and an HTML part rendered
from the same facts by templates/email/. The HTML keeps to what mail
clients draw: a table layout, inline styles and no remote assets, so
opening the mail fetches nothing (no fonts, images or stylesheets). Mail
clients do not read CSS variables, so the colours are the design system's
light theme resolved to plain values from theme.css (palette()).
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone, tzinfo
from email.message import EmailMessage
from functools import lru_cache
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
_STEWARD = Path(__file__).resolve().parent.parent
# The part each colour plays in an email, and the theme token it is.
_ROLES = {
"page": "--fs-surface-page", "card": "--fs-surface-raised", "border": "--fs-border-color",
"text": "--fs-text-primary", "muted": "--fs-text-secondary", "faint": "--fs-text-tertiary",
"action": "--fs-action-primary", "on_action": "--fs-text-on-action",
"ok": "--fs-success", "warn": "--fs-warning", "crit": "--fs-error", "info": "--fs-info",
}
# A "dim" state is drawn in secondary text: the faintest is under 4.5:1 on the card.
_TONE_DIM = "muted"
_env = Environment(loader=FileSystemLoader(_STEWARD / "templates" / "email"),
autoescape=select_autoescape(["html"]), trim_blocks=True, lstrip_blocks=True,
keep_trailing_newline=True)
def _block(css: str, selector: str) -> dict[str, str]:
i = css.index(selector + " {")
body = re.sub(r"/\*.*?\*/", "", css[i:css.index("\n}", i)], flags=re.S)
return {k: v.strip() for k, v in re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body)}
@lru_cache(maxsize=1)
def palette() -> dict[str, str]:
"""{role: colour} from theme.css, the light theme over :root with var()
followed to its value, and the body font as "font"."""
css = (_STEWARD / "static" / "theme.css").read_text()
tokens = {**_block(css, ":root"), **_block(css, '[data-theme="light"]')}
def resolve(name: str) -> str:
m = re.fullmatch(r"var\((--[\w-]+)\)", tokens[name])
return resolve(m.group(1)) if m else tokens[name]
colours = {role: resolve(token) for role, token in _ROLES.items()}
colours["dim"] = colours[_TONE_DIM]
colours["font"] = tokens["--fs-font-body"] # the stack's fallbacks; nothing is loaded
return colours
def _render(name: str, **ctx) -> tuple[str, str]:
ctx = {"c": palette(), **ctx}
return (_env.get_template(f"{name}.txt").render(ctx),
_env.get_template(f"{name}.html").render(ctx))
def sender(cfg: dict) -> str:
return cfg.get("from") or cfg.get("username") or "steward@localhost"
def message(cfg: dict, subject: str, text: str, html: str) -> EmailMessage:
msg = EmailMessage()
# A header may not hold a line break; a rule name can (MCP takes any string).
msg["Subject"] = " ".join(subject.split())
msg["From"] = sender(cfg)
msg["To"] = ", ".join(cfg["recipients"])
msg.set_content(text)
msg.add_alternative(html, subtype="html")
return msg
def moment(at: datetime, tz: tzinfo) -> str:
"""'Thu 8 Oct 2026 13:58 EDT': a time in an email, always with its date
and zone, since a mail is read long after it is sent."""
local = at.astimezone(tz)
return f"{local:%a} {local.day} {local:%b %Y %H:%M} {local.tzname()}"
def _lower_first(label: str) -> str:
"""A label mid-sentence: "Response time" -> "response time", "CPU" stays."""
return label if label[:2].isupper() else label[:1].lower() + label[1:]
def alert_facts(alert: dict, tz: tzinfo) -> dict:
"""What an alert email says, in words, from the dict alerts dispatch."""
from steward.alerts.service import _BINARY, condition_text
from steward.core.formatting import metric_label, metric_value, status_info
tone, label = status_info(alert["state"])
metric, resource, value = alert["metric"], alert["resource"], alert.get("value")
reading = metric_value(metric, value) if value is not None else None
what = _lower_first(metric_label(metric))
state = alert["state"].lower()
if state == "no_data":
headline = f"Steward has had no {what} reading from {resource}."
elif state == "data_resumed":
headline = f"{resource} is reporting {what} again."
elif metric in _BINARY:
headline = f"{resource} is {reading or 'back to normal'}."
elif state == "resolved":
headline = f"{metric_label(metric)} on {resource} is back within its limit" + (
f", at {reading}." if reading else ".")
else:
headline = f"{metric_label(metric)} on {resource} is {reading}."
at = datetime.fromisoformat(alert["timestamp"])
local, utc = moment(at, tz), moment(at, timezone.utc)
return {
"state": label, "tone": tone, "headline": headline,
"rule": alert["rule_name"], "resource": resource,
"reading": reading or "no data",
"condition": condition_text(metric, alert["operator"], alert["threshold"]),
"when": local, "when_utc": utc if utc != local else "",
"note": alert.get("note") or "",
"subject": f"[Steward] {label}: {alert['rule_name']} — {resource}"
+ (f" {reading}" if reading and state in ("firing", "test") else ""),
}
def alert_email(cfg: dict, alert: dict) -> tuple[str, str, str]:
"""(subject, text, html) for an alert, in the zone the SMTP settings were
saved in, linking to Steward when its address is set."""
from steward.core.formatting import zone
facts = alert_facts(alert, zone(cfg.get("tz")))
base = (cfg.get("base_url") or "").rstrip("/")
text, html = _render("alert", a=facts, link=f"{base}/alerts/" if base else "")
return facts["subject"], text, html
def report_email(cfg: dict, data: dict, tz: tzinfo) -> tuple[str, str, str]:
"""(subject, text, html) for the weekly report built by
reports.build_report_data, its dates in the schedule's zone."""
from steward.core.formatting import status_info
end = data["generated_at"].astimezone(tz)
start = end - timedelta(days=7)
period = (f"{start.day} {start:%b} – {end.day} {end:%b %Y}" if start.year == end.year
else f"{start.day} {start:%b %Y} – {end.day} {end:%b %Y}")
alerts = [{**a, "tone": status_info(a["state"])[0], "state": status_info(a["state"])[1]}
for a in data["active_alerts"]]
base = (cfg.get("base_url") or "").rstrip("/")
text, html = _render("report", d=data, alerts=alerts, period=period,
sent=moment(data["generated_at"], tz), base=base)
return f"[Steward] Weekly report, {period}", text, html
+3 -19
View File
@@ -41,27 +41,11 @@ def smtp_mode(port, tls) -> str:
return "starttls" if tls else "plain"
def email_body(alert: dict) -> str:
value = "no data" if alert.get("value") is None else alert["value"]
return (
f"Alert: {alert['state']}\n"
f"Rule: {alert['rule_name']}\n"
f"Resource: {alert['resource']}\n"
f"Metric: {alert['metric']} = {value}\n"
f"Threshold: {alert['threshold']}\n"
+ (f"Note: {alert['note']}\n" if alert.get("note") else "")
+ f"Time: {alert['timestamp']}\n"
)
async def _send_email(cfg: dict, alert: dict) -> dict:
from steward.core.mail import alert_email, message
try:
msg = EmailMessage()
# A header may not hold a line break; a rule name can (MCP takes any string).
msg["Subject"] = " ".join(f"[Steward] {alert['state']} — {alert['rule_name']}".split())
msg["From"] = cfg.get("from") or cfg.get("username") or "steward@localhost"
msg["To"] = ", ".join(cfg["recipients"])
msg.set_content(email_body(alert))
subject, text, html = alert_email(cfg, alert)
msg = message(cfg, subject, text, html)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
return {"sent": True, "error": None}
+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")
+3
View File
@@ -63,6 +63,7 @@ DEFAULTS: dict[str, Any] = {
"smtp.password": "",
"smtp.recipients": [],
"smtp.from": "",
"smtp.tz": "", # the zone of whoever saved Notifications: alert emails give times in it
"webhook.url": "",
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
"ansible.sources": [],
@@ -388,6 +389,8 @@ def to_smtp_cfg(settings: dict[str, Any]) -> dict:
"password": settings.get("smtp.password", ""),
"recipients": settings.get("smtp.recipients", []),
"from": settings.get("smtp.from", ""),
"tz": settings.get("smtp.tz", ""),
"base_url": settings.get("general.public_base_url", ""),
}
+6 -3
View File
@@ -240,6 +240,7 @@ async def save_notifications():
await set_setting(db, "smtp.password", form.get("smtp.password"))
await set_setting(db, "smtp.recipients", recipients)
await set_setting(db, "smtp.from", form.get("smtp.from", "").strip())
await set_setting(db, "smtp.tz", session.get("tz") or "")
if "webhook.remove" in form:
await set_setting(db, "webhook.url", "")
elif form.get("webhook.url", "").strip():
@@ -255,7 +256,7 @@ def _sample_alert() -> dict:
has quotes in it, so a template that cannot take them shows it."""
from datetime import datetime, timezone
return {"rule_name": 'Test alert "from" Steward', "state": "TEST", "metric": "cpu_pct",
"value": 97.5, "threshold": 90.0, "resource": "example-host",
"value": 97.5, "operator": ">", "threshold": 90.0, "resource": "example-host",
"source_module": "host_agent", "note": "sent from Settings → Notifications",
"timestamp": datetime.now(timezone.utc).isoformat()}
@@ -296,7 +297,8 @@ async def test_webhook():
async def test_email():
"""Send a sample alert email using the SMTP settings in the form (unsaved);
a blank password uses the saved one."""
from steward.core.notifications import _send_email, email_body, smtp_mode
from steward.core.mail import alert_email
from steward.core.notifications import _send_email, smtp_mode
form = await request.form
async with current_app.db_sessionmaker() as db:
saved = to_smtp_cfg(await get_all_settings(db))
@@ -308,6 +310,7 @@ async def test_email():
"tls": "smtp.tls" in form, "username": form.get("smtp.username", ""),
"password": form.get("smtp.password") or saved["password"],
"from": (form.get("smtp.from") or "").strip(),
"tz": session.get("tz") or "", "base_url": saved["base_url"],
"recipients": [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]}
if not cfg["host"] or not cfg["recipients"]:
return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.")
@@ -316,7 +319,7 @@ async def test_email():
how = {"ssl": "implicit TLS", "starttls": "STARTTLS", "plain": "no TLS"}[smtp_mode(port, cfg["tls"])]
result = (f"Sent to {', '.join(cfg['recipients'])} via {cfg['host']}:{port} ({how})."
if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}")
return await _notify_test_result("email", sent["sent"], result, email_body(alert))
return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert)[1])
# ── Ansible Sources ───────────────────────────────────────────────────────────
+27
View File
@@ -0,0 +1,27 @@
{# The frame every Steward email's HTML part sits in (core/mail.py, #5335).
Tables and inline styles only, colours from c (the light theme resolved
to values), and nothing fetched from anywhere. #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{% block title %}{% endblock %}</title>
</head>
<body style="margin:0;padding:0;background:{{ c.page }};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{{ c.page }};">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="max-width:600px;background:{{ c.card }};border:1px solid {{ c.border }};border-radius:12px;">
<tr><td style="padding:20px 24px 0 24px;font:600 12px/1.4 {{ c.font }};letter-spacing:0.06em;text-transform:uppercase;color:{{ c.muted }};">Steward</td></tr>
{% block body %}{% endblock %}
<tr><td style="padding:16px 24px 20px 24px;border-top:1px solid {{ c.border }};font:12px/1.5 {{ c.font }};color:{{ c.muted }};">
{% block footer %}{% endblock %}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{# Pieces the email HTML parts share; import "with context" so c is there. #}
{% macro badge(tone, text) -%}
<span style="display:inline-block;padding:2px 10px;border:1px solid {{ c[tone] }};border-radius:9999px;font:600 12px/1.6 {{ c.font }};color:{{ c[tone] }};">{{ text }}</span>
{%- endmacro %}
{% macro button(href, text) -%}
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td style="border-radius:8px;background:{{ c.action }};">
<a href="{{ href }}" style="display:inline-block;padding:10px 18px;font:600 14px/1.2 {{ c.font }};color:{{ c.on_action }};text-decoration:none;border-radius:8px;">{{ text }}</a>
</td></tr></table>
{%- endmacro %}
{% macro fact(label, value) -%}
<tr>
<td valign="top" style="padding:6px 16px 6px 0;width:110px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">{{ label }}</td>
<td valign="top" style="padding:6px 0;font:14px/1.5 {{ c.font }};color:{{ c.text }};">{{ caller() if caller else value }}</td>
</tr>
{%- endmacro %}
{% macro section(title, href="", more="") -%}
<tr><td style="padding:20px 24px 6px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td style="font:600 15px/1.4 {{ c.font }};color:{{ c.text }};">{{ title }}</td>
{% if href %}<td align="right" style="font:13px/1.4 {{ c.font }};"><a href="{{ href }}" style="color:{{ c.action }};">{{ more or "Open in Steward" }}</a></td>{% endif %}
</tr></table>
</td></tr>
{%- endmacro %}
{% macro rows() -%}
<tr><td style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:1px solid {{ c.border }};">
{{ caller() }}
</table>
</td></tr>
{%- endmacro %}
{% macro row(cells, last_right=True) -%}
<tr>
{% for cell in cells %}
<td valign="top" {% if last_right and loop.last and not loop.first %}align="right" {% endif %}style="padding:7px {{ '0' if loop.last else '12px' }} 7px 0;border-bottom:1px solid {{ c.border }};font:14px/1.45 {{ c.font }};color:{{ c.text }};">{{ cell }}</td>
{% endfor %}
</tr>
{%- endmacro %}
+27
View File
@@ -0,0 +1,27 @@
{# An alert email's HTML part: a = mail.alert_facts(), link = the alerts page or "". #}
{% extends "_layout.html" %}
{% from "_parts.html" import badge, button, fact with context %}
{% block title %}{{ a.state }}: {{ a.rule }}{% endblock %}
{% block body %}
<tr><td style="padding:14px 24px 0 24px;">{{ badge(a.tone, a.state) }}</td></tr>
<tr><td style="padding:10px 24px 4px 24px;font:600 20px/1.35 {{ c.font }};color:{{ c.text }};">{{ a.headline }}</td></tr>
<tr><td style="padding:10px 24px 4px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{ fact("Rule", a.rule) }}
{{ fact("Resource", a.resource) }}
{{ fact("Reading", a.reading) }}
{{ fact("Fires when", a.condition) }}
{% call fact("When") %}{{ a.when }}{% if a.when_utc %}<br><span style="color:{{ c.muted }};">{{ a.when_utc }}</span>{% endif %}{% endcall %}
{% if a.note %}{{ fact("Note", a.note) }}{% endif %}
</table>
</td></tr>
{% if link %}
<tr><td style="padding:16px 24px 24px 24px;">{{ button(link, "View in Steward") }}</td></tr>
{% else %}
<tr><td style="padding:0 0 12px 0;"></td></tr>
{% endif %}
{% endblock %}
{% block footer %}
Steward sends this to everyone on its email list when an alert rule changes state.
Who gets it is set in Settings › Notifications.
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{# An alert email's plain-text part; the same facts as alert.html. #}
{{ a.state }}: {{ a.headline }}
Rule: {{ a.rule }}
Resource: {{ a.resource }}
Reading: {{ a.reading }}
Fires when: {{ a.condition }}
When: {{ a.when }}{% if a.when_utc %} ({{ a.when_utc }}){% endif %}
{% if a.note %}
Note: {{ a.note }}
{% endif %}
{% if link %}
View in Steward: {{ link }}
{% endif %}
--
Steward sends this to everyone on its email list when an alert rule changes
state. Who gets it is set in Settings › Notifications.
+63
View File
@@ -0,0 +1,63 @@
{# The weekly report's HTML part: d = reports.build_report_data(), alerts = its
active alerts with tone and label, period, sent, base = Steward's address or "". #}
{% extends "_layout.html" %}
{% from "_parts.html" import badge, button, section, rows, row with context %}
{% block title %}Weekly report, {{ period }}{% endblock %}
{% block body %}
<tr><td style="padding:10px 24px 0 24px;font:600 22px/1.3 {{ c.font }};color:{{ c.text }};">Weekly report</td></tr>
<tr><td style="padding:2px 24px 0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">{{ period }}</td></tr>
{% if d.hosts_down %}
<tr><td style="padding:16px 24px 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="border:1px solid {{ c.crit }};border-left:4px solid {{ c.crit }};border-radius:8px;">
<tr><td style="padding:10px 14px;font:14px/1.5 {{ c.font }};color:{{ c.text }};">
<strong style="color:{{ c.crit }};">{{ d.hosts_down|length }} {{ "monitor is" if d.hosts_down|length == 1 else "monitors are" }} down now:</strong>
{{ d.hosts_down|join(", ") }}
</td></tr></table>
</td></tr>
{% endif %}
{{ section("Active alerts", base ~ "/alerts/" if base else "") }}
{% if alerts %}
{% call rows() %}
{% for a in alerts %}
{{ row([badge(a.tone, a.state), a.name ~ " — " ~ a.resource, a.condition]) }}
{% endfor %}
{% endcall %}
{% else %}
<tr><td style="padding:0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">None. Nothing is firing.</td></tr>
{% endif %}
<tr><td style="padding:8px 24px 0 24px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">
This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved.
</td></tr>
{{ section("Uptime over 7 days", base ~ "/monitors/" if base else "") }}
{% if d.uptime_summary %}
{% call rows() %}
{% for m in d.uptime_summary %}
{{ row([m.name, "—" if m.pct_7d is none else "%.2f%%"|format(m.pct_7d)]) }}
{% endfor %}
{% endcall %}
{% else %}
<tr><td style="padding:0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">No monitors are enabled.</td></tr>
{% endif %}
{% if d.top_routers %}
{{ section("Busiest Traefik routers, last 24 hours", base ~ "/plugins/traefik/" if base else "") }}
{% call rows() %}
{% for r in d.top_routers %}
{{ row([r.name, "%.2f req/s"|format(r.avg_rate)]) }}
{% endfor %}
{% endcall %}
{% endif %}
{% if base %}
<tr><td style="padding:24px 24px 24px 24px;">{{ button(base ~ "/", "Open Steward") }}</td></tr>
{% else %}
<tr><td style="padding:0 0 16px 0;"></td></tr>
{% endif %}
{% endblock %}
{% block footer %}
Sent {{ sent }}. The weekly report is turned on, and its day and hour set, in Settings › Reports.
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{# The weekly report's plain-text part; the same facts as report.html. #}
Steward weekly report, {{ period }}
{% if d.hosts_down %}
{{ d.hosts_down|length }} {{ "monitor is" if d.hosts_down|length == 1 else "monitors are" }} down now: {{ d.hosts_down|join(", ") }}
{% endif %}
ACTIVE ALERTS{% if base %} ({{ base }}/alerts/){% endif %}
{% for a in alerts %}
- {{ a.state }}: {{ a.name }} — {{ a.resource }} ({{ a.condition }})
{% else %}
None. Nothing is firing.
{% endfor %}
This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved.
UPTIME OVER 7 DAYS{% if base %} ({{ base }}/monitors/){% endif %}
{% for m in d.uptime_summary %}
- {{ m.name }}: {{ "—" if m.pct_7d is none else "%.2f%%"|format(m.pct_7d) }}
{% else %}
No monitors are enabled.
{% endfor %}
{% if d.top_routers %}
BUSIEST TRAEFIK ROUTERS, LAST 24 HOURS{% if base %} ({{ base }}/plugins/traefik/){% endif %}
{% for r in d.top_routers %}
- {{ r.name }}: {{ "%.2f req/s"|format(r.avg_rate) }}
{% endfor %}
{% endif %}
--
Sent {{ sent }}. The weekly report is turned on, and its day and hour set,
in Settings › Reports.
@@ -56,6 +56,10 @@
<input type="text" name="smtp.recipients"
value="{{ settings['smtp.recipients'] | join(', ') }}"
placeholder="admin@example.com, ops@example.com">
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
Alert emails give times in {{ tz_label() }}, your zone when you save, with UTC beside them.
{% if not settings['general.public_base_url'] %}Set Steward's address in <a href="/settings/">General</a> and they link back here.{% endif %}
</p>
</div>
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"
+134
View File
@@ -0,0 +1,134 @@
"""Emails Steward sends read as designed messages (#5335): an HTML part and
a plain-text twin carrying the same facts, names escaped in the HTML, times
in the saved zone beside UTC, and nothing fetched when the mail is opened."""
import re
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from steward.core import mail
from steward.core import notifications as n
from steward.core import reports
ALERT = {"rule_name": 'Disk <b>"root"</b> on web01', "state": "FIRING", "metric": "disk_used_pct",
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01",
"source_module": "host_agent", "note": None, "timestamp": "2026-10-08T17:58:00+00:00"}
CFG = {"host": "mx", "port": 587, "recipients": ["a@example.com"], "from": "alerts@example.com",
"tz": "America/New_York", "base_url": "https://steward.example/"}
REPORT = {"generated_at": datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc),
"uptime_summary": [{"name": "web01", "pct_7d": 99.95}, {"name": "nas <x>", "pct_7d": None}],
"active_alerts": [{"state": "firing", "name": "Disk root", "resource": "web01",
"condition": "Disk used >= 90%"}],
"alert_events": {"fired": 3, "resolved": 1},
"top_routers": [{"name": "api@docker", "avg_rate": 12.345}], "hosts_down": ["nas"]}
_URL = re.compile(r"""https?://[^\s"'<>)]+""")
_FETCHES = re.compile(r"""\ssrc\s*=|url\(|<link\b|@import""", re.I)
def foreign_or_fetched(html: str, base: str) -> list[str]:
"""What in an email's HTML would reach outside Steward, or load at all."""
return ([u for u in _URL.findall(html) if not u.startswith(base)]
+ _FETCHES.findall(html))
def test_the_asset_guard_can_fail():
assert foreign_or_fetched('<img src="https://cdn.example/x.png">', "https://steward.example")
assert foreign_or_fetched('<td style="background:url(x.png)">', "https://steward.example")
assert not foreign_or_fetched('<a href="https://steward.example/alerts/">', "https://steward.example")
def test_every_colour_is_a_theme_value():
colours = {k: v for k, v in mail.palette().items() if k != "font"}
assert set(colours) >= {"page", "card", "text", "muted", "action", "on_action", "ok", "warn", "crit"}
assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", v) for v in colours.values()), colours
async def _sent(monkeypatch, cfg, alert):
out = []
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msg: out.append(msg))
assert (await n._send_email(cfg, alert))["sent"]
return out[0]
async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
msg = await _sent(monkeypatch, CFG, ALERT)
assert msg.get_content_type() == "multipart/alternative"
text, html = (msg.get_body(("plain",)).get_content(), msg.get_body(("html",)).get_content())
for part in (text, html):
for fact in ("Disk used on web01 is 94%.", "Disk used &gt;= 90%" if part is html else "Disk used >= 90%",
"Thu 8 Oct 2026 13:58 EDT", "Thu 8 Oct 2026 17:58 UTC", "https://steward.example/alerts/"):
assert fact in part, (fact, part)
assert msg["Subject"] == '[Steward] Firing: Disk <b>"root"</b> on web01 — web01 94%'
assert msg["From"] == "alerts@example.com"
async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
msg = await _sent(monkeypatch, CFG, ALERT)
html = msg.get_body(("html",)).get_content()
assert "<b>" not in html and "&lt;b&gt;&#34;root&#34;&lt;/b&gt;" in html
assert 'Disk <b>"root"</b> on web01' in msg.get_body(("plain",)).get_content()
async def test_opening_an_email_fetches_nothing(monkeypatch):
msg = await _sent(monkeypatch, CFG, ALERT)
assert not foreign_or_fetched(msg.get_body(("html",)).get_content(), "https://steward.example")
_, _, html = mail.report_email(CFG, REPORT, ZoneInfo("UTC"))
assert not foreign_or_fetched(html, "https://steward.example")
def test_without_steward_s_address_there_is_no_link_and_utc_is_said_once():
_, text, html = mail.alert_email({"recipients": []}, ALERT)
assert "View in Steward" not in text + html and "http" not in text + html
assert text.count("17:58 UTC") == 1
def test_each_state_reads_as_a_sentence():
head = {s: mail.alert_facts({**ALERT, "state": s, "value": v}, timezone.utc)["headline"]
for s, v in (("FIRING", 93.5), ("RESOLVED", 71.0), ("NO_DATA", None), ("DATA_RESUMED", 80.0))}
assert head == {"FIRING": "Disk used on web01 is 94%.",
"RESOLVED": "Disk used on web01 is back within its limit, at 71%.",
"NO_DATA": "Steward has had no disk used reading from web01.",
"DATA_RESUMED": "web01 is reporting disk used again."}
down = mail.alert_facts({**ALERT, "metric": "is_up", "operator": "<", "threshold": 0.5, "value": 0.0},
timezone.utc)
assert (down["headline"], down["condition"]) == ("web01 is down.", "is down")
def test_the_report_has_its_sections_in_both_parts():
subject, text, html = mail.report_email(CFG, REPORT, ZoneInfo("America/New_York"))
assert subject == "[Steward] Weekly report, 4 Oct – 11 Oct 2026"
for part in (text, html):
for fact in ("1 monitor is down now", "Disk used &gt;= 90%" if part is html else "Disk used >= 90%",
"99.95%", "—", "12.35 req/s", "3 alerts fired and 1 resolved",
"Sun 11 Oct 2026 08:00 EDT", "https://steward.example/monitors/"):
assert fact in part, (fact, part)
assert "nas &lt;x&gt;" in html and "nas <x>" in text
for stale in ("Generated", "bvandeusen/Steward"):
assert stale not in text + html
async def test_the_report_is_sent_from_the_configured_address(monkeypatch):
class _Session:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class _App:
config = {"SMTP": {**CFG, "username": "login@example.com"}}
db_sessionmaker = staticmethod(_Session)
async def _data(app):
return REPORT
async def _setting(db, key):
return "America/New_York"
out = []
monkeypatch.setattr(reports, "build_report_data", _data)
monkeypatch.setattr(reports, "get_setting", _setting)
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msg: out.append(msg))
ok, _ = await reports.send_report(_App())
assert ok and out[0]["From"] == "alerts@example.com"
assert out[0].get_content_type() == "multipart/alternative"
+2 -8
View File
@@ -9,7 +9,7 @@ import pytest
from steward.core import notifications as n
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
"value": 93.5, "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
"note": None, "timestamp": "2026-10-06T12:00:00+00:00"}
@@ -116,13 +116,7 @@ async def test_email_from_falls_back_and_a_line_break_in_the_name_still_sends(mo
assert (await n._send_email(cfg, ALERT))["sent"]
assert [f for f, _ in sent] == ["alerts@example.com", "u@example.com", "steward@localhost"]
# The rule name has a line break; the subject folds it to a space rather than failing.
assert sent[0][1] == '[Steward] FIRING — Disk "root" on web01 \\ backslash'
def test_email_body_names_no_data_and_the_note():
body = n.email_body({**ALERT, "value": None, "note": "no sample for 18m"})
assert "Metric: disk_used_pct = no data\n" in body and "Note: no sample for 18m\n" in body
assert "Note:" not in n.email_body(ALERT)
assert sent[0][1] == '[Steward] Firing: Disk "root" on web01 \\ backslash — web01 94%'
# ── _send_webhook ─────────────────────────────────────────────────────────────
@@ -111,7 +111,7 @@ def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypat
assert "Sent to a@example.com, b@example.com via mx.example:465 (implicit TLS)." in page
assert "Rule: Test alert" in page
assert sent == [{"host": "mx.example", "port": 465, "tls": False, "username": "u",
"password": "pw", "from": "alerts@example.com",
"password": "pw", "from": "alerts@example.com", "tz": "", "base_url": "",
"recipients": ["a@example.com", "b@example.com"]}]
resp = await client.post("/settings/notifications/test-email", form={