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:
|
def rule_condition(rule: AlertRule) -> str:
|
||||||
"""What a rule fires on, in words: "CPU > 90%", "Fullest disk >= 85%",
|
"""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"."""
|
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.alerts import _is_breached
|
||||||
from steward.core.formatting import metric_label, metric_value
|
from steward.core.formatting import metric_label, metric_value
|
||||||
metric = rule.metric_name
|
operator = AlertOperator(operator)
|
||||||
if metric in _BINARY:
|
if metric in _BINARY:
|
||||||
fires_on = [metric_value(metric, v) for v in (1.0, 0.0)
|
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:
|
if len(fires_on) == 1:
|
||||||
return f"is {fires_on[0]}"
|
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,
|
def alert_sentence(rule: AlertRule, label: str, value: float | None,
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ async def _dispatch_notification(
|
|||||||
"state": notif_type.upper(),
|
"state": notif_type.upper(),
|
||||||
"metric": rule.metric_name,
|
"metric": rule.metric_name,
|
||||||
"value": value,
|
"value": value,
|
||||||
|
"operator": rule.operator.value,
|
||||||
"threshold": rule.threshold,
|
"threshold": rule.threshold,
|
||||||
"resource": rule.resource_name,
|
"resource": rule.resource_name,
|
||||||
"source_module": rule.source_module,
|
"source_module": rule.source_module,
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ STATES: dict[str, tuple[str, str]] = {
|
|||||||
"stale": ("warn", "Stale"),
|
"stale": ("warn", "Stale"),
|
||||||
"no_data": ("warn", "No data"),
|
"no_data": ("warn", "No data"),
|
||||||
"acknowledged": ("info", "Acknowledged"),
|
"acknowledged": ("info", "Acknowledged"),
|
||||||
|
"data_resumed": ("ok", "Reporting again"),
|
||||||
|
"test": ("info", "Test"),
|
||||||
"inactive": ("dim", "Inactive"),
|
"inactive": ("dim", "Inactive"),
|
||||||
"paused": ("dim", "Paused"),
|
"paused": ("dim", "Paused"),
|
||||||
"unknown": ("dim", "Unknown"),
|
"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"
|
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:
|
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||||
|
from steward.core.mail import alert_email, message
|
||||||
try:
|
try:
|
||||||
msg = EmailMessage()
|
subject, text, html = alert_email(cfg, alert)
|
||||||
# A header may not hold a line break; a rule name can (MCP takes any string).
|
msg = message(cfg, subject, text, html)
|
||||||
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))
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||||
return {"sent": True, "error": None}
|
return {"sent": True, "error": None}
|
||||||
|
|||||||
+10
-79
@@ -7,20 +7,19 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from email.message import EmailMessage
|
|
||||||
|
|
||||||
from sqlalchemy import and_, case, func, select
|
from sqlalchemy import and_, case, func, select
|
||||||
|
|
||||||
from steward.alerts import service as alert_svc
|
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.alerts import AlertEvent
|
||||||
from steward.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
from steward.models.monitors import Monitor, MonitorResult
|
from steward.models.monitors import Monitor, MonitorResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
||||||
|
|
||||||
|
|
||||||
async def build_report_data(app) -> dict:
|
async def build_report_data(app) -> dict:
|
||||||
"""Collect all data for the weekly digest."""
|
"""Collect all data for the weekly digest."""
|
||||||
now = datetime.now(timezone.utc)
|
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_rows = await alert_svc.active_alerts(db)
|
||||||
active_alerts = [
|
active_alerts = [
|
||||||
{
|
{
|
||||||
"state": alert_svc.alert_label(state).upper(),
|
"state": alert_svc.alert_label(state),
|
||||||
"name": rule.name,
|
"name": rule.name,
|
||||||
"resource": rule.resource_name,
|
"resource": rule.resource_name,
|
||||||
"metric": rule.metric_name,
|
"condition": alert_svc.rule_condition(rule),
|
||||||
"operator": rule.operator.value,
|
|
||||||
"threshold": rule.threshold,
|
|
||||||
}
|
}
|
||||||
for state, rule in active_rows
|
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]:
|
async def send_report(app) -> tuple[bool, str]:
|
||||||
"""Build and email the weekly digest. Returns (success, message)."""
|
"""Build and email the weekly digest. Returns (success, message)."""
|
||||||
smtp_cfg = app.config.get("SMTP", {})
|
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")
|
logger.exception("Failed to build report data")
|
||||||
return False, f"Data collection failed: {exc}"
|
return False, f"Data collection failed: {exc}"
|
||||||
|
|
||||||
body = _render_report_text(data)
|
async with app.db_sessionmaker() as db:
|
||||||
date_str = data["generated_at"].strftime("%Y-%m-%d")
|
tz = zone(await get_setting(db, "reports.schedule_tz"))
|
||||||
subject = f"[Steward] Weekly Report — {date_str}"
|
subject, text, html = report_email(smtp_cfg, data, tz)
|
||||||
|
msg = message(smtp_cfg, subject, text, html)
|
||||||
msg = EmailMessage()
|
|
||||||
msg["Subject"] = subject
|
|
||||||
msg["From"] = smtp_cfg.get("username", "steward@localhost")
|
|
||||||
msg["To"] = ", ".join(smtp_cfg["recipients"])
|
|
||||||
msg.set_content(body)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
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:
|
async def check_and_send(app) -> None:
|
||||||
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
|
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
|
||||||
from steward.core.formatting import zone
|
from steward.core.settings import set_setting
|
||||||
from steward.core.settings import get_setting, set_setting
|
|
||||||
|
|
||||||
async with app.db_sessionmaker() as db:
|
async with app.db_sessionmaker() as db:
|
||||||
enabled = await get_setting(db, "reports.enabled")
|
enabled = await get_setting(db, "reports.enabled")
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"smtp.password": "",
|
"smtp.password": "",
|
||||||
"smtp.recipients": [],
|
"smtp.recipients": [],
|
||||||
"smtp.from": "",
|
"smtp.from": "",
|
||||||
|
"smtp.tz": "", # the zone of whoever saved Notifications: alert emails give times in it
|
||||||
"webhook.url": "",
|
"webhook.url": "",
|
||||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
"ansible.sources": [],
|
"ansible.sources": [],
|
||||||
@@ -388,6 +389,8 @@ def to_smtp_cfg(settings: dict[str, Any]) -> dict:
|
|||||||
"password": settings.get("smtp.password", ""),
|
"password": settings.get("smtp.password", ""),
|
||||||
"recipients": settings.get("smtp.recipients", []),
|
"recipients": settings.get("smtp.recipients", []),
|
||||||
"from": settings.get("smtp.from", ""),
|
"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.password", form.get("smtp.password"))
|
||||||
await set_setting(db, "smtp.recipients", recipients)
|
await set_setting(db, "smtp.recipients", recipients)
|
||||||
await set_setting(db, "smtp.from", form.get("smtp.from", "").strip())
|
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:
|
if "webhook.remove" in form:
|
||||||
await set_setting(db, "webhook.url", "")
|
await set_setting(db, "webhook.url", "")
|
||||||
elif form.get("webhook.url", "").strip():
|
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."""
|
has quotes in it, so a template that cannot take them shows it."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
return {"rule_name": 'Test alert "from" Steward', "state": "TEST", "metric": "cpu_pct",
|
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",
|
"source_module": "host_agent", "note": "sent from Settings → Notifications",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat()}
|
"timestamp": datetime.now(timezone.utc).isoformat()}
|
||||||
|
|
||||||
@@ -296,7 +297,8 @@ async def test_webhook():
|
|||||||
async def test_email():
|
async def test_email():
|
||||||
"""Send a sample alert email using the SMTP settings in the form (unsaved);
|
"""Send a sample alert email using the SMTP settings in the form (unsaved);
|
||||||
a blank password uses the saved one."""
|
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
|
form = await request.form
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
saved = to_smtp_cfg(await get_all_settings(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", ""),
|
"tls": "smtp.tls" in form, "username": form.get("smtp.username", ""),
|
||||||
"password": form.get("smtp.password") or saved["password"],
|
"password": form.get("smtp.password") or saved["password"],
|
||||||
"from": (form.get("smtp.from") or "").strip(),
|
"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()]}
|
"recipients": [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]}
|
||||||
if not cfg["host"] or not cfg["recipients"]:
|
if not cfg["host"] or not cfg["recipients"]:
|
||||||
return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.")
|
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"])]
|
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})."
|
result = (f"Sent to {', '.join(cfg['recipients'])} via {cfg['host']}:{port} ({how})."
|
||||||
if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}")
|
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 ───────────────────────────────────────────────────────────
|
# ── 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"
|
<input type="text" name="smtp.recipients"
|
||||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||||
placeholder="admin@example.com, ops@example.com">
|
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>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"
|
<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
|
from steward.core import notifications as n
|
||||||
|
|
||||||
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
|
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"}
|
"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 (await n._send_email(cfg, ALERT))["sent"]
|
||||||
assert [f for f, _ in sent] == ["alerts@example.com", "u@example.com", "steward@localhost"]
|
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.
|
# 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'
|
assert sent[0][1] == '[Steward] Firing: Disk "root" on web01 \\ backslash — web01 94%'
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ── _send_webhook ─────────────────────────────────────────────────────────────
|
# ── _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 "Sent to a@example.com, b@example.com via mx.example:465 (implicit TLS)." in page
|
||||||
assert "Rule: Test alert" in page
|
assert "Rule: Test alert" in page
|
||||||
assert sent == [{"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
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"]}]
|
"recipients": ["a@example.com", "b@example.com"]}]
|
||||||
|
|
||||||
resp = await client.post("/settings/notifications/test-email", form={
|
resp = await client.post("/settings/notifications/test-email", form={
|
||||||
|
|||||||
Reference in New Issue
Block a user