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 (