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,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")
|
||||||
@@ -4,12 +4,16 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user
|
from app.auth.jwt_auth import get_current_user
|
||||||
from app.database import get_db
|
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.services.notification_settings import (
|
||||||
VALID_SEVERITIES,
|
VALID_SEVERITIES,
|
||||||
|
EmailConfig,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
|
get_effective_email_config,
|
||||||
get_effective_telegram_config,
|
get_effective_telegram_config,
|
||||||
get_effective_webhook_config,
|
get_effective_webhook_config,
|
||||||
|
upsert_email_channel,
|
||||||
upsert_telegram_channel,
|
upsert_telegram_channel,
|
||||||
upsert_webhook_channel,
|
upsert_webhook_channel,
|
||||||
)
|
)
|
||||||
@@ -56,9 +60,25 @@ class WebhookSettingsResponse(BaseModel):
|
|||||||
source: str = Field(description="env или db")
|
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):
|
class NotificationSettingsResponse(BaseModel):
|
||||||
telegram: TelegramSettingsResponse
|
telegram: TelegramSettingsResponse
|
||||||
webhook: WebhookSettingsResponse
|
webhook: WebhookSettingsResponse
|
||||||
|
email: EmailSettingsResponse
|
||||||
|
|
||||||
|
|
||||||
class TelegramSettingsUpdate(BaseModel):
|
class TelegramSettingsUpdate(BaseModel):
|
||||||
@@ -76,6 +96,19 @@ class WebhookSettingsUpdate(BaseModel):
|
|||||||
secret: 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):
|
class ChannelTestResponse(BaseModel):
|
||||||
ok: bool = True
|
ok: bool = True
|
||||||
message: str
|
message: str
|
||||||
@@ -104,6 +137,23 @@ def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||||
def get_notification_settings(
|
def get_notification_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -112,6 +162,7 @@ def get_notification_settings(
|
|||||||
return NotificationSettingsResponse(
|
return NotificationSettingsResponse(
|
||||||
telegram=_telegram_to_response(get_effective_telegram_config(db)),
|
telegram=_telegram_to_response(get_effective_telegram_config(db)),
|
||||||
webhook=_webhook_to_response(get_effective_webhook_config(db)),
|
webhook=_webhook_to_response(get_effective_webhook_config(db)),
|
||||||
|
email=_email_to_response(get_effective_email_config(db)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +209,33 @@ def update_webhook_settings(
|
|||||||
return _webhook_to_response(cfg)
|
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)
|
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
||||||
def test_telegram_settings(
|
def test_telegram_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -188,3 +266,18 @@ def test_webhook_settings(
|
|||||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
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
|
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||||
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
|
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_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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` на пилотных хостах
|
||||||
|
|||||||
@@ -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 → каналы» без сложного конструктора | Одна строка в настройках |
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user