Add email notification settings management
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s

Implemented backend and frontend support for managing SMTP settings for email notifications. Includes API endpoints, database migration, and UI integration for configuring and testing email alerts.
This commit is contained in:
2026-02-12 15:05:21 +01:00
parent 882ad2dca8
commit 51eece14c2
8 changed files with 545 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.routes import admin_users, alerts, auth, health, me, targets
from app.api.routes import admin_settings, admin_users, alerts, auth, health, me, targets
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
@@ -8,3 +8,4 @@ api_router.include_router(me.router, tags=["auth"])
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])

View File

@@ -0,0 +1,124 @@
from email.message import EmailMessage
import smtplib
import ssl
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import require_roles
from app.models.models import EmailNotificationSettings, User
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
from app.services.audit import write_audit_log
from app.services.crypto import decrypt_secret, encrypt_secret
router = APIRouter()
async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings:
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
if settings:
return settings
settings = EmailNotificationSettings()
db.add(settings)
await db.commit()
await db.refresh(settings)
return settings
def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
recipients = settings.alert_recipients if isinstance(settings.alert_recipients, list) else []
return EmailSettingsOut(
enabled=settings.enabled,
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
smtp_username=settings.smtp_username,
from_email=settings.from_email,
use_starttls=settings.use_starttls,
use_ssl=settings.use_ssl,
alert_recipients=recipients,
has_password=bool(settings.encrypted_smtp_password),
updated_at=settings.updated_at,
)
@router.get("/email", response_model=EmailSettingsOut)
async def get_email_settings(
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> EmailSettingsOut:
_ = admin
settings = await _get_or_create_settings(db)
return _to_out(settings)
@router.put("/email", response_model=EmailSettingsOut)
async def update_email_settings(
payload: EmailSettingsUpdate,
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> EmailSettingsOut:
settings = await _get_or_create_settings(db)
settings.enabled = payload.enabled
settings.smtp_host = payload.smtp_host.strip() if payload.smtp_host else None
settings.smtp_port = payload.smtp_port
settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None
settings.from_email = str(payload.from_email) if payload.from_email else None
settings.use_starttls = payload.use_starttls
settings.use_ssl = payload.use_ssl
settings.alert_recipients = [str(item) for item in payload.alert_recipients]
if payload.clear_smtp_password:
settings.encrypted_smtp_password = None
elif payload.smtp_password:
settings.encrypted_smtp_password = encrypt_secret(payload.smtp_password)
await db.commit()
await db.refresh(settings)
await write_audit_log(db, "admin.email_settings.update", admin.id, {"enabled": settings.enabled})
return _to_out(settings)
@router.post("/email/test")
async def test_email_settings(
payload: EmailSettingsTestRequest,
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> dict:
settings = await _get_or_create_settings(db)
if not settings.smtp_host:
raise HTTPException(status_code=400, detail="SMTP host is not configured")
if not settings.from_email:
raise HTTPException(status_code=400, detail="From email is not configured")
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
message = EmailMessage()
message["From"] = settings.from_email
message["To"] = str(payload.recipient)
message["Subject"] = payload.subject
message.set_content(payload.message)
try:
if settings.use_ssl:
with smtplib.SMTP_SSL(
settings.smtp_host,
settings.smtp_port,
timeout=10,
context=ssl.create_default_context(),
) as smtp:
if settings.smtp_username:
smtp.login(settings.smtp_username, password or "")
smtp.send_message(message)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
if settings.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if settings.smtp_username:
smtp.login(settings.smtp_username, password or "")
smtp.send_message(message)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"SMTP test failed: {exc}")
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
return {"status": "sent", "recipient": str(payload.recipient)}

View File

@@ -1,3 +1,3 @@
from app.models.models import AlertDefinition, AuditLog, Metric, QueryStat, Target, User
from app.models.models import AlertDefinition, AuditLog, EmailNotificationSettings, Metric, QueryStat, Target, User
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog", "AlertDefinition"]
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog", "AlertDefinition", "EmailNotificationSettings"]

View File

@@ -99,3 +99,25 @@ class AlertDefinition(Base):
)
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
class EmailNotificationSettings(Base):
__tablename__ = "email_notification_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
alert_recipients: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator, model_validator
class EmailSettingsOut(BaseModel):
enabled: bool
smtp_host: str | None
smtp_port: int
smtp_username: str | None
from_email: EmailStr | None
use_starttls: bool
use_ssl: bool
alert_recipients: list[EmailStr]
has_password: bool
updated_at: datetime | None
class EmailSettingsUpdate(BaseModel):
enabled: bool = False
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
clear_smtp_password: bool = False
from_email: EmailStr | None = None
use_starttls: bool = True
use_ssl: bool = False
alert_recipients: list[EmailStr] = []
@field_validator("smtp_port")
@classmethod
def validate_port(cls, value: int) -> int:
if value < 1 or value > 65535:
raise ValueError("smtp_port must be between 1 and 65535")
return value
@model_validator(mode="after")
def validate_tls_combo(self):
if self.use_starttls and self.use_ssl:
raise ValueError("use_starttls and use_ssl cannot both be true")
return self
class EmailSettingsTestRequest(BaseModel):
recipient: EmailStr
subject: str = "NexaPG test notification"
message: str = "This is a test alert notification from NexaPG."