Files
security-alert-center/backend/app/services/email_notify.py
T
PTah ebb450be92 feat: global notification policy severity to channels (notif-22)
Singleton notification_policy table, NOTIFY_MIN_SEVERITY/CHANNELS env,
central dispatch gate, Settings policy UI, migration 007.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:19:26 +10:00

132 lines
4.3 KiB
Python

"""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_severity import severity_meets_minimum
from app.services.notification_settings import EmailConfig, get_effective_email_config
logger = logging.getLogger(__name__)
class EmailNotConfiguredError(Exception):
"""Email disabled or missing SMTP settings."""
class EmailSendError(Exception):
pass
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, apply_policy_gate: bool = True) -> None:
cfg = get_effective_email_config(db)
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
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, apply_policy_gate: bool = True) -> None:
cfg = get_effective_email_config(db)
if not cfg.active:
return
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
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)