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" - > -
- -