diff --git a/internal/notify/templates/deletion.go b/internal/notify/templates/deletion.go
new file mode 100644
index 00000000..e9280876
--- /dev/null
+++ b/internal/notify/templates/deletion.go
@@ -0,0 +1,239 @@
+package templates
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "time"
+
+ "github.com/getsentry/sentry-go"
+)
+
+// Danger-zone (scheduled deletion) emails. These move off the old
+// standalone wrapper onto the shared base shell so they match the rest
+// of the transactional mail, while keeping semantic accent colours:
+// red for a scheduled deletion, amber for an approaching reminder,
+// green for a cancellation. Resource/user names interpolate through
+// html/template and are auto-escaped; dates are pre-formatted strings.
+
+// formatDeletionTime renders an absolute UTC timestamp. People skim
+// these on phones, so every date is unambiguous and timezone-explicit.
+func formatDeletionTime(t time.Time) string {
+ return t.UTC().Format("Monday, 02 January 2006 at 15:04 UTC")
+}
+
+const deletionFooterNote = `
+
+
+You're receiving this because a destructive action was scheduled on your account. If you didn't request this, cancel it right away and reset your password.
+
+`
+
+// ─── Organization scheduled ─────────────────────────────────────────
+
+const orgDeletionScheduledContent = `
+
+Scheduled deletion
+
+
+Organization scheduled for deletion
+
+
+Your organization {{.Name}} has been scheduled for permanent deletion.
+
+` + deletionDetailBlock + `
+
+What happens now
+
+
+| · Campaigns keep running until the deletion date |
+| · All members keep access during the grace period |
+| · On the deletion date, the organization and all its data are permanently removed |
+
+` + deletionCancelButton + deletionFooterNote
+
+// ─── User scheduled ─────────────────────────────────────────────────
+
+const userDeletionScheduledContent = `
+
+Scheduled deletion
+
+
+Account scheduled for deletion
+
+
+Hi {{.Name}}, your Warmbly account has been scheduled for permanent deletion.
+
+` + deletionDetailBlock + `
+
+What happens now
+
+
+| · You can keep using your account during the grace period |
+| · Cancelling any time before the deletion date keeps it intact |
+| · On the deletion date, your account and all owned data are permanently removed |
+
+` + deletionCancelButton + deletionFooterNote
+
+// Shared red callout: the one fact that matters, the date, plus grace.
+const deletionDetailBlock = `
+
+|
+ Will be deleted on
+{{.DeleteOn}}
+Grace period: {{.GraceDays}} days. You can cancel any time before that date.
+ |
+
+`
+
+// Shared neutral cancel CTA (slate, on-brand) used by scheduled mails.
+const deletionCancelButton = `
+
+`
+
+// ─── Cancelled (org + user) ─────────────────────────────────────────
+
+const deletionCancelledContent = `
+
+Deletion cancelled
+
+
+{{.Heading}}
+
+
+{{.Message}}
+
+
+Originally scheduled for: {{.OriginalDate}}
+
+`
+
+// ─── Reminder ───────────────────────────────────────────────────────
+
+const deletionReminderContent = `
+
+Reminder
+
+
+Deletion in {{.Window}}
+
+
+{{.Name}} is scheduled to be permanently deleted on {{.DeleteOn}}.
+
+
+If you didn't mean to do this, cancel now while you still can. After the deletion runs, recovery is not possible.
+
+` + deletionCancelButton + deletionFooterNote
+
+// ─── Completed ──────────────────────────────────────────────────────
+
+const deletionCompletedContent = `
+
+Deletion completed
+
+
+Deletion completed
+
+
+The scheduled deletion has been completed. All associated data has been permanently removed.
+
+
+Scheduled at: {{.ScheduledAt}}
Executed at: {{.ExecutedAt}}
+
+`
+
+var (
+ orgDeletionScheduledTmpl = template.Must(template.New("org_deletion_scheduled").Parse(orgDeletionScheduledContent))
+ userDeletionScheduledTmpl = template.Must(template.New("user_deletion_scheduled").Parse(userDeletionScheduledContent))
+ deletionCancelledTmpl = template.Must(template.New("deletion_cancelled").Parse(deletionCancelledContent))
+ deletionReminderTmpl = template.Must(template.New("deletion_reminder").Parse(deletionReminderContent))
+ deletionCompletedTmpl = template.Must(template.New("deletion_completed").Parse(deletionCompletedContent))
+)
+
+func renderDeletion(tmpl *template.Template, subject string, data any) (string, error) {
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ sentry.CaptureException(err)
+ return "", err
+ }
+ return renderEmail(subject, buf.String())
+}
+
+// GenerateOrgDeletionScheduledHTML renders the org "scheduled for
+// deletion" notice.
+func GenerateOrgDeletionScheduledHTML(orgName string, executeAfter time.Time, graceDays int, cancelURL string) (string, error) {
+ return renderDeletion(orgDeletionScheduledTmpl, "Organization scheduled for deletion", struct {
+ Name string
+ DeleteOn string
+ GraceDays int
+ CancelURL string
+ }{orgName, formatDeletionTime(executeAfter), graceDays, cancelURL})
+}
+
+// GenerateUserDeletionScheduledHTML renders the account "scheduled for
+// deletion" notice. firstName falls back to the email upstream.
+func GenerateUserDeletionScheduledHTML(firstName string, executeAfter time.Time, graceDays int, cancelURL string) (string, error) {
+ return renderDeletion(userDeletionScheduledTmpl, "Your Warmbly account is scheduled for deletion", struct {
+ Name string
+ DeleteOn string
+ GraceDays int
+ CancelURL string
+ }{firstName, formatDeletionTime(executeAfter), graceDays, cancelURL})
+}
+
+// GenerateOrgDeletionCancelledHTML renders the org deletion-cancelled
+// confirmation.
+func GenerateOrgDeletionCancelledHTML(orgName string, originalDate time.Time) (string, error) {
+ msg := fmt.Sprintf("The scheduled deletion for %s has been cancelled. Your organization is safe and operating normally.", orgName)
+ return renderDeletion(deletionCancelledTmpl, "Deletion cancelled", struct {
+ Heading string
+ Message string
+ OriginalDate string
+ }{"Deletion cancelled", msg, formatDeletionTime(originalDate)})
+}
+
+// GenerateUserDeletionCancelledHTML renders the account deletion-
+// cancelled confirmation.
+func GenerateUserDeletionCancelledHTML(firstName string, originalDate time.Time) (string, error) {
+ msg := fmt.Sprintf("Hi %s, the scheduled deletion of your account has been cancelled. Your account is active and back to normal.", firstName)
+ return renderDeletion(deletionCancelledTmpl, "Account deletion cancelled", struct {
+ Heading string
+ Message string
+ OriginalDate string
+ }{"Account deletion cancelled", msg, formatDeletionTime(originalDate)})
+}
+
+// GenerateDeletionReminderHTML renders an approaching-deletion reminder.
+// The human-friendly window is derived from how long is left.
+func GenerateDeletionReminderHTML(resourceName string, executeAfter time.Time, cancelURL string) (string, error) {
+ hours := int(time.Until(executeAfter).Hours())
+ var window string
+ switch {
+ case hours <= 24:
+ window = "less than 24 hours"
+ case hours <= 24*8:
+ window = fmt.Sprintf("about %d days", hours/24)
+ default:
+ window = fmt.Sprintf("%d days", hours/24)
+ }
+ return renderDeletion(deletionReminderTmpl, "Deletion reminder", struct {
+ Window string
+ Name string
+ DeleteOn string
+ CancelURL string
+ }{window, resourceName, formatDeletionTime(executeAfter), cancelURL})
+}
+
+// GenerateDeletionCompletedHTML renders the post-deletion confirmation.
+func GenerateDeletionCompletedHTML(scheduledAt, executedAt time.Time) (string, error) {
+ return renderDeletion(deletionCompletedTmpl, "Deletion completed", struct {
+ ScheduledAt string
+ ExecutedAt string
+ }{formatDeletionTime(scheduledAt), formatDeletionTime(executedAt)})
+}