diff --git a/steward/account/routes.py b/steward/account/routes.py index 86dacbe..d59b536 100644 --- a/steward/account/routes.py +++ b/steward/account/routes.py @@ -2,8 +2,6 @@ page writes its times in. Any role reaches it, for themselves only.""" from __future__ import annotations -from itertools import groupby - from quart import Blueprint, abort, current_app, redirect, render_template, request, session from steward.auth.middleware import require_role @@ -15,14 +13,6 @@ account_bp = Blueprint("account", __name__, url_prefix="/account") _SOURCES = {"local": "a Steward password", "ldap": "LDAP", "oidc": "single sign-on"} -def _zones_by_region() -> list[tuple[str, list[str]]]: - """The Account page's choices: UTC alone, then every zone under its - region ('America', 'Europe', …).""" - named = [z for z in formatting.zone_names() if "/" in z] - return [("", ["UTC"])] + [(region, list(zones)) for region, zones - in groupby(named, key=lambda z: z.split("/", 1)[0])] - - async def _me(db) -> User: # A share link opens widgets read-only; it has no account. user_id = session.get("user_id") @@ -36,14 +26,18 @@ async def _me(db) -> User: @account_bp.get("/") @require_role(UserRole.viewer) -async def account(error: str | None = None, status: int = 200): +async def account(error: str | None = None, status: int = 200, email_error: str | None = None): + from steward.core.mail import role_defaults + from steward.core.settings import get_all_settings async with current_app.db_sessionmaker() as db: user = await _me(db) + defaults = role_defaults(user, await get_all_settings(db)) tz = formatting.zone(user.time_zone) return await render_template( "account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source), zone_name=user.time_zone or "UTC", zone_label=formatting.tz_label(tz), - zones=_zones_by_region(), error=error), status + zones=formatting.zones_by_region(), defaults=defaults, error=error, + email_error=email_error), status @account_bp.post("/") @@ -65,6 +59,31 @@ async def account_save(): return redirect("/account/") +@account_bp.post("/email") +@require_role(UserRole.viewer) +async def account_email(): + """Your address and whether you're emailed alerts and the weekly report + (milestone 495).""" + from sqlalchemy.exc import IntegrityError + from steward.core.audit import log_audit + from steward.core.mail import apply_email_form + form = await request.form + try: + async with current_app.db_sessionmaker() as db: + async with db.begin(): + user = await _me(db) + changes, error = apply_email_form(user, form) + if error: + raise ValueError(error) + except ValueError as exc: + return await account(status=400, email_error=str(exc)) + except IntegrityError: + return await account(status=400, email_error="Another account already uses that address.") + if changes: + await log_audit(current_app, user.id, user.username, "account.email", detail=changes) + return redirect("/account/#email") + + @account_bp.post("/time-zone/adopt") @require_role(UserRole.viewer) async def adopt_time_zone(): diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index fc20e60..18ae454 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -102,9 +102,14 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]: return action, None -def _notify() -> list[str]: - """Where alerts' notifications go now, from the settings the app runs with.""" - return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {})) +async def _notify() -> list[str]: + """Where alerts' notifications go now, from the settings the app runs with + and the accounts that get alert email.""" + from steward.core.mail import audience + async with current_app.db_sessionmaker() as db: + accounts = len((await audience(db))["alerts"]) + return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}), + accounts) @alerts_bp.get("/") @@ -123,7 +128,7 @@ async def list_alerts(): rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states} return await render_template( "alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired, - notify=_notify(), operators=_OPERATORS, + notify=await _notify(), operators=_OPERATORS, poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60)) @@ -135,7 +140,7 @@ async def active_fragment(): active = await alert_svc.active_rows(db, datetime.now(timezone.utc)) rules = await alert_svc.list_rules(db) return await render_template( - "alerts/_active.html", active=active, notify=_notify(), rules_total=len(rules), + "alerts/_active.html", active=active, notify=await _notify(), rules_total=len(rules), rules_off=sum(1 for r in rules if not r.enabled)) @@ -246,7 +251,7 @@ async def new_rule(): metrics=alert_svc.metric_catalog().get(first_source, []), catalog=alert_svc.metric_catalog(), ansible_sources=_ansible_source_names(), - notify=_notify(), + notify=await _notify(), ) @@ -264,7 +269,7 @@ async def edit_rule(rule_id: str): fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id) return await render_template( "alerts/rules_form.html", - rule=rule, history_count=history_count, fired=fired, notify=_notify(), + rule=rule, history_count=history_count, fired=fired, notify=await _notify(), operators=_OPERATORS, source_modules=alert_svc.source_modules(), initial_source=rule.source_module, diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 080eb1a..523438c 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -257,17 +257,19 @@ async def active_count(db) -> int: )).scalar_one() -def notify_targets(smtp: dict, webhook: dict) -> list[str]: +def notify_targets(smtp: dict, webhook: dict, accounts: int = 0) -> list[str]: """Where an alert's notifications go, in words, by the same tests core.notifications.dispatch_notifications sends by: email when a server - and recipients are set, a webhook when its URL is. Empty: nobody (#5315).""" + is set and `accounts` (that get alerts) or Also send to addresses exist, + a webhook when its URL is. Empty: nobody (#5315, milestone 495).""" from urllib.parse import urlsplit out = [] recipients = smtp.get("recipients") or [] if isinstance(recipients, str): recipients = [r.strip() for r in recipients.split(",") if r.strip()] - if smtp.get("host") and recipients: - out.append("email to " + ", ".join(recipients)) + who = ([f"{accounts} account{'s' if accounts != 1 else ''}"] if accounts else []) + recipients + if smtp.get("host") and who: + out.append("email to " + ", ".join(who)) if url := webhook.get("url"): out.append("a webhook to " + (urlsplit(url).hostname or url)) return out diff --git a/steward/audit/service.py b/steward/audit/service.py index 8f2cbe6..f6dfa5c 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -119,9 +119,11 @@ ACTIONS = { "auth.login_failed": "failed to sign in", "auth.setup": "set up Steward", "account.time_zone": "set their time zone", + "account.email": "changed their email settings", "user.created": "added user", "user.renamed": "renamed user", "user.role_changed": "changed the role of user", + "user.email_settings": "changed the email settings of user", "user.password_reset": "set a new password for user", "user.disabled": "disabled user", "user.enabled": "enabled user", diff --git a/steward/core/alerts.py b/steward/core/alerts.py index 3c8c080..552f7f6 100644 --- a/steward/core/alerts.py +++ b/steward/core/alerts.py @@ -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: diff --git a/steward/core/formatting.py b/steward/core/formatting.py index a0a9d78..160aab8 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -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.""" diff --git a/steward/core/mail.py b/steward/core/mail.py index f1223c9..ea91f66 100644 --- a/steward/core/mail.py +++ b/steward/core/mail.py @@ -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 diff --git a/steward/core/notifications.py b/steward/core/notifications.py index f5793bc..cc1f17e 100644 --- a/steward/core/notifications.py +++ b/steward/core/notifications.py @@ -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: diff --git a/steward/core/reports.py b/steward/core/reports.py index ebeec5a..f3a88f4 100644 --- a/steward/core/reports.py +++ b/steward/core/reports.py @@ -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: diff --git a/steward/core/settings.py b/steward/core/settings.py index e782d11..8715b8c 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -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": [], diff --git a/steward/migrations/versions/0036_user_email_choices.py b/steward/migrations/versions/0036_user_email_choices.py new file mode 100644 index 0000000..d5ff98e --- /dev/null +++ b/steward/migrations/versions/0036_user_email_choices.py @@ -0,0 +1,25 @@ +"""Each account's email switches: alert emails and the weekly report +(milestone 495). NULL follows the account's role default. + +Revision ID: 0036_user_email_choices +Revises: 0035_drop_plugin_catalog +Create Date: 2026-10-08 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0036_user_email_choices" +down_revision: Union[str, None] = "0035_drop_plugin_catalog" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("notify_alerts", sa.Boolean, nullable=True)) + op.add_column("users", sa.Column("notify_report", sa.Boolean, nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "notify_report") + op.drop_column("users", "notify_alerts") diff --git a/steward/models/users.py b/steward/models/users.py index d908f5f..d59ee82 100644 --- a/steward/models/users.py +++ b/steward/models/users.py @@ -40,3 +40,7 @@ class User(Base): # "America/New_York". Their browser's zone is saved the first time they # sign in without one; they change it on the Account page. None reads as UTC. time_zone: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Whether this account is emailed alerts and the weekly report (milestone + # 495). None follows its role's default (notify.. in settings). + notify_alerts: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + notify_report: Mapped[bool | None] = mapped_column(Boolean, nullable=True) diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 47ea0cf..8afff9a 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -216,18 +216,31 @@ async def save_thresholds(): @settings_bp.get("/notifications/") @require_role(UserRole.admin) async def notifications(): + from steward.core.formatting import zones_by_region + from steward.core.mail import audience async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) + if not settings["smtp.tz"] and session.get("tz"): + # "Also send to" reads times in the first admin's zone until one is chosen. + async with db.begin(): + await set_setting(db, "smtp.tz", session["tz"]) + settings["smtp.tz"] = session["tz"] + await _reload_app_config() + who = await audience(db) from urllib.parse import urlsplit webhook_host = urlsplit(settings.get("webhook.url") or "").hostname or "" return await render_template("settings/notifications.html", settings=settings, - webhook_host=webhook_host) + webhook_host=webhook_host, audience=who, + roles=list(UserRole), zones=zones_by_region()) @settings_bp.post("/notifications/") @require_role(UserRole.admin) async def save_notifications(): + from steward.core.formatting import zone_names + from steward.core.mail import KINDS form = await request.form + extra_tz = form.get("smtp.tz", "") async with current_app.db_sessionmaker() as db: async with db.begin(): recipients_raw = form.get("smtp.recipients", "") @@ -240,7 +253,12 @@ async def save_notifications(): await set_setting(db, "smtp.password", form.get("smtp.password")) await set_setting(db, "smtp.recipients", recipients) await set_setting(db, "smtp.from", form.get("smtp.from", "").strip()) - await set_setting(db, "smtp.tz", session.get("tz") or "") + if extra_tz in zone_names(): + await set_setting(db, "smtp.tz", extra_tz) + for kind in KINDS: + for role in UserRole: + key = f"notify.{kind}.{role.value}" + await set_setting(db, key, key in form) if "webhook.remove" in form: await set_setting(db, "webhook.url", "") elif form.get("webhook.url", "").strip(): @@ -295,31 +313,42 @@ async def test_webhook(): @settings_bp.post("/notifications/test-email") @require_role(UserRole.admin) async def test_email(): - """Send a sample alert email using the SMTP settings in the form (unsaved); - a blank password uses the saved one.""" - from steward.core.mail import alert_email + """Send a sample alert email using the SMTP settings in the form (unsaved): + to you, in your zone, and to the Also send to addresses in the form, in + the zone chosen for them. A blank password uses the saved one.""" + from steward.core.formatting import zone + from steward.core.mail import Recipient, alert_email, is_real_address, pick_recipients from steward.core.notifications import _send_email, smtp_mode + from steward.models.users import User form = await request.form async with current_app.db_sessionmaker() as db: saved = to_smtp_cfg(await get_all_settings(db)) + me = await db.get(User, session.get("user_id")) try: port = int(form.get("smtp.port") or 587) except ValueError: return await _notify_test_result("email", False, "The port must be a number.") + extra = [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()] cfg = {"host": (form.get("smtp.host") or "").strip(), "port": port, "tls": "smtp.tls" in form, "username": form.get("smtp.username", ""), "password": form.get("smtp.password") or saved["password"], - "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()]} - if not cfg["host"] or not cfg["recipients"]: - return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.") + "from": (form.get("smtp.from") or "").strip(), "base_url": saved["base_url"]} + to = ([Recipient(me.email.strip(), zone(me.time_zone), False, me.username)] + if me is not None and is_real_address(me.email) else []) + to += [r for r in pick_recipients([], "alerts", {}, extra, form.get("smtp.tz") or saved["tz"]) + if r.address.lower() not in {t.address.lower() for t in to}] + if not cfg["host"]: + return await _notify_test_result("email", False, "Enter an SMTP host.") + if not to: + return await _notify_test_result( + "email", False, "Your account has no email address mail can reach, and no Also send to " + "addresses are entered. Set your address on the Account page.") alert = _sample_alert() - sent = await _send_email(cfg, alert) + sent = await _send_email(cfg, alert, to) 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(r.address for r in to)} via {cfg['host']}:{port} ({how})." if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}") - return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert)[1]) + return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert, to[0])[1]) # ── Ansible Sources ─────────────────────────────────────────────────────────── diff --git a/steward/settings/users.py b/steward/settings/users.py index 34be7d5..2571b7f 100644 --- a/steward/settings/users.py +++ b/steward/settings/users.py @@ -16,7 +16,9 @@ from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from steward.auth.middleware import require_role +from steward.core import mail from steward.core.audit import audit_request +from steward.core.settings import get_all_settings from steward.models.alerts import AlertRule, MaintenanceWindow from steward.models.ansible import AnsibleRun from steward.models.api_tokens import ApiToken @@ -102,9 +104,13 @@ async def users_list(): users = (await db.execute(select(User))).scalars().all() last = await _last_sign_ins(db) tokens = await _token_counts(db) + settings = await get_all_settings(db) users = sorted(users, key=lambda u: (not u.is_active, u.username.lower())) + gets = {u.id: [label for kind, label in (("alerts", "alerts"), ("report", "report")) + if u.is_active and mail.wants(u, kind, settings)] for u in users} return await render_template( "settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES, + gets=gets, reachable={u.id for u in users if mail.is_real_address(u.email)}, admins=sum(1 for u in users if u.role == UserRole.admin and u.is_active), disabled=sum(1 for u in users if not u.is_active)) @@ -150,6 +156,7 @@ async def user_create(): _DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.", + "email": "Email settings saved.", "password": "Password set. It works from their next sign-in.", "disabled": "Account disabled. They are signed out at their next click.", "enabled": "Account enabled."} @@ -162,9 +169,11 @@ async def _user_page(user_id: str, error: str | None = None, status: int = 200): last = (await _last_sign_ins(db)).get(user.id) tokens = (await _token_counts(db)).get(user.id, 0) holds = await held_by(db, user.id) + defaults = mail.role_defaults(user, await get_all_settings(db)) me = session.get("user_id") return await render_template( "settings/user.html", user=user, last_sign_in=last, tokens=tokens, holds=holds, + defaults=defaults, reachable=mail.is_real_address(user.email), source=SOURCES.get(user.auth_source, user.auth_source), roles=list(UserRole), min_password=MIN_PASSWORD, error=error, done=_DONE.get(request.args.get("done", "")), demote_refused=refusal("demote", user, me, admins), @@ -231,6 +240,23 @@ async def user_role(user_id: str): return redirect(f"/settings/users/{user_id}?done=role") +@users_bp.post("//email") +@require_role(UserRole.admin) +async def user_email(user_id: str): + """Whether this account is emailed alerts and the weekly report (milestone + 495); its address is edited with its name.""" + form = {k: v for k, v in (await request.form).items() if k != "email"} + async with current_app.db_sessionmaker() as db: + async with db.begin(): + user = await _target(db, user_id) + changes, error = mail.apply_email_form(user, form) + if error: + return await _user_page(user_id, error, 400) + if changes: + await audit_request("user.email_settings", "user", user_id, {"username": user.username} | changes) + return redirect(f"/settings/users/{user_id}?done=email") + + @users_bp.post("//password") @require_role(UserRole.admin) async def user_password(user_id: str): diff --git a/steward/templates/_macros.html b/steward/templates/_macros.html index 1e94cb9..4b9a9b0 100644 --- a/steward/templates/_macros.html +++ b/steward/templates/_macros.html @@ -123,3 +123,15 @@ {{ label }} {%- endmacro %} + +{# A time-zone picker: `zones` is formatting.zones_by_region(), `selected` an + IANA name ("UTC" for none). #} +{% macro zone_select(name, id, selected, zones) -%} + +{%- endmacro %} diff --git a/steward/templates/account/_email_choices.html b/steward/templates/account/_email_choices.html new file mode 100644 index 0000000..6c3cb9e --- /dev/null +++ b/steward/templates/account/_email_choices.html @@ -0,0 +1,34 @@ +{# The email form for one account (milestone 495): its address, and whether + it gets alert emails and the weekly report. Used by the Account page (your + own) and an account's page in Settings › Users (an admin's view). + `defaults` is {kind: bool}, the account's role defaults. #} +{% macro email_choices(user, defaults, action, error=none, with_address=true) -%} +
{{ csrf_field() }} +
+

Email

+ {% if error %}{% endif %} + {% if with_address %} +
+ + + {% if user.auth_source != "local" %}Your sign-in provider set this first; change it if mail doesn't reach it.{% endif %} +
+ {% endif %} +
+ {% for kind, label in (("alerts", "Alert emails"), ("report", "Weekly report")) %} + {% set choice = user["notify_" ~ kind] %} +
+ + +
+ {% endfor %} +
+ Times in these emails are in {{ user.time_zone or "UTC" }}. A role's default is set in Settings › Notifications. + +
+
+{%- endmacro %} diff --git a/steward/templates/account/index.html b/steward/templates/account/index.html index d12cdb8..1669304 100644 --- a/steward/templates/account/index.html +++ b/steward/templates/account/index.html @@ -1,7 +1,8 @@ {# account/index.html — the signed-in user's own account (milestone 490): who they are, and the time zone every page writes its times in. #} {% extends "base.html" %} -{% from "_macros.html" import page_header %} +{% from "_macros.html" import page_header, zone_select %} +{% from "account/_email_choices.html" import email_choices with context %} {% block title %}Your account — Steward{% endblock %} {% block content %} {{ page_header("Your account") }} @@ -17,16 +18,12 @@ {% if error %}{% endif %}
- + {{ zone_select("time_zone", "acct-tz", zone_name, zones) }} Every page writes its times in this zone, on a 24-hour clock.
+ +{{ email_choices(user, defaults, "/account/email", email_error) }} {% endblock %} diff --git a/steward/templates/settings/notifications.html b/steward/templates/settings/notifications.html index c50fc43..f41dc87 100644 --- a/steward/templates/settings/notifications.html +++ b/steward/templates/settings/notifications.html @@ -1,6 +1,6 @@ {# steward/templates/settings/notifications.html #} {% extends "base.html" %} -{% from "_macros.html" import crumbs %} +{% from "_macros.html" import crumbs, zone_select %} {% block title %}Settings — Notifications — Steward{% endblock %} {% from "settings/_tabs.html" import settings_tabs %} {% block section_tabs %}{{ settings_tabs("notifications") }}{% endblock %} @@ -51,23 +51,68 @@ placeholder="steward@example.com"> -
- - -

- 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 General and they link back here.{% endif %} -

-
- +

It goes to you, at your account's address and in your zone, and to the Also send to addresses below.

+ {# ── Who gets email (milestone 495) ───────────────────────────────────────── #} +
+

Who gets email

+

+ Each account gets its own copy at its own address, with times in its own zone. These are the defaults + for each role; an account's own choice, on its Account page or in Users, wins.

+ + + + {% for r in roles %} + + + {% for kind in ("alerts", "report") %} + {% set key = "notify." ~ kind ~ "." ~ r.value %} + + {% endfor %} + + {% endfor %} + +
RoleAlert emailsWeekly report
{{ r.value|capitalize }}
+

+ Alerts reach {{ audience.alerts|length }} account{{ 's' if audience.alerts|length != 1 }} + {%- if audience.alerts %} ({{ audience.alerts|map(attribute='username')|join(', ') }}){% endif %} + · the weekly report reaches {{ audience.report|length }} + {%- if audience.report %} ({{ audience.report|map(attribute='username')|join(', ') }}){% endif %}. +

+ {% if audience.unreachable %} +

+ {{ audience.unreachable|length }} account{{ 's have' if audience.unreachable|length != 1 else ' has' }} no address mail can reach: + {% for u in audience.unreachable %}{{ u.username }}{{ ", " if not loop.last }}{% endfor %}. +

+ {% endif %} +
+ + {# ── Also send to ─────────────────────────────────────────────────────────── #} +
+

Also send to

+
+ + +

For a mailbox that isn't a person. These get alerts and the weekly report.

+
+
+ + {{ zone_select("smtp.tz", "smtp-tz", settings['smtp.tz'] or "UTC", zones) }} +

With UTC beside, since nobody's own zone is known for them.

+
+ {% if not settings['general.public_base_url'] %} +

Set Steward's address in General and every email links back here.

+ {% endif %} +
+ {# ── Webhook ───────────────────────────────────────────────────────────── #}

Webhook

@@ -94,7 +139,7 @@ {{ '{{ alert.rule_name }}' }} is escaped to sit inside a JSON string, so quotes or line breaks in a name can't break the body; a missing value reads "no data". For a bare JSON value use {{ '{{ raw.value | tojson }}' }} (a number, or null). - Fields: state, rule_name, resource, metric, value, threshold, source_module, note, timestamp.

+ Fields: state, rule_name, resource, metric, value, operator, threshold, source_module, note, timestamp.