Add customizable email templates and remove alert recipients
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s

Replaced the fixed `alert_recipients` list with customizable subject and body templates for alerts and warnings. This allows for more flexible and dynamic email notifications using placeholder variables. Updated relevant backend and frontend components to support this feature.
This commit is contained in:
2026-02-12 16:32:53 +01:00
parent e5a9acfa91
commit c437e72c2b
7 changed files with 204 additions and 35 deletions

View File

@@ -0,0 +1,31 @@
"""email templates and drop recipients list
Revision ID: 0007_email_templates
Revises: 0006_email_from_name
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0007_email_templates"
down_revision = "0006_email_from_name"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("email_notification_settings", sa.Column("warning_subject_template", sa.Text(), nullable=True))
op.add_column("email_notification_settings", sa.Column("alert_subject_template", sa.Text(), nullable=True))
op.add_column("email_notification_settings", sa.Column("warning_body_template", sa.Text(), nullable=True))
op.add_column("email_notification_settings", sa.Column("alert_body_template", sa.Text(), nullable=True))
op.drop_column("email_notification_settings", "alert_recipients")
def downgrade() -> None:
op.add_column("email_notification_settings", sa.Column("alert_recipients", sa.JSON(), nullable=False, server_default="[]"))
op.drop_column("email_notification_settings", "alert_body_template")
op.drop_column("email_notification_settings", "warning_body_template")
op.drop_column("email_notification_settings", "alert_subject_template")
op.drop_column("email_notification_settings", "warning_subject_template")

View File

@@ -29,7 +29,6 @@ async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings
def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut: def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
recipients = settings.alert_recipients if isinstance(settings.alert_recipients, list) else []
return EmailSettingsOut( return EmailSettingsOut(
enabled=settings.enabled, enabled=settings.enabled,
smtp_host=settings.smtp_host, smtp_host=settings.smtp_host,
@@ -39,7 +38,10 @@ def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
from_email=settings.from_email, from_email=settings.from_email,
use_starttls=settings.use_starttls, use_starttls=settings.use_starttls,
use_ssl=settings.use_ssl, use_ssl=settings.use_ssl,
alert_recipients=recipients, warning_subject_template=settings.warning_subject_template,
alert_subject_template=settings.alert_subject_template,
warning_body_template=settings.warning_body_template,
alert_body_template=settings.alert_body_template,
has_password=bool(settings.encrypted_smtp_password), has_password=bool(settings.encrypted_smtp_password),
updated_at=settings.updated_at, updated_at=settings.updated_at,
) )
@@ -70,7 +72,10 @@ async def update_email_settings(
settings.from_email = str(payload.from_email) if payload.from_email else None settings.from_email = str(payload.from_email) if payload.from_email else None
settings.use_starttls = payload.use_starttls settings.use_starttls = payload.use_starttls
settings.use_ssl = payload.use_ssl settings.use_ssl = payload.use_ssl
settings.alert_recipients = [str(item) for item in payload.alert_recipients] settings.warning_subject_template = payload.warning_subject_template.strip() if payload.warning_subject_template else None
settings.alert_subject_template = payload.alert_subject_template.strip() if payload.alert_subject_template else None
settings.warning_body_template = payload.warning_body_template.strip() if payload.warning_body_template else None
settings.alert_body_template = payload.alert_body_template.strip() if payload.alert_body_template else None
if payload.clear_smtp_password: if payload.clear_smtp_password:
settings.encrypted_smtp_password = None settings.encrypted_smtp_password = None

View File

@@ -134,7 +134,10 @@ class EmailNotificationSettings(Base):
from_email: Mapped[str | None] = mapped_column(String(255), 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_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
alert_recipients: Mapped[list] = mapped_column(JSON, nullable=False, default=list) warning_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
alert_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
warning_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
alert_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),

View File

@@ -12,7 +12,10 @@ class EmailSettingsOut(BaseModel):
from_email: EmailStr | None from_email: EmailStr | None
use_starttls: bool use_starttls: bool
use_ssl: bool use_ssl: bool
alert_recipients: list[EmailStr] warning_subject_template: str | None
alert_subject_template: str | None
warning_body_template: str | None
alert_body_template: str | None
has_password: bool has_password: bool
updated_at: datetime | None updated_at: datetime | None
@@ -28,7 +31,10 @@ class EmailSettingsUpdate(BaseModel):
from_email: EmailStr | None = None from_email: EmailStr | None = None
use_starttls: bool = True use_starttls: bool = True
use_ssl: bool = False use_ssl: bool = False
alert_recipients: list[EmailStr] = [] warning_subject_template: str | None = None
alert_subject_template: str | None = None
warning_body_template: str | None = None
alert_body_template: str | None = None
@field_validator("smtp_port") @field_validator("smtp_port")
@classmethod @classmethod

View File

@@ -81,6 +81,32 @@ def _render_body(item) -> str:
return "\n".join(lines) return "\n".join(lines)
def _template_context(item) -> dict[str, str]:
return {
"severity": str(item.severity),
"target_name": str(item.target_name),
"target_id": str(item.target_id),
"alert_name": str(item.name),
"category": str(item.category),
"description": str(item.description),
"message": str(item.message),
"value": "" if item.value is None else str(item.value),
"warning_threshold": "" if item.warning_threshold is None else str(item.warning_threshold),
"alert_threshold": "" if item.alert_threshold is None else str(item.alert_threshold),
"checked_at": item.checked_at.isoformat() if item.checked_at else "",
"alert_key": str(item.alert_key),
}
def _safe_format(template: str | None, context: dict[str, str], fallback: str) -> str:
if not template:
return fallback
rendered = template
for key, value in context.items():
rendered = rendered.replace("{" + key + "}", value)
return rendered
async def process_target_owner_notifications(db: AsyncSession, status: AlertStatusResponse) -> None: async def process_target_owner_notifications(db: AsyncSession, status: AlertStatusResponse) -> None:
settings = await db.scalar(select(EmailNotificationSettings).limit(1)) settings = await db.scalar(select(EmailNotificationSettings).limit(1))
if not settings or not settings.enabled: if not settings or not settings.enabled:
@@ -125,8 +151,15 @@ async def process_target_owner_notifications(db: AsyncSession, status: AlertStat
should_send = existing is None or (now - existing.last_sent_at) >= _NOTIFICATION_COOLDOWN should_send = existing is None or (now - existing.last_sent_at) >= _NOTIFICATION_COOLDOWN
if should_send: if should_send:
subject = _render_subject(item) fallback_subject = _render_subject(item)
body = _render_body(item) fallback_body = _render_body(item)
context = _template_context(item)
if item.severity == "alert":
subject = _safe_format(settings.alert_subject_template, context, fallback_subject)
body = _safe_format(settings.alert_body_template, context, fallback_body)
else:
subject = _safe_format(settings.warning_subject_template, context, fallback_subject)
body = _safe_format(settings.warning_body_template, context, fallback_body)
for recipient in recipients: for recipient in recipients:
try: try:
await _smtp_send( await _smtp_send(

View File

@@ -17,7 +17,10 @@ export function AdminUsersPage() {
from_email: "", from_email: "",
use_starttls: true, use_starttls: true,
use_ssl: false, use_ssl: false,
alert_recipients: "", warning_subject_template: "",
alert_subject_template: "",
warning_body_template: "",
alert_body_template: "",
}); });
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null }); const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
const [testRecipient, setTestRecipient] = useState(""); const [testRecipient, setTestRecipient] = useState("");
@@ -42,7 +45,10 @@ export function AdminUsersPage() {
from_email: smtp.from_email || "", from_email: smtp.from_email || "",
use_starttls: !!smtp.use_starttls, use_starttls: !!smtp.use_starttls,
use_ssl: !!smtp.use_ssl, use_ssl: !!smtp.use_ssl,
alert_recipients: (smtp.alert_recipients || []).join(", "), warning_subject_template: smtp.warning_subject_template || "",
alert_subject_template: smtp.alert_subject_template || "",
warning_body_template: smtp.warning_body_template || "",
alert_body_template: smtp.alert_body_template || "",
})); }));
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at }); setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
setTestRecipient(smtp.from_email || ""); setTestRecipient(smtp.from_email || "");
@@ -79,10 +85,6 @@ export function AdminUsersPage() {
setError(""); setError("");
setSmtpInfo(""); setSmtpInfo("");
try { try {
const recipients = emailSettings.alert_recipients
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const payload = { const payload = {
...emailSettings, ...emailSettings,
smtp_host: emailSettings.smtp_host.trim() || null, smtp_host: emailSettings.smtp_host.trim() || null,
@@ -90,7 +92,10 @@ export function AdminUsersPage() {
from_name: emailSettings.from_name.trim() || null, from_name: emailSettings.from_name.trim() || null,
from_email: emailSettings.from_email.trim() || null, from_email: emailSettings.from_email.trim() || null,
smtp_password: emailSettings.smtp_password || null, smtp_password: emailSettings.smtp_password || null,
alert_recipients: recipients, warning_subject_template: emailSettings.warning_subject_template.trim() || null,
alert_subject_template: emailSettings.alert_subject_template.trim() || null,
warning_body_template: emailSettings.warning_body_template.trim() || null,
alert_body_template: emailSettings.alert_body_template.trim() || null,
}; };
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh); await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
setSmtpInfo("SMTP settings saved."); setSmtpInfo("SMTP settings saved.");
@@ -120,6 +125,19 @@ export function AdminUsersPage() {
} }
}; };
const protocolMode = emailSettings.use_ssl ? "ssl" : emailSettings.use_starttls ? "starttls" : "plain";
const setProtocolMode = (mode) => {
if (mode === "ssl") {
setEmailSettings({ ...emailSettings, use_ssl: true, use_starttls: false });
return;
}
if (mode === "starttls") {
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: true });
return;
}
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: false });
};
return ( return (
<div className="admin-settings-page"> <div className="admin-settings-page">
<h2>Admin Settings</h2> <h2>Admin Settings</h2>
@@ -269,32 +287,74 @@ export function AdminUsersPage() {
/> />
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Alert recipients (comma-separated)</label> <label>Transport mode</label>
<input <div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
value={emailSettings.alert_recipients} <button
placeholder="dba@example.com, oncall@example.com" type="button"
onChange={(e) => setEmailSettings({ ...emailSettings, alert_recipients: e.target.value })} className={`smtp-mode-btn ${protocolMode === "starttls" ? "active" : ""}`}
/> aria-pressed={protocolMode === "starttls"}
onClick={() => setProtocolMode("starttls")}
>
STARTTLS
</button>
<button
type="button"
className={`smtp-mode-btn ${protocolMode === "ssl" ? "active" : ""}`}
aria-pressed={protocolMode === "ssl"}
onClick={() => setProtocolMode("ssl")}
>
SSL/TLS (SMTPS)
</button>
<button
type="button"
className={`smtp-mode-btn ${protocolMode === "plain" ? "active" : ""}`}
aria-pressed={protocolMode === "plain"}
onClick={() => setProtocolMode("plain")}
>
No TLS
</button>
</div>
<small className="muted">Select exactly one mode to avoid STARTTLS/SSL conflicts.</small>
</div>
<div className="admin-field">
<label>Template variables</label>
<small className="muted">
Use placeholders like: {"{target_name}"}, {"{alert_name}"}, {"{severity}"}, {"{description}"}, {"{message}"}, {"{value}"}, {"{warning_threshold}"}, {"{alert_threshold}"}, {"{checked_at}"}, {"{alert_key}"}
</small>
</div> </div>
<label className="toggle-check"> <div className="admin-field field-full">
<label>Warning subject template</label>
<input <input
type="checkbox" value={emailSettings.warning_subject_template}
checked={emailSettings.use_starttls} placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
onChange={(e) => setEmailSettings({ ...emailSettings, use_starttls: e.target.checked, use_ssl: e.target.checked ? false : emailSettings.use_ssl })} onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
/> />
<span className="toggle-ui" /> </div>
<span>Use STARTTLS</span> <div className="admin-field field-full">
</label> <label>Alert subject template</label>
<label className="toggle-check">
<input <input
type="checkbox" value={emailSettings.alert_subject_template}
checked={emailSettings.use_ssl} placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
onChange={(e) => setEmailSettings({ ...emailSettings, use_ssl: e.target.checked, use_starttls: e.target.checked ? false : emailSettings.use_starttls })} onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
/> />
<span className="toggle-ui" /> </div>
<span>Use SSL/TLS (SMTPS)</span> <div className="admin-field field-full">
</label> <label>Warning body template</label>
<textarea
value={emailSettings.warning_body_template}
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nChecked At: {checked_at}"}
onChange={(e) => setEmailSettings({ ...emailSettings, warning_body_template: e.target.value })}
/>
</div>
<div className="admin-field field-full">
<label>Alert body template</label>
<textarea
value={emailSettings.alert_body_template}
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nCurrent Value: {value}\nWarning Threshold: {warning_threshold}\nAlert Threshold: {alert_threshold}\nChecked At: {checked_at}\nAlert Key: {alert_key}"}
onChange={(e) => setEmailSettings({ ...emailSettings, alert_body_template: e.target.value })}
/>
</div>
<label className="toggle-check field-full"> <label className="toggle-check field-full">
<input <input

View File

@@ -1127,6 +1127,37 @@ td {
min-height: 38px; min-height: 38px;
} }
.admin-field textarea {
width: 100%;
min-height: 90px;
resize: vertical;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
}
.smtp-mode-picker {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.smtp-mode-btn {
min-height: 32px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #3d6ca8;
background: linear-gradient(180deg, #123258, #0f2a4c);
color: #d4e8ff;
font-weight: 650;
font-size: 12px;
}
.smtp-mode-btn.active {
border-color: #57cbf8;
background: linear-gradient(180deg, #1a5f96, #164f7d);
box-shadow: 0 0 0 2px #28bdf32d;
}
.admin-users-table-wrap table tbody .admin-user-row td { .admin-users-table-wrap table tbody .admin-user-row td {
padding-top: 11px; padding-top: 11px;
padding-bottom: 11px; padding-bottom: 11px;