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

@@ -0,0 +1,38 @@
"""add email notification settings
Revision ID: 0004_email_settings
Revises: 0003_pg_stat_statements_flag
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0004_email_settings"
down_revision = "0003_pg_stat_statements_flag"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"email_notification_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("smtp_host", sa.String(length=255), nullable=True),
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")),
sa.Column("smtp_username", sa.String(length=255), nullable=True),
sa.Column("encrypted_smtp_password", sa.Text(), nullable=True),
sa.Column("from_email", sa.String(length=255), nullable=True),
sa.Column("use_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("alert_recipients", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("email_notification_settings")

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."

View File

@@ -6,10 +6,44 @@ export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
const [emailSettings, setEmailSettings] = useState({
enabled: false,
smtp_host: "",
smtp_port: 587,
smtp_username: "",
smtp_password: "",
clear_smtp_password: false,
from_email: "",
use_starttls: true,
use_ssl: false,
alert_recipients: "",
});
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
const [testRecipient, setTestRecipient] = useState("");
const [smtpInfo, setSmtpInfo] = useState("");
const [error, setError] = useState("");
const load = async () => {
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
const [userRows, smtp] = await Promise.all([
apiFetch("/admin/users", {}, tokens, refresh),
apiFetch("/admin/settings/email", {}, tokens, refresh),
]);
setUsers(userRows);
setEmailSettings((prev) => ({
...prev,
enabled: !!smtp.enabled,
smtp_host: smtp.smtp_host || "",
smtp_port: smtp.smtp_port || 587,
smtp_username: smtp.smtp_username || "",
smtp_password: "",
clear_smtp_password: false,
from_email: smtp.from_email || "",
use_starttls: !!smtp.use_starttls,
use_ssl: !!smtp.use_ssl,
alert_recipients: (smtp.alert_recipients || []).join(", "),
}));
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
setTestRecipient(smtp.from_email || "");
};
useEffect(() => {
@@ -38,26 +72,78 @@ export function AdminUsersPage() {
}
};
const saveSmtp = async (e) => {
e.preventDefault();
setError("");
setSmtpInfo("");
try {
const recipients = emailSettings.alert_recipients
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const payload = {
...emailSettings,
smtp_host: emailSettings.smtp_host.trim() || null,
smtp_username: emailSettings.smtp_username.trim() || null,
from_email: emailSettings.from_email.trim() || null,
smtp_password: emailSettings.smtp_password || null,
alert_recipients: recipients,
};
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
setSmtpInfo("SMTP settings saved.");
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const sendTestMail = async () => {
setError("");
setSmtpInfo("");
try {
const recipient = testRecipient.trim();
if (!recipient) {
throw new Error("Please enter a test recipient email.");
}
await apiFetch(
"/admin/settings/email/test",
{ method: "POST", body: JSON.stringify({ recipient }) },
tokens,
refresh
);
setSmtpInfo(`Test email sent to ${recipient}.`);
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Admin Users</h2>
<div className="admin-settings-page">
<h2>Admin Settings - Users</h2>
{error && <div className="card error">{error}</div>}
<form className="card grid three" onSubmit={create}>
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input
type="password"
value={form.password}
placeholder="password"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button>Create user</button>
</form>
<div className="card">
<h3>User Management</h3>
<form className="grid three admin-user-form" onSubmit={create}>
<input value={form.email} placeholder="E-Mail" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input
type="password"
value={form.password}
placeholder="Password"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<div className="form-actions field-full">
<button className="primary-btn" type="submit">Create User</button>
</div>
</form>
</div>
<div className="card admin-users-table-wrap">
<table>
<thead>
<tr>
@@ -69,16 +155,148 @@ export function AdminUsersPage() {
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
<tr key={u.id} className="admin-user-row">
<td className="user-col-id">{u.id}</td>
<td className="user-col-email">{u.email}</td>
<td>
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
</td>
<td>
{u.id !== me.id && (
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="12" height="12">
<path
d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z"
fill="currentColor"
/>
</svg>
</span>
<span>Delete</span>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h3>Alert Email Notifications (SMTP)</h3>
<p className="muted">
Configure outgoing SMTP for alert emails. This is send-only; no inbound mailbox is used.
</p>
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
<label className="toggle-check field-full">
<input
type="checkbox"
checked={emailSettings.enabled}
onChange={(e) => setEmailSettings({ ...emailSettings, enabled: e.target.checked })}
/>
<span className="toggle-ui" />
<span>
<strong>Enable alert emails</strong>
</span>
</label>
<div>
<label>SMTP host</label>
<input
value={emailSettings.smtp_host}
placeholder="smtp.example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
/>
</div>
<div>
<label>SMTP port</label>
<input
type="number"
value={emailSettings.smtp_port}
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
/>
</div>
<div>
<label>SMTP username</label>
<input
value={emailSettings.smtp_username}
placeholder="alerts@example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
/>
</div>
<div>
<label>SMTP password</label>
<input
type="password"
value={emailSettings.smtp_password}
placeholder={smtpState.has_password ? "Stored (enter to replace)" : "Set password"}
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })}
/>
</div>
<div>
<label>From email</label>
<input
value={emailSettings.from_email}
placeholder="noreply@example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
/>
</div>
<div>
<label>Alert recipients (comma-separated)</label>
<input
value={emailSettings.alert_recipients}
placeholder="dba@example.com, oncall@example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, alert_recipients: e.target.value })}
/>
</div>
<label className="toggle-check">
<input
type="checkbox"
checked={emailSettings.use_starttls}
onChange={(e) => setEmailSettings({ ...emailSettings, use_starttls: e.target.checked, use_ssl: e.target.checked ? false : emailSettings.use_ssl })}
/>
<span className="toggle-ui" />
<span>Use STARTTLS</span>
</label>
<label className="toggle-check">
<input
type="checkbox"
checked={emailSettings.use_ssl}
onChange={(e) => setEmailSettings({ ...emailSettings, use_ssl: e.target.checked, use_starttls: e.target.checked ? false : emailSettings.use_starttls })}
/>
<span className="toggle-ui" />
<span>Use SSL/TLS (SMTPS)</span>
</label>
<label className="toggle-check field-full">
<input
type="checkbox"
checked={emailSettings.clear_smtp_password}
onChange={(e) => setEmailSettings({ ...emailSettings, clear_smtp_password: e.target.checked, smtp_password: e.target.checked ? "" : emailSettings.smtp_password })}
/>
<span className="toggle-ui" />
<span>Clear stored SMTP password</span>
</label>
<div className="form-actions field-full">
<input
className="admin-test-recipient"
value={testRecipient}
placeholder="test recipient email"
onChange={(e) => setTestRecipient(e.target.value)}
/>
<button className="secondary-btn" type="button" onClick={sendTestMail}>Send Test Mail</button>
<button className="primary-btn" type="submit">Save SMTP Settings</button>
</div>
<small className="muted field-full">
Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"}
</small>
</form>
</div>
</div>
);
}

View File

@@ -979,6 +979,74 @@ td {
border-color: #7f1d1d;
}
.admin-settings-page h2 {
margin-top: 4px;
margin-bottom: 14px;
}
.admin-settings-page h3 {
margin-top: 0;
margin-bottom: 10px;
}
.admin-user-form {
gap: 10px;
}
.admin-users-table-wrap table tbody .admin-user-row td {
padding-top: 11px;
padding-bottom: 11px;
vertical-align: middle;
}
.user-col-id {
width: 64px;
color: #a8c0df;
}
.user-col-email {
font-weight: 600;
}
.role-pill {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.role-pill.role-admin {
color: #e9dcff;
border-color: #8f77e3;
background: #31235d;
}
.role-pill.role-operator {
color: #c7e7ff;
border-color: #4e8dc8;
background: #18375a;
}
.role-pill.role-viewer {
color: #cde2f8;
border-color: #4a6692;
background: #192b49;
}
.admin-smtp-form {
margin-top: 8px;
gap: 10px;
}
.admin-test-recipient {
min-width: 260px;
}
.muted {
color: #9eb8d6;
}