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 (
-
- );
-}
-
function SuppressionRow({ entry }: { entry: Suppression }) {
const confirm = useConfirm();
const write = useWriteGuard("MANAGE_CONTACTS");
@@ -204,7 +132,7 @@ function SuppressionRow({ entry }: { entry: Suppression }) {
>
{SOURCE_LABEL[entry.source] ?? entry.source}
-
+
{fmtAbsolute(entry.created_at)}
@@ -218,8 +146,7 @@ function SuppressionRow({ entry }: { entry: Suppression }) {
- write.guard(askRemove)({})} danger>
-
+ write.guard(askRemove)({})} icon={} danger>
Remove from list
diff --git a/web/src/components/app/contacts/AddSuppressionsDialog.tsx b/web/src/components/app/contacts/AddSuppressionsDialog.tsx
new file mode 100644
index 000000000..8e5707c51
--- /dev/null
+++ b/web/src/components/app/contacts/AddSuppressionsDialog.tsx
@@ -0,0 +1,273 @@
+import React from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { BanIcon, GlobeIcon, MailIcon, TriangleAlertIcon, XIcon } from "lucide-react";
+import toast from "react-hot-toast";
+
+import { Label, TextInput } from "@/components/ui/field";
+import { Loading } from "@/components/loader";
+import { useConfirm } from "@/hooks/context/confirm";
+import { useAddSuppressions } from "@/lib/api/hooks/app/suppressions/useSuppressions";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+
+// 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. Every value
+// is classified as it is typed so the user sees what will land before adding.
+
+type Parsed = { raw: string; value: string; kind: "email" | "domain" | "invalid" };
+
+// Mirrors the server's classification: a value with an @ must be an address,
+// anything else must be a bare host. The server is still the authority.
+const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
+// The server takes at most this many entries per request.
+const MAX_ENTRIES = 5000;
+// Enough chips to eyeball a paste without rendering thousands of nodes.
+const PREVIEW_CHIPS = 200;
+
+function parseSuppressionInput(raw: string): Parsed[] {
+ const seen = new Set();
+ const out: Parsed[] = [];
+ for (const token of raw.split(/[\s,;]+/)) {
+ const trimmed = token.trim();
+ if (!trimmed) continue;
+ const value = trimmed.toLowerCase().replace(/^@/, "");
+ if (!value || seen.has(value)) continue;
+ seen.add(value);
+ const kind = value.includes("@") ? (EMAIL.test(value) ? "email" : "invalid") : DOMAIN.test(value) ? "domain" : "invalid";
+ out.push({ raw: trimmed, value, kind });
+ }
+ return out;
+}
+
+export default function AddSuppressionsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const add = useAddSuppressions();
+ const confirm = useConfirm();
+ const [raw, setRaw] = React.useState("");
+ const [reason, setReason] = React.useState("");
+ const textareaRef = React.useRef(null);
+
+ const parsed = React.useMemo(() => parseSuppressionInput(raw), [raw]);
+ const emails = parsed.filter((p) => p.kind === "email");
+ const domains = parsed.filter((p) => p.kind === "domain");
+ const invalid = parsed.filter((p) => p.kind === "invalid");
+ const valid = emails.length + domains.length;
+ const overLimit = valid > MAX_ENTRIES;
+ const busy = add.isPending;
+ const dirty = raw.trim() !== "" || reason.trim() !== "";
+
+ React.useEffect(() => {
+ if (!open) return;
+ setRaw("");
+ setReason("");
+ const t = setTimeout(() => textareaRef.current?.focus(), 60);
+ return () => clearTimeout(t);
+ }, [open]);
+
+ const requestClose = React.useCallback(() => {
+ if (busy) return;
+ if (!dirty) {
+ onClose();
+ return;
+ }
+ confirm.show("Discard what you pasted? Nothing has been added yet.", async () => onClose());
+ }, [busy, dirty, onClose, confirm]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ // The discard confirm or a floating picker owns this Escape.
+ if (document.querySelector("[data-floating], [role='alertdialog']")) return;
+ e.preventDefault();
+ requestClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, requestClose]);
+
+ async function submit() {
+ if (valid === 0 || overLimit || busy) return;
+ try {
+ const res = await add.mutateAsync({
+ entries: [...emails, ...domains].map((p) => ({ value: p.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 (
+
+ {open && (
+ <>
+
+