"""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