diff --git a/steward/alerts/service.py b/steward/alerts/service.py
index cca5da1..080eb1a 100644
--- a/steward/alerts/service.py
+++ b/steward/alerts/service.py
@@ -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,
diff --git a/steward/core/alerts.py b/steward/core/alerts.py
index 87801a1..3c8c080 100644
--- a/steward/core/alerts.py
+++ b/steward/core/alerts.py
@@ -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,
diff --git a/steward/core/formatting.py b/steward/core/formatting.py
index 7194b03..a0a9d78 100644
--- a/steward/core/formatting.py
+++ b/steward/core/formatting.py
@@ -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"),
diff --git a/steward/core/mail.py b/steward/core/mail.py
new file mode 100644
index 0000000..f1223c9
--- /dev/null
+++ b/steward/core/mail.py
@@ -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
diff --git a/steward/core/notifications.py b/steward/core/notifications.py
index 03dfbec..f5793bc 100644
--- a/steward/core/notifications.py
+++ b/steward/core/notifications.py
@@ -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}
diff --git a/steward/core/reports.py b/steward/core/reports.py
index fbed313..ebeec5a 100644
--- a/steward/core/reports.py
+++ b/steward/core/reports.py
@@ -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")
diff --git a/steward/core/settings.py b/steward/core/settings.py
index a15e08f..e782d11 100644
--- a/steward/core/settings.py
+++ b/steward/core/settings.py
@@ -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", ""),
}
diff --git a/steward/settings/routes.py b/steward/settings/routes.py
index 2daad8f..47ea0cf 100644
--- a/steward/settings/routes.py
+++ b/steward/settings/routes.py
@@ -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 ───────────────────────────────────────────────────────────
diff --git a/steward/templates/email/_layout.html b/steward/templates/email/_layout.html
new file mode 100644
index 0000000..052e3e3
--- /dev/null
+++ b/steward/templates/email/_layout.html
@@ -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. #}
+
+
+
+
+
+
+
+{% block title %}{% endblock %}
+
+
+
+
+
+Steward
+{% block body %}{% endblock %}
+
+{% block footer %}{% endblock %}
+
+
+
+
+
+
diff --git a/steward/templates/email/_parts.html b/steward/templates/email/_parts.html
new file mode 100644
index 0000000..7954c93
--- /dev/null
+++ b/steward/templates/email/_parts.html
@@ -0,0 +1,44 @@
+{# Pieces the email HTML parts share; import "with context" so c is there. #}
+
+{% macro badge(tone, text) -%}
+{{ text }}
+{%- endmacro %}
+
+{% macro button(href, text) -%}
+
+{%- endmacro %}
+
+{% macro fact(label, value) -%}
+
+{{ label }}
+{{ caller() if caller else value }}
+
+{%- endmacro %}
+
+{% macro section(title, href="", more="") -%}
+
+
+
+{%- endmacro %}
+
+{% macro rows() -%}
+
+
+
+{%- endmacro %}
+
+{% macro row(cells, last_right=True) -%}
+
+{% for cell in cells %}
+{{ cell }}
+{% endfor %}
+
+{%- endmacro %}
diff --git a/steward/templates/email/alert.html b/steward/templates/email/alert.html
new file mode 100644
index 0000000..069fabb
--- /dev/null
+++ b/steward/templates/email/alert.html
@@ -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 %}
+{{ badge(a.tone, a.state) }}
+{{ a.headline }}
+
+
+{{ 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 %}{{ a.when_utc }} {% endif %}{% endcall %}
+{% if a.note %}{{ fact("Note", a.note) }}{% endif %}
+
+
+{% if link %}
+{{ button(link, "View in Steward") }}
+{% else %}
+
+{% 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 %}
diff --git a/steward/templates/email/alert.txt b/steward/templates/email/alert.txt
new file mode 100644
index 0000000..36cda95
--- /dev/null
+++ b/steward/templates/email/alert.txt
@@ -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.
diff --git a/steward/templates/email/report.html b/steward/templates/email/report.html
new file mode 100644
index 0000000..c0ff029
--- /dev/null
+++ b/steward/templates/email/report.html
@@ -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 %}
+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 %}
+
+{{ 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 %}
+None. Nothing is firing.
+{% endif %}
+
+This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved.
+
+
+{{ 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 %}
+No monitors are enabled.
+{% 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 %}
+{{ button(base ~ "/", "Open Steward") }}
+{% else %}
+
+{% endif %}
+{% endblock %}
+{% block footer %}
+Sent {{ sent }}. The weekly report is turned on, and its day and hour set, in Settings › Reports.
+{% endblock %}
diff --git a/steward/templates/email/report.txt b/steward/templates/email/report.txt
new file mode 100644
index 0000000..f66602d
--- /dev/null
+++ b/steward/templates/email/report.txt
@@ -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.
diff --git a/steward/templates/settings/notifications.html b/steward/templates/settings/notifications.html
index 360ef2f..c50fc43 100644
--- a/steward/templates/settings/notifications.html
+++ b/steward/templates/settings/notifications.html
@@ -56,6 +56,10 @@
+
+ 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 General and they link back here.{% endif %}
+
"root" 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 ", "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\(| 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(' ', "https://steward.example")
+ assert foreign_or_fetched('', "https://steward.example")
+ assert not foreign_or_fetched('', "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 >= 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 "root" 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 "" not in html and "<b>"root"</b>" in html
+ assert 'Disk "root" 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 >= 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 <x>" in html and "nas " 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"
diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py
index f476341..84edf24 100644
--- a/tests/core/test_notifications.py
+++ b/tests/core/test_notifications.py
@@ -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 ─────────────────────────────────────────────────────────────
diff --git a/tests/integration/test_notification_test_send.py b/tests/integration/test_notification_test_send.py
index 8cd9436..64a6943 100644
--- a/tests/integration/test_notification_test_send.py
+++ b/tests/integration/test_notification_test_send.py
@@ -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={