From 35fca57397e07d35c042492d1a910855d562e92b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:56:36 -0400 Subject: [PATCH] feat(notifications): JSON-safe webhook template, SMTP timeout and implicit TLS, test-send buttons (#5064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5064 "Notifications that arrive": - render_webhook: alert.* is escaped to sit inside a JSON string (a quote or newline in a rule name no longer breaks the body); raw.* is the plain value for |tojson; a missing value reads "no data"; the template runs sandboxed and the body is checked to be JSON before it is sent. - SMTP: 15s timeout; port 465 connects with implicit TLS, otherwise the box means STARTTLS; a From address setting (falls back to the username). - A webhook error no longer carries the URL, which is a secret. - Settings → Notifications: "Send a test email" / "Send a test webhook" send a sample alert with the form's values (unsaved) and show the result and the body that went out; template help names alert.* and raw.*. - Notification audits go through audit_request; the test send is audited as settings.notification.tested, with the channel and outcome only. Co-Authored-By: Claude Opus 5.5 --- steward/core/notifications.py | 103 ++++++++---- steward/core/settings.py | 2 + steward/settings/routes.py | 75 ++++++++- steward/templates/settings/_notify_test.html | 8 + steward/templates/settings/notifications.html | 25 ++- tests/core/test_notifications.py | 152 ++++++++++++++++++ .../test_notification_test_send.py | 125 ++++++++++++++ 7 files changed, 454 insertions(+), 36 deletions(-) create mode 100644 steward/templates/settings/_notify_test.html create mode 100644 tests/core/test_notifications.py create mode 100644 tests/integration/test_notification_test_send.py diff --git a/steward/core/notifications.py b/steward/core/notifications.py index fed6999..1ee6fa4 100644 --- a/steward/core/notifications.py +++ b/steward/core/notifications.py @@ -8,7 +8,7 @@ import types from email.message import EmailMessage import httpx -from jinja2 import Template +from jinja2.sandbox import SandboxedEnvironment logger = logging.getLogger(__name__) @@ -30,22 +30,37 @@ async def dispatch_notifications( return results +SMTP_TIMEOUT_S = 15 + + +def smtp_mode(port, tls) -> str: + """How to talk to the SMTP server: "ssl" (implicit TLS, port 465), + "starttls" (upgrade a plain connection, when TLS is ticked) or "plain".""" + if int(port or 0) == 465: + return "ssl" + return "starttls" if tls else "plain" + + +def email_body(alert: dict) -> str: + value = "no data" if alert.get("value") is None else alert["value"] + return ( + f"Alert: {alert['state']}\n" + f"Rule: {alert['rule_name']}\n" + f"Resource: {alert['resource']}\n" + f"Metric: {alert['metric']} = {value}\n" + f"Threshold: {alert['threshold']}\n" + + (f"Note: {alert['note']}\n" if alert.get("note") else "") + + f"Time: {alert['timestamp']}\n" + ) + + async def _send_email(cfg: dict, alert: dict) -> dict: try: msg = EmailMessage() msg["Subject"] = f"[Steward] {alert['state']} — {alert['rule_name']}" - msg["From"] = cfg.get("username", "steward@localhost") + msg["From"] = cfg.get("from") or cfg.get("username") or "steward@localhost" msg["To"] = ", ".join(cfg["recipients"]) - value = "no data" if alert.get("value") is None else alert["value"] - msg.set_content( - f"Alert: {alert['state']}\n" - f"Rule: {alert['rule_name']}\n" - f"Resource: {alert['resource']}\n" - f"Metric: {alert['metric']} = {value}\n" - f"Threshold: {alert['threshold']}\n" - + (f"Note: {alert['note']}\n" if alert.get("note") else "") - + f"Time: {alert['timestamp']}\n" - ) + msg.set_content(email_body(alert)) loop = asyncio.get_running_loop() await loop.run_in_executor(None, _smtp_send_sync, cfg, msg) return {"sent": True, "error": None} @@ -55,37 +70,59 @@ async def _send_email(cfg: dict, alert: dict) -> dict: def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None: - ctx = ssl.create_default_context() if cfg.get("tls", True) else None - with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp: - if ctx: + port = int(cfg.get("port") or 587) + mode = smtp_mode(port, cfg.get("tls", True)) + ctx = ssl.create_default_context() + if mode == "ssl": + smtp = smtplib.SMTP_SSL(cfg["host"], port, timeout=SMTP_TIMEOUT_S, context=ctx) + else: + smtp = smtplib.SMTP(cfg["host"], port, timeout=SMTP_TIMEOUT_S) + with smtp: + if mode == "starttls": smtp.starttls(context=ctx) if cfg.get("username"): smtp.login(cfg["username"], cfg.get("password", "")) smtp.send_message(msg) -async def _send_webhook(cfg: dict, alert: dict) -> dict: - template_str = cfg.get( - "template", - '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}', - ) +def _json_str(value) -> str: + """A value as it may sit between the quotes of a JSON string.""" + return json.dumps("" if value is None else str(value))[1:-1] - # Render Jinja2 template with alert as a namespace object (attribute access) + +def render_webhook(template_str: str, alert: dict) -> str: + """The webhook body for an alert, checked to be JSON. + + In the template, `alert.` is escaped to sit inside a JSON string + ("{{ alert.rule_name }}" stays valid whatever the name holds), with a + missing value reading "no data"; `raw.` is the plain value for use + with |tojson ({{ raw.value | tojson }} gives a number or null). The + template runs sandboxed. ValueError says why a body could not be made. + """ + shown = dict(alert) + if shown.get("value") is None: + shown["value"] = "no data" + env = SandboxedEnvironment() try: - alert_ns = types.SimpleNamespace(**alert) - rendered = Template(template_str).render(alert=alert_ns) + rendered = env.from_string(template_str).render( + alert=types.SimpleNamespace(**{k: _json_str(v) for k, v in shown.items()}), + raw=types.SimpleNamespace(**alert)) except Exception as exc: - error = f"Template render failed: {exc}" - logger.error(error) - return {"sent": False, "error": error} - - # Validate JSON before sending + raise ValueError(f"Template render failed: {exc}") from None try: json.loads(rendered) except json.JSONDecodeError as exc: - error = f"Rendered webhook template is not valid JSON: {exc}" - logger.error(error) - return {"sent": False, "error": error} + raise ValueError(f"Rendered webhook template is not valid JSON: {exc}") from None + return rendered + + +async def _send_webhook(cfg: dict, alert: dict) -> dict: + template_str = cfg.get("template") or '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}' + try: + rendered = render_webhook(template_str, alert) + except ValueError as exc: + logger.error(str(exc)) + return {"sent": False, "error": str(exc)} try: async with httpx.AsyncClient(timeout=10) as client: @@ -97,6 +134,8 @@ async def _send_webhook(cfg: dict, alert: dict) -> dict: response.raise_for_status() return {"sent": True, "error": None} except Exception as exc: - error = str(exc) + # The URL is a secret (it usually carries its own token) and httpx + # puts it in its error messages. + error = str(exc).replace(cfg["url"], "") logger.error(f"Webhook notification failed: {error}") return {"sent": False, "error": error} diff --git a/steward/core/settings.py b/steward/core/settings.py index aad7d41..7b1db2e 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -55,6 +55,7 @@ DEFAULTS: dict[str, Any] = { "smtp.username": "", "smtp.password": "", "smtp.recipients": [], + "smtp.from": "", "webhook.url": "", "webhook.template": _DEFAULT_WEBHOOK_TEMPLATE, "ansible.sources": [], @@ -370,6 +371,7 @@ def to_smtp_cfg(settings: dict[str, Any]) -> dict: "username": settings.get("smtp.username", ""), "password": settings.get("smtp.password", ""), "recipients": settings.get("smtp.recipients", []), + "from": settings.get("smtp.from", ""), } diff --git a/steward/settings/routes.py b/steward/settings/routes.py index b0d7632..5e1666f 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -5,7 +5,7 @@ from pathlib import Path from quart import Blueprint, current_app, render_template, request, redirect, url_for, session from steward.auth.middleware import require_role -from steward.core.audit import log_audit +from steward.core.audit import audit_request, log_audit from steward.models.users import UserRole from steward.core.settings import ( get_all_settings, set_setting, delete_setting, @@ -229,17 +229,86 @@ async def save_notifications(): if form.get("smtp.password"): 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()) if "webhook.remove" in form: await set_setting(db, "webhook.url", "") elif form.get("webhook.url", "").strip(): await set_setting(db, "webhook.url", form.get("webhook.url").strip()) await set_setting(db, "webhook.template", form.get("webhook.template", "")) await _reload_app_config() - await log_audit(current_app, session.get("user_id"), session.get("username", ""), - "settings.saved", detail={"section": "notifications"}) + await audit_request("settings.saved", detail={"section": "notifications"}) return redirect(url_for("settings.notifications")) +def _sample_alert() -> dict: + """What a test notification carries: a made-up firing alert whose name + has quotes in it, so a template that cannot take them shows it.""" + from datetime import datetime, timezone + return {"rule_name": 'Test alert "from" Steward', "state": "TEST", "metric": "cpu_pct", + "value": 97.5, "threshold": 90.0, "resource": "example-host", + "source_module": "host_agent", "note": "sent from Settings → Notifications", + "timestamp": datetime.now(timezone.utc).isoformat()} + + +async def _notify_test_result(channel: str, ok: bool, result: str, payload: str | None = None): + await audit_request("settings.notification.tested", detail={"channel": channel, "sent": ok}) + return await render_template("settings/_notify_test.html", ok=ok, result=result, + payload=payload) + + +@settings_bp.post("/notifications/test-webhook") +@require_role(UserRole.admin) +async def test_webhook(): + """Render the webhook template (as in the form, unsaved) for a sample + alert and send it to the URL in the form, or the saved one.""" + from steward.core.notifications import _send_webhook, render_webhook + form = await request.form + async with current_app.db_sessionmaker() as db: + saved = to_webhook_cfg(await get_all_settings(db)) + url = (form.get("webhook.url") or "").strip() or saved["url"] + template = form.get("webhook.template") or saved["template"] + alert = _sample_alert() + try: + payload = render_webhook(template, alert) + except ValueError as exc: + return await _notify_test_result("webhook", False, str(exc)) + if not url: + return await _notify_test_result( + "webhook", False, "No webhook URL is saved or entered. This is the body it would get.", + payload) + sent = await _send_webhook({"url": url, "template": template}, alert) + return await _notify_test_result( + "webhook", sent["sent"], "Delivered." if sent["sent"] else sent["error"], payload) + + +@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.notifications import _send_email, email_body, smtp_mode + form = await request.form + async with current_app.db_sessionmaker() as db: + saved = to_smtp_cfg(await get_all_settings(db)) + try: + port = int(form.get("smtp.port") or 587) + except ValueError: + return await _notify_test_result("email", False, "The port must be a number.") + 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(), + "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.") + alert = _sample_alert() + sent = await _send_email(cfg, alert) + 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})." + if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}") + return await _notify_test_result("email", sent["sent"], result, email_body(alert)) + + # ── Ansible Sources ─────────────────────────────────────────────────────────── async def _get_ansible_sources() -> list[dict]: diff --git a/steward/templates/settings/_notify_test.html b/steward/templates/settings/_notify_test.html new file mode 100644 index 0000000..0d85e31 --- /dev/null +++ b/steward/templates/settings/_notify_test.html @@ -0,0 +1,8 @@ +{# settings/_notify_test.html — result of a test send from Settings → Notifications #} +
+ {{ result }} +
+{% if payload %} +

What was sent:

+
{{ payload }}
+{% endif %} diff --git a/steward/templates/settings/notifications.html b/steward/templates/settings/notifications.html index 4cbb834..131caf4 100644 --- a/steward/templates/settings/notifications.html +++ b/steward/templates/settings/notifications.html @@ -28,9 +28,11 @@
- +
+

+ Port 465 always connects with implicit TLS; on other ports the box upgrades the connection with STARTTLS.

@@ -43,12 +45,23 @@
+
+ + +
+
+ + +
{# ── Webhook ───────────────────────────────────────────────────────────── #} @@ -73,7 +86,17 @@ +

+ {{ '{{ 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.

+ + +
diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py new file mode 100644 index 0000000..1a6a473 --- /dev/null +++ b/tests/core/test_notifications.py @@ -0,0 +1,152 @@ +"""Notification delivery (#5064): the webhook body is JSON whatever an alert +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 + +import httpx +import pytest + +from steward.core import notifications as n + +ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct", + "value": 93.5, "threshold": 90.0, "resource": "web01", "source_module": "host_agent", + "note": None, "timestamp": "2026-10-06T12:00:00+00:00"} + + +# ── render_webhook ──────────────────────────────────────────────────────────── + +def test_alert_fields_are_escaped_into_json_strings(): + body = n.render_webhook('{"content": "{{ alert.state }} — {{ alert.rule_name }}"}', ALERT) + assert json.loads(body) == {"content": 'FIRING — Disk "root" on\nweb01 \\ backslash'} + + +def test_the_default_template_survives_quotes_in_a_name(): + from steward.core.settings import DEFAULTS + body = json.loads(n.render_webhook(DEFAULTS["webhook.template"], ALERT)) + assert 'Disk "root" on\nweb01' in body["content"] and "93.5" in body["content"] + + +def test_raw_fields_give_bare_json_values(): + tpl = '{"value": {{ raw.value | tojson }}, "name": {{ raw.rule_name | tojson }}}' + assert json.loads(n.render_webhook(tpl, ALERT)) == {"value": 93.5, "name": ALERT["rule_name"]} + assert json.loads(n.render_webhook(tpl, {**ALERT, "value": None}))["value"] is None + + +def test_a_missing_value_reads_no_data(): + body = n.render_webhook('{"v": "{{ alert.value }}", "note": "{{ alert.note }}"}', {**ALERT, "value": None}) + assert json.loads(body) == {"v": "no data", "note": ""} + + +@pytest.mark.parametrize("tpl, says", [ + ('{"v": {{ alert.value }', "Template render failed"), # Jinja syntax error + ('{"v": {{ raw.rule_name }}}', "not valid JSON"), # unquoted string + ("{{ raw.__class__.__mro__ }}", "Template render failed"), # sandbox refuses +]) +def test_a_bad_template_raises_valueerror_saying_why(tpl, says): + with pytest.raises(ValueError, match=says): + n.render_webhook(tpl, ALERT) + + +# ── smtp_mode / _smtp_send_sync ─────────────────────────────────────────────── + +@pytest.mark.parametrize("port, tls, mode", [ + (465, True, "ssl"), (465, False, "ssl"), ("465", False, "ssl"), + (587, True, "starttls"), (587, False, "plain"), + (25, True, "starttls"), (25, False, "plain"), + (None, True, "starttls"), (None, False, "plain"), +]) +def test_smtp_mode(port, tls, mode): + assert n.smtp_mode(port, tls) == mode + + +class _FakeSMTP: + made: list = [] + + def __init__(self, host, port, timeout=None, context=None): + self.calls = [("connect", type(self).__name__, host, port, timeout)] + _FakeSMTP.made.append(self) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def starttls(self, context=None): + self.calls.append(("starttls",)) + + def login(self, user, password): + self.calls.append(("login", user, password)) + + def send_message(self, msg): + self.calls.append(("send", msg["From"])) + + +class SMTP(_FakeSMTP): + pass + + +class SMTP_SSL(_FakeSMTP): + pass + + +@pytest.mark.parametrize("port, tls, expected", [ + (465, True, [("connect", "SMTP_SSL", "mx", 465, n.SMTP_TIMEOUT_S)]), + (587, True, [("connect", "SMTP", "mx", 587, n.SMTP_TIMEOUT_S), ("starttls",)]), + (25, False, [("connect", "SMTP", "mx", 25, n.SMTP_TIMEOUT_S)]), +]) +def test_smtp_send_connects_by_mode_with_a_timeout(monkeypatch, port, tls, expected): + 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")] + + +async def test_email_from_address_falls_back_to_username_then_localhost(monkeypatch): + sent = [] + monkeypatch.setattr(n, "_smtp_send_sync", lambda cfg, msg: sent.append(msg["From"])) + base = {"host": "mx", "port": 587, "recipients": ["a@example.com"]} + 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 sent == ["alerts@example.com", "u@example.com", "steward@localhost"] + + +def test_email_body_names_no_data_and_the_note(): + body = n.email_body({**ALERT, "value": None, "note": "no sample for 18m"}) + assert "Metric: disk_used_pct = no data\n" in body and "Note: no sample for 18m\n" in body + assert "Note:" not in n.email_body(ALERT) + + +# ── _send_webhook ───────────────────────────────────────────────────────────── + +def _client_with(monkeypatch, handler): + real = httpx.AsyncClient + monkeypatch.setattr(n.httpx, "AsyncClient", + lambda **kw: real(transport=httpx.MockTransport(handler), **kw)) + + +async def test_a_webhook_error_never_carries_the_url(monkeypatch): + url = "https://discord.example/api/webhooks/123/s3cr3t-token" + _client_with(monkeypatch, lambda req: httpx.Response(404)) + result = await n._send_webhook({"url": url, "template": ""}, ALERT) + assert result["sent"] is False + assert "s3cr3t-token" not in result["error"] and "" in result["error"] + + +async def test_a_webhook_posts_the_rendered_json(monkeypatch): + seen = [] + + def handler(req): + seen.append(json.loads(req.content)) + return httpx.Response(204) + + _client_with(monkeypatch, handler) + result = await n._send_webhook({"url": "https://hook.example/x", "template": ""}, ALERT) + assert result == {"sent": True, "error": None} + assert seen == [{"content": f'FIRING — {ALERT["rule_name"]}'}] diff --git a/tests/integration/test_notification_test_send.py b/tests/integration/test_notification_test_send.py new file mode 100644 index 0000000..8cd9436 --- /dev/null +++ b/tests/integration/test_notification_test_send.py @@ -0,0 +1,125 @@ +"""Integration: the test-send buttons on Settings → Notifications (#5064). + +Each one sends a sample alert with the form's values, unsaved, and answers +with what happened and the body that went out. The routes run for real +against Postgres; only the delivery itself is replaced. +""" +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +async def _admin_client(app): + from steward.models.users import User, UserRole + uid = str(uuid.uuid4()) + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(User(id=uid, username=f"notify-{uid[:8]}", email=f"{uid}@test", + password_hash="x", role=UserRole.admin, is_active=True)) + client = app.test_client() + async with client.session_transaction() as sess: + sess["user_id"] = uid + sess["user_role"] = "admin" + sess["username"] = f"notify-{uid[:8]}" + sess["_csrf"] = "test-token" + sess["login_at"] = time.time() + return client, uid + + +async def _audits(app, uid): + from sqlalchemy import select + from steward.models.audit import AuditEvent + async with app.db_sessionmaker() as s: + rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid) + .order_by(AuditEvent.timestamp))).scalars().all() + return [(r.action, json.loads(r.detail_json)) for r in rows] + + +def test_test_webhook_sends_the_form_template_and_shows_the_body(app, monkeypatch): + from steward.core import notifications + sent = [] + + async def fake_send(cfg, alert): + sent.append((cfg, alert)) + return {"sent": False, "error": "Client error '404 Not Found' for url ''"} + + monkeypatch.setattr(notifications, "_send_webhook", fake_send) + + async def _go(): + client, uid = await _admin_client(app) + csrf = {"X-CSRF-Token": "test-token"} + url = "https://hook.example/api/webhooks/1/s3cr3t" + resp = await client.post("/settings/notifications/test-webhook", form={ + "webhook.url": url, "webhook.template": '{"text": "{{ alert.rule_name }}"}'}, headers=csrf) + assert resp.status_code == 200 + page = await resp.get_data(as_text=True) + assert "alert-error" in page and "404 Not Found" in page + # The body shown is the one that would go out, and the quoted name kept it JSON. + assert "Test alert \\"from\\" Steward" in page + ((cfg, alert),) = sent + assert cfg["url"] == url and alert["rule_name"] == 'Test alert "from" Steward' + + # A template that cannot make JSON is refused before anything is sent. + resp = await client.post("/settings/notifications/test-webhook", form={ + "webhook.url": url, "webhook.template": '{"text": {{ raw.rule_name }}}'}, headers=csrf) + page = await resp.get_data(as_text=True) + assert "not valid JSON" in page and len(sent) == 1 + + audits = await _audits(app, uid) + assert [a for a, _ in audits] == ["settings.notification.tested"] * 2 + assert all(d == {"channel": "webhook", "sent": False} for _, d in audits) + assert "s3cr3t" not in json.dumps([d for _, d in audits]) + + asyncio.run(_go()) + + +def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypatch): + from steward.core import notifications + sent = [] + + async def fake_send(cfg, alert): + sent.append(cfg) + return {"sent": True, "error": None} + + monkeypatch.setattr(notifications, "_send_email", fake_send) + + async def _go(): + client, uid = await _admin_client(app) + csrf = {"X-CSRF-Token": "test-token"} + resp = await client.post("/settings/notifications/test-email", form={ + "smtp.host": "mx.example", "smtp.port": "465", "smtp.username": "u", + "smtp.password": "pw", "smtp.from": "alerts@example.com", + "smtp.recipients": "a@example.com, b@example.com"}, headers=csrf) + page = await resp.get_data(as_text=True) + 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", + "recipients": ["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 len(sent) == 1 + + assert [d for _, d in await _audits(app, uid)] == [ + {"channel": "email", "sent": True}, {"channel": "email", "sent": False}] + + asyncio.run(_go())