diff --git a/internal/app/dangerzone/service.go b/internal/app/dangerzone/service.go
index d8aa0c1c..5c2e33c8 100644
--- a/internal/app/dangerzone/service.go
+++ b/internal/app/dangerzone/service.go
@@ -402,30 +402,26 @@ func (s *service) sendOrgScheduledEmail(ctx context.Context, org *models.Organiz
if s.notifier == nil {
return
}
- owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
- if owner == nil {
+ recipients := s.orgRecipients(ctx, org)
+ if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("%s scheduled for deletion", org.Name)
body := orgScheduledHTML(org, d, s.frontendBaseURL)
- if err := s.notifier.Send(ctx, []string{owner.Email}, nil, nil, subject, body); err != nil {
- sentry.CaptureException(err)
- }
+ s.sendToEach(ctx, recipients, subject, body)
}
func (s *service) sendOrgCancelledEmail(ctx context.Context, org *models.Organization, d *models.ScheduledDeletion) {
if s.notifier == nil {
return
}
- owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
- if owner == nil {
+ recipients := s.orgRecipients(ctx, org)
+ if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("Deletion cancelled for %s", org.Name)
body := orgCancelledHTML(org, d)
- if err := s.notifier.Send(ctx, []string{owner.Email}, nil, nil, subject, body); err != nil {
- sentry.CaptureException(err)
- }
+ s.sendToEach(ctx, recipients, subject, body)
}
func (s *service) sendUserScheduledEmail(ctx context.Context, user *models.User, d *models.ScheduledDeletion) {
@@ -455,38 +451,36 @@ func (s *service) sendReminderEmail(ctx context.Context, d *models.ScheduledDele
return
}
- to, subject, body := s.buildReminder(ctx, d, bit)
- if to == "" {
+ recipients, subject, body := s.buildReminder(ctx, d, bit)
+ if len(recipients) == 0 {
return
}
- if err := s.notifier.Send(ctx, []string{to}, nil, nil, subject, body); err != nil {
- sentry.CaptureException(err)
- }
+ s.sendToEach(ctx, recipients, subject, body)
}
-func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion, bit int) (to, subject, body string) {
+func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion, bit int) (recipients []string, subject, body string) {
var resourceName string
switch d.ResourceType {
case models.DeletionResourceOrganization:
org, _ := s.orgRepo.GetByID(ctx, d.ResourceID)
if org == nil {
- return "", "", ""
+ return nil, "", ""
}
- owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID)
- if owner == nil {
- return "", "", ""
- }
- to = owner.Email
+ recipients = s.orgRecipients(ctx, org)
resourceName = org.Name
case models.DeletionResourceUser:
user, _ := s.userRepo.GetUser(ctx, d.ResourceID)
if user == nil {
- return "", "", ""
+ return nil, "", ""
}
- to = user.Email
+ recipients = []string{user.Email}
resourceName = displayName(user)
default:
- return "", "", ""
+ return nil, "", ""
+ }
+
+ if len(recipients) == 0 {
+ return nil, "", ""
}
switch bit {
@@ -499,7 +493,7 @@ func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion
}
body = reminderHTML(resourceName, d, s.frontendBaseURL)
- return to, subject, body
+ return recipients, subject, body
}
func (s *service) sendCompletionEmail(ctx context.Context, d *models.ScheduledDeletion) {
@@ -550,3 +544,48 @@ func displayName(u *models.User) string {
}
return u.Email
}
+
+// orgRecipients returns every member email for an org, owner first.
+// Owner is always included even if GetMembers somehow fails to load
+// them (defensive — the owner is the only person who can actually
+// cancel, so they MUST get the email).
+func (s *service) orgRecipients(ctx context.Context, org *models.Organization) []string {
+ seen := make(map[string]struct{}, 8)
+ out := make([]string, 0, 8)
+
+ if owner, _ := s.userRepo.GetUser(ctx, org.OwnerUserID); owner != nil && owner.Email != "" {
+ key := strings.ToLower(owner.Email)
+ seen[key] = struct{}{}
+ out = append(out, owner.Email)
+ }
+
+ members, err := s.orgRepo.GetMembers(ctx, org.ID)
+ if err != nil {
+ sentry.CaptureException(err)
+ return out
+ }
+ for i := range members {
+ u := members[i].User
+ if u == nil || u.Email == "" {
+ continue
+ }
+ key := strings.ToLower(u.Email)
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, u.Email)
+ }
+ return out
+}
+
+// sendToEach mails one-to-one rather than To/Cc/Bcc-fanning. Keeps
+// every recipient unaware of the others' addresses, and makes per-
+// person bounces/spam reports cleanly attributable.
+func (s *service) sendToEach(ctx context.Context, recipients []string, subject, body string) {
+ for _, to := range recipients {
+ if err := s.notifier.Send(ctx, []string{to}, nil, nil, subject, body); err != nil {
+ sentry.CaptureException(err)
+ }
+ }
+}
diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
index ca712fca..ddc6758f 100644
--- a/web/src/components/layout/AppShell.tsx
+++ b/web/src/components/layout/AppShell.tsx
@@ -19,6 +19,7 @@ import { Outlet } from "react-router-dom";
import { SkyChrome } from "./SkyChrome";
import { AppHeader } from "./AppHeader";
import { AppNav } from "./AppNav";
+import PendingDeletionBar from "./PendingDeletionBar";
import { RouteBoundary } from "./ErrorBoundary";
import { ShortcutsModal } from "@/components/shared/ShortcutsModal";
import { CommandPalette } from "@/components/shared/CommandPalette";
@@ -32,6 +33,11 @@ export function AppShell() {
+ {/* Sits above the header so it can't be missed. Only
+ renders when the current workspace or the user's
+ own account is scheduled for deletion. */}
+
+
diff --git a/web/src/components/layout/PendingDeletionBar.tsx b/web/src/components/layout/PendingDeletionBar.tsx
new file mode 100644
index 00000000..1c2d331a
--- /dev/null
+++ b/web/src/components/layout/PendingDeletionBar.tsx
@@ -0,0 +1,184 @@
+// App-wide banner shown when the current workspace (or the user's own
+// account) is scheduled for deletion. Sits above AppHeader so it's
+// impossible to miss while clicking around the dashboard.
+//
+// Two banners can stack:
+// 1. Workspace pending deletion (anyone in the org sees this)
+// 2. Personal account pending deletion (only the user themselves)
+//
+// We deliberately don't make these dismissible — they need to nag.
+
+import React from "react";
+import { Link } from "react-router-dom";
+import { AlertOctagonIcon, Loader2Icon, UndoIcon } from "lucide-react";
+import toast from "react-hot-toast";
+import useFeatureAccess from "@/hooks/useFeatureAccess";
+import useOrganizationDangerZone from "@/lib/api/hooks/app/dangerzone/useOrganizationDangerZone";
+import useAccountDangerZone from "@/lib/api/hooks/app/dangerzone/useAccountDangerZone";
+import useCancelOrganizationDeletion from "@/lib/api/hooks/app/dangerzone/useCancelOrganizationDeletion";
+import useCancelAccountDeletion from "@/lib/api/hooks/app/dangerzone/useCancelAccountDeletion";
+import buildError from "@/lib/helper/buildError";
+import type { AppError } from "@/lib/api/client/normalizeError";
+
+// Re-render every minute so "X days Y hours" stays honest on a tab
+// that's been open for hours. Cheap — single setState per banner.
+function useMinuteTick() {
+ const [, setTick] = React.useState(0);
+ React.useEffect(() => {
+ const id = window.setInterval(() => setTick((t) => t + 1), 60_000);
+ return () => window.clearInterval(id);
+ }, []);
+}
+
+function formatRemaining(executeAfter: Date): string {
+ const ms = executeAfter.getTime() - Date.now();
+ if (ms <= 0) return "any moment now";
+ const totalMinutes = Math.floor(ms / 60_000);
+ const days = Math.floor(totalMinutes / (60 * 24));
+ const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
+ const minutes = totalMinutes % 60;
+ if (days > 0) return `${days}d ${hours}h`;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+export default function PendingDeletionBar() {
+ useMinuteTick();
+
+ const access = useFeatureAccess();
+ const org = useOrganizationDangerZone();
+ const account = useAccountDangerZone();
+
+ const cancelOrg = useCancelOrganizationDeletion();
+ const cancelAccount = useCancelAccountDeletion();
+
+ const orgPending = org.data?.pending_deletion;
+ const accountPending = account.data?.pending_deletion;
+
+ if (!orgPending && !accountPending) return null;
+
+ return (
+
+ {orgPending && org.data && (
+
+ This workspace will be permanently deleted in{" "}
+
+ {formatRemaining(new Date(orgPending.execute_after))}
+
+ .
+ >
+ }
+ detail={
+ <>
+ {org.data.resource_name} is scheduled
+ for deletion on{" "}
+ {new Date(orgPending.execute_after).toLocaleString()}.
+ {access.isOwner
+ ? " Cancel below to keep it."
+ : " Only the workspace owner can cancel — reach out to them now."}
+ >
+ }
+ showCancel={access.isOwner}
+ onCancel={async () => {
+ try {
+ await cancelOrg.mutateAsync(undefined);
+ toast.success("Workspace deletion cancelled");
+ } catch (err) {
+ toast.error(buildError(err as AppError));
+ }
+ }}
+ cancelLoading={cancelOrg.isPending}
+ />
+ )}
+
+ {accountPending && account.data && (
+
+ Your account will be permanently deleted in{" "}
+
+ {formatRemaining(new Date(accountPending.execute_after))}
+
+ .
+ >
+ }
+ detail={
+ <>
+ All workspaces you own and every campaign / contact /
+ mailbox in them will be removed on{" "}
+ {new Date(accountPending.execute_after).toLocaleString()}.
+ >
+ }
+ showCancel={true}
+ onCancel={async () => {
+ try {
+ await cancelAccount.mutateAsync(undefined);
+ toast.success("Account deletion cancelled");
+ } catch (err) {
+ toast.error(buildError(err as AppError));
+ }
+ }}
+ cancelLoading={cancelAccount.isPending}
+ />
+ )}
+