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

This commit is contained in:
Matthew Meszaros
2026-09-19 08:23:10 -07:00
parent 7dd0177e0a
commit 69ddac8c75
5 changed files with 296 additions and 83 deletions
@@ -0,0 +1 @@
-- Data cleanup; the removed rows were never meant to exist and are not restored.
@@ -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');
+9 -82
View File
@@ -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() {
<SearchInput value={query} onChange={setQuery} placeholder="Search addresses or domains…" className="w-full sm:w-72" />
</SectionBar>
<AddSuppressionsDialog open={adding} onClose={() => setAdding(false)} />
<PageBody>
{adding && <AddForm onClose={() => setAdding(false)} />}
{list.isLoading ? (
<div className="px-5 py-3 space-y-2">
{[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 (
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="px-5 py-3 border-b border-slate-200/60 bg-sky-50/40 space-y-2.5"
>
<div>
<Label>Addresses or domains</Label>
<Textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
rows={4}
autoFocus
placeholder={"jane@acme.com\nacme.com\n@partner.io"}
className="w-full max-w-[520px] text-[12.5px] font-mono"
/>
<p className="mt-1 text-[11px] text-slate-500">
One per line, or paste a column. A bare domain suppresses every address at it.
</p>
</div>
<div>
<Label>Reason (optional)</Label>
<TextInput value={reason} onChange={setReason} placeholder="Existing customer" className="w-full max-w-[520px]" />
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={values.length === 0 || add.isPending}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium transition-colors disabled:opacity-50"
>
{add.isPending ? "Adding…" : values.length > 1 ? `Add ${values.length}` : "Add"}
</button>
<button
type="button"
onClick={onClose}
className="h-7 px-2.5 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
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}
</span>
<span className="hidden md:inline text-[11px] text-slate-400 tabular-nums w-28 text-right shrink-0">
<span className="hidden md:inline text-[11px] text-slate-400 tabular-nums whitespace-nowrap text-right shrink-0">
{fmtAbsolute(entry.created_at)}
</span>
<PopoverMenu align="end">
@@ -218,8 +146,7 @@ function SuppressionRow({ entry }: { entry: Suppression }) {
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={200}>
<PopoverMenuItem onSelect={() => write.guard(askRemove)({})} danger>
<XIcon className="w-3.5 h-3.5" />
<PopoverMenuItem onSelect={() => write.guard(askRemove)({})} icon={<XIcon className="w-3.5 h-3.5" />} danger>
Remove from list
</PopoverMenuItem>
</PopoverMenuContent>
@@ -0,0 +1,257 @@
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])?)+$/;
function parseSuppressionInput(raw: string): Parsed[] {
const seen = new Set<string>();
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<HTMLTextAreaElement>(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 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 || 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 (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 bg-slate-900/40"
onMouseDown={requestClose}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<motion.form
role="dialog"
aria-modal="true"
aria-labelledby="add-suppressions-title"
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2 }}
onMouseDown={(e) => e.stopPropagation()}
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="pointer-events-auto w-full max-w-[520px] max-h-[88dvh] flex flex-col rounded-lg border border-slate-200 bg-white shadow-[0_24px_60px_-12px_rgba(15,23,42,0.35)]"
>
<div className="px-5 h-14 flex items-center gap-3 border-b border-slate-200 shrink-0">
<div className="w-8 h-8 rounded-lg bg-rose-50 text-rose-600 flex items-center justify-center shrink-0">
<BanIcon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div id="add-suppressions-title" className="text-[13px] font-medium text-slate-900">
Add to suppression list
</div>
<div className="text-[11px] text-slate-400">No campaign will email these again</div>
</div>
<button
type="button"
onClick={requestClose}
disabled={busy}
aria-label="Close"
className="w-7 h-7 rounded-md flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
<XIcon className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4 overflow-y-auto">
<div>
<Label>Addresses or domains</Label>
<textarea
ref={textareaRef}
value={raw}
onChange={(e) => setRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void submit();
}
}}
rows={6}
spellCheck={false}
placeholder={"jane@acme.com\nacme.com\n@partner.io"}
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] font-mono leading-5 text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-2 focus:ring-sky-100 resize-y"
/>
<p className="mt-1 text-[11px] text-slate-500">
One per line, or paste a column. A bare domain suppresses every address at it.
</p>
</div>
{parsed.length > 0 && (
<div className="rounded-md border border-slate-200 bg-slate-50/60 divide-y divide-slate-200/70">
<div className="px-3 h-8 flex items-center gap-3 text-[11px]">
<Count icon={<MailIcon className="w-3 h-3" />} n={emails.length} word="address" plural="addresses" />
<Count icon={<GlobeIcon className="w-3 h-3" />} n={domains.length} word="domain" plural="domains" />
{invalid.length > 0 && (
<span className="inline-flex items-center gap-1 text-rose-600 font-medium">
<TriangleAlertIcon className="w-3 h-3" />
{invalid.length} not recognized
</span>
)}
</div>
<div className="px-3 py-2 flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
{parsed.map((p) => (
<Chip key={p.value} parsed={p} />
))}
</div>
</div>
)}
<div>
<Label>Reason (optional)</Label>
<TextInput value={reason} onChange={setReason} placeholder="Existing customer" maxLength={200} className="w-full" />
<p className="mt-1 text-[11px] text-slate-500">Shown next to each entry so the team knows why it is here.</p>
</div>
</div>
<div className="px-5 h-14 flex items-center justify-between gap-2 border-t border-slate-200 bg-slate-50/60 shrink-0">
<span className="text-[11px] text-slate-400 hidden sm:inline">Ctrl/⌘ + Enter to add</span>
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={requestClose}
disabled={busy}
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={busy || valid === 0}
className="h-8 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy && <Loading className="!w-3.5 h-3.5 text-white" />}
{valid > 1 ? `Add ${valid}` : "Add"}
</button>
</div>
</div>
</motion.form>
</div>
</>
)}
</AnimatePresence>
);
}
function Count({ icon, n, word, plural }: { icon: React.ReactNode; n: number; word: string; plural: string }) {
return (
<span className={`inline-flex items-center gap-1 ${n > 0 ? "text-slate-700" : "text-slate-400"}`}>
{icon}
{n} {n === 1 ? word : plural}
</span>
);
}
function Chip({ parsed }: { parsed: Parsed }) {
const cls =
parsed.kind === "invalid"
? "border-rose-200 bg-rose-50 text-rose-700 line-through decoration-rose-300"
: parsed.kind === "domain"
? "border-sky-200 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-700";
const Icon = parsed.kind === "invalid" ? TriangleAlertIcon : parsed.kind === "domain" ? GlobeIcon : MailIcon;
return (
<span
title={parsed.kind === "invalid" ? "Not an address or domain; it will be skipped" : undefined}
className={`inline-flex items-center gap-1 h-6 max-w-full px-2 rounded-md border text-[11.5px] font-mono ${cls}`}
>
<Icon className="w-3 h-3 shrink-0" />
<span className="truncate">{parsed.kind === "domain" ? `@${parsed.value}` : parsed.raw}</span>
</span>
);
}
+1 -1
View File
@@ -359,7 +359,7 @@ export function PopoverMenuItem({
disabled && "opacity-50 cursor-not-allowed",
)}
>
{icon && <span className="shrink-0 text-slate-400 group-hover:text-slate-600">{icon}</span>}
{icon && <span className={cn("shrink-0", danger ? "text-red-500" : "text-slate-400")}>{icon}</span>}
<span className="flex-1 truncate">{children}</span>
{trailing !== undefined ? (
<span className="shrink-0">{trailing}</span>