feat(email): each account gets its own email, in its own zone (milestone 495)
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:
@@ -492,9 +492,12 @@ async def _dispatch_notification(
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
from steward.core.mail import recipients
|
||||
smtp_cfg = app.config.get("SMTP", {})
|
||||
webhook_cfg = app.config.get("WEBHOOK", {})
|
||||
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data)
|
||||
async with app.db_sessionmaker() as db:
|
||||
to = await recipients(db, "alerts", smtp_cfg) if smtp_cfg.get("host") else []
|
||||
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data, to)
|
||||
|
||||
# Update event row with delivery results in a new transaction
|
||||
async with app.db_sessionmaker() as db:
|
||||
|
||||
@@ -87,6 +87,15 @@ def zone_names() -> tuple[str, ...]:
|
||||
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
|
||||
|
||||
|
||||
def zones_by_region() -> list[tuple[str, list[str]]]:
|
||||
"""A zone picker's choices (the zone_select macro): UTC alone, then every
|
||||
zone under its region ('America', 'Europe', …)."""
|
||||
from itertools import groupby
|
||||
named = [z for z in zone_names() if "/" in z]
|
||||
return [("", ["UTC"])] + [(region, list(zones)) for region, zones
|
||||
in groupby(named, key=lambda z: z.split("/", 1)[0])]
|
||||
|
||||
|
||||
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
|
||||
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
|
||||
column header that says which zone its times are in."""
|
||||
|
||||
+126
-12
@@ -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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
@@ -17,12 +16,15 @@ async def dispatch_notifications(
|
||||
smtp_cfg: dict,
|
||||
webhook_cfg: dict,
|
||||
alert_data: dict,
|
||||
recipients: list = (),
|
||||
) -> dict:
|
||||
"""Dispatch all configured notification channels. Returns results dict."""
|
||||
"""Dispatch all configured notification channels: email to each of
|
||||
`recipients` (mail.Recipient, from mail.recipients) when a server is set.
|
||||
Returns results dict."""
|
||||
results = {}
|
||||
|
||||
if smtp_cfg.get("host") and smtp_cfg.get("recipients"):
|
||||
results["email"] = await _send_email(smtp_cfg, alert_data)
|
||||
if smtp_cfg.get("host") and recipients:
|
||||
results["email"] = await _send_email(smtp_cfg, alert_data, recipients)
|
||||
|
||||
if webhook_cfg.get("url"):
|
||||
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
||||
@@ -41,20 +43,19 @@ def smtp_mode(port, tls) -> str:
|
||||
return "starttls" if tls else "plain"
|
||||
|
||||
|
||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||
from steward.core.mail import alert_email, message
|
||||
async def _send_email(cfg: dict, alert: dict, recipients: list) -> dict:
|
||||
"""Each recipient their own copy, in their own zone, over one connection."""
|
||||
from steward.core.mail import alert_email, message, send
|
||||
try:
|
||||
subject, text, html = alert_email(cfg, alert)
|
||||
msg = message(cfg, subject, text, html)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||
messages = [message(cfg, to.address, *alert_email(cfg, alert, to)) for to in recipients]
|
||||
await send(cfg, messages)
|
||||
return {"sent": True, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error(f"Email notification failed: {exc}")
|
||||
return {"sent": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||
def _smtp_send_sync(cfg: dict, messages: list[EmailMessage]) -> None:
|
||||
port = int(cfg.get("port") or 587)
|
||||
mode = smtp_mode(port, cfg.get("tls", True))
|
||||
ctx = ssl.create_default_context()
|
||||
@@ -67,7 +68,8 @@ def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||
smtp.starttls(context=ctx)
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||
smtp.send_message(msg)
|
||||
for msg in messages:
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
def _json_str(value) -> str:
|
||||
|
||||
+13
-15
@@ -4,7 +4,6 @@ Weekly digest report: uptime, active alerts, alert events, top metrics.
|
||||
Called by the scheduler (hourly check) or triggered manually from Settings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -12,7 +11,7 @@ from sqlalchemy import and_, case, func, select
|
||||
|
||||
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.mail import message, recipients, report_email, send
|
||||
from steward.core.settings import get_setting
|
||||
from steward.models.alerts import AlertEvent
|
||||
from steward.models.metrics import PluginMetric
|
||||
@@ -118,10 +117,16 @@ async def build_report_data(app) -> dict:
|
||||
|
||||
|
||||
async def send_report(app) -> tuple[bool, str]:
|
||||
"""Build and email the weekly digest. Returns (success, message)."""
|
||||
"""Email the weekly digest to each account that gets it, and the "Also
|
||||
send to" addresses, each in its own zone. Returns (success, message)."""
|
||||
smtp_cfg = app.config.get("SMTP", {})
|
||||
if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"):
|
||||
return False, "SMTP not configured — set host and recipients in Settings → Notifications."
|
||||
if not smtp_cfg.get("host"):
|
||||
return False, "No mail server is set. Set one in Settings → Notifications."
|
||||
async with app.db_sessionmaker() as db:
|
||||
to = await recipients(db, "report", smtp_cfg)
|
||||
if not to:
|
||||
return False, ("Nobody gets the weekly report: no account has it on with a real email "
|
||||
"address, and there are no Also send to addresses.")
|
||||
|
||||
try:
|
||||
data = await build_report_data(app)
|
||||
@@ -129,20 +134,13 @@ async def send_report(app) -> tuple[bool, str]:
|
||||
logger.exception("Failed to build report data")
|
||||
return False, f"Data collection failed: {exc}"
|
||||
|
||||
async with app.db_sessionmaker() as db:
|
||||
tz = zone(await get_setting(db, "reports.schedule_tz"))
|
||||
subject, text, html = report_email(smtp_cfg, data, tz)
|
||||
msg = message(smtp_cfg, subject, text, html)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
from steward.core.notifications import _smtp_send_sync
|
||||
await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg)
|
||||
logger.info("Weekly report sent to %s", smtp_cfg["recipients"])
|
||||
return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}."
|
||||
await send(smtp_cfg, [message(smtp_cfg, r.address, *report_email(smtp_cfg, data, r)) for r in to])
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send weekly report: %s", exc)
|
||||
return False, f"Send failed: {exc}"
|
||||
logger.info("Weekly report sent to %d address(es)", len(to))
|
||||
return True, f"Report sent to {', '.join(r.address for r in to)}."
|
||||
|
||||
|
||||
def report_due(now: datetime, day: int, hour: int, tz) -> bool:
|
||||
|
||||
@@ -63,7 +63,16 @@ DEFAULTS: dict[str, Any] = {
|
||||
"smtp.password": "",
|
||||
"smtp.recipients": [],
|
||||
"smtp.from": "",
|
||||
"smtp.tz": "", # the zone of whoever saved Notifications: alert emails give times in it
|
||||
# The zone "Also send to" addresses read times in: the first admin to open
|
||||
# Notifications, changeable there. Accounts read their own (milestone 495).
|
||||
"smtp.tz": "",
|
||||
# Whether an account of each role is emailed, unless it chooses otherwise.
|
||||
"notify.alerts.admin": True,
|
||||
"notify.alerts.operator": True,
|
||||
"notify.alerts.viewer": True,
|
||||
"notify.report.admin": True,
|
||||
"notify.report.operator": True,
|
||||
"notify.report.viewer": True,
|
||||
"webhook.url": "",
|
||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||
"ansible.sources": [],
|
||||
|
||||
Reference in New Issue
Block a user