From 69ddac8c7573b2bdd6c3af180a685ddfa63eb126 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 19 Sep 2026 08:23:10 -0700 Subject: [PATCH 1/2] feat: replace the inline suppression add form with a modal dialog that previews each pasted address and domain as chips with counts and flags unrecognized values, fix the suppression row menu so Remove from list sits on one line with its icon and the added date no longer wraps, tint danger menu item icons red, and add migration 000186 removing suppression entries and deliverability events that only warmup sends produced --- .../000186_purge_warmup_suppressions.down.sql | 1 + .../000186_purge_warmup_suppressions.up.sql | 28 ++ .../app/app/contacts/suppressions/page.tsx | 91 +------ .../app/contacts/AddSuppressionsDialog.tsx | 257 ++++++++++++++++++ web/src/components/ui/popover-menu.tsx | 2 +- 5 files changed, 296 insertions(+), 83 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.down.sql create mode 100644 internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.up.sql create mode 100644 web/src/components/app/contacts/AddSuppressionsDialog.tsx diff --git a/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.down.sql b/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.down.sql new file mode 100644 index 000000000..40207d1e2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.down.sql @@ -0,0 +1 @@ +-- Data cleanup; the removed rows were never meant to exist and are not restored. diff --git a/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.up.sql b/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.up.sql new file mode 100644 index 000000000..2c667018a --- /dev/null +++ b/internal/infrastructure/db/migrations/000186_purge_warmup_suppressions.up.sql @@ -0,0 +1,28 @@ +-- A warmup send's bounce or complaint never suppresses a recipient; drop entries only warmup evidence explains. +DELETE FROM suppressed_recipients s +WHERE s.source IN ('bounce', 'complaint') + AND EXISTS ( + SELECT 1 + FROM deliverability_events de + JOIN tasks t ON t.id = de.task_id + WHERE de.organization_id = s.organization_id + AND lower(de.recipient_email) = s.email + AND de.event_type IN ('bounce', 'complaint') + AND t.task_type = 'warmup' + ) + AND NOT EXISTS ( + SELECT 1 + FROM deliverability_events de + LEFT JOIN tasks t ON t.id = de.task_id + WHERE de.organization_id = s.organization_id + AND lower(de.recipient_email) = s.email + AND de.event_type IN ('bounce', 'complaint') + AND (t.id IS NULL OR t.task_type <> 'warmup') + ); + +-- Warmup bounces and complaints belong to warmup health, not the campaign rates. +DELETE FROM deliverability_events de +USING tasks t +WHERE t.id = de.task_id + AND t.task_type = 'warmup' + AND de.event_type IN ('bounce', 'complaint'); diff --git a/web/src/app/app/contacts/suppressions/page.tsx b/web/src/app/app/contacts/suppressions/page.tsx index 40a2d77aa..005e3639f 100644 --- a/web/src/app/app/contacts/suppressions/page.tsx +++ b/web/src/app/app/contacts/suppressions/page.tsx @@ -1,23 +1,23 @@ // Suppression list: every address and domain campaign mail must not go to, // however it got there (a click, a reply, a bounce, a complaint, or the -// team). Search, paste-to-add, and per-entry removal. Lifting an entry the -// recipient made themselves is confirmed with a stronger warning. +// team). Search, a paste-to-add dialog, and per-entry removal. Lifting an +// entry the recipient made themselves is confirmed with a stronger warning. import React from "react"; import { GlobeIcon, MailIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react"; import toast from "react-hot-toast"; import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page"; -import { Label, SearchInput, TextInput } from "@/components/ui/field"; -import { Textarea } from "@/components/ui/textarea"; +import { SearchInput } from "@/components/ui/field"; import { PopoverMenu, PopoverMenuContent, PopoverMenuItem, PopoverMenuTrigger } from "@/components/ui/popover-menu"; import { useConfirm } from "@/hooks/context/confirm"; import { useWriteGuard } from "@/hooks/usePermission"; -import { useAddSuppressions, useRemoveSuppression, useSuppressions } from "@/lib/api/hooks/app/suppressions/useSuppressions"; +import { useRemoveSuppression, useSuppressions } from "@/lib/api/hooks/app/suppressions/useSuppressions"; import { SOURCE_LABEL, recipientTriggered, type default as Suppression } from "@/lib/api/models/app/suppressions/Suppression"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { fmtAbsolute } from "@/components/app/contacts/contact-edit/format"; +import AddSuppressionsDialog from "@/components/app/contacts/AddSuppressionsDialog"; export default function SuppressionsPage() { const write = useWriteGuard("MANAGE_CONTACTS"); @@ -44,8 +44,9 @@ export default function SuppressionsPage() { + setAdding(false)} /> + - {adding && setAdding(false)} />} {list.isLoading ? (
{[0, 1, 2].map((i) => ( @@ -92,79 +93,6 @@ export default function SuppressionsPage() { ); } -// Paste-to-add: one address or domain per line, comma or whitespace also -// accepted, so a column copied out of a spreadsheet works as is. -function AddForm({ onClose }: { onClose: () => void }) { - const add = useAddSuppressions(); - const [raw, setRaw] = React.useState(""); - const [reason, setReason] = React.useState(""); - const values = React.useMemo( - () => Array.from(new Set(raw.split(/[\s,;]+/).map((v) => v.trim()).filter(Boolean))), - [raw], - ); - - async function submit() { - if (values.length === 0 || add.isPending) return; - try { - const res = await add.mutateAsync({ entries: values.map((value) => ({ value })), reason: reason.trim() || undefined }); - const skipped = res.skipped?.length ?? 0; - toast.success( - skipped - ? `Added ${res.added}; skipped ${skipped} that did not look like an address or domain` - : `Added ${res.added} to the suppression list`, - ); - onClose(); - } catch (err) { - toast.error(buildError(err as AppError)); - } - } - - return ( -
{ - e.preventDefault(); - void submit(); - }} - className="px-5 py-3 border-b border-slate-200/60 bg-sky-50/40 space-y-2.5" - > -
- -