#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>
149 lines
6.7 KiB
Python
149 lines
6.7 KiB
Python
"""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
|