feat(email): each account gets its own email, in its own zone (milestone 495)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m0s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m32s

Milestone 495 "Email goes to each account, in its own time zone", on the
operator's ruling (System 156): notifications go to each user, times in
their zone, defaults on, toggled per user or per role.

- users.notify_alerts / notify_report (migration 0036): None follows the
  role's default, notify.<kind>.<role> in settings, all on to start.
- mail.pick_recipients: active accounts that want the email and have an
  address mail can reach (an SSO placeholder like name@oidc is skipped and
  flagged), each in its own zone with no UTC beside it, then the Also send
  to addresses in their chosen zone with UTC beside. One copy per address,
  all over one SMTP connection.
- Alerts, the weekly report and the test email (to you, in your zone, plus
  the typed addresses) send that way. The save-time smtp.tz capture is gone.
- Notifications: a role × email table of defaults, who each email reaches
  and who can't be reached, and Also send to with its own zone, which starts
  as the first admin's to open the page.
- Account page: your address and your two switches. Users: an Emailed
  column, and an admin sets an account's switches on its page.
- The Alerts page counts the accounts it emails. zone_select macro and
  formatting.zones_by_region shared by Account and Notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:16:17 -04:00
co-authored by Claude Opus 5.5
parent c533eec33a
commit 22090b02d8
27 changed files with 601 additions and 132 deletions
+126 -12
View File
@@ -9,6 +9,7 @@ light theme resolved to plain values from theme.css (palette()).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo
from email.message import EmailMessage
from functools import lru_cache
@@ -55,6 +56,118 @@ def palette() -> dict[str, str]:
return colours
# ── Who gets an email (milestone 495) ─────────────────────────────────────────
KINDS = ("alerts", "report")
_REAL_ADDRESS = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
@dataclass(frozen=True)
class Recipient:
"""One address an email goes to, and the zone its times are written in.
`user` is the account's username, "" for an "Also send to" address."""
address: str
tz: tzinfo
show_utc: bool
user: str = ""
def is_real_address(address: str | None) -> bool:
"""Whether mail can reach it: an SSO account its provider gave no email
holds a placeholder like "name@oidc", which has no domain to deliver to."""
return bool(address and _REAL_ADDRESS.fullmatch(address.strip()))
def wants(user, kind: str, settings: dict) -> bool:
"""Whether an account is emailed `kind` ("alerts" or "report"): its own
choice, or else its role's default."""
choice = getattr(user, f"notify_{kind}")
if choice is None:
return bool(settings.get(f"notify.{kind}.{user.role.value}", True))
return choice
def pick_recipients(users, kind: str, settings: dict, extra: list[str], extra_tz: str | None) -> list[Recipient]:
"""Each active account that wants `kind` and has a real address, in its own
zone (UTC when it has none, and never UTC beside a zone it chose), then the
"Also send to" addresses in `extra_tz` with UTC beside. An address is sent
to once."""
from steward.core.formatting import zone
out: dict[str, Recipient] = {}
for u in sorted(users, key=lambda u: u.username.lower()):
if u.is_active and wants(u, kind, settings) and is_real_address(u.email):
out.setdefault(u.email.strip().lower(), Recipient(u.email.strip(), zone(u.time_zone), False, u.username))
for address in extra or []:
if is_real_address(address):
out.setdefault(address.strip().lower(), Recipient(address.strip(), zone(extra_tz), True))
return list(out.values())
# An account's choice for each kind of email, as a form sends it.
CHOICES = {"": None, "on": True, "off": False}
def role_defaults(user, settings: dict) -> dict[str, bool]:
"""{kind: whether this account's role gets it by default}."""
return {kind: bool(settings.get(f"notify.{kind}.{user.role.value}", True)) for kind in KINDS}
def apply_email_form(user, form) -> tuple[dict, str | None]:
"""Apply an email form to `user`: "email" (when the form has it) and
"notify_alerts" / "notify_report" ("", "on" or "off"). Returns
({field: [before, after]} for what changed, None), or ({}, why) having
changed nothing."""
wanted = {}
if "email" in form:
address = (form.get("email") or "").strip()
if not is_real_address(address) or len(address) > 255:
return {}, "That isn't an email address mail can reach."
wanted["email"] = address
for kind in KINDS:
value = form.get(f"notify_{kind}", "")
if value not in CHOICES:
return {}, "Pick On, Off or the role's default."
wanted[f"notify_{kind}"] = CHOICES[value]
changes = {k: [getattr(user, k), v] for k, v in wanted.items() if getattr(user, k) != v}
for k, (_, v) in changes.items():
setattr(user, k, v)
return changes, None
async def recipients(db, kind: str, smtp_cfg: dict) -> list[Recipient]:
"""pick_recipients from the accounts and settings as they are now."""
from sqlalchemy import select
from steward.core.settings import get_all_settings
from steward.models.users import User
users = (await db.execute(select(User).where(User.is_active.is_(True)))).scalars().all()
return pick_recipients(users, kind, await get_all_settings(db),
smtp_cfg.get("recipients") or [], smtp_cfg.get("tz"))
async def audience(db) -> dict:
"""Who the emails reach, for the Notifications page and the Alerts page:
{"alerts": [User], "report": [User]} the active accounts that get each,
and "unreachable": the active accounts mail can't reach because their
address isn't real."""
from sqlalchemy import select
from steward.core.settings import get_all_settings
from steward.models.users import User
users = sorted((await db.execute(select(User).where(User.is_active.is_(True)))).scalars().all(),
key=lambda u: u.username.lower())
settings = await get_all_settings(db)
out = {kind: [u for u in users if wants(u, kind, settings) and is_real_address(u.email)]
for kind in KINDS}
out["unreachable"] = [u for u in users if not is_real_address(u.email)]
return out
async def send(cfg: dict, messages: list[EmailMessage]) -> None:
"""Send every message over one SMTP connection, off the event loop."""
import asyncio
from steward.core.notifications import _smtp_send_sync
await asyncio.get_running_loop().run_in_executor(None, _smtp_send_sync, cfg, messages)
def _render(name: str, **ctx) -> tuple[str, str]:
ctx = {"c": palette(), **ctx}
return (_env.get_template(f"{name}.txt").render(ctx),
@@ -65,12 +178,12 @@ 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:
def message(cfg: dict, to: str, 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["To"] = to
msg.set_content(text)
msg.add_alternative(html, subtype="html")
return msg
@@ -88,8 +201,9 @@ def _lower_first(label: str) -> str:
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."""
def alert_facts(alert: dict, tz: tzinfo, show_utc: bool = True) -> dict:
"""What an alert email says, in words, from the dict alerts dispatch, its
time in `tz` (and in UTC beside it when `show_utc` and they differ)."""
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"])
@@ -115,27 +229,27 @@ def alert_facts(alert: dict, tz: tzinfo) -> dict:
"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 "",
"when": local, "when_utc": utc if show_utc and 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")))
def alert_email(cfg: dict, alert: dict, to: Recipient) -> tuple[str, str, str]:
"""(subject, text, html) for an alert, written for one recipient in their
zone, linking to Steward when its address is set."""
facts = alert_facts(alert, to.tz, to.show_utc)
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]:
def report_email(cfg: dict, data: dict, to: Recipient) -> tuple[str, str, str]:
"""(subject, text, html) for the weekly report built by
reports.build_report_data, its dates in the schedule's zone."""
reports.build_report_data, its dates in the recipient's zone."""
from steward.core.formatting import status_info
tz = to.tz
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