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
+135
View File
@@ -0,0 +1,135 @@
"""SMTP email notifications from SAC."""
from __future__ import annotations
import logging
import re
import smtplib
from email.mime.text import MIMEText
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services.notification_settings import EmailConfig, get_effective_email_config
from app.services.telegram_notify import SEVERITY_ORDER
logger = logging.getLogger(__name__)
class EmailNotConfiguredError(Exception):
"""Email disabled or missing SMTP settings."""
class EmailSendError(Exception):
pass
def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def _should_notify_severity(severity: str, *, config: EmailConfig) -> bool:
return _severity_value(severity) >= _severity_value(config.min_severity)
def parse_mail_recipients(mail_to: str) -> list[str]:
return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()]
def send_email_message(
subject: str,
body: str,
*,
config: EmailConfig | None = None,
force: bool = False,
) -> None:
cfg = config or get_effective_email_config()
if not force and not cfg.active:
return
if not cfg.configured:
if force:
raise EmailNotConfiguredError("SMTP: не задан host, from или to")
return
recipients = parse_mail_recipients(cfg.mail_to)
if not recipients:
if force:
raise EmailNotConfiguredError("SMTP: список получателей пуст")
return
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = cfg.mail_from
msg["To"] = ", ".join(recipients)
try:
if cfg.smtp_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=12)
else:
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=12)
with server:
server.ehlo()
if cfg.smtp_starttls and not cfg.smtp_ssl:
server.starttls()
server.ehlo()
if cfg.smtp_user:
server.login(cfg.smtp_user, cfg.smtp_password)
server.sendmail(cfg.mail_from, recipients, msg.as_string())
except Exception as exc:
logger.exception("smtp send failed")
raise EmailSendError(str(exc)) from exc
def send_email_test_message(*, config: EmailConfig | None = None) -> None:
cfg = config or get_effective_email_config()
if not cfg.enabled:
raise EmailNotConfiguredError("Email отключён (enabled=false)")
if not cfg.configured:
raise EmailNotConfiguredError("Не задан SMTP host, from или to")
send_email_message(
"SAC: тестовое письмо",
"Тестовое сообщение Security Alert Center.\nКанал SMTP для оповещений работает.",
config=cfg,
force=True,
)
def notify_event(event: Event, *, db: Session | None = None) -> None:
cfg = get_effective_email_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
return
host = event.host.hostname if event.host else "unknown"
body = (
f"SAC событие\n"
f"Host: {host}\n"
f"Severity: {event.severity}\n"
f"Type: {event.type}\n"
f"Title: {event.title}\n"
f"Summary: {event.summary}"
)
try:
send_email_message(f"SAC: {event.type} ({event.severity})", body, config=cfg)
except EmailSendError:
logger.exception("notify_event email failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
cfg = get_effective_email_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
return
host = problem.host.hostname if problem.host else "unknown"
related = ""
if event is not None:
related = f"\nEvent: {event.type} ({event.severity})"
body = (
f"SAC Problem opened\n"
f"Host: {host}\n"
f"Severity: {problem.severity}\n"
f"Rule: {problem.rule_id or '-'}\n"
f"Title: {problem.title}\n"
f"Summary: {problem.summary}{related}"
)
try:
send_email_message(f"SAC Problem: {problem.title}", body, config=cfg)
except EmailSendError:
logger.exception("notify_problem email failed problem_id=%s", problem.id)
@@ -13,6 +13,7 @@ from app.models.notification_channel import NotificationChannel
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
CHANNEL_TELEGRAM = "telegram"
CHANNEL_WEBHOOK = "webhook"
CHANNEL_EMAIL = "email"
@dataclass(frozen=True)
@@ -50,6 +51,29 @@ class WebhookConfig:
return self.enabled and self.configured
@dataclass(frozen=True)
class EmailConfig:
enabled: bool
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
mail_from: str
mail_to: str
smtp_starttls: bool
smtp_ssl: bool
min_severity: str
source: str # env | db
@property
def configured(self) -> bool:
return bool(self.smtp_host.strip() and self.mail_from.strip() and self.mail_to.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def _telegram_from_env() -> TelegramConfig:
settings = get_settings()
return TelegramConfig(
@@ -211,3 +235,121 @@ def upsert_webhook_channel(
db.commit()
db.refresh(row)
return get_effective_webhook_config(db)
def _email_from_env() -> EmailConfig:
settings = get_settings()
return EmailConfig(
enabled=bool(settings.smtp_enabled),
smtp_host=settings.smtp_host.strip(),
smtp_port=int(settings.smtp_port or 587),
smtp_user=settings.smtp_user.strip(),
smtp_password=settings.smtp_password,
mail_from=settings.smtp_from.strip(),
mail_to=settings.smtp_to.strip(),
smtp_starttls=bool(settings.smtp_starttls),
smtp_ssl=bool(settings.smtp_ssl),
min_severity=settings.smtp_min_severity.strip() or "high",
source="env",
)
def get_effective_email_config(db: Session | None = None) -> EmailConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_email_config(session)
finally:
session.close()
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL))
env_cfg = _email_from_env()
if row is None:
return env_cfg
host = (row.smtp_host or "").strip() or env_cfg.smtp_host
port = row.smtp_port if row.smtp_port is not None else env_cfg.smtp_port
user = (row.smtp_user or "").strip() or env_cfg.smtp_user
password = (row.smtp_password or "") or env_cfg.smtp_password
mail_from = (row.mail_from or "").strip() or env_cfg.mail_from
mail_to = (row.mail_to or "").strip() or env_cfg.mail_to
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
if min_sev not in VALID_SEVERITIES:
min_sev = env_cfg.min_severity
return EmailConfig(
enabled=bool(row.enabled),
smtp_host=host,
smtp_port=int(port or 587),
smtp_user=user,
smtp_password=password,
mail_from=mail_from,
mail_to=mail_to,
smtp_starttls=bool(row.smtp_starttls),
smtp_ssl=bool(row.smtp_ssl),
min_severity=min_sev,
source="db",
)
def upsert_email_channel(
db: Session,
*,
enabled: bool | None = None,
smtp_host: str | None = None,
smtp_port: int | None = None,
smtp_user: str | None = None,
smtp_password: str | None = None,
mail_from: str | None = None,
mail_to: str | None = None,
smtp_starttls: bool | None = None,
smtp_ssl: bool | None = None,
min_severity: str | None = None,
) -> EmailConfig:
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL))
env_cfg = _email_from_env()
if row is None:
row = NotificationChannel(
channel=CHANNEL_EMAIL,
enabled=env_cfg.enabled,
min_severity=env_cfg.min_severity,
smtp_host=env_cfg.smtp_host or None,
smtp_port=env_cfg.smtp_port,
smtp_user=env_cfg.smtp_user or None,
smtp_password=env_cfg.smtp_password or None,
mail_from=env_cfg.mail_from or None,
mail_to=env_cfg.mail_to or None,
smtp_starttls=env_cfg.smtp_starttls,
smtp_ssl=env_cfg.smtp_ssl,
)
db.add(row)
if enabled is not None:
row.enabled = enabled
if min_severity is not None:
if min_severity not in VALID_SEVERITIES:
raise ValueError(f"invalid min_severity: {min_severity}")
row.min_severity = min_severity
if smtp_host is not None and smtp_host.strip():
row.smtp_host = smtp_host.strip()
if smtp_port is not None:
row.smtp_port = smtp_port
if smtp_user is not None:
row.smtp_user = smtp_user.strip() or None
if smtp_password is not None and smtp_password.strip():
row.smtp_password = smtp_password
if mail_from is not None and mail_from.strip():
row.mail_from = mail_from.strip()
if mail_to is not None and mail_to.strip():
row.mail_to = mail_to.strip()
if smtp_starttls is not None:
row.smtp_starttls = smtp_starttls
if smtp_ssl is not None:
row.smtp_ssl = smtp_ssl
db.commit()
db.refresh(row)
return get_effective_email_config(db)
+3 -1
View File
@@ -3,14 +3,16 @@
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services import telegram_notify, webhook_notify
from app.services import email_notify, telegram_notify, webhook_notify
def notify_event(event: Event, *, db: Session | None = None) -> None:
telegram_notify.notify_event(event, db=db)
webhook_notify.notify_event(event, db=db)
email_notify.notify_event(event, db=db)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
telegram_notify.notify_problem(problem, event, db=db)
webhook_notify.notify_problem(problem, event, db=db)
email_notify.notify_problem(problem, event, db=db)