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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user