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:
@@ -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)
|
||||
Reference in New Issue
Block a user