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