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
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:
31
backend/alembic/versions/0007_email_templates.py
Normal file
31
backend/alembic/versions/0007_email_templates.py
Normal 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")
|
||||
@@ -29,7 +29,6 @@ async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings
|
||||
|
||||
|
||||
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,
|
||||
@@ -39,7 +38,10 @@ def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
|
||||
from_email=settings.from_email,
|
||||
use_starttls=settings.use_starttls,
|
||||
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),
|
||||
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.use_starttls = payload.use_starttls
|
||||
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:
|
||||
settings.encrypted_smtp_password = None
|
||||
|
||||
@@ -134,7 +134,10 @@ class EmailNotificationSettings(Base):
|
||||
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)
|
||||
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())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -12,7 +12,10 @@ class EmailSettingsOut(BaseModel):
|
||||
from_email: EmailStr | None
|
||||
use_starttls: 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
|
||||
updated_at: datetime | None
|
||||
|
||||
@@ -28,7 +31,10 @@ class EmailSettingsUpdate(BaseModel):
|
||||
from_email: EmailStr | None = None
|
||||
use_starttls: bool = True
|
||||
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")
|
||||
@classmethod
|
||||
|
||||
@@ -81,6 +81,32 @@ def _render_body(item) -> str:
|
||||
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:
|
||||
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
|
||||
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
|
||||
|
||||
if should_send:
|
||||
subject = _render_subject(item)
|
||||
body = _render_body(item)
|
||||
fallback_subject = _render_subject(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:
|
||||
try:
|
||||
await _smtp_send(
|
||||
|
||||
@@ -17,7 +17,10 @@ export function AdminUsersPage() {
|
||||
from_email: "",
|
||||
use_starttls: true,
|
||||
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 [testRecipient, setTestRecipient] = useState("");
|
||||
@@ -42,7 +45,10 @@ export function AdminUsersPage() {
|
||||
from_email: smtp.from_email || "",
|
||||
use_starttls: !!smtp.use_starttls,
|
||||
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 });
|
||||
setTestRecipient(smtp.from_email || "");
|
||||
@@ -79,10 +85,6 @@ export function AdminUsersPage() {
|
||||
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,
|
||||
@@ -90,7 +92,10 @@ export function AdminUsersPage() {
|
||||
from_name: emailSettings.from_name.trim() || null,
|
||||
from_email: emailSettings.from_email.trim() || 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);
|
||||
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 (
|
||||
<div className="admin-settings-page">
|
||||
<h2>Admin Settings</h2>
|
||||
@@ -269,32 +287,74 @@ export function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<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 })}
|
||||
/>
|
||||
<label>Transport mode</label>
|
||||
<div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
<label className="toggle-check">
|
||||
<div className="admin-field field-full">
|
||||
<label>Warning subject template</label>
|
||||
<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 })}
|
||||
value={emailSettings.warning_subject_template}
|
||||
placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
|
||||
onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
|
||||
/>
|
||||
<span className="toggle-ui" />
|
||||
<span>Use STARTTLS</span>
|
||||
</label>
|
||||
<label className="toggle-check">
|
||||
</div>
|
||||
<div className="admin-field field-full">
|
||||
<label>Alert subject template</label>
|
||||
<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 })}
|
||||
value={emailSettings.alert_subject_template}
|
||||
placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
|
||||
onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
|
||||
/>
|
||||
<span className="toggle-ui" />
|
||||
<span>Use SSL/TLS (SMTPS)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-field field-full">
|
||||
<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">
|
||||
<input
|
||||
|
||||
@@ -1127,6 +1127,37 @@ td {
|
||||
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 {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 11px;
|
||||
|
||||
Reference in New Issue
Block a user