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
@@ -0,0 +1,43 @@
"""notification_channels: SMTP/email columns
Revision ID: 006
Revises: 005
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("notification_channels", sa.Column("smtp_host", sa.Text(), nullable=True))
op.add_column("notification_channels", sa.Column("smtp_port", sa.Integer(), nullable=True))
op.add_column("notification_channels", sa.Column("smtp_user", sa.String(length=128), nullable=True))
op.add_column("notification_channels", sa.Column("smtp_password", sa.Text(), nullable=True))
op.add_column("notification_channels", sa.Column("mail_from", sa.String(length=256), nullable=True))
op.add_column("notification_channels", sa.Column("mail_to", sa.Text(), nullable=True))
op.add_column(
"notification_channels",
sa.Column("smtp_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
op.add_column(
"notification_channels",
sa.Column("smtp_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
def downgrade() -> None:
op.drop_column("notification_channels", "smtp_ssl")
op.drop_column("notification_channels", "smtp_starttls")
op.drop_column("notification_channels", "mail_to")
op.drop_column("notification_channels", "mail_from")
op.drop_column("notification_channels", "smtp_password")
op.drop_column("notification_channels", "smtp_user")
op.drop_column("notification_channels", "smtp_port")
op.drop_column("notification_channels", "smtp_host")
+283 -190
View File
@@ -1,190 +1,283 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field from sqlalchemy.orm import Session
from sqlalchemy.orm import Session from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
from app.services.notification_settings import (
from app.auth.jwt_auth import get_current_user VALID_SEVERITIES,
EmailConfig,
from app.database import get_db TelegramConfig,
WebhookConfig,
from app.services.notification_settings import ( get_effective_email_config,
get_effective_telegram_config,
VALID_SEVERITIES, get_effective_webhook_config,
upsert_email_channel,
TelegramConfig, upsert_telegram_channel,
upsert_webhook_channel,
WebhookConfig, )
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
get_effective_telegram_config, from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
get_effective_webhook_config, router = APIRouter(prefix="/settings", tags=["settings"])
upsert_telegram_channel,
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
upsert_webhook_channel, text = value.strip()
if not text:
) return None
if len(text) <= visible_tail:
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message return "*" * len(text)
return f"{'*' * 8}{text[-visible_tail:]}"
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
def _mask_url(value: str) -> str | None:
text = value.strip()
router = APIRouter(prefix="/settings", tags=["settings"]) if not text:
return None
if len(text) <= 20:
return f"{'*' * 6}{text[-4:]}"
return f"{text[:16]}{text[-6:]}"
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
class TelegramSettingsResponse(BaseModel):
text = value.strip() enabled: bool
configured: bool
if not text: chat_id_hint: str | None = None
bot_token_hint: str | None = None
return None min_severity: str
source: str = Field(description="env или db")
if len(text) <= visible_tail:
return "*" * len(text) class WebhookSettingsResponse(BaseModel):
enabled: bool
return f"{'*' * 8}{text[-visible_tail:]}" configured: bool
url_hint: str | None = None
secret_header: str | None = None
secret_hint: str | None = None
min_severity: str
source: str = Field(description="env или db")
def _mask_url(value: str) -> str | None:
text = value.strip() class EmailSettingsResponse(BaseModel):
enabled: bool
if not text: configured: bool
smtp_host_hint: str | None = None
return None smtp_port: int
smtp_user: str | None = None
if len(text) <= 20: smtp_password_hint: str | None = None
mail_from: str | None = None
return f"{'*' * 6}{text[-4:]}" mail_to_hint: str | None = None
smtp_starttls: bool
return f"{text[:16]}{text[-6:]}" smtp_ssl: bool
min_severity: str
source: str = Field(description="env или db")
class NotificationSettingsResponse(BaseModel):
class TelegramSettingsResponse(BaseModel): telegram: TelegramSettingsResponse
webhook: WebhookSettingsResponse
enabled: bool email: EmailSettingsResponse
configured: bool
class TelegramSettingsUpdate(BaseModel):
chat_id_hint: str | None = None enabled: bool
min_severity: str
bot_token_hint: str | None = None bot_token: str | None = None
chat_id: str | None = None
min_severity: str
source: str = Field(description="env или db") class WebhookSettingsUpdate(BaseModel):
enabled: bool
min_severity: str
url: str | None = None
secret_header: str | None = None
secret: str | None = None
class WebhookSettingsResponse(BaseModel):
enabled: bool class EmailSettingsUpdate(BaseModel):
enabled: bool
configured: bool min_severity: str
smtp_host: str | None = None
url_hint: str | None = None smtp_port: int | None = None
smtp_user: str | None = None
secret_header: str | None = None smtp_password: str | None = None
mail_from: str | None = None
secret_hint: str | None = None mail_to: str | None = None
smtp_starttls: bool | None = None
min_severity: str smtp_ssl: bool | None = None
source: str = Field(description="env или db")
class ChannelTestResponse(BaseModel):
ok: bool = True
message: str
class NotificationSettingsResponse(BaseModel): def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
return TelegramSettingsResponse(
telegram: TelegramSettingsResponse enabled=cfg.enabled,
configured=cfg.configured,
webhook: WebhookSettingsResponse chat_id_hint=_mask_secret(cfg.chat_id),
bot_token_hint=_mask_secret(cfg.bot_token),
min_severity=cfg.min_severity,
source=cfg.source,
)
class TelegramSettingsUpdate(BaseModel):
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
enabled: bool return WebhookSettingsResponse(
enabled=cfg.enabled,
min_severity: str configured=cfg.configured,
url_hint=_mask_url(cfg.url),
bot_token: str | None = None secret_header=cfg.secret_header or None,
secret_hint=_mask_secret(cfg.secret),
chat_id: str | None = None min_severity=cfg.min_severity,
source=cfg.source,
)
def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse:
class WebhookSettingsUpdate(BaseModel): return EmailSettingsResponse(
enabled=cfg.enabled,
enabled: bool configured=cfg.configured,
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
min_severity: str smtp_port=cfg.smtp_port,
smtp_user=cfg.smtp_user or None,
url: str | None = None smtp_password_hint=_mask_secret(cfg.smtp_password),
mail_from=cfg.mail_from or None,
secret_header: str | None = None mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
smtp_starttls=cfg.smtp_starttls,
secret: str | None = None smtp_ssl=cfg.smtp_ssl,
min_severity=cfg.min_severity,
source=cfg.source,
)
class ChannelTestResponse(BaseModel): @router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings(
ok: bool = True db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
message: str ) -> NotificationSettingsResponse:
return NotificationSettingsResponse(
telegram=_telegram_to_response(get_effective_telegram_config(db)),
webhook=_webhook_to_response(get_effective_webhook_config(db)),
email=_email_to_response(get_effective_email_config(db)),
)
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
return TelegramSettingsResponse( @router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
def update_telegram_settings(
enabled=cfg.enabled, body: TelegramSettingsUpdate,
db: Session = Depends(get_db),
configured=cfg.configured, _user: str = Depends(get_current_user),
) -> TelegramSettingsResponse:
chat_id_hint=_mask_secret(cfg.chat_id), if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
bot_token_hint=_mask_secret(cfg.bot_token), try:
cfg = upsert_telegram_channel(
min_severity=cfg.min_severity, db,
enabled=body.enabled,
source=cfg.source, min_severity=body.min_severity,
bot_token=body.bot_token,
) chat_id=body.chat_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _telegram_to_response(cfg)
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
def update_webhook_settings(
body: WebhookSettingsUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> WebhookSettingsResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
try:
cfg = upsert_webhook_channel(
db,
enabled=body.enabled,
min_severity=body.min_severity,
url=body.url,
secret_header=body.secret_header,
secret=body.secret,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _webhook_to_response(cfg)
@router.put("/notifications/email", response_model=EmailSettingsResponse)
def update_email_settings(
body: EmailSettingsUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EmailSettingsResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
try:
cfg = upsert_email_channel(
db,
enabled=body.enabled,
min_severity=body.min_severity,
smtp_host=body.smtp_host,
smtp_port=body.smtp_port,
smtp_user=body.smtp_user,
smtp_password=body.smtp_password,
mail_from=body.mail_from,
mail_to=body.mail_to,
smtp_starttls=body.smtp_starttls,
smtp_ssl=body.smtp_ssl,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _email_to_response(cfg)
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
def test_telegram_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> ChannelTestResponse:
cfg = get_effective_telegram_config(db)
try:
send_telegram_test_message(config=cfg)
except TelegramNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TelegramSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram")
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
def test_webhook_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> ChannelTestResponse:
cfg = get_effective_webhook_config(db)
try:
send_webhook_test_message(config=cfg)
except WebhookNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except WebhookSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
def test_email_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> ChannelTestResponse:
cfg = get_effective_email_config(db)
try:
send_email_test_message(config=cfg)
except EmailNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EmailSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
+11
View File
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
webhook_secret: str = "" webhook_secret: str = ""
webhook_min_severity: str = "high" webhook_min_severity: str = "high"
smtp_enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = ""
smtp_to: str = ""
smtp_starttls: bool = True
smtp_ssl: bool = False
smtp_min_severity: str = "high"
# Порог «живости» агента # Порог «живости» агента
sac_heartbeat_stale_minutes: int = 780 sac_heartbeat_stale_minutes: int = 780
@@ -17,6 +17,14 @@ class NotificationChannel(Base):
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True) webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True) webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True)
webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True) webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True)
smtp_port: Mapped[int | None] = mapped_column(nullable=True)
smtp_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
mail_from: Mapped[str | None] = mapped_column(String(256), nullable=True)
mail_to: Mapped[str | None] = mapped_column(Text, nullable=True)
smtp_starttls: Mapped[bool] = mapped_column(default=True)
smtp_ssl: Mapped[bool] = mapped_column(default=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
+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"}) VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
CHANNEL_TELEGRAM = "telegram" CHANNEL_TELEGRAM = "telegram"
CHANNEL_WEBHOOK = "webhook" CHANNEL_WEBHOOK = "webhook"
CHANNEL_EMAIL = "email"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -50,6 +51,29 @@ class WebhookConfig:
return self.enabled and self.configured 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: def _telegram_from_env() -> TelegramConfig:
settings = get_settings() settings = get_settings()
return TelegramConfig( return TelegramConfig(
@@ -211,3 +235,121 @@ def upsert_webhook_channel(
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return get_effective_webhook_config(db) 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 sqlalchemy.orm import Session
from app.models import Event, Problem 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: def notify_event(event: Event, *, db: Session | None = None) -> None:
telegram_notify.notify_event(event, db=db) telegram_notify.notify_event(event, db=db)
webhook_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: def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
telegram_notify.notify_problem(problem, event, db=db) telegram_notify.notify_problem(problem, event, db=db)
webhook_notify.notify_problem(problem, event, db=db) webhook_notify.notify_problem(problem, event, db=db)
email_notify.notify_problem(problem, event, db=db)
+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 body["telegram"]["bot_token_hint"].endswith("ghij")
assert "webhook" in body assert "webhook" in body
assert body["webhook"]["enabled"] is False 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): def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
+12
View File
@@ -32,6 +32,18 @@ WEBHOOK_SECRET_HEADER=
WEBHOOK_SECRET= WEBHOOK_SECRET=
WEBHOOK_MIN_SEVERITY=high WEBHOOK_MIN_SEVERITY=high
# SMTP email (F-NOT-01)
SMTP_ENABLED=false
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_TO=
SMTP_STARTTLS=true
SMTP_SSL=false
SMTP_MIN_SEVERITY=high
# Статус хоста по agent.heartbeat # Статус хоста по agent.heartbeat
SAC_HEARTBEAT_STALE_MINUTES=780 SAC_HEARTBEAT_STALE_MINUTES=780
+1 -1
View File
@@ -50,7 +50,7 @@ sudo /opt/sac-deploy.sh
## Следующие шаги (2026-05-29) ## Следующие шаги (2026-05-29)
1. **Деплой SAC v0.5.0**`sudo /opt/sac-deploy.sh` (включает `alembic upgrade head`, миграция **`004_notification_channels`**) 1. **Деплой SAC v0.5.0**`sudo /opt/sac-deploy.sh` (миграции **`004``006`**: Telegram, webhook, SMTP)
2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod 2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod
3. **Настройки UI**`/settings`: Telegram `warning+`, кнопка «Проверить Telegram» 3. **Настройки UI**`/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах 4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах
+2 -2
View File
@@ -12,7 +12,7 @@
| Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` | | Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` |
| Вызов при ingest | `events.py``notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента | | Вызов при ingest | `events.py``notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента |
| UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` | | UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` |
| Email / webhook | TZ F-NOT-01 | Webhook ✅; SMTP — `notif-20` | | Email / webhook | TZ F-NOT-01 | Email ✅, Webhook ✅ |
**Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. **Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems.
@@ -44,7 +44,7 @@
| ID | Задача | Критерий | | ID | Задача | Критерий |
|----|--------|----------| |----|--------|----------|
| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | Отправка тестового письма | | `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | ✅ PUT/test API + UI + ingest |
| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest | | `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest |
| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках | | `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках |
+175 -1
View File
@@ -117,6 +117,74 @@
</form> </form>
<p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p> <p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p>
</section> </section>
<section class="card settings-card">
<h2>Email (SMTP)</h2>
<form class="settings-form" @submit.prevent="saveEmail">
<label class="settings-field">
<input v-model="emailForm.enabled" type="checkbox" />
Включить email
</label>
<label class="settings-field">
Мин. severity
<select v-model="emailForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
SMTP host
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
</label>
<label class="settings-field">
SMTP port
<input v-model.number="emailForm.smtp_port" type="number" min="1" max="65535" />
</label>
<label class="settings-field">
SMTP user
<input v-model="emailForm.smtp_user" type="text" autocomplete="off" />
</label>
<label class="settings-field">
SMTP password
<input
v-model="emailForm.smtp_password"
type="password"
autocomplete="off"
:placeholder="smtpPasswordPlaceholder"
/>
</label>
<label class="settings-field">
From
<input v-model="emailForm.mail_from" type="email" autocomplete="off" />
</label>
<label class="settings-field">
To (через запятую или ;)
<input v-model="emailForm.mail_to" type="text" autocomplete="off" :placeholder="mailToPlaceholder" />
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_starttls" type="checkbox" />
STARTTLS
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_ssl" type="checkbox" />
SSL (порт 465)
</label>
<p v-if="emailLoaded" class="settings-meta">
Источник: <code>{{ emailLoaded.source }}</code>
· настроен: {{ emailLoaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingEmail">
{{ savingEmail ? "Сохранение…" : "Сохранить" }}
</button>
<button type="button" class="secondary" :disabled="testingEmail" @click="testEmail">
{{ testingEmail ? "Отправка" : "Проверить email" }}
</button>
</div>
</form>
</section>
</template> </template>
</div> </div>
</template> </template>
@@ -144,15 +212,33 @@ interface WebhookSettings {
source: string; source: string;
} }
interface EmailSettings {
enabled: boolean;
configured: boolean;
smtp_host_hint: string | null;
smtp_port: number;
smtp_user: string | null;
smtp_password_hint: string | null;
mail_from: string | null;
mail_to_hint: string | null;
smtp_starttls: boolean;
smtp_ssl: boolean;
min_severity: string;
source: string;
}
const loading = ref(true); const loading = ref(true);
const savingTelegram = ref(false); const savingTelegram = ref(false);
const savingWebhook = ref(false); const savingWebhook = ref(false);
const savingEmail = ref(false);
const testingTelegram = ref(false); const testingTelegram = ref(false);
const testingWebhook = ref(false); const testingWebhook = ref(false);
const testingEmail = ref(false);
const error = ref(""); const error = ref("");
const success = ref(""); const success = ref("");
const telegramLoaded = ref<TelegramSettings | null>(null); const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null); const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null);
const telegramForm = reactive({ const telegramForm = reactive({
enabled: false, enabled: false,
@@ -169,6 +255,19 @@ const webhookForm = reactive({
secret: "", secret: "",
}); });
const emailForm = reactive({
enabled: false,
min_severity: "warning",
smtp_host: "",
smtp_port: 587,
smtp_user: "",
smtp_password: "",
mail_from: "",
mail_to: "",
smtp_starttls: true,
smtp_ssl: false,
});
const tokenPlaceholder = computed(() => const tokenPlaceholder = computed(() =>
telegramLoaded.value?.bot_token_hint telegramLoaded.value?.bot_token_hint
? `оставить ${telegramLoaded.value.bot_token_hint}` ? `оставить ${telegramLoaded.value.bot_token_hint}`
@@ -185,16 +284,26 @@ const urlPlaceholder = computed(() =>
const webhookSecretPlaceholder = computed(() => const webhookSecretPlaceholder = computed(() =>
webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно", webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно",
); );
const smtpHostPlaceholder = computed(() =>
emailLoaded.value?.smtp_host_hint ? `оставить ${emailLoaded.value.smtp_host_hint}` : "smtp.example.com",
);
const smtpPasswordPlaceholder = computed(() =>
emailLoaded.value?.smtp_password_hint ? `оставить ${emailLoaded.value.smtp_password_hint}` : "необязательно",
);
const mailToPlaceholder = computed(() =>
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
);
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings }>( const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings; email: EmailSettings }>(
"/api/v1/settings/notifications", "/api/v1/settings/notifications",
); );
telegramLoaded.value = data.telegram; telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook; webhookLoaded.value = data.webhook;
emailLoaded.value = data.email;
telegramForm.enabled = data.telegram.enabled; telegramForm.enabled = data.telegram.enabled;
telegramForm.min_severity = data.telegram.min_severity; telegramForm.min_severity = data.telegram.min_severity;
telegramForm.bot_token = ""; telegramForm.bot_token = "";
@@ -204,6 +313,16 @@ async function load() {
webhookForm.url = ""; webhookForm.url = "";
webhookForm.secret_header = data.webhook.secret_header ?? ""; webhookForm.secret_header = data.webhook.secret_header ?? "";
webhookForm.secret = ""; webhookForm.secret = "";
emailForm.enabled = data.email.enabled;
emailForm.min_severity = data.email.min_severity;
emailForm.smtp_host = "";
emailForm.smtp_port = data.email.smtp_port || 587;
emailForm.smtp_user = data.email.smtp_user ?? "";
emailForm.smtp_password = "";
emailForm.mail_from = data.email.mail_from ?? "";
emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl;
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally { } finally {
@@ -296,6 +415,55 @@ async function testWebhook() {
} }
} }
async function saveEmail() {
savingEmail.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: emailForm.enabled,
min_severity: emailForm.min_severity,
smtp_port: emailForm.smtp_port,
smtp_starttls: emailForm.smtp_starttls,
smtp_ssl: emailForm.smtp_ssl,
};
if (emailForm.smtp_host.trim()) body.smtp_host = emailForm.smtp_host.trim();
if (emailForm.smtp_user.trim()) body.smtp_user = emailForm.smtp_user.trim();
if (emailForm.smtp_password.trim()) body.smtp_password = emailForm.smtp_password.trim();
if (emailForm.mail_from.trim()) body.mail_from = emailForm.mail_from.trim();
if (emailForm.mail_to.trim()) body.mail_to = emailForm.mail_to.trim();
emailLoaded.value = await apiFetch<EmailSettings>("/api/v1/settings/notifications/email", {
method: "PUT",
body: JSON.stringify(body),
});
emailForm.smtp_host = "";
emailForm.smtp_password = "";
emailForm.mail_to = "";
success.value = "Настройки email сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingEmail.value = false;
}
}
async function testEmail() {
testingEmail.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/email/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testingEmail.value = false;
}
}
onMounted(load); onMounted(load);
</script> </script>
@@ -337,6 +505,12 @@ onMounted(load);
max-width: 28rem; max-width: 28rem;
} }
.settings-inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.settings-meta { .settings-meta {
font-size: 0.85rem; font-size: 0.85rem;
color: #9aa4b2; color: #9aa4b2;