@@ -418,11 +431,12 @@ function ProviderRow({ p }: { p: ProviderPlacement }) {
{ n: p.inbox, tone: "emerald" as DitherTone, label: "Inbox" },
{ n: p.promotions, tone: "violet" as DitherTone, label: "Promotions" },
{ n: p.spam, tone: "rose" as DitherTone, label: "Spam" },
- { n: p.other, tone: "slate" as DitherTone, label: "Other" },
+ { n: p.other, tone: "sky" as DitherTone, label: "Other tabs" },
+ { n: p.missing ?? 0, tone: "slate" as DitherTone, label: "Never arrived" },
].filter((s) => s.n > 0);
return (
-
{providerLabel(p.provider)}
+
{p.label || providerLabel(p.provider)}
`${s.label} ${s.n}`).join(" · ")}>
({ frac: s.n / Math.max(1, p.samples), tone: s.tone }))}
@@ -432,6 +446,9 @@ function ProviderRow({ p }: { p: ProviderPlacement }) {
{pct(p.inbox_rate)} inbox
{pct(p.spam_rate)} spam
+ {(p.missing ?? 0) > 0 && (
+ {num(p.missing)} missing
+ )}
{num(p.samples)} samples
diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx
index cc3ccb907..3312a727b 100644
--- a/web/src/app/app/emails/page.tsx
+++ b/web/src/app/app/emails/page.tsx
@@ -224,7 +224,12 @@ export default function AddressesPage() {
await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] });
setSelected([]);
const verb = action === "start" ? "started" : "paused";
- if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be updated`);
+ const seeds = results.filter(
+ (r) => r.status === "rejected" && (r.reason as AppError | null)?.code === "mailbox_is_seed",
+ ).length;
+ if (failed > 0 && seeds === failed) {
+ toast.error(`${seeds} mailbox${seeds > 1 ? "es are placement seed inboxes" : " is a placement seed inbox"}, and seeds never warm up.`);
+ } else if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be updated`);
else toast.success(`Warmup ${verb} for ${n} mailbox${n > 1 ? "es" : ""}`);
};
@@ -690,7 +695,7 @@ function MailboxRow({
const run = (action: "start" | "pause" | "resume", verb: string) => {
life.mutate(action, {
onSuccess: () => toast.success(`Warmup ${verb} for ${box.email}`),
- onError: () => toast.error("Couldn't update warmup"),
+ onError: (e) => toast.error(warmupErrorMessage(e as unknown as AppError)),
});
};
@@ -701,8 +706,8 @@ function MailboxRow({
try {
await life.mutateAsync("stop");
toast.success(`Warmup stopped for ${box.email}`);
- } catch {
- toast.error("Couldn't update warmup");
+ } catch (e) {
+ toast.error(warmupErrorMessage(e as AppError));
}
},
);
@@ -923,3 +928,13 @@ function MailboxRow({
);
}
+
+// A refusal the server explains with a code (a seed inbox, a blocked pool)
+// reads better as its own sentence than as a generic failure.
+function warmupErrorMessage(e: AppError | null | undefined): string {
+ if (e?.code === "mailbox_is_seed") {
+ return e.message || "This mailbox is a placement seed inbox, and seeds never warm up. Remove it from your seed inboxes first.";
+ }
+ if (e?.code && e.message) return e.message;
+ return "Couldn't update warmup";
+}
diff --git a/web/src/app/app/placement/[id]/page.tsx b/web/src/app/app/placement/[id]/page.tsx
new file mode 100644
index 000000000..4cc2a9a75
--- /dev/null
+++ b/web/src/app/app/placement/[id]/page.tsx
@@ -0,0 +1,406 @@
+// One placement test: where every copy landed, by folder, provider and seed,
+// the content check of the copy that was sent, and for a tracking comparison
+// the two halves side by side. Live through PLACEMENT_TEST_UPDATED.
+
+import React from "react";
+import { Link, useParams } from "react-router-dom";
+import { ArrowLeftIcon, ArrowUpRightIcon, Loader2Icon, SquareIcon } from "lucide-react";
+import toast from "react-hot-toast";
+import { EmptyBlock, SectionBar } from "@/components/layout/Page";
+import PermissionButton from "@/components/ui/PermissionButton";
+import EmailBody from "@/components/app/unibox/EmailBody";
+import { IssueRow } from "@/components/app/campaigns/ContentScore";
+import { useConfirm } from "@/hooks/context/confirm";
+import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
+import { useCancelPlacementTest, usePlacementTest } from "@/lib/api/hooks/app/placement/usePlacement";
+import {
+ PANEL_LABEL,
+ type PlacementCounts,
+ type PlacementResult,
+ type PlacementTest,
+ type PlacementTestDetail,
+} from "@/lib/api/models/app/placement/Placement";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import {
+ FolderChip,
+ PlacementBar,
+ PlacementCaveat,
+ PlacementLegend,
+ StatusChip,
+ TrackingBadge,
+} from "@/components/app/placement/tests/PlacementParts";
+import {
+ FOLDER,
+ ORIGIN_LABEL,
+ fmtDate,
+ fmtRate,
+ isTracked,
+ rateTone,
+ resolvedCount,
+} from "@/components/app/placement/tests/placementTests";
+import { cn } from "@/lib/utils";
+
+export default function PlacementTestPage() {
+ const { id = "" } = useParams();
+ const q = usePlacementTest(id);
+
+ return (
+
+
+
+
+ Placement tests
+
+
+ {q.isLoading ? (
+
+
+
+ ) : q.isError || !q.data ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function Detail({ test }: { test: PlacementTestDetail }) {
+ const confirm = useConfirm();
+ const cancel = useCancelPlacementTest();
+ const campaign = useCampaign(test.campaign_id ?? "");
+ const running = test.status === "running";
+ const s = test.summary;
+
+ const onCancel = () =>
+ confirm.show(
+ "Stop this test? Copies not sent yet are cancelled. The ones already sent keep being classified.",
+ async () => {
+ try {
+ await cancel.mutateAsync(test.id);
+ toast.success("Test stopped.");
+ } catch (e) {
+ const err = e as AppError;
+ toast.error(err?.code === "placement_not_running" ? "This test has already finished." : buildError(err));
+ }
+ },
+ );
+
+ return (
+ <>
+ {/* Header */}
+
+
+
+
{test.subject || "(no subject)"}
+
+
+
+
+
+ From {test.sender_email || "a deleted mailbox"}
+
+
{PANEL_LABEL[test.panel] ?? test.panel}
+
Started {fmtDate(test.created_at)}
+ {test.finished_at &&
Finished {fmtDate(test.finished_at)} }
+ {test.origin !== "manual" &&
{ORIGIN_LABEL[test.origin] ?? test.origin} }
+ {test.campaign_id && (
+
+ {campaign.data?.name ?? "Campaign"}
+
+
+ )}
+ {test.compare && (
+
+ {isTracked(test.compare) ? "Tracked half" : "Untracked half"}
+
+
+ )}
+
+ {test.error &&
{test.error}
}
+
+ {running && (
+
+ {cancel.isPending ? : }
+ Stop test
+
+ )}
+
+
+ {/* Folder breakdown */}
+
+
+ {test.compare &&
}
+
+ {/* By provider */}
+
+
+
+ {(test.families ?? []).length === 0 ? (
+
No copies have a verdict yet.
+ ) : (
+
+
+
+
+ Provider
+
+ Inbox
+ Tabs
+ Spam
+ Missing
+ Copies
+
+
+
+ {(test.families ?? []).map((f) => (
+
+ {f.label || f.family}
+
+
+
+
+ {fmtRate(f.counts.inbox_rate)}
+
+ {fmtRate(f.counts.tabs_rate)}
+ {fmtRate(f.counts.spam_rate)}
+ {fmtRate(f.counts.missing_rate)}
+
+ {resolvedCount(f.counts)}/{f.counts.total}
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Per seed */}
+
+
+ {/* Content check + the copy */}
+
+
+
+
+
+
+
+ Subject:
+ {test.subject || "(no subject)"}
+
+
+ {test.body_html || test.body_plain ? (
+
+ ) : (
+
The body is not stored for this test.
+ )}
+
+
+
+ The template as written. Each seed got it rendered for the chosen contact, with the signature,
+ opt-out footer and unsubscribe header the real send adds.
+
+
+
+
+
+
+ >
+ );
+}
+
+// Hairlines for a 2x2 grid on phones and one row of four from md up.
+const CELL_BORDER = ["border-r max-md:border-b", "md:border-r max-md:border-b", "border-r", ""];
+
+function Breakdown({ counts, running }: { counts: PlacementCounts; running: boolean }) {
+ const tabs = counts.promotions + counts.other;
+ const cells: { label: string; n: number; rate: number | null; tone: string; dot: string; sub?: string }[] = [
+ { label: "Inbox", n: counts.inbox, rate: counts.inbox_rate, tone: FOLDER.inbox.text, dot: FOLDER.inbox.dot },
+ {
+ label: "Gmail tabs",
+ n: tabs,
+ rate: counts.tabs_rate,
+ tone: FOLDER.promotions.text,
+ dot: FOLDER.promotions.dot,
+ sub: `${counts.promotions} Promotions, ${counts.other} other tabs`,
+ },
+ { label: "Spam", n: counts.spam, rate: counts.spam_rate, tone: FOLDER.spam.text, dot: FOLDER.spam.dot },
+ {
+ label: "Never arrived",
+ n: counts.missing,
+ rate: counts.missing_rate,
+ tone: FOLDER.missing.text,
+ dot: FOLDER.missing.dot,
+ sub: "Not seen within 2 hours",
+ },
+ ];
+ const notSent = counts.failed + counts.cancelled;
+ return (
+
+
+ {cells.map((c, i) => (
+
+
+
+ {c.label}
+
+
+ {fmtRate(c.rate)}
+
+
+ {c.n} cop{c.n === 1 ? "y" : "ies"}
+ {c.sub ? `, ${c.sub}` : ""}
+
+
+ ))}
+
+
+
+
+ {running
+ ? `${resolvedCount(counts)} of ${counts.total} copies have a verdict. This page updates as they arrive.`
+ : `${counts.delivered} of ${counts.total} copies were sent and classified.`}
+ {notSent > 0 && ` ${notSent} not sent (${counts.failed} failed, ${counts.cancelled} cancelled).`}
+ {" "}Rates are shares of the copies that were sent.
+
+
+
+ );
+}
+
+// Two tests to the same seeds, one untracked and one tracked, side by side.
+function Comparison({ test, other }: { test: PlacementTest; other: PlacementTest }) {
+ const [without, withT] = isTracked(test) ? [other, test] : [test, other];
+ const rows: { label: string; key: keyof PlacementCounts }[] = [
+ { label: "Inbox", key: "inbox_rate" },
+ { label: "Gmail tabs", key: "tabs_rate" },
+ { label: "Spam", key: "spam_rate" },
+ { label: "Never arrived", key: "missing_rate" },
+ ];
+ const diff = (a: number | null, b: number | null) => (a == null || b == null ? null : Math.round((b - a) * 100));
+ const inboxDelta = diff(without.summary.inbox_rate, withT.summary.inbox_rate);
+ return (
+
+
+
+ {[
+ { title: "Without tracking", t: without },
+ { title: "With tracking", t: withT },
+ ].map(({ title, t }, i) => (
+
+
+
{title}
+
+ {t.id !== test.id && (
+
+ Open
+
+
+ )}
+
+
+
+ {rows.map((r) => (
+
+
{r.label}
+ {fmtRate(t.summary[r.key] as number | null)}
+
+ ))}
+
+
+ ))}
+
+
+ {inboxDelta == null
+ ? "The difference shows once both halves have verdicts."
+ : inboxDelta === 0
+ ? "Tracking made no difference to how much reached the inbox in this test."
+ : inboxDelta < 0
+ ? `The tracked copies reached the inbox ${Math.abs(inboxDelta)} points less often. Run it again before turning tracking off: one test is noise.`
+ : `The tracked copies reached the inbox ${inboxDelta} points more often. With this few seeds that is within the noise.`}
+
+
+ );
+}
+
+function SeedResults({ results, masked }: { results: PlacementResult[]; masked: boolean }) {
+ if (results.length === 0) return
No seeds were picked for this test.
;
+ return (
+ <>
+ {masked && (
+
Addresses on a shared panel are partly hidden.
+ )}
+
+ {results.map((r, i) => (
+
+
+
{r.seed}
+
+ {r.family_label || r.family}
+ {r.detected_at
+ ? `, seen ${fmtDate(r.detected_at)}`
+ : r.sent_at
+ ? `, sent ${fmtDate(r.sent_at)}`
+ : r.scheduled_at && r.folder === "pending"
+ ? `, sends ${fmtDate(r.scheduled_at)}`
+ : ""}
+
+ {r.error &&
{r.error}
}
+
+
+
+ ))}
+
+ >
+ );
+}
+
+function ContentCheck({ test }: { test: PlacementTestDetail }) {
+ const { score } = test.content;
+ const issues = test.content.issues ?? [];
+ const tone = score >= 80 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
+ const label = score >= 80 ? "Looks good" : score >= 50 ? "Could improve" : "Needs work";
+ return (
+
+
+ {score}
+ out of 100
+ {label}
+
+ {issues.length === 0 ? (
+
Nothing in the copy stands out to a spam filter.
+ ) : (
+
+ {issues.map((issue, i) => (
+
+ ))}
+
+ )}
+
+ The same rules the step editor checks, run on the copy that was tested. Placement also depends on the
+ sender's reputation, which no content check sees.
+
+
+ );
+}
diff --git a/web/src/app/app/placement/page.tsx b/web/src/app/app/placement/page.tsx
new file mode 100644
index 000000000..2c94a771b
--- /dev/null
+++ b/web/src/app/app/placement/page.tsx
@@ -0,0 +1,275 @@
+// Inbox placement tests: the workspace's tests, the seed panels it can test
+// against, and its own seed inboxes. Live through PLACEMENT_TEST_UPDATED and
+// the audit spine; nothing here polls.
+
+import React from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import { motion } from "framer-motion";
+import { InboxIcon, Loader2Icon, ListIcon, PlusIcon, XIcon } from "lucide-react";
+import { EmptyBlock, Page, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page";
+import ScrollStrip from "@/components/ui/scroll-strip";
+import { usePermission, showPermissionDenied } from "@/hooks/usePermission";
+import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
+import { usePlacementOverview, usePlacementTests } from "@/lib/api/hooks/app/placement/usePlacement";
+import { PANEL_LABEL, type PlacementTest } from "@/lib/api/models/app/placement/Placement";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import NewPlacementTestDialog from "@/components/app/placement/tests/NewPlacementTestDialog";
+import SeedInboxes from "@/components/app/placement/tests/SeedInboxes";
+import {
+ PanelStrip,
+ PlacementBar,
+ PlacementCaveat,
+ PlacementLegend,
+ StatusChip,
+ TrackingBadge,
+} from "@/components/app/placement/tests/PlacementParts";
+import { ORIGIN_LABEL, fmtDate, fmtRate, rateTone, usageLabel } from "@/components/app/placement/tests/placementTests";
+import { cn } from "@/lib/utils";
+
+type Tab = "tests" | "seeds";
+
+const TABS: { key: Tab; label: string; icon: typeof ListIcon }[] = [
+ { key: "tests", label: "Tests", icon: ListIcon },
+ { key: "seeds", label: "Seed inboxes", icon: InboxIcon },
+];
+
+export default function PlacementPage() {
+ const [params, setParams] = useSearchParams();
+ const tab: Tab = params.get("tab") === "seeds" ? "seeds" : "tests";
+ const campaignId = params.get("campaign_id");
+ const canStart = usePermission("SEND_CAMPAIGNS");
+ const overview = usePlacementOverview();
+ const [dialogOpen, setDialogOpen] = React.useState(false);
+
+ const setTab = (t: Tab) => {
+ const next = new URLSearchParams(params);
+ if (t === "tests") next.delete("tab");
+ else next.set("tab", t);
+ setParams(next, { replace: true });
+ };
+
+ const openNew = () => {
+ if (!canStart) {
+ showPermissionDenied("SEND_CAMPAIGNS");
+ return;
+ }
+ setDialogOpen(true);
+ };
+
+ const usage = overview.data?.usage;
+ const exhausted = usage?.limit != null && usage.used >= usage.limit;
+
+ return (
+
+
+ {exhausted && (
+ Your own seed inboxes are never counted.
+ )}
+ } onClick={openNew}>
+ New test
+
+
+
+
+ {TABS.map((t) => {
+ const active = tab === t.key;
+ return (
+ setTab(t.key)}
+ className={cn(
+ "relative h-10 px-2.5 inline-flex shrink-0 items-center gap-1.5 text-[12.5px] transition-colors",
+ active ? "text-slate-900 font-medium" : "text-slate-500 hover:text-slate-800",
+ )}
+ >
+
+ {t.label}
+ {t.key === "seeds" && overview.data && (
+ {overview.data.workspace_seeds}
+ )}
+ {active && (
+
+ )}
+
+ );
+ })}
+
+
+ {tab === "seeds" ? (
+
+ ) : (
+ <>
+ {overview.data && }
+ {
+ const next = new URLSearchParams(params);
+ next.delete("campaign_id");
+ setParams(next, { replace: true });
+ }}
+ onNew={openNew}
+ />
+ >
+ )}
+
+ setDialogOpen(false)}
+ prefill={campaignId ? { campaignId } : undefined}
+ />
+
+ );
+}
+
+function TestsTable({
+ campaignId,
+ onClearCampaign,
+ onNew,
+}: {
+ campaignId: string | null;
+ onClearCampaign: () => void;
+ onNew: () => void;
+}) {
+ const navigate = useNavigate();
+ const list = usePlacementTests(campaignId);
+ const campaign = useCampaign(campaignId ?? "");
+
+ return (
+
+
+ {campaignId && (
+
+ Campaign: {campaign.data?.name ?? "…"}
+
+
+
+
+ )}
+
+
+
+ {list.isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : list.isError ? (
+
+ ) : list.tests.length === 0 ? (
+
+
+ New test
+
+ }
+ />
+ ) : (
+
+
+
+
+ Sender and subject
+ Panel
+ Tracking
+ Status
+ Where it landed
+ Inbox
+ Started
+
+
+
+ {list.tests.map((t) => (
+ navigate(`/app/placement/${t.id}`)} />
+ ))}
+
+
+ {list.hasNextPage && (
+
+ list.fetchNextPage()}
+ disabled={list.isFetchingNextPage}
+ className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] font-medium text-slate-700 inline-flex items-center gap-1.5 disabled:opacity-60"
+ >
+ {list.isFetchingNextPage && }
+ Load more
+
+
+ )}
+
+ )}
+
+ {list.tests.length > 0 && }
+
+ );
+}
+
+function TestRow({ test, onOpen }: { test: PlacementTest; onOpen: () => void }) {
+ const s = test.summary;
+ return (
+
{
+ if (e.key === "Enter") onOpen();
+ }}
+ tabIndex={0}
+ className="h-12 cursor-pointer hover:bg-slate-50/80 transition-colors outline-none focus-visible:bg-slate-50"
+ >
+
+
+ {test.sender_email || "Deleted mailbox"}
+ {test.origin !== "manual" && (
+
+ {ORIGIN_LABEL[test.origin] ?? test.origin}
+
+ )}
+
+ {test.subject || "(no subject)"}
+
+ {PANEL_LABEL[test.panel] ?? test.panel}
+
+
+
+
+
+
+
+
+
+
+ {fmtRate(s.inbox_rate)}
+
+
+ e.stopPropagation()} className="hover:text-slate-700">
+ {fmtDate(test.created_at)}
+
+
+
+ );
+}
diff --git a/web/src/app/app/settings/notifications/page.tsx b/web/src/app/app/settings/notifications/page.tsx
index a999c7aa5..5960bc0e6 100644
--- a/web/src/app/app/settings/notifications/page.tsx
+++ b/web/src/app/app/settings/notifications/page.tsx
@@ -28,6 +28,8 @@ const HEALTH: { key: NotificationCategoryKey; label: string; hint: string }[] =
{ key: "health_worker_downtime", label: "Worker downtime", hint: "A sender worker stops responding." },
{ key: "health_domain_auth", label: "Domain authentication failing", hint: "A sending domain lost its SPF or DMARC record. Cold sending and warmup stop from it if it is not fixed." },
{ key: "campaign_paused", label: "Campaign auto-paused", hint: "A guardrail stopped a campaign because its bounce, complaint, or reply rate left the band." },
+ { key: "placement_alert", label: "Placement monitor alert", hint: "A campaign's scheduled placement test found less of its mail in the inbox than its alert threshold." },
+ { key: "placement_finished", label: "Placement test finished", hint: "A placement test you started has a verdict for every copy." },
];
const SECURITY: { key: NotificationCategoryKey; label: string; hint: string }[] = [
@@ -111,6 +113,8 @@ export default function NotificationsSettingsPage() {
"health_worker_downtime",
"health_domain_auth",
"campaign_paused",
+ "placement_alert",
+ "placement_finished",
"security_new_signin",
"billing_alert",
"team_activity",
diff --git a/web/src/components/app/campaigns/ContentScore.tsx b/web/src/components/app/campaigns/ContentScore.tsx
index 73e232683..ea293a9ed 100644
--- a/web/src/components/app/campaigns/ContentScore.tsx
+++ b/web/src/components/app/campaigns/ContentScore.tsx
@@ -105,7 +105,8 @@ function SpanList({ spans }: { spans: TemplateScoreSpan[] }) {
);
}
-function IssueRow({ issue }: { issue: TemplateScoreIssue }) {
+// Exported for the placement test detail, which shows the same rules pass.
+export function IssueRow({ issue }: { issue: TemplateScoreIssue }) {
const high = issue.severity === "high";
const Icon = high ? AlertCircleIcon : AlertTriangleIcon;
const spans = issue.spans ?? [];
diff --git a/web/src/components/app/campaigns/preferences/CampaignPlacementMonitor.tsx b/web/src/components/app/campaigns/preferences/CampaignPlacementMonitor.tsx
new file mode 100644
index 000000000..814c2c554
--- /dev/null
+++ b/web/src/components/app/campaigns/preferences/CampaignPlacementMonitor.tsx
@@ -0,0 +1,256 @@
+// A campaign's scheduled placement test: every few days its first email step
+// is sent to a seed panel from one of its mailboxes, and a low inbox rate
+// alerts the team (and can pause the campaign). Saves as it changes, through
+// its own endpoint, apart from the campaign's save bar.
+
+import React from "react";
+import { Link } from "react-router-dom";
+import { AlertTriangleIcon, ArrowUpRightIcon, Loader2Icon } from "lucide-react";
+import toast from "react-hot-toast";
+import { Label, NumberInput } from "@/components/ui/field";
+import { SelectMenu } from "@/components/ui/select-menu";
+import { useConfirm } from "@/hooks/context/confirm";
+import { usePermission } from "@/hooks/usePermission";
+import {
+ useDeletePlacementMonitor,
+ usePlacementMonitor,
+ usePlacementOverview,
+ usePutPlacementMonitor,
+} from "@/lib/api/hooks/app/placement/usePlacement";
+import {
+ PANEL_LABEL,
+ PLACEMENT_MONITOR_INTERVAL_MAX,
+ PLACEMENT_MONITOR_INTERVAL_MIN,
+ type PlacementMonitorInput,
+ type PlacementPanel,
+} from "@/lib/api/models/app/placement/Placement";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import { fmtDate } from "@/components/app/placement/tests/placementTests";
+import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox";
+
+// Defaults a new monitor starts from, matching the backend's.
+const DEFAULT_INTERVAL = 7;
+const DEFAULT_ALERT_BELOW = 70;
+
+export function PlacementMonitorSection({ campaignId }: { campaignId: string }) {
+ const monitor = usePlacementMonitor(campaignId);
+ const overview = usePlacementOverview();
+ const put = usePutPlacementMonitor(campaignId);
+ const remove = useDeletePlacementMonitor(campaignId);
+ const confirm = useConfirm();
+ const canEdit = usePermission("SEND_CAMPAIGNS");
+
+ const m = monitor.data ?? null;
+ const [intervalDays, setIntervalDays] = React.useState(m?.interval_days ?? DEFAULT_INTERVAL);
+ const [alertBelow, setAlertBelow] = React.useState(m?.alert_below ?? DEFAULT_ALERT_BELOW);
+ const [panel, setPanel] = React.useState
(m?.panel ?? "instance");
+ const [pauseOnAlert, setPauseOnAlert] = React.useState(m?.pause_on_alert ?? false);
+
+ // A teammate's edit lands live, so the controls follow the stored monitor.
+ React.useEffect(() => {
+ if (!m) return;
+ setIntervalDays(m.interval_days);
+ setAlertBelow(m.alert_below);
+ setPanel(m.panel);
+ setPauseOnAlert(m.pause_on_alert);
+ }, [m]);
+
+ const save = async (input: PlacementMonitorInput) => {
+ try {
+ await put.mutateAsync(input);
+ } catch (e) {
+ toast.error(buildError(e as AppError));
+ }
+ };
+
+ // Only an existing monitor saves field by field; a new one is created with
+ // every value at once when it is switched on.
+ const change = (input: PlacementMonitorInput) => {
+ if (m) void save(input);
+ };
+
+ const enabled = !!m?.enabled;
+ const toggle = (on: boolean) => {
+ if (!m) {
+ if (on) void save({ enabled: true, interval_days: intervalDays, alert_below: alertBelow, panel, pause_on_alert: pauseOnAlert });
+ return;
+ }
+ void save({ enabled: on });
+ };
+
+ const onRemove = () =>
+ confirm.show("Remove this campaign's placement monitor? Its past tests stay in Placement tests.", async () => {
+ try {
+ await remove.mutateAsync();
+ toast.success("Placement monitor removed.");
+ } catch (e) {
+ toast.error(buildError(e as AppError));
+ }
+ });
+
+ const panels = overview.data?.panels ?? [];
+ const panelOptions = (panels.length > 0 ? panels : [{ panel: "instance" as const, available: true, reason: "" }]).map((p) => ({
+ value: p.panel,
+ label: p.available ? PANEL_LABEL[p.panel] : `${PANEL_LABEL[p.panel]} (unavailable)`,
+ disabled: !p.available && p.panel !== panel,
+ }));
+ const selectedPanel = panels.find((p) => p.panel === panel);
+
+ if (monitor.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const disabled = !canEdit || put.isPending;
+
+ return (
+
+ {m?.last_error && (
+
+
+
The last scheduled test did not run: {m.last_error}
+
+ )}
+
+
+ {put.isPending && }
+
+
+ }
+ />
+
+
+
+
+ Test every
+ {
+ if (v !== m?.interval_days) change({ interval_days: v });
+ }}
+ suffix="days"
+ disabled={!canEdit}
+ className="w-36"
+ />
+
+
+ Alert below inbox rate
+ {
+ if (v !== m?.alert_below) change({ alert_below: v });
+ }}
+ suffix="%"
+ disabled={!canEdit}
+ className="w-36"
+ />
+
+
+ Seed panel
+ {
+ setPanel(v as PlacementPanel);
+ change({ panel: v as PlacementPanel });
+ }}
+ options={panelOptions}
+ disabled={!canEdit}
+ fullWidth
+ aria-label="Seed panel"
+ />
+
+
+ {selectedPanel && !selectedPanel.available && (
+
{selectedPanel.reason || "This panel cannot run a test right now."}
+ )}
+ {selectedPanel?.metered && (
+
Each scheduled test counts toward your monthly placement tests.
+ )}
+
+
{
+ setPauseOnAlert(v);
+ change({ pause_on_alert: v });
+ }}
+ disabled={!canEdit}
+ />
+ }
+ />
+
+ {m && (
+
+
+
Last run
+
+ {m.last_run_at ? fmtDate(m.last_run_at) : "Not yet"}
+ {m.last_test_id && (
+
+ View
+
+
+ )}
+
+
+
+
Next run
+ {m.enabled ? fmtDate(m.next_run_at) : "Off"}
+
+
+
Last alert
+ {m.last_alert_at ? fmtDate(m.last_alert_at) : "None"}
+
+
+ )}
+
+
+ {m
+ ? "Changes save as you make them."
+ : "Switch it on to start. The first test runs within a few minutes."}{" "}
+ Every copy counts against the sending mailbox's daily limit.
+
+
+
+
+
+ This campaign's placement tests
+
+
+ {m && canEdit && (
+
+ Remove monitor
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
index 8ee527724..65cc67c07 100644
--- a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
+++ b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx
@@ -24,7 +24,7 @@ import type { TemplatePreview } from "@/lib/api/client/app/campaigns/previewTemp
import type Contact from "@/lib/api/models/app/contacts/Contact";
import type Inbox from "@/lib/api/models/app/emails/Inbox";
import formatBytes from "@/lib/helper/formatBytes";
-import { PreviewContactPicker, PreviewMailboxPicker, SendTestButton } from "./PreviewControls";
+import { PlacementTestButton, PreviewContactPicker, PreviewMailboxPicker, SendTestButton } from "./PreviewControls";
import { SAMPLE_CONTACT_LABEL, contactLabel, useCampaignSenderInboxes } from "./previewContext";
import { Label, TextInput } from "@/components/ui/field";
import {
@@ -333,7 +333,8 @@ export default function EmailContentEditor({
{stepId && canSendTest && (
-
+
+
);
}
+
+// Opens a placement test of the saved step: the same copy sent to a panel of
+// seed inboxes to see whether it reaches the inbox, a tab or spam.
+export function PlacementTestButton({ campaignId, stepId, dirty }: { campaignId: string; stepId: string; dirty: boolean }) {
+ const [open, setOpen] = React.useState(false);
+ const canStart = usePermission("SEND_CAMPAIGNS");
+ const onClick = () => {
+ if (!canStart) {
+ showPermissionDenied("SEND_CAMPAIGNS");
+ return;
+ }
+ if (dirty) {
+ toast.error("Save the step first so the test carries the latest copy.");
+ return;
+ }
+ setOpen(true);
+ };
+ return (
+ <>
+
+
+ Test inbox placement
+ Placement
+
+ setOpen(false)} prefill={{ campaignId, stepId }} />
+ >
+ );
+}
diff --git a/web/src/components/app/placement/tests/NewPlacementTestDialog.tsx b/web/src/components/app/placement/tests/NewPlacementTestDialog.tsx
new file mode 100644
index 000000000..f284b8d33
--- /dev/null
+++ b/web/src/components/app/placement/tests/NewPlacementTestDialog.tsx
@@ -0,0 +1,766 @@
+// Starts an inbox placement test: one mailbox sends a campaign step or custom
+// copy to a panel of seed inboxes, one copy at a time, and the detail page
+// shows where each landed. Every refusal the server can give is shown beside
+// the part of the form it is about.
+
+import React from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertCircleIcon,
+ Loader2Icon,
+ MailCheckIcon,
+ MailIcon,
+ MegaphoneIcon,
+ PlayIcon,
+ UserRoundIcon,
+ XIcon,
+} from "lucide-react";
+import toast from "react-hot-toast";
+import { Label, SearchInput, TextInput } from "@/components/ui/field";
+import {
+ PopoverMenu,
+ PopoverMenuContent,
+ PopoverMenuItem,
+ PopoverMenuLabel,
+ PopoverMenuSeparator,
+ PopoverMenuTrigger,
+ SelectButton,
+} from "@/components/ui/popover-menu";
+import { SelectMenu } from "@/components/ui/select-menu";
+import { OptionSelect, Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
+import RichTextEditor from "@/components/app/campaigns/sequences/RichTextEditor";
+import { VARIABLES, htmlToPlain } from "@/components/app/campaigns/sequences/emailPreview";
+import { contactLabel } from "@/components/app/campaigns/sequences/previewContext";
+import { LINK_VARIABLES } from "@/lib/templateVars";
+import { useConfirm } from "@/hooks/context/confirm";
+import useDebouncedValue from "@/hooks/useDebouncedValue";
+import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
+import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
+import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders";
+import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
+import getSequences from "@/lib/api/client/app/campaigns/sequences/getSequences";
+import { useCreatePlacementTest, usePlacementOverview, usePlacementSeeds } from "@/lib/api/hooks/app/placement/usePlacement";
+import {
+ PANEL_LABEL,
+ type CreatePlacementTestRequest,
+ type PlacementPanel,
+ type PlacementTracking,
+} from "@/lib/api/models/app/placement/Placement";
+import type Contact from "@/lib/api/models/app/contacts/Contact";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import { cn } from "@/lib/utils";
+import { placementErrorMessage, type PlacementErrorField } from "./placementTests";
+
+type Source = "step" | "custom";
+
+interface Draft {
+ senderId: string;
+ source: Source;
+ campaignId: string;
+ stepId: string;
+ subject: string;
+ bodyHtml: string;
+ bodyPlain: string;
+ bodyCode: boolean;
+ contact: Contact | null;
+ tracking: PlacementTracking;
+ panel: PlacementPanel;
+}
+
+export interface NewPlacementTestPrefill {
+ campaignId?: string;
+ stepId?: string;
+}
+
+function emptyDraft(prefill?: NewPlacementTestPrefill): Draft {
+ const fromStep = !!prefill?.campaignId;
+ return {
+ senderId: "",
+ source: fromStep ? "step" : "custom",
+ campaignId: prefill?.campaignId ?? "",
+ stepId: prefill?.stepId ?? "",
+ subject: "",
+ bodyHtml: "",
+ bodyPlain: "",
+ bodyCode: false,
+ contact: null,
+ tracking: fromStep ? "campaign" : "off",
+ panel: "instance",
+ };
+}
+
+// What the user typed or picked, for the discard prompt. The sender and panel
+// defaults the dialog fills in itself do not count.
+function draftKey(d: Draft): string {
+ return JSON.stringify([d.source, d.campaignId, d.stepId, d.subject, d.bodyHtml, d.contact?.id ?? "", d.tracking]);
+}
+
+function newKey(): string {
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+export default function NewPlacementTestDialog({
+ open,
+ onClose,
+ prefill,
+}: {
+ open: boolean;
+ onClose: () => void;
+ prefill?: NewPlacementTestPrefill;
+}) {
+ if (typeof document === "undefined") return null;
+ return createPortal(
+ {open && } ,
+ document.body,
+ );
+}
+
+function DialogBody({ onClose, prefill }: { onClose: () => void; prefill?: NewPlacementTestPrefill }) {
+ const navigate = useNavigate();
+ const confirm = useConfirm();
+ const overview = usePlacementOverview();
+ const seeds = usePlacementSeeds();
+ const create = useCreatePlacementTest();
+
+ const [draft, setDraft] = React.useState(() => emptyDraft(prefill));
+ const initialKey = React.useRef(draftKey(emptyDraft(prefill)));
+ const [error, setError] = React.useState<{ field: PlacementErrorField; message: string } | null>(null);
+ const [nudged, setNudged] = React.useState(false);
+
+ // A retried submit of the same form lands on the tests the first one
+ // started; any edit makes it a new request and clears the last refusal.
+ const idemKey = React.useRef(newKey());
+ const patch = (p: Partial) => {
+ idemKey.current = newKey();
+ setError(null);
+ setDraft((d) => ({ ...d, ...p }));
+ };
+
+ const campaign = useCampaign(draft.source === "step" ? draft.campaignId : "");
+ const campaignSenders = useCampaignSenders(draft.campaignId, draft.source === "step" && !!draft.campaignId);
+ const steps = useQuery({
+ queryKey: ["campaigns", draft.campaignId, "sequences"],
+ queryFn: () => getSequences(draft.campaignId),
+ enabled: draft.source === "step" && !!draft.campaignId,
+ });
+ const emailSteps = React.useMemo(
+ () => (steps.data ?? []).filter((s) => (s.kind ?? "email") === "email"),
+ [steps.data],
+ );
+
+ // Senders: connected mailboxes that are not seeds, the campaign's own first.
+ const inCampaign = React.useMemo(
+ () => new Set((campaignSenders.data ?? []).filter((s) => s.enabled).map((s) => s.email_account_id)),
+ [campaignSenders.data],
+ );
+ const senders = React.useMemo(() => {
+ const list = (seeds.data ?? []).filter((m) => m.status === "active" && !m.seed);
+ return [...list].sort((a, b) => Number(inCampaign.has(b.email_account_id)) - Number(inCampaign.has(a.email_account_id)));
+ }, [seeds.data, inCampaign]);
+ const sender = senders.find((s) => s.email_account_id === draft.senderId) ?? null;
+
+ // Default the sender to the campaign's first usable mailbox, else the first.
+ React.useEffect(() => {
+ if (sender || senders.length === 0) return;
+ const pick = senders.find((s) => inCampaign.has(s.email_account_id)) ?? senders[0];
+ setDraft((d) => ({ ...d, senderId: pick.email_account_id }));
+ }, [sender, senders, inCampaign]);
+
+ // Default the step to the campaign's first email step.
+ React.useEffect(() => {
+ if (draft.source !== "step" || !draft.campaignId || emailSteps.length === 0) return;
+ if (emailSteps.some((s) => s.id === draft.stepId)) return;
+ setDraft((d) => ({ ...d, stepId: emailSteps[0].id }));
+ }, [draft.source, draft.campaignId, draft.stepId, emailSteps]);
+
+ // Default the panel to the first one that can run a test.
+ const panels = React.useMemo(() => overview.data?.panels ?? [], [overview.data]);
+ const panel = panels.find((p) => p.panel === draft.panel);
+ React.useEffect(() => {
+ if (panels.length === 0 || panel?.available) return;
+ const first = panels.find((p) => p.available);
+ if (first) setDraft((d) => ({ ...d, panel: first.panel }));
+ }, [panels, panel?.available]);
+
+ const textOnly = draft.source === "step" && !!campaign.data?.text_only;
+ React.useEffect(() => {
+ if (textOnly && (draft.tracking === "on" || draft.tracking === "compare")) {
+ setDraft((d) => ({ ...d, tracking: "campaign" }));
+ }
+ }, [textOnly, draft.tracking]);
+
+ const compare = draft.tracking === "compare";
+ const seedsPerTest = overview.data?.seeds_per_test ?? 0;
+ const perTest = panel ? Math.min(panel.seeds, seedsPerTest || panel.seeds) : 0;
+ const copies = perTest * (compare ? 2 : 1);
+ const spacing = overview.data?.spacing_seconds ?? 20;
+ const minutes = Math.max(1, Math.round((copies * spacing) / 60));
+ const usage = overview.data?.usage;
+ const testsNeeded = compare ? 2 : 1;
+ const overQuota =
+ !!panel?.metered && usage?.limit != null && usage.used + testsNeeded > usage.limit;
+
+ // Why the form cannot be sent yet, shown in the footer instead of a
+ // silently disabled button.
+ const issue: string | null = !sender
+ ? senders.length === 0 && !seeds.isLoading
+ ? "Connect a mailbox first. Seed inboxes cannot send a test."
+ : "Pick the mailbox to send from."
+ : draft.source === "step" && !draft.campaignId
+ ? "Pick a campaign."
+ : draft.source === "step" && !draft.stepId
+ ? emailSteps.length === 0 && !steps.isLoading
+ ? "This campaign has no email step to test."
+ : "Pick a step."
+ : draft.source === "custom" && !draft.subject.trim()
+ ? "Write a subject."
+ : draft.source === "custom" && !(draft.bodyPlain.trim() || (draft.bodyCode && draft.bodyHtml.trim()))
+ ? "Write the email body."
+ : !panel || !panel.available
+ ? "Pick a panel that can run a test."
+ : panel.seeds === 0
+ ? panel.panel === "workspace"
+ ? "You have no seed inboxes yet. Mark one on the Seed inboxes tab of Placement tests."
+ : "This panel has no seed inboxes yet."
+ : overQuota
+ ? `This month's tests are used up${compare && usage && usage.limit != null && usage.used < usage.limit ? " (a comparison counts as 2)" : ""}. Your own seed inboxes are never counted.`
+ : null;
+
+ const dirty = draftKey(draft) !== initialKey.current;
+ const pending = create.isPending;
+
+ const requestClose = React.useCallback(() => {
+ if (pending) return;
+ if (dirty) {
+ confirm.show("Discard this placement test?", async () => onClose());
+ return;
+ }
+ onClose();
+ }, [pending, dirty, confirm, onClose]);
+
+ React.useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ // An open picker or the discard confirm owns this Escape.
+ if (document.querySelector("[data-floating], [role='alertdialog']")) return;
+ e.preventDefault();
+ requestClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [requestClose]);
+
+ async function submit() {
+ if (pending) return;
+ if (issue) {
+ setNudged(true);
+ return;
+ }
+ const body: CreatePlacementTestRequest = {
+ sender_account_id: draft.senderId,
+ tracking: draft.tracking,
+ panel: draft.panel,
+ ...(draft.contact ? { contact_id: draft.contact.id } : {}),
+ };
+ if (draft.source === "step") {
+ body.campaign_id = draft.campaignId;
+ body.sequence_id = draft.stepId;
+ } else {
+ body.subject = draft.subject.trim();
+ body.body_html = draft.bodyHtml;
+ body.body_plain = draft.bodyCode ? htmlToPlain(draft.bodyHtml) : draft.bodyPlain;
+ }
+ try {
+ const tests = await create.mutateAsync({ body, idempotencyKey: idemKey.current });
+ toast.success(tests.length > 1 ? "Comparison started." : "Placement test started.");
+ onClose();
+ if (tests[0]) navigate(`/app/placement/${tests[0].id}`);
+ } catch (err) {
+ setError(placementErrorMessage(err as AppError, { resetsOn: usage?.period_end, panel: draft.panel }));
+ }
+ }
+
+ const fieldError = (f: PlacementErrorField) =>
+ error?.field === f ? : null;
+
+ return (
+
+ e.stopPropagation()}
+ className="w-full max-w-[680px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]"
+ >
+
+
+
+
+
New
+
+
Placement test
+
+
+
+
+
+
+ {/* Sender */}
+
+ Send from
+ patch({ senderId: id })}
+ />
+
+ Only connected mailboxes can send. Seed inboxes are left out.
+
+ {fieldError("sender")}
+
+
+ {/* What to test */}
+
+
+ What to test
+
+ value={draft.source}
+ onChange={(v) =>
+ patch({
+ source: v,
+ tracking: v === "step" ? "campaign" : draft.tracking === "campaign" ? "off" : draft.tracking,
+ })
+ }
+ options={[
+ { value: "step", label: "Campaign step" },
+ { value: "custom", label: "Custom copy" },
+ ]}
+ />
+
+
+ {draft.source === "step" ? (
+
+
+ Campaign
+ patch({ campaignId: id, stepId: "", contact: null })}
+ />
+
+
+ Step
+ patch({ stepId: v })}
+ disabled={!draft.campaignId || steps.isLoading}
+ fullWidth
+ placeholder={
+ !draft.campaignId
+ ? "Pick a campaign first"
+ : steps.isLoading
+ ? "Loading steps…"
+ : emailSteps.length === 0
+ ? "No email steps"
+ : "Pick a step"
+ }
+ options={emailSteps.map((s, i) => ({
+ value: s.id,
+ label: `${s.name || `Step ${i + 1}`}${s.subject ? `: ${s.subject}` : ""}`,
+ }))}
+ aria-label="Step"
+ />
+
+
+ The saved step is rendered exactly as the campaign sends it: merge fields, spintax,
+ signature, opt-out footer and unsubscribe header.
+
+
+ ) : (
+
+
+ Subject
+ patch({ subject: v })}
+ placeholder="Quick question, {{.FirstName}}"
+ />
+
+
+ Body
+
+ patch({ bodyHtml: html, bodyPlain: draft.bodyCode ? "" : htmlToPlain(html) })
+ }
+ code={draft.bodyCode}
+ onCodeChange={(c) => patch({ bodyCode: c })}
+ variables={VARIABLES}
+ links={LINK_VARIABLES}
+ placeholder="Hi {{.FirstName}}, …"
+ />
+
+
+ )}
+ {fieldError("source")}
+
+
+
Render for
+
patch({ contact: c })}
+ />
+
+ Fills the merge fields. Nobody but the seed inboxes receives the copies.
+
+
+
+
+ {/* Tracking */}
+
+ Tracking
+
+ value={draft.tracking}
+ onChange={(v) => patch({ tracking: v })}
+ cols={2}
+ aria-label="Tracking"
+ options={[
+ ...(draft.source === "step"
+ ? [{ value: "campaign" as const, label: "As the campaign", hint: "Uses the campaign's open and click tracking." }]
+ : []),
+ ...(textOnly
+ ? []
+ : [{ value: "on" as const, label: "On", hint: "Open pixel and tracked links." }]),
+ { value: "off" as const, label: "Off", hint: "No pixel, links left as written." },
+ ...(textOnly
+ ? []
+ : [
+ {
+ value: "compare" as const,
+ label: "Compare with and without",
+ hint: "Two tests to the same seeds. Counts as 2 tests.",
+ },
+ ]),
+ ]}
+ />
+ {textOnly && (
+ This campaign sends plain text, which carries no tracking.
+ )}
+ {fieldError("tracking")}
+
+
+ {/* Panel */}
+
+ Seed panel
+ {overview.isLoading ? (
+
+ ) : (
+
+ {panels.map((p) => {
+ const active = p.panel === draft.panel;
+ return (
+ patch({ panel: p.panel })}
+ className={cn(
+ "flex w-full items-start gap-2.5 rounded-md border px-3 py-2 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-sky-100",
+ active && p.available
+ ? "border-sky-300 bg-sky-50"
+ : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50",
+ !p.available && "opacity-60 cursor-not-allowed hover:bg-white hover:border-slate-200",
+ )}
+ >
+
+
+
+ {PANEL_LABEL[p.panel]}
+
+
+ {p.seeds} seed{p.seeds === 1 ? "" : "s"}
+
+ {p.metered && p.available && (
+
+ Counted
+
+ )}
+
+
+ {!p.available
+ ? p.reason || "Not available on this workspace."
+ : p.metered
+ ? usage?.limit != null
+ ? `Counts toward your monthly tests: ${usage.used} of ${usage.limit} used.`
+ : "Counts toward your monthly tests."
+ : "Not counted toward your monthly tests."}
+
+
+
+
+ );
+ })}
+
+ )}
+ {fieldError("panel")}
+
+
+ {/* Cost */}
+ {sender && panel?.available && copies > 0 && (
+
+ Sends up to {copies} email{copies === 1 ? "" : "s"} from{" "}
+ {sender.email} , one every ~{spacing} seconds, counted
+ against its daily limit. Sending takes about {minutes} minute{minutes === 1 ? "" : "s"}; a copy
+ not seen within 2 hours counts as never arrived. Seeds on the sender's own domain are skipped.
+
+ )}
+
+
+
+
+ {error?.field === "general" ? (
+
+ ) : nudged && issue ? (
+
+ ) : null}
+
+
+ Cancel
+
+
+ {pending ? : }
+ {compare ? "Start comparison" : "Start test"}
+
+
+
+
+ );
+}
+
+function InlineError({ message, compact = false }: { message: string; compact?: boolean }) {
+ return (
+
+
+ {message}
+
+ );
+}
+
+function SenderPicker({
+ senders,
+ inCampaign,
+ value,
+ loading,
+ onChange,
+}: {
+ senders: { email_account_id: string; email: string; label: string }[];
+ inCampaign: Set;
+ value: string;
+ loading: boolean;
+ onChange: (id: string) => void;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [q, setQ] = React.useState("");
+ const current = senders.find((s) => s.email_account_id === value);
+ const needle = q.trim().toLowerCase();
+ const shown = needle ? senders.filter((s) => s.email.toLowerCase().includes(needle)) : senders;
+ const campaignRows = shown.filter((s) => inCampaign.has(s.email_account_id));
+ const otherRows = shown.filter((s) => !inCampaign.has(s.email_account_id));
+
+ const row = (s: (typeof senders)[number]) => (
+ onChange(s.email_account_id)}>
+ {s.email}
+ {s.label && {s.label} }
+
+ );
+
+ return (
+
+
+ }
+ label={current ? current.email : loading ? "Loading mailboxes…" : "Pick a mailbox"}
+ className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
+ />
+
+
+
+
+
+ {shown.length === 0 ? (
+
+ {senders.length === 0 ? "No connected mailbox can send a test." : "No mailbox matches that."}
+
+ ) : (
+ <>
+ {campaignRows.length > 0 && (
+ <>
+ In this campaign
+ {campaignRows.map(row)}
+ {otherRows.length > 0 && }
+ >
+ )}
+ {otherRows.length > 0 && (
+ <>
+ {campaignRows.length > 0 && Other mailboxes }
+ {otherRows.map(row)}
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function CampaignPicker({ value, name, onChange }: { value: string; name?: string; onChange: (id: string) => void }) {
+ const [open, setOpen] = React.useState(false);
+ const [q, setQ] = React.useState("");
+ const debounced = useDebouncedValue(q.trim(), 250);
+ const list = useCampaigns({ query: debounced, folder: "", limit: 20, enabled: open });
+ return (
+
+
+ }
+ label={value ? (name ?? "Loading…") : "Pick a campaign"}
+ className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
+ />
+
+
+
+
+
+ {list.isLoading && list.campaigns.length === 0 ? (
+
+ Loading…
+
+ ) : list.campaigns.length === 0 ? (
+ No campaign matches that.
+ ) : (
+ list.campaigns.map((c) => (
+ onChange(c.id)}>
+ {c.name}
+
+ ))
+ )}
+
+
+ );
+}
+
+// Whose merge fields fill the copy. Empty = the campaign's first lead, or the
+// built-in sample contact for custom copy.
+function ContactPicker({
+ campaignId,
+ value,
+ onChange,
+}: {
+ campaignId: string;
+ value: Contact | null;
+ onChange: (c: Contact | null) => void;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [q, setQ] = React.useState("");
+ const debounced = useDebouncedValue(q.trim(), 250);
+ const searching = debounced.length > 0;
+ const search = useSearchContacts({
+ options: {
+ query: debounced,
+ custom_field_filters: [],
+ campaign_ids: searching || !campaignId ? [] : [campaignId],
+ sort_by: "updated_at",
+ reverse: false,
+ },
+ limit: 8,
+ enabled: open,
+ keepPrevious: true,
+ });
+ const contacts = search.contacts ?? [];
+ const fallback = campaignId ? "The campaign's first lead" : "A sample contact";
+ return (
+
+
+ }
+ label={value ? contactLabel(value) : fallback}
+ className="w-full [&>span:nth-child(2)]:max-w-none [&>span:nth-child(2)]:flex-1 [&>span:nth-child(2)]:text-left"
+ />
+
+
+
+
+
+ onChange(null)} icon={ }>
+ {fallback}
+
+
+ {searching || !campaignId ? "Contacts" : "Leads in this campaign"}
+
+ {search.isLoading && contacts.length === 0 ? (
+
+ Loading…
+
+ ) : contacts.length === 0 ? (
+
+ {searching ? "No contact matches that." : "No contacts yet. Type to search."}
+
+ ) : (
+ contacts.map((c) => (
+
onChange(c)}>
+ {contactLabel(c)}
+ {c.email}
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/web/src/components/app/placement/tests/PlacementParts.tsx b/web/src/components/app/placement/tests/PlacementParts.tsx
new file mode 100644
index 000000000..55d7f8b9c
--- /dev/null
+++ b/web/src/components/app/placement/tests/PlacementParts.tsx
@@ -0,0 +1,161 @@
+// Small presentational pieces shared by the placement test list, detail and
+// campaign surfaces.
+import React from "react";
+import { EyeIcon, EyeOffIcon, InfoIcon, SplitIcon } from "lucide-react";
+import { DitherStack } from "@/components/ui/dither";
+import { cn } from "@/lib/utils";
+import type {
+ PlacementCounts,
+ PlacementFolder,
+ PlacementOverview,
+ PlacementTest,
+ PlacementTestStatus,
+} from "@/lib/api/models/app/placement/Placement";
+import { PANEL_HINT, PANEL_LABEL } from "@/lib/api/models/app/placement/Placement";
+import { FOLDER, LANDED_FOLDERS, STATUS, isTracked } from "./placementTests";
+
+export function StatusChip({ status, counts }: { status: PlacementTestStatus; counts?: PlacementCounts }) {
+ const s = STATUS[status] ?? STATUS.failed;
+ const progress = status === "running" && counts ? ` ${Math.max(0, counts.total - counts.pending)}/${counts.total}` : "";
+ return (
+
+
+ {s.label}
+ {progress && {progress} }
+
+ );
+}
+
+export function TrackingBadge({ test }: { test: Pick }) {
+ const tracked = isTracked(test);
+ const Icon = tracked ? EyeIcon : EyeOffIcon;
+ const parts = [test.open_tracking && "opens", test.link_tracking && "clicks"].filter(Boolean).join(" and ");
+ return (
+
+
+
+ {tracked ? "Tracked" : "Untracked"}
+
+ {test.compare_group_id && (
+
+
+ Compare
+
+ )}
+
+ );
+}
+
+export function FolderChip({ folder }: { folder: PlacementFolder }) {
+ const f = FOLDER[folder] ?? FOLDER.pending;
+ return (
+
+
+ {f.label}
+
+ );
+}
+
+// Inbox, tabs, spam and never arrived as shares of every copy in the test, so
+// the part still waiting shows as the empty track.
+export function PlacementBar({ counts, height = 6, className }: { counts: PlacementCounts; height?: number; className?: string }) {
+ const { total, inbox, promotions, other, spam, missing } = counts;
+ const segments = React.useMemo(() => {
+ const denom = Math.max(1, total);
+ const n: Record<(typeof LANDED_FOLDERS)[number], number> = { inbox, promotions, other, spam, missing };
+ return LANDED_FOLDERS.filter((f) => n[f] > 0).map((f) => ({ frac: n[f] / denom, tone: FOLDER[f].tone }));
+ }, [total, inbox, promotions, other, spam, missing]);
+ const title = LANDED_FOLDERS.map((f) => `${FOLDER[f].label} ${counts[f]}`).join(", ");
+ return (
+
+
+
+ );
+}
+
+export function PlacementLegend({ className }: { className?: string }) {
+ return (
+
+ {LANDED_FOLDERS.map((f) => (
+
+
+ {FOLDER[f].label}
+
+ ))}
+
+ );
+}
+
+// Seed results are a signal about the copy and the sender, not a forecast.
+export function PlacementCaveat({ className }: { className?: string }) {
+ return (
+
+
+
+ Seed results are a signal, not a forecast. One test is noise, so look for the same result across a few.
+ Seed inboxes have no history with your sender and do not sit behind corporate filters, so real
+ recipients can see something different.
+
+
+ );
+}
+
+// One card per seed panel: whether it can run a test, how many seeds it has
+// and which providers they cover.
+export function PanelStrip({ overview }: { overview: PlacementOverview }) {
+ return (
+ = 3 ? "md:grid-cols-3" : overview.panels.length === 2 ? "md:grid-cols-2" : "",
+ )}
+ >
+ {overview.panels.map((p, i) => {
+ const families = p.families ?? [];
+ return (
+
+
+
+ {PANEL_LABEL[p.panel]}
+
+ {p.seeds} seed{p.seeds === 1 ? "" : "s"}
+
+
+
+ {p.available ? PANEL_HINT[p.panel] : p.reason || "Not available on this workspace."}
+ {p.available && (p.metered ? " Counts toward your monthly tests." : " Not counted toward your monthly tests.")}
+
+ {families.length > 0 && (
+
+ {families.map((f) => (
+
+ {f.label}
+ {f.seeds}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/app/placement/tests/SeedInboxes.tsx b/web/src/components/app/placement/tests/SeedInboxes.tsx
new file mode 100644
index 000000000..61567b042
--- /dev/null
+++ b/web/src/components/app/placement/tests/SeedInboxes.tsx
@@ -0,0 +1,154 @@
+// The workspace's own seed panel: any connected mailbox can be marked as a
+// seed inbox. Tests on this panel are never counted toward the monthly
+// allowance, and a seed never warms up or sends campaign mail.
+
+import React from "react";
+import { InboxIcon, Loader2Icon } from "lucide-react";
+import toast from "react-hot-toast";
+import { Link } from "react-router-dom";
+import { Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
+import { EmptyBlock, SectionBar } from "@/components/layout/Page";
+import { SearchInput } from "@/components/ui/field";
+import { useConfirm } from "@/hooks/context/confirm";
+import { usePermission } from "@/hooks/usePermission";
+import { usePlacementSeeds, useSetPlacementSeed } from "@/lib/api/hooks/app/placement/usePlacement";
+import type { PlacementWorkspaceSeed } from "@/lib/api/models/app/placement/Placement";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import { cn } from "@/lib/utils";
+
+const STATUS_LABEL: Record = {
+ active: "Connected",
+ inactive: "Off",
+ revoked: "Needs reconnecting",
+};
+
+export default function SeedInboxes() {
+ const seeds = usePlacementSeeds();
+ const canManage = usePermission("MANAGE_EMAILS");
+ const [q, setQ] = React.useState("");
+ const rows = seeds.data ?? [];
+ const needle = q.trim().toLowerCase();
+ const shown = needle ? rows.filter((r) => r.email.toLowerCase().includes(needle) || r.label.toLowerCase().includes(needle)) : rows;
+ // Seeds first, so the panel reads at a glance.
+ const sorted = [...shown].sort((a, b) => Number(b.seed) - Number(a.seed) || a.email.localeCompare(b.email));
+ const seedCount = rows.filter((r) => r.seed).length;
+
+ return (
+
+
+
+ A seed inbox receives test copies so you can see where they land. Tests on your own seeds are never
+ counted toward your monthly tests.
+
+
+ A seed never warms up and never sends campaign mail, so use a test mailbox, not one you send from.
+ It should be on a different domain than your senders: a personal Gmail, Outlook.com or Yahoo address
+ works best. Seeds on a sender's own domain are skipped, because mail inside one domain is not
+ filtered like mail from outside.
+
+
+
+
+
+
+
+ {seeds.isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : seeds.isError ? (
+
+ ) : rows.length === 0 ? (
+
+ Connect a mailbox
+
+ }
+ />
+ ) : sorted.length === 0 ? (
+
+ ) : (
+
+ {sorted.map((r) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SeedRow({ row, canManage }: { row: PlacementWorkspaceSeed; canManage: boolean }) {
+ const set = useSetPlacementSeed();
+ const confirm = useConfirm();
+
+ const apply = async (seed: boolean) => {
+ try {
+ await set.mutateAsync({ emailAccountId: row.email_account_id, seed });
+ toast.success(seed ? `${row.email} is now a seed inbox.` : `${row.email} is no longer a seed inbox.`);
+ } catch (e) {
+ const err = e as AppError;
+ toast.error(
+ err?.code === "placement_seed_limit"
+ ? "A workspace can have up to 50 seed inboxes."
+ : err?.code === "placement_seed_unavailable"
+ ? err.message || "This mailbox cannot be changed right now."
+ : buildError(err),
+ );
+ }
+ };
+
+ // Turning a seed on stops its warmup, so it is confirmed; turning it off is harmless.
+ const toggle = (seed: boolean) => {
+ if (!seed) {
+ void apply(false);
+ return;
+ }
+ confirm.show(
+ `Make ${row.email} a seed inbox? Its warmup turns off and it stops sending campaign mail while it is a seed.`,
+ () => apply(true),
+ );
+ };
+
+ const blocked = !!row.blocker;
+ const disabled = !canManage || blocked || set.isPending;
+
+ return (
+
+
+
+
+ {row.email}
+ {row.label && {row.label} }
+ {row.status !== "active" && (
+
+ {STATUS_LABEL[row.status] ?? row.status}
+
+ )}
+
+ {row.blocker &&
{row.blocker}
}
+
+
{row.seed ? "Seed" : ""}
+ {set.isPending &&
}
+
+
+
+
+ );
+}
diff --git a/web/src/components/app/placement/tests/placementTests.ts b/web/src/components/app/placement/tests/placementTests.ts
new file mode 100644
index 000000000..f503cd651
--- /dev/null
+++ b/web/src/components/app/placement/tests/placementTests.ts
@@ -0,0 +1,132 @@
+// Shared vocabulary for inbox placement tests: folder labels and tones, rate
+// formatting, and the messages for every refusal the start endpoint returns.
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import type { DitherTone } from "@/components/ui/dither";
+import type {
+ PlacementCounts,
+ PlacementFolder,
+ PlacementOverview,
+ PlacementPanel,
+ PlacementTest,
+ PlacementTestStatus,
+} from "@/lib/api/models/app/placement/Placement";
+
+export interface FolderStyle {
+ label: string;
+ tone: DitherTone;
+ dot: string;
+ text: string;
+ chip: string;
+}
+
+export const FOLDER: Record = {
+ inbox: { label: "Inbox", tone: "emerald", dot: "bg-emerald-500", text: "text-emerald-600", chip: "bg-emerald-50 text-emerald-700 border-emerald-200" },
+ promotions: { label: "Promotions", tone: "violet", dot: "bg-violet-500", text: "text-violet-600", chip: "bg-violet-50 text-violet-700 border-violet-200" },
+ other: { label: "Other tab", tone: "sky", dot: "bg-sky-500", text: "text-sky-600", chip: "bg-sky-50 text-sky-700 border-sky-200" },
+ spam: { label: "Spam", tone: "rose", dot: "bg-rose-500", text: "text-rose-600", chip: "bg-rose-50 text-rose-700 border-rose-200" },
+ missing: { label: "Never arrived", tone: "slate", dot: "bg-slate-500", text: "text-slate-600", chip: "bg-slate-100 text-slate-700 border-slate-200" },
+ pending: { label: "Waiting", tone: "slate", dot: "bg-slate-300", text: "text-slate-400", chip: "bg-white text-slate-500 border-slate-200" },
+ failed: { label: "Not sent", tone: "amber", dot: "bg-amber-500", text: "text-amber-600", chip: "bg-amber-50 text-amber-700 border-amber-200" },
+ cancelled: { label: "Cancelled", tone: "slate", dot: "bg-slate-300", text: "text-slate-400", chip: "bg-slate-50 text-slate-500 border-slate-200" },
+};
+
+// The folders a copy that left can land in, in the order the bars stack.
+export const LANDED_FOLDERS = ["inbox", "promotions", "other", "spam", "missing"] as const;
+
+export const STATUS: Record = {
+ running: { label: "Running", chip: "bg-sky-50 text-sky-700 border-sky-200", dot: "bg-sky-500 animate-pulse" },
+ completed: { label: "Completed", chip: "bg-emerald-50 text-emerald-700 border-emerald-200", dot: "bg-emerald-500" },
+ cancelled: { label: "Cancelled", chip: "bg-slate-50 text-slate-600 border-slate-200", dot: "bg-slate-400" },
+ failed: { label: "Failed", chip: "bg-rose-50 text-rose-700 border-rose-200", dot: "bg-rose-500" },
+};
+
+export const ORIGIN_LABEL: Record = {
+ manual: "Manual",
+ monitor: "Monitor",
+ admin: "Operator",
+ remote: "Linked instance",
+};
+
+/** A 0..1 fraction as a whole percentage, or a dash while there is none. */
+export function fmtRate(r: number | null | undefined): string {
+ if (r == null) return "—";
+ return `${Math.round(r * 100)}%`;
+}
+
+export function rateTone(r: number | null | undefined): string {
+ if (r == null) return "text-slate-400";
+ if (r >= 0.8) return "text-emerald-600";
+ if (r >= 0.6) return "text-amber-600";
+ return "text-rose-600";
+}
+
+/** Copies that have a verdict, sent or not. */
+export function resolvedCount(c: PlacementCounts): number {
+ return Math.max(0, c.total - c.pending);
+}
+
+export function isTracked(t: Pick): boolean {
+ return t.open_tracking || t.link_tracking;
+}
+
+export function fmtDate(d: Date | string | null | undefined): string {
+ if (!d) return "—";
+ const date = d instanceof Date ? d : new Date(d);
+ if (Number.isNaN(date.getTime())) return "—";
+ return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
+}
+
+export function fmtDay(d: Date | string | null | undefined): string {
+ if (!d) return "—";
+ const date = d instanceof Date ? d : new Date(d);
+ if (Number.isNaN(date.getTime())) return "—";
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+export function usageLabel(o: PlacementOverview | undefined): string {
+ if (!o) return "";
+ const { used, limit } = o.usage;
+ if (limit == null) return "Unlimited";
+ return `${used} of ${limit} test${limit === 1 ? "" : "s"} this month`;
+}
+
+// Which part of the new-test form a refusal is about, so it shows beside it.
+export type PlacementErrorField = "sender" | "source" | "tracking" | "panel" | "general";
+
+export function placementErrorMessage(
+ err: AppError,
+ ctx: { resetsOn?: Date | null; panel?: PlacementPanel } = {},
+): { field: PlacementErrorField; message: string } {
+ switch (err?.code) {
+ case "placement_not_entitled":
+ return { field: "general", message: "Placement tests need an active trial or subscription." };
+ case "placement_quota_exceeded":
+ return {
+ field: "panel",
+ message: `This month's placement tests are used up${ctx.resetsOn ? ` until ${fmtDay(ctx.resetsOn)}` : ""}. Tests on your own seed inboxes are never counted.`,
+ };
+ case "placement_too_many_running":
+ return { field: "general", message: "Three tests are already running in this workspace. Wait for one to finish, or cancel one." };
+ case "placement_sender_busy":
+ return { field: "sender", message: "This mailbox is still sending another test. Pick another mailbox or wait until that one has sent every copy." };
+ case "placement_sender_unavailable":
+ return { field: "sender", message: "This mailbox cannot send a test: it is not connected, or it is a seed inbox itself." };
+ case "placement_daily_budget":
+ return { field: "sender", message: "This mailbox does not have enough of today's sending limit left for every copy. Pick another mailbox or try again tomorrow." };
+ case "placement_no_seeds":
+ return {
+ field: "panel",
+ message:
+ ctx.panel === "workspace"
+ ? "None of your seed inboxes can take this test. Add a seed inbox on a different domain than the sender."
+ : "This panel has no seed inbox this mailbox can reach. Seeds on the sender's own domain are always skipped.",
+ };
+ case "placement_panel_unavailable":
+ return { field: "panel", message: "The Warmbly Cloud panel needs this instance linked to Warmbly Cloud." };
+ case "placement_invalid_tracking":
+ return { field: "tracking", message: "This campaign sends plain text, which carries no tracking. Pick Off or As the campaign." };
+ default:
+ return { field: "general", message: buildError(err) };
+ }
+}
diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx
index 7a9bea53e..f88467465 100644
--- a/web/src/components/layout/AppNav.tsx
+++ b/web/src/components/layout/AppNav.tsx
@@ -22,6 +22,7 @@ import {
ListChecksIcon,
type LucideIcon,
MailIcon,
+ MailCheckIcon,
MegaphoneIcon,
SettingsIcon,
ShieldCheckIcon,
@@ -151,6 +152,7 @@ const sections: NavSection[] = [
{ title: "Forms", requires: "subscription", url: "/app/forms", icon: ClipboardListIcon, permission: "VIEW_CONTACTS", permissionLabel: "View contacts" },
{ title: "Analytics", requires: "subscription", url: "/app/analytics", icon: BarChart3Icon, indicator: "analytics", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
{ title: "Deliverability", requires: "subscription", url: "/app/deliverability", icon: ShieldCheckIcon, advisorSurface: "deliverability", permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
+ { title: "Placement tests", requires: "subscription", url: "/app/placement", icon: MailCheckIcon, permission: "VIEW_ANALYTICS", permissionLabel: "View analytics" },
],
},
{
diff --git a/web/src/components/layout/NotificationBell.tsx b/web/src/components/layout/NotificationBell.tsx
index 75dae1514..61aa1fac5 100644
--- a/web/src/components/layout/NotificationBell.tsx
+++ b/web/src/components/layout/NotificationBell.tsx
@@ -10,6 +10,8 @@ import {
ClockIcon,
CreditCardIcon,
KeyRoundIcon,
+ MailCheckIcon,
+ MailWarningIcon,
ReplyIcon,
ServerCrashIcon,
PauseCircleIcon,
@@ -40,6 +42,8 @@ const CATEGORY_META: Record = {
team_activity: { icon: UsersIcon, tone: "bg-emerald-50 text-emerald-600" },
campaign_paused: { icon: PauseCircleIcon, tone: "bg-rose-50 text-rose-600" },
health_domain_auth: { icon: ShieldOffIcon, tone: "bg-rose-50 text-rose-600" },
+ placement_finished: { icon: MailCheckIcon, tone: "bg-sky-50 text-sky-600" },
+ placement_alert: { icon: MailWarningIcon, tone: "bg-rose-50 text-rose-600" },
};
const FALLBACK_META = { icon: BellIcon, tone: "bg-slate-100 text-slate-500" };
diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts
index 61681fd89..7f6b2e57b 100644
--- a/web/src/hooks/useDocumentTitle.ts
+++ b/web/src/hooks/useDocumentTitle.ts
@@ -50,6 +50,7 @@ const ROUTE_TITLES: Record = {
"/app/campaigns": "Campaigns",
"/app/analytics": "Analytics",
"/app/deliverability": "Deliverability",
+ "/app/placement": "Placement tests",
"/app/crm/pipelines": "Pipelines",
"/app/crm/deals": "Deals",
"/app/crm/tasks": "Tasks",
@@ -98,6 +99,7 @@ const PARAM_ROUTES: ReadonlyArray = [
[/^\/app\/campaigns\/[^/]+\/steps$/, "Campaign steps"],
[/^\/app\/campaigns\/[^/]+$/, "Campaign"],
[/^\/app\/automations\/[^/]+$/, "Automation"],
+ [/^\/app\/placement\/[^/]+$/, "Placement test"],
[/^\/app\/forms\/[^/]+$/, "Form"],
[/^\/app\/contacts\/segments\/[^/]+$/, "Segment"],
[/^\/app\/unibox(\/.*)?$/, "Unibox"],
diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts
index dfeaa5edf..8edb7adfb 100644
--- a/web/src/hooks/useRealtimeEvents.ts
+++ b/web/src/hooks/useRealtimeEvents.ts
@@ -73,6 +73,14 @@ export function useRealtimeEvents() {
// and must never trigger a react-query refetch.
if (event.startsWith('LIVE_')) return
+ // A placement test started, got a verdict, finished or was cancelled.
+ // Checked this early because its name would otherwise match the warmup
+ // placement and campaign branches below.
+ if (event === 'PLACEMENT_TEST_UPDATED') {
+ invalidate([['placement'], ['analytics', 'deliverability']])
+ return
+ }
+
const getString = (key: string) => {
const value = payload[key]
return typeof value === 'string' && value.length > 0 ? value : null
@@ -421,7 +429,8 @@ export function useRealtimeEvents() {
// rolled plan), so a teammate retuning a mailbox's sending behaviour
// refreshes everyone's open drawer instead of only the list row.
// A mailbox write can move it off Google sign-in, so the migration list follows.
- email_account: [['emails'], ['analytics', 'accounts'], ['sending-domains'], ['mailbox-grants', 'migration'], ['pool-link']],
+ // A seed toggle is audited here too, so the placement seed list follows.
+ email_account: [['emails'], ['analytics', 'accounts'], ['sending-domains'], ['mailbox-grants', 'migration'], ['pool-link'], ['placement', 'seeds'], ['placement', 'overview']],
// A mailbox import created, retried or cancelled by a teammate; it may move mailboxes onto a grant.
mailbox_import: [['emails', 'imports'], ['mailbox-grants', 'migration']],
// An admin grant added, re-checked or removed, and an inbox vendor
@@ -481,6 +490,9 @@ export function useRealtimeEvents() {
crm_deal: [['crm', 'deals'], ['contacts']],
crm_task: [['crm', 'tasks'], ['crm', 'deals']],
warmup_routing_rule: [['analytics', 'warmup']],
+ // Placement tests started or stopped and campaign monitors changed.
+ placement_test: [['placement']],
+ placement_monitor: [['placement']],
cloud_link: [['cloud-link'], ['emails']],
pool_link: [['pool-link'], ['emails']],
// Folders / tags / categories ride the user payload.
diff --git a/web/src/lib/api/client/app/placement/placement.ts b/web/src/lib/api/client/app/placement/placement.ts
new file mode 100644
index 000000000..4a3e94f04
--- /dev/null
+++ b/web/src/lib/api/client/app/placement/placement.ts
@@ -0,0 +1,118 @@
+import type {
+ CreatePlacementTestRequest,
+ PlacementMonitor,
+ PlacementMonitorInput,
+ PlacementOverview,
+ PlacementTest,
+ PlacementTestDetail,
+ PlacementTestList,
+ PlacementWorkspaceSeed,
+} from "@/lib/api/models/app/placement/Placement";
+import Request from "../../Request";
+
+export async function getPlacementOverview(): Promise {
+ const res = await Request<{ data: PlacementOverview }>({
+ method: "GET",
+ url: "/placement/overview",
+ authorization: true,
+ });
+ return res.data;
+}
+
+export async function listPlacementTests(
+ cursor: string | null,
+ limit: number,
+ campaignId?: string | null,
+): Promise {
+ const params = new URLSearchParams();
+ params.set("limit", String(limit));
+ if (cursor) params.set("cursor", cursor);
+ if (campaignId) params.set("campaign_id", campaignId);
+ return await Request({
+ method: "GET",
+ url: `/placement/tests?${params.toString()}`,
+ authorization: true,
+ });
+}
+
+export async function getPlacementTest(id: string): Promise {
+ const res = await Request<{ data: PlacementTestDetail }>({
+ method: "GET",
+ url: `/placement/tests/${id}`,
+ authorization: true,
+ });
+ return res.data;
+}
+
+// One test, or two sharing a compare_group_id for a tracking comparison. The
+// key makes a retried submit land on the tests the first attempt started.
+export async function createPlacementTest(
+ body: CreatePlacementTestRequest,
+ idempotencyKey?: string,
+): Promise {
+ const res = await Request<{ data: PlacementTest[] | null }>({
+ method: "POST",
+ url: "/placement/tests",
+ data: body,
+ headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined,
+ authorization: true,
+ });
+ return res.data ?? [];
+}
+
+// Stops the copies not sent yet; the sent ones keep being classified.
+export async function cancelPlacementTest(id: string): Promise {
+ const res = await Request<{ data: PlacementTest }>({
+ method: "POST",
+ url: `/placement/tests/${id}/cancel`,
+ authorization: true,
+ });
+ return res.data;
+}
+
+export async function listPlacementSeeds(): Promise {
+ const res = await Request<{ data: PlacementWorkspaceSeed[] | null }>({
+ method: "GET",
+ url: "/placement/seeds",
+ authorization: true,
+ });
+ return res.data ?? [];
+}
+
+export async function setPlacementSeed(emailAccountId: string, seed: boolean): Promise {
+ const res = await Request<{ data: PlacementWorkspaceSeed }>({
+ method: "PUT",
+ url: `/placement/seeds/${emailAccountId}`,
+ data: { seed },
+ authorization: true,
+ });
+ return res.data;
+}
+
+export async function getPlacementMonitor(campaignId: string): Promise {
+ const res = await Request<{ data: PlacementMonitor | null }>({
+ method: "GET",
+ url: `/campaigns/${campaignId}/placement-monitor`,
+ authorization: true,
+ });
+ return res.data ?? null;
+}
+
+// A full-state write, so a retry lands on the same monitor.
+export async function putPlacementMonitor(campaignId: string, input: PlacementMonitorInput): Promise {
+ const res = await Request<{ data: PlacementMonitor }>({
+ method: "PUT",
+ url: `/campaigns/${campaignId}/placement-monitor`,
+ data: input,
+ authorization: true,
+ });
+ return res.data;
+}
+
+export async function deletePlacementMonitor(campaignId: string): Promise {
+ await Request({
+ method: "DELETE",
+ url: `/campaigns/${campaignId}/placement-monitor`,
+ authorization: true,
+ });
+}
diff --git a/web/src/lib/api/hooks/app/placement/usePlacement.ts b/web/src/lib/api/hooks/app/placement/usePlacement.ts
new file mode 100644
index 000000000..b213a1946
--- /dev/null
+++ b/web/src/lib/api/hooks/app/placement/usePlacement.ts
@@ -0,0 +1,133 @@
+import { useContext } from "react";
+import { SocketContext } from "@/hooks/context/socket";
+import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
+import {
+ cancelPlacementTest,
+ createPlacementTest,
+ deletePlacementMonitor,
+ getPlacementMonitor,
+ getPlacementOverview,
+ getPlacementTest,
+ listPlacementSeeds,
+ listPlacementTests,
+ putPlacementMonitor,
+ setPlacementSeed,
+} from "@/lib/api/client/app/placement/placement";
+import type {
+ CreatePlacementTestRequest,
+ PlacementMonitorInput,
+ PlacementTestList,
+} from "@/lib/api/models/app/placement/Placement";
+
+// Everything lives under ["placement"], which PLACEMENT_TEST_UPDATED and the
+// placement_test / placement_monitor audit spine invalidate, so these views
+// stay live with no polling.
+export const PLACEMENT_KEY = ["placement"] as const;
+
+export function usePlacementOverview(enabled = true) {
+ return useQuery({
+ queryKey: [...PLACEMENT_KEY, "overview"],
+ queryFn: getPlacementOverview,
+ enabled,
+ });
+}
+
+export function usePlacementTests(campaignId: string | null = null, limit = 25) {
+ const query = useInfiniteQuery<
+ PlacementTestList,
+ Error,
+ InfiniteData,
+ (string | number | null)[],
+ string | null
+ >({
+ queryKey: [...PLACEMENT_KEY, "tests", campaignId, limit],
+ queryFn: ({ pageParam }) => listPlacementTests(pageParam, limit, campaignId),
+ initialPageParam: null,
+ getNextPageParam: (last) => (last.pagination.has_more ? last.pagination.next_cursor : undefined),
+ placeholderData: keepPreviousData,
+ });
+ const tests = query.data?.pages.flatMap((p) => p.data ?? []) ?? [];
+ const total = query.data?.pages[0]?.pagination.total ?? null;
+ return { ...query, tests, total };
+}
+
+export function usePlacementTest(id: string) {
+ // Realtime drives a running test; only a dropped socket falls back to a slow poll.
+ const socketUp = useContext(SocketContext)?.isConnected ?? true;
+ return useQuery({
+ queryKey: [...PLACEMENT_KEY, "test", id],
+ queryFn: () => getPlacementTest(id),
+ enabled: !!id,
+ refetchInterval: (query) => (!socketUp && query.state.data?.status === "running" ? 15_000 : false),
+ });
+}
+
+export function usePlacementSeeds(enabled = true) {
+ return useQuery({
+ queryKey: [...PLACEMENT_KEY, "seeds"],
+ queryFn: listPlacementSeeds,
+ enabled,
+ });
+}
+
+export function useCreatePlacementTest() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ body, idempotencyKey }: { body: CreatePlacementTestRequest; idempotencyKey?: string }) =>
+ createPlacementTest(body, idempotencyKey),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: PLACEMENT_KEY });
+ // Every copy is charged to the sender's daily limit.
+ qc.invalidateQueries({ queryKey: ["emails", "list"] });
+ },
+ });
+}
+
+export function useCancelPlacementTest() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => cancelPlacementTest(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: PLACEMENT_KEY }),
+ });
+}
+
+export function useSetPlacementSeed() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ emailAccountId, seed }: { emailAccountId: string; seed: boolean }) =>
+ setPlacementSeed(emailAccountId, seed),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: PLACEMENT_KEY });
+ // Marking a seed turns its warmup off.
+ qc.invalidateQueries({ queryKey: ["emails"] });
+ },
+ });
+}
+
+export function usePlacementMonitor(campaignId: string) {
+ return useQuery({
+ queryKey: [...PLACEMENT_KEY, "monitor", campaignId],
+ queryFn: () => getPlacementMonitor(campaignId),
+ enabled: !!campaignId,
+ });
+}
+
+export function usePutPlacementMonitor(campaignId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (input: PlacementMonitorInput) => putPlacementMonitor(campaignId, input),
+ onSuccess: (m) => {
+ qc.setQueryData([...PLACEMENT_KEY, "monitor", campaignId], m);
+ },
+ });
+}
+
+export function useDeletePlacementMonitor(campaignId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => deletePlacementMonitor(campaignId),
+ onSuccess: () => {
+ qc.setQueryData([...PLACEMENT_KEY, "monitor", campaignId], null);
+ },
+ });
+}
diff --git a/web/src/lib/api/models/app/analytics/Deliverability.ts b/web/src/lib/api/models/app/analytics/Deliverability.ts
index 4d6195e84..fd8ce9f6d 100644
--- a/web/src/lib/api/models/app/analytics/Deliverability.ts
+++ b/web/src/lib/api/models/app/analytics/Deliverability.ts
@@ -36,13 +36,18 @@ export interface CampaignDeliverability {
band: DeliverabilityBand;
}
+// One mail host family's seed placement: `provider` is the family id
+// (gmail, microsoft365, yahoo, ...) and `label` its display name.
export interface ProviderPlacement {
provider: string;
+ label?: string;
samples: number;
inbox: number;
promotions: number;
spam: number;
other: number;
+ // Copies that never arrived within the detection window.
+ missing?: number;
inbox_rate: number;
spam_rate: number;
}
diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts
index 1b3c24418..98a0f8f38 100644
--- a/web/src/lib/api/models/app/notifications/Notification.ts
+++ b/web/src/lib/api/models/app/notifications/Notification.ts
@@ -31,6 +31,8 @@ export interface NotificationPreferences {
team_activity: CategoryPref;
campaign_paused: CategoryPref;
health_domain_auth: CategoryPref;
+ placement_finished: CategoryPref;
+ placement_alert: CategoryPref;
email_digest_minutes: number;
}
@@ -74,6 +76,10 @@ export function normalizeNotificationPreferences(
// Emails by default too: a sending domain the platform will stop
// sending from has to reach whoever can edit the DNS.
health_domain_auth: p?.health_domain_auth ?? billing,
+ placement_finished: p?.placement_finished ?? on,
+ // Emails by default: a campaign landing in spam has to reach whoever
+ // can fix it even when nobody has the dashboard open.
+ placement_alert: p?.placement_alert ?? billing,
email_digest_minutes: Math.min(Math.max(minutes, EMAIL_WINDOW_MIN_MINUTES), EMAIL_WINDOW_MAX_MINUTES),
};
}
diff --git a/web/src/lib/api/models/app/placement/Placement.ts b/web/src/lib/api/models/app/placement/Placement.ts
new file mode 100644
index 000000000..4f6dd025c
--- /dev/null
+++ b/web/src/lib/api/models/app/placement/Placement.ts
@@ -0,0 +1,190 @@
+// Inbox placement tests (mirrors internal/models/placement.go and
+// internal/app/placement/view.go). Timestamps arrive as ISO strings and are
+// revived into Date objects by Request.
+import type Pagination from "../Pagination";
+import type { TemplateScoreIssue } from "../campaigns/TemplateScore";
+
+export type PlacementPanel = "instance" | "workspace" | "cloud";
+export type PlacementTracking = "campaign" | "on" | "off" | "compare";
+export type PlacementTestStatus = "running" | "completed" | "cancelled" | "failed";
+export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote";
+export type PlacementFolder = "pending" | "inbox" | "promotions" | "other" | "spam" | "missing" | "failed" | "cancelled";
+
+export interface PlacementPanelFamily {
+ family: string;
+ label: string;
+ seeds: number;
+}
+
+export interface PlacementPanelInfo {
+ panel: PlacementPanel;
+ available: boolean;
+ /** One sentence saying why, when the panel is unavailable. */
+ reason?: string;
+ seeds: number;
+ families: PlacementPanelFamily[] | null;
+ /** Counts against the monthly allowance. */
+ metered: boolean;
+}
+
+export interface PlacementUsage {
+ used: number;
+ /** null = unmetered. */
+ limit: number | null;
+ period_start: Date;
+ period_end: Date;
+}
+
+export interface PlacementOverview {
+ panels: PlacementPanelInfo[];
+ usage: PlacementUsage;
+ workspace_seeds: number;
+ /** Most seeds one test sends to. */
+ seeds_per_test: number;
+ spacing_seconds: number;
+}
+
+// Rates are fractions 0..1 of `delivered`, null while nothing has a verdict.
+export interface PlacementCounts {
+ total: number;
+ pending: number;
+ inbox: number;
+ promotions: number;
+ other: number;
+ spam: number;
+ missing: number;
+ failed: number;
+ cancelled: number;
+ /** inbox + promotions + other + spam + missing. */
+ delivered: number;
+ inbox_rate: number | null;
+ tabs_rate: number | null;
+ spam_rate: number | null;
+ missing_rate: number | null;
+}
+
+export interface PlacementFamilyCounts {
+ family: string;
+ label: string;
+ counts: PlacementCounts;
+}
+
+export interface PlacementTest {
+ id: string;
+ sender_account_id: string | null;
+ sender_email: string;
+ created_by: string | null;
+ campaign_id: string | null;
+ sequence_id: string | null;
+ contact_id: string | null;
+ monitor_id: string | null;
+ subject: string;
+ /** Only on GET /placement/tests/:id. */
+ body_html?: string;
+ body_plain?: string;
+ open_tracking: boolean;
+ link_tracking: boolean;
+ compare_group_id: string | null;
+ origin: PlacementOrigin;
+ panel: PlacementPanel;
+ status: PlacementTestStatus;
+ error?: string;
+ created_at: Date;
+ finished_at: Date | null;
+ summary: PlacementCounts;
+ families: PlacementFamilyCounts[] | null;
+}
+
+export interface PlacementResult {
+ /** Masked ("a***@gmail.com") on the instance and cloud panels. */
+ seed: string;
+ family: string;
+ family_label: string;
+ folder: PlacementFolder;
+ scheduled_at: Date | null;
+ sent_at: Date | null;
+ detected_at: Date | null;
+ error?: string;
+}
+
+export interface PlacementContentCheck {
+ score: number;
+ issues: TemplateScoreIssue[] | null;
+}
+
+export interface PlacementTestDetail extends PlacementTest {
+ results: PlacementResult[] | null;
+ content: PlacementContentCheck;
+ /** The other half of a tracking comparison. */
+ compare?: PlacementTest;
+}
+
+export interface PlacementTestList {
+ data: PlacementTest[] | null;
+ pagination: Pagination;
+}
+
+export interface CreatePlacementTestRequest {
+ sender_account_id: string;
+ campaign_id?: string;
+ sequence_id?: string;
+ contact_id?: string;
+ subject?: string;
+ body_html?: string;
+ body_plain?: string;
+ tracking?: PlacementTracking;
+ panel?: PlacementPanel;
+}
+
+export interface PlacementWorkspaceSeed {
+ email_account_id: string;
+ email: string;
+ family: string;
+ label: string;
+ status: "active" | "inactive" | "revoked" | string;
+ seed: boolean;
+ /** Why the mailbox cannot be toggled right now. */
+ blocker?: string;
+}
+
+export interface PlacementMonitor {
+ id: string;
+ campaign_id: string;
+ created_by: string | null;
+ enabled: boolean;
+ interval_days: number;
+ panel: PlacementPanel;
+ /** Primary-inbox percentage (0-100) below which the monitor alerts. */
+ alert_below: number;
+ pause_on_alert: boolean;
+ next_run_at: Date;
+ last_run_at: Date | null;
+ last_test_id: string | null;
+ last_alert_at: Date | null;
+ last_error?: string;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface PlacementMonitorInput {
+ enabled?: boolean;
+ interval_days?: number;
+ panel?: PlacementPanel;
+ alert_below?: number;
+ pause_on_alert?: boolean;
+}
+
+export const PLACEMENT_MONITOR_INTERVAL_MIN = 1;
+export const PLACEMENT_MONITOR_INTERVAL_MAX = 30;
+
+export const PANEL_LABEL: Record = {
+ instance: "Shared panel",
+ workspace: "Your seed inboxes",
+ cloud: "Warmbly Cloud panel",
+};
+
+export const PANEL_HINT: Record = {
+ instance: "Seed inboxes run for every workspace on this instance.",
+ workspace: "Test mailboxes your workspace marked as seeds.",
+ cloud: "Warmbly Cloud's seed inboxes, reached through your linked account.",
+};
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 5956b895a..abb2170d9 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -29,6 +29,8 @@ import CampaignSchedule from './app/app/campaigns/[id]/schedule/page';
import CampaignSteps from './app/app/campaigns/[id]/steps/page';
import AnalyticsPage from './app/app/analytics/page';
import DeliverabilityPage from './app/app/deliverability/page';
+import PlacementPage from './app/app/placement/page';
+import PlacementTestPage from './app/app/placement/[id]/page';
import PipelinesPage from './app/app/crm/pipelines/page';
import DealsPage from './app/app/crm/deals/page';
import TasksPage from './app/app/crm/tasks/page';
@@ -318,6 +320,13 @@ const router = createBrowserRouter([
path: "deliverability",
element: ,
},
+ {
+ path: "placement",
+ children: [
+ { index: true, element: },
+ { path: ":id", element: },
+ ],
+ },
{
path: "crm",
children: [