mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 16:02:18 +00:00
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:
@@ -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');
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user