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 = ` + + + + +
+Cancel deletion +
+` + +// ─── 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)}) +}