diff --git a/backend/alembic/versions/0004_email_settings.py b/backend/alembic/versions/0004_email_settings.py new file mode 100644 index 0000000..88e5c87 --- /dev/null +++ b/backend/alembic/versions/0004_email_settings.py @@ -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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 25d2953..d436c93 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/routes/admin_settings.py b/backend/app/api/routes/admin_settings.py new file mode 100644 index 0000000..d51e9fc --- /dev/null +++ b/backend/app/api/routes/admin_settings.py @@ -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)} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9f1dd1a..8b1cf77 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/models.py b/backend/app/models/models.py index b6e5a89..885af99 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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(), + ) diff --git a/backend/app/schemas/admin_settings.py b/backend/app/schemas/admin_settings.py new file mode 100644 index 0000000..8a8c734 --- /dev/null +++ b/backend/app/schemas/admin_settings.py @@ -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." diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 1619075..6af0a0f 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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 ( -
-

Admin Users

+
+

Admin Settings - Users

{error &&
{error}
} -
- setForm({ ...form, email: e.target.value })} /> - setForm({ ...form, password: e.target.value })} - /> - - -
+
+

User Management

+
+ setForm({ ...form, email: e.target.value })} /> + setForm({ ...form, password: e.target.value })} + /> + +
+ +
+
+
+ +
@@ -69,16 +155,148 @@ export function AdminUsersPage() { {users.map((u) => ( - - - - - + + + + + ))}
{u.id}{u.email}{u.role}{u.id !== me.id && }
{u.id}{u.email} + {u.role} + + {u.id !== me.id && ( + + )} +
+ +
+

Alert Email Notifications (SMTP)

+

+ Configure outgoing SMTP for alert emails. This is send-only; no inbound mailbox is used. +

+ {smtpInfo &&
{smtpInfo}
} +
+ + +
+ + setEmailSettings({ ...emailSettings, smtp_host: e.target.value })} + /> +
+
+ + setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })} + /> +
+ +
+ + setEmailSettings({ ...emailSettings, smtp_username: e.target.value })} + /> +
+
+ + setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })} + /> +
+ +
+ + setEmailSettings({ ...emailSettings, from_email: e.target.value })} + /> +
+
+ + setEmailSettings({ ...emailSettings, alert_recipients: e.target.value })} + /> +
+ + + + + + +
+ setTestRecipient(e.target.value)} + /> + + +
+ + Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"} + +
+
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f523cfc..24e2cf9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }