Files
FabledSteward/steward/core/mail.py
T
bvandeusenandClaude Opus 5.5 ec55faf737
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m49s
CI / publish (push) Successful in 1m13s
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>
2026-10-08 14:08:05 -04:00

149 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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