feat: SMTP email notification channel with UI and ingest

Migration 006, email config DB/env, smtplib sender, settings API,
Settings UI section, and dispatch on event/problem ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:12:55 +10:00
parent 20fa7e2c27
commit 9b177c145b
13 changed files with 945 additions and 195 deletions
+71
View File
@@ -0,0 +1,71 @@
"""Tests for SAC email notification helpers."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event
from app.services import email_notify
from app.services.notification_settings import EmailConfig
def test_notify_event_respects_min_severity():
sent: list[tuple[str, str]] = []
def fake_send(subject: str, body: str, **kwargs) -> None:
sent.append((subject, body))
event = Event(
event_id="00000000-0000-4000-8000-000000000301",
host_id=1,
occurred_at=datetime(2026, 5, 29, 14, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="failed",
summary="smtp test",
payload={},
)
cfg = EmailConfig(
enabled=True,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_user="u",
smtp_password="p",
mail_from="sac@example.com",
mail_to="admin@example.com",
smtp_starttls=True,
smtp_ssl=False,
min_severity="high",
source="env",
)
with patch.object(email_notify, "get_effective_email_config", return_value=cfg):
with patch.object(email_notify, "send_email_message", side_effect=fake_send):
email_notify.notify_event(event)
assert sent == []
cfg_warn = EmailConfig(
enabled=True,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_user="",
smtp_password="",
mail_from="sac@example.com",
mail_to="admin@example.com",
smtp_starttls=True,
smtp_ssl=False,
min_severity="warning",
source="env",
)
with patch.object(email_notify, "get_effective_email_config", return_value=cfg_warn):
with patch.object(email_notify, "send_email_message", side_effect=fake_send):
email_notify.notify_event(event)
assert len(sent) == 1
def test_parse_mail_recipients():
assert email_notify.parse_mail_recipients("a@x.com; b@x.com, c@x.com") == [
"a@x.com",
"b@x.com",
"c@x.com",
]
+59
View File
@@ -24,6 +24,65 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc
assert body["telegram"]["bot_token_hint"].endswith("ghij")
assert "webhook" in body
assert body["webhook"]["enabled"] is False
assert "email" in body
assert body["email"]["enabled"] is False
def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SMTP_ENABLED", "false")
monkeypatch.setenv("SMTP_HOST", "")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/email",
headers=jwt_headers,
json={
"enabled": True,
"min_severity": "warning",
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_user": "monitor",
"smtp_password": "secret",
"mail_from": "sac@example.com",
"mail_to": "admin@example.com,ops@example.com",
"smtp_starttls": True,
"smtp_ssl": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["source"] == "db"
assert body["configured"] is True
assert body["smtp_port"] == 587
row = db_session.get(NotificationChannel, "email")
assert row is not None
assert row.smtp_host == "smtp.example.com"
assert row.mail_from == "sac@example.com"
def test_post_email_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SMTP_ENABLED", "false")
get_settings.cache_clear()
db_session.add(
NotificationChannel(
channel="email",
enabled=True,
min_severity="warning",
smtp_host="smtp.example.com",
smtp_port=587,
mail_from="sac@example.com",
mail_to="admin@example.com",
)
)
db_session.commit()
with patch("app.api.v1.settings.send_email_test_message") as mock_test:
response = client.post("/api/v1/settings/notifications/email/test", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["ok"] is True
mock_test.assert_called_once()
def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch):