feat(email): alert and weekly-report emails read as designed messages (#5335)
#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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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.
|
||||
@@ -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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 >= 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 "<b>"root"</b>" 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 >= 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 <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"
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user