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:
@@ -153,6 +153,10 @@ def test_where_alerts_go_is_said_as_the_dispatcher_decides():
|
||||
"""#5315: email needs a server and recipients; a webhook its URL."""
|
||||
assert alert_svc.notify_targets({}, {}) == []
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}, accounts=3) == ["email to 3 accounts"]
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": ["ops@x"]}, {}, accounts=1) == [
|
||||
"email to 1 account, ops@x"]
|
||||
assert alert_svc.notify_targets({"host": "", "recipients": []}, {}, accounts=3) == [] # no server, no email
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
|
||||
== ["email to a@x, b@x", "a webhook to hooks.example"]
|
||||
|
||||
+104
-17
@@ -12,8 +12,10 @@ 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/"}
|
||||
CFG = {"host": "mx", "port": 587, "from": "alerts@example.com", "base_url": "https://steward.example/"}
|
||||
NY = ZoneInfo("America/New_York")
|
||||
EXTRA_NY = mail.Recipient("ops@example.com", NY, True) # an Also send to address: UTC beside
|
||||
PERSON_NY = mail.Recipient("ann@example.com", NY, False, "ann")
|
||||
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",
|
||||
@@ -43,11 +45,11 @@ def test_every_colour_is_a_theme_value():
|
||||
assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", v) for v in colours.values()), colours
|
||||
|
||||
|
||||
async def _sent(monkeypatch, cfg, alert):
|
||||
async def _sent(monkeypatch, cfg, alert, to=(EXTRA_NY,)):
|
||||
out = []
|
||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msg: out.append(msg))
|
||||
assert (await n._send_email(cfg, alert))["sent"]
|
||||
return out[0]
|
||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msgs: out.extend(msgs))
|
||||
assert (await n._send_email(cfg, alert, list(to)))["sent"]
|
||||
return out[0] if len(to) == 1 else out
|
||||
|
||||
|
||||
async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
|
||||
@@ -59,7 +61,17 @@ async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
|
||||
"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"
|
||||
assert (msg["From"], msg["To"]) == ("alerts@example.com", "ops@example.com")
|
||||
|
||||
|
||||
async def test_each_recipient_gets_their_own_copy_in_their_own_zone(monkeypatch):
|
||||
berlin = mail.Recipient("bo@example.com", ZoneInfo("Europe/Berlin"), False, "bo")
|
||||
ann, bo = await _sent(monkeypatch, CFG, ALERT, to=(PERSON_NY, berlin))
|
||||
assert (ann["To"], bo["To"]) == ("ann@example.com", "bo@example.com")
|
||||
ann_text, bo_text = (m.get_body(("plain",)).get_content() for m in (ann, bo))
|
||||
assert "13:58 EDT" in ann_text and "19:58 CEST" in bo_text
|
||||
# A person whose zone is known is never shown UTC beside it.
|
||||
assert "UTC" not in ann_text + bo_text
|
||||
|
||||
|
||||
async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
|
||||
@@ -72,12 +84,12 @@ async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
|
||||
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"))
|
||||
_, _, html = mail.report_email(CFG, REPORT, mail.Recipient("x@example.com", timezone.utc, False))
|
||||
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)
|
||||
_, text, html = mail.alert_email({}, ALERT, mail.Recipient("x@example.com", timezone.utc, True))
|
||||
assert "View in Steward" not in text + html and "http" not in text + html
|
||||
assert text.count("17:58 UTC") == 1
|
||||
|
||||
@@ -95,7 +107,7 @@ def test_each_state_reads_as_a_sentence():
|
||||
|
||||
|
||||
def test_the_report_has_its_sections_in_both_parts():
|
||||
subject, text, html = mail.report_email(CFG, REPORT, ZoneInfo("America/New_York"))
|
||||
subject, text, html = mail.report_email(CFG, REPORT, PERSON_NY)
|
||||
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%",
|
||||
@@ -122,13 +134,88 @@ async def test_the_report_is_sent_from_the_configured_address(monkeypatch):
|
||||
async def _data(app):
|
||||
return REPORT
|
||||
|
||||
async def _setting(db, key):
|
||||
return "America/New_York"
|
||||
async def _recipients(db, kind, smtp_cfg):
|
||||
assert kind == "report"
|
||||
return [PERSON_NY, EXTRA_NY]
|
||||
|
||||
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"
|
||||
monkeypatch.setattr(reports, "recipients", _recipients)
|
||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msgs: out.extend(msgs))
|
||||
ok, said = await reports.send_report(_App())
|
||||
assert ok and said == "Report sent to ann@example.com, ops@example.com."
|
||||
assert [(m["From"], m["To"]) for m in out] == [("alerts@example.com", "ann@example.com"),
|
||||
("alerts@example.com", "ops@example.com")]
|
||||
assert all(m.get_content_type() == "multipart/alternative" for m in out)
|
||||
|
||||
|
||||
async def test_nobody_to_send_the_report_to_is_said(monkeypatch):
|
||||
class _App:
|
||||
config = {"SMTP": CFG}
|
||||
|
||||
@staticmethod
|
||||
def db_sessionmaker():
|
||||
class _S:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
return _S()
|
||||
|
||||
async def _nobody(db, kind, smtp_cfg):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(reports, "recipients", _nobody)
|
||||
ok, said = await reports.send_report(_App())
|
||||
assert not ok and said.startswith("Nobody gets the weekly report")
|
||||
|
||||
|
||||
# ── Who gets an email (milestone 495) ─────────────────────────────────────────
|
||||
|
||||
def _user(name, role="viewer", email=None, tz=None, active=True, alerts=None, report=None):
|
||||
from steward.models.users import User, UserRole
|
||||
return User(username=name, email=email if email is not None else f"{name}@example.com",
|
||||
password_hash="x", role=UserRole(role), is_active=active, time_zone=tz,
|
||||
notify_alerts=alerts, notify_report=report)
|
||||
|
||||
|
||||
def test_each_account_follows_its_own_choice_or_else_its_role():
|
||||
settings = {"notify.alerts.viewer": False, "notify.alerts.admin": True}
|
||||
users = [_user("adm", "admin"), # role on
|
||||
_user("vic"), # role off
|
||||
_user("val", alerts=True), # role off, chose on
|
||||
_user("ada", "admin", alerts=False), # role on, chose off
|
||||
_user("gone", "admin", active=False), # disabled
|
||||
_user("sso", "admin", email="sso@oidc")] # no address mail can reach
|
||||
got = mail.pick_recipients(users, "alerts", settings, [], None)
|
||||
assert [r.user for r in got] == ["adm", "val"]
|
||||
# A role with no setting yet gets email: the defaults start on.
|
||||
assert [r.user for r in mail.pick_recipients([_user("op", "operator")], "report", {}, [], None)] == ["op"]
|
||||
|
||||
|
||||
def test_a_person_reads_their_own_zone_and_extra_addresses_read_the_chosen_one():
|
||||
users = [_user("ann", "admin", tz="America/New_York"), _user("utc", "admin")]
|
||||
got = mail.pick_recipients(users, "alerts", {}, ["ops@example.com", "ANN@example.com", "nonsense"],
|
||||
"Europe/Berlin")
|
||||
assert [(r.address, str(r.tz), r.show_utc) for r in got] == [
|
||||
("ann@example.com", "America/New_York", False),
|
||||
("utc@example.com", "UTC", False),
|
||||
("ops@example.com", "Europe/Berlin", True)] # ANN@ is ann, once; "nonsense" is no address
|
||||
|
||||
|
||||
def test_the_email_form_changes_only_what_it_says_and_nothing_on_a_bad_value():
|
||||
u = _user("ann", alerts=None, report=True)
|
||||
changes, error = mail.apply_email_form(u, {"email": "new@example.com", "notify_alerts": "off",
|
||||
"notify_report": "on"})
|
||||
assert error is None and changes == {"email": ["ann@example.com", "new@example.com"],
|
||||
"notify_alerts": [None, False]}
|
||||
assert (u.email, u.notify_alerts, u.notify_report) == ("new@example.com", False, True)
|
||||
for bad in ({"email": "ann@oidc", "notify_alerts": "", "notify_report": ""},
|
||||
{"notify_alerts": "maybe", "notify_report": ""}):
|
||||
changes, error = mail.apply_email_form(u, bad)
|
||||
assert changes == {} and error
|
||||
assert (u.email, u.notify_alerts) == ("new@example.com", False)
|
||||
# Without an address in the form (an admin's view), the address is left alone.
|
||||
assert mail.apply_email_form(u, {"notify_alerts": "", "notify_report": ""})[0] == {
|
||||
"notify_alerts": [False, None], "notify_report": [True, None]}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
holds, SMTP picks implicit TLS / STARTTLS / plain from port and box and never
|
||||
waits without a timeout, and a webhook error never carries the URL."""
|
||||
import json
|
||||
from datetime import timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from steward.core import notifications as n
|
||||
from steward.core.mail import Recipient
|
||||
|
||||
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
|
||||
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
|
||||
@@ -99,21 +101,24 @@ def test_smtp_send_connects_by_mode_with_a_timeout(monkeypatch, port, tls, expec
|
||||
monkeypatch.setattr(n.smtplib, "SMTP", SMTP)
|
||||
monkeypatch.setattr(n.smtplib, "SMTP_SSL", SMTP_SSL)
|
||||
_FakeSMTP.made = []
|
||||
msg = n.EmailMessage()
|
||||
msg["From"] = "steward@example.com"
|
||||
n._smtp_send_sync({"host": "mx", "port": port, "tls": tls, "username": "u", "password": "p"}, msg)
|
||||
(smtp,) = _FakeSMTP.made
|
||||
assert smtp.calls == expected + [("login", "u", "p"), ("send", "steward@example.com")]
|
||||
msgs = [n.EmailMessage(), n.EmailMessage()]
|
||||
msgs[0]["From"], msgs[1]["From"] = "steward@example.com", "other@example.com"
|
||||
n._smtp_send_sync({"host": "mx", "port": port, "tls": tls, "username": "u", "password": "p"}, msgs)
|
||||
(smtp,) = _FakeSMTP.made # every message over one connection
|
||||
assert smtp.calls == expected + [("login", "u", "p"), ("send", "steward@example.com"),
|
||||
("send", "other@example.com")]
|
||||
|
||||
|
||||
async def test_email_from_falls_back_and_a_line_break_in_the_name_still_sends(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda cfg, msg: sent.append((msg["From"], msg["Subject"])))
|
||||
base = {"host": "mx", "port": 587, "recipients": ["a@example.com"]}
|
||||
monkeypatch.setattr(n, "_smtp_send_sync",
|
||||
lambda cfg, msgs: sent.extend((m["From"], m["Subject"]) for m in msgs))
|
||||
to = [Recipient("a@example.com", timezone.utc, False)]
|
||||
base = {"host": "mx", "port": 587}
|
||||
for cfg in ({**base, "from": "alerts@example.com", "username": "u@example.com"},
|
||||
{**base, "from": "", "username": "u@example.com"},
|
||||
{**base, "from": "", "username": ""}):
|
||||
assert (await n._send_email(cfg, ALERT))["sent"]
|
||||
assert (await n._send_email(cfg, ALERT, to))["sent"]
|
||||
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.
|
||||
assert sent[0][1] == '[Steward] Firing: Disk "root" on web01 \\ backslash — web01 94%'
|
||||
|
||||
@@ -93,8 +93,8 @@ def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypat
|
||||
from steward.core import notifications
|
||||
sent = []
|
||||
|
||||
async def fake_send(cfg, alert):
|
||||
sent.append(cfg)
|
||||
async def fake_send(cfg, alert, to):
|
||||
sent.append((cfg, [r.address for r in to]))
|
||||
return {"sent": True, "error": None}
|
||||
|
||||
monkeypatch.setattr(notifications, "_send_email", fake_send)
|
||||
@@ -110,13 +110,14 @@ def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypat
|
||||
assert "alert-success" 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 sent == [{"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
||||
"password": "pw", "from": "alerts@example.com", "tz": "", "base_url": "",
|
||||
"recipients": ["a@example.com", "b@example.com"]}]
|
||||
# The admin's own address (uuid@test) isn't one mail can reach, so only the typed ones.
|
||||
assert sent == [({"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
||||
"password": "pw", "from": "alerts@example.com", "base_url": ""},
|
||||
["a@example.com", "b@example.com"])]
|
||||
|
||||
resp = await client.post("/settings/notifications/test-email", form={
|
||||
"smtp.host": "", "smtp.port": "587", "smtp.recipients": ""}, headers=csrf)
|
||||
assert "Enter an SMTP host and at least one recipient." in await resp.get_data(as_text=True)
|
||||
assert "Enter an SMTP host." in await resp.get_data(as_text=True)
|
||||
assert len(sent) == 1
|
||||
|
||||
assert [d for _, d in await _audits(app, uid)] == [
|
||||
|
||||
@@ -168,3 +168,33 @@ def test_only_an_admin_reaches_users(app):
|
||||
assert (await client.post("/settings/users/new", headers=CSRF, form={})).status_code == 403
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_an_account_sets_its_own_address_and_switches_and_an_admin_can_too(app):
|
||||
async def _go():
|
||||
viewer, vid = await _client(app, role="viewer")
|
||||
page = await (await viewer.get("/account/")).get_data(as_text=True)
|
||||
assert 'action="/account/email"' in page and "As for viewers (on)" in page
|
||||
|
||||
resp = await viewer.post("/account/email", headers=CSRF, form={
|
||||
"email": "nope@oidc", "notify_alerts": "off", "notify_report": ""})
|
||||
assert resp.status_code == 400 and "mail can reach" in await resp.get_data(as_text=True)
|
||||
address = f"v-{vid[:8]}@example.com"
|
||||
assert (await viewer.post("/account/email", headers=CSRF, form={
|
||||
"email": address, "notify_alerts": "off", "notify_report": ""})).status_code == 302
|
||||
me = await _user(app, vid)
|
||||
assert (me.email, me.notify_alerts, me.notify_report) == (address, False, None)
|
||||
assert [a for a, _ in await _audits(app, vid)] == ["account.email"]
|
||||
|
||||
admin, aid = await _client(app)
|
||||
resp = await admin.post(f"/settings/users/{vid}/email", headers=CSRF,
|
||||
form={"email": "ignored@example.com", "notify_alerts": "", "notify_report": "off"})
|
||||
assert resp.status_code == 302
|
||||
me = await _user(app, vid)
|
||||
assert (me.email, me.notify_alerts, me.notify_report) == (address, None, False) # address kept
|
||||
assert [a for a, _ in await _audits(app, aid)] == ["user.email_settings"]
|
||||
|
||||
page = await (await admin.get("/settings/notifications/")).get_data(as_text=True)
|
||||
assert "Who gets email" in page and 'name="notify.alerts.viewer"' in page and "Also send to" in page
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
@@ -5,7 +5,7 @@ def test_user_model_fields():
|
||||
"""User model has all required fields."""
|
||||
cols = {c.key for c in User.__table__.columns}
|
||||
assert cols == {"id", "username", "email", "password_hash", "role", "created_at", "is_active",
|
||||
"auth_source", "external_id", "time_zone"}
|
||||
"auth_source", "external_id", "time_zone", "notify_alerts", "notify_report"}
|
||||
|
||||
|
||||
def test_user_role_enum():
|
||||
|
||||
Reference in New Issue
Block a user