From c437e72c2b7a43b4b04056bf7b2d518ecfc2108e Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 12 Feb 2026 16:32:53 +0100 Subject: [PATCH] Add customizable email templates and remove alert recipients 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. --- .../alembic/versions/0007_email_templates.py | 31 +++++ backend/app/api/routes/admin_settings.py | 11 +- backend/app/models/models.py | 5 +- backend/app/schemas/admin_settings.py | 10 +- backend/app/services/alert_notifications.py | 37 +++++- frontend/src/pages/AdminUsersPage.jsx | 114 +++++++++++++----- frontend/src/styles.css | 31 +++++ 7 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 backend/alembic/versions/0007_email_templates.py diff --git a/backend/alembic/versions/0007_email_templates.py b/backend/alembic/versions/0007_email_templates.py new file mode 100644 index 0000000..739832f --- /dev/null +++ b/backend/alembic/versions/0007_email_templates.py @@ -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") diff --git a/backend/app/api/routes/admin_settings.py b/backend/app/api/routes/admin_settings.py index 158b768..5c227c5 100644 --- a/backend/app/api/routes/admin_settings.py +++ b/backend/app/api/routes/admin_settings.py @@ -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 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4131338..47ee6a7 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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), diff --git a/backend/app/schemas/admin_settings.py b/backend/app/schemas/admin_settings.py index 7427294..81bca13 100644 --- a/backend/app/schemas/admin_settings.py +++ b/backend/app/schemas/admin_settings.py @@ -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 diff --git a/backend/app/services/alert_notifications.py b/backend/app/services/alert_notifications.py index 6280ec2..547bb54 100644 --- a/backend/app/services/alert_notifications.py +++ b/backend/app/services/alert_notifications.py @@ -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( diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index d4dda3e..80359af 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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 (

Admin Settings

@@ -269,32 +287,74 @@ export function AdminUsersPage() { />
- - setEmailSettings({ ...emailSettings, alert_recipients: e.target.value })} - /> + +
+ + + +
+ Select exactly one mode to avoid STARTTLS/SSL conflicts. +
+
+ + + Use placeholders like: {"{target_name}"}, {"{alert_name}"}, {"{severity}"}, {"{description}"}, {"{message}"}, {"{value}"}, {"{warning_threshold}"}, {"{alert_threshold}"}, {"{checked_at}"}, {"{alert_key}"} +
-