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:
+283
-190
@@ -1,190 +1,283 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.services.notification_settings import (
|
||||
|
||||
VALID_SEVERITIES,
|
||||
|
||||
TelegramConfig,
|
||||
|
||||
WebhookConfig,
|
||||
|
||||
get_effective_telegram_config,
|
||||
|
||||
get_effective_webhook_config,
|
||||
|
||||
upsert_telegram_channel,
|
||||
|
||||
upsert_webhook_channel,
|
||||
|
||||
)
|
||||
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
|
||||
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||
|
||||
text = value.strip()
|
||||
|
||||
if not text:
|
||||
|
||||
return None
|
||||
|
||||
if len(text) <= visible_tail:
|
||||
|
||||
return "*" * len(text)
|
||||
|
||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _mask_url(value: str) -> str | None:
|
||||
|
||||
text = value.strip()
|
||||
|
||||
if not text:
|
||||
|
||||
return None
|
||||
|
||||
if len(text) <= 20:
|
||||
|
||||
return f"{'*' * 6}…{text[-4:]}"
|
||||
|
||||
return f"{text[:16]}…{text[-6:]}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TelegramSettingsResponse(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
configured: bool
|
||||
|
||||
chat_id_hint: str | None = None
|
||||
|
||||
bot_token_hint: str | None = None
|
||||
|
||||
min_severity: str
|
||||
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class WebhookSettingsResponse(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
|
||||
telegram: TelegramSettingsResponse
|
||||
|
||||
webhook: WebhookSettingsResponse
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
min_severity: str
|
||||
|
||||
bot_token: str | None = None
|
||||
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class WebhookSettingsUpdate(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
min_severity: str
|
||||
|
||||
url: str | None = None
|
||||
|
||||
secret_header: str | None = None
|
||||
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ChannelTestResponse(BaseModel):
|
||||
|
||||
ok: bool = True
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||
|
||||
return TelegramSettingsResponse(
|
||||
|
||||
enabled=cfg.enabled,
|
||||
|
||||
configured=cfg.configured,
|
||||
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
|
||||
min_severity=cfg.min_severity,
|
||||
|
||||
source=cfg.source,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
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 (
|
||||
VALID_SEVERITIES,
|
||||
EmailConfig,
|
||||
TelegramConfig,
|
||||
WebhookConfig,
|
||||
get_effective_email_config,
|
||||
get_effective_telegram_config,
|
||||
get_effective_webhook_config,
|
||||
upsert_email_channel,
|
||||
upsert_telegram_channel,
|
||||
upsert_webhook_channel,
|
||||
)
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= visible_tail:
|
||||
return "*" * len(text)
|
||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||
|
||||
|
||||
def _mask_url(value: str) -> str | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= 20:
|
||||
return f"{'*' * 6}…{text[-4:]}"
|
||||
return f"{text[:16]}…{text[-6:]}"
|
||||
|
||||
|
||||
class TelegramSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
chat_id_hint: str | None = None
|
||||
bot_token_hint: str | None = None
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class WebhookSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
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")
|
||||
|
||||
|
||||
class EmailSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
smtp_host_hint: str | None = None
|
||||
smtp_port: int
|
||||
smtp_user: str | None = None
|
||||
smtp_password_hint: str | None = None
|
||||
mail_from: str | None = None
|
||||
mail_to_hint: str | None = None
|
||||
smtp_starttls: bool
|
||||
smtp_ssl: bool
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
telegram: TelegramSettingsResponse
|
||||
webhook: WebhookSettingsResponse
|
||||
email: EmailSettingsResponse
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
min_severity: str
|
||||
bot_token: str | None = None
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
class WebhookSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
min_severity: str
|
||||
url: str | None = None
|
||||
secret_header: str | None = None
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
class EmailSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
min_severity: str
|
||||
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
|
||||
|
||||
|
||||
class ChannelTestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||
return TelegramSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
min_severity=cfg.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
||||
return WebhookSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
url_hint=_mask_url(cfg.url),
|
||||
secret_header=cfg.secret_header or None,
|
||||
secret_hint=_mask_secret(cfg.secret),
|
||||
min_severity=cfg.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse:
|
||||
return EmailSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
|
||||
smtp_port=cfg.smtp_port,
|
||||
smtp_user=cfg.smtp_user or None,
|
||||
smtp_password_hint=_mask_secret(cfg.smtp_password),
|
||||
mail_from=cfg.mail_from or None,
|
||||
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
|
||||
smtp_starttls=cfg.smtp_starttls,
|
||||
smtp_ssl=cfg.smtp_ssl,
|
||||
min_severity=cfg.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> 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)),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||
def update_telegram_settings(
|
||||
body: TelegramSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> TelegramSettingsResponse:
|
||||
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_telegram_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
@@ -56,6 +56,17 @@ class Settings(BaseSettings):
|
||||
webhook_secret: str = ""
|
||||
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
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ class NotificationChannel(Base):
|
||||
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
webhook_secret_header: Mapped[str | None] = mapped_column(String(64), 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(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user