From 989daede3c0bf9efe822ecdf2dce9c4c98e384c4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 21:03:26 -0700 Subject: [PATCH] feat: add the Placement tests dashboard with the test list, detail and comparison views, New test dialog, Seed inboxes tab, campaign step Test inbox placement action and Placement monitor card, live updates over PLACEMENT_TEST_UPDATED with a slow poll only while the socket is down, the two placement notification categories, and seed-aware warmup errors on Mailboxes --- docs/content/docs/guides/placement-tests.mdx | 6 +- .../app/campaigns/[id]/preferences/page.tsx | 8 + web/src/app/app/deliverability/page.tsx | 25 +- web/src/app/app/emails/page.tsx | 23 +- web/src/app/app/placement/[id]/page.tsx | 406 ++++++++++ web/src/app/app/placement/page.tsx | 275 +++++++ .../app/app/settings/notifications/page.tsx | 4 + .../components/app/campaigns/ContentScore.tsx | 3 +- .../preferences/CampaignPlacementMonitor.tsx | 256 ++++++ .../sequences/EmailContentEditor.tsx | 5 +- .../campaigns/sequences/PreviewControls.tsx | 37 +- .../tests/NewPlacementTestDialog.tsx | 766 ++++++++++++++++++ .../app/placement/tests/PlacementParts.tsx | 161 ++++ .../app/placement/tests/SeedInboxes.tsx | 154 ++++ .../app/placement/tests/placementTests.ts | 132 +++ web/src/components/layout/AppNav.tsx | 2 + .../components/layout/NotificationBell.tsx | 4 + web/src/hooks/useDocumentTitle.ts | 2 + web/src/hooks/useRealtimeEvents.ts | 14 +- .../lib/api/client/app/placement/placement.ts | 118 +++ .../api/hooks/app/placement/usePlacement.ts | 133 +++ .../models/app/analytics/Deliverability.ts | 5 + .../models/app/notifications/Notification.ts | 6 + .../lib/api/models/app/placement/Placement.ts | 190 +++++ web/src/main.tsx | 9 + 25 files changed, 2728 insertions(+), 16 deletions(-) create mode 100644 web/src/app/app/placement/[id]/page.tsx create mode 100644 web/src/app/app/placement/page.tsx create mode 100644 web/src/components/app/campaigns/preferences/CampaignPlacementMonitor.tsx create mode 100644 web/src/components/app/placement/tests/NewPlacementTestDialog.tsx create mode 100644 web/src/components/app/placement/tests/PlacementParts.tsx create mode 100644 web/src/components/app/placement/tests/SeedInboxes.tsx create mode 100644 web/src/components/app/placement/tests/placementTests.ts create mode 100644 web/src/lib/api/client/app/placement/placement.ts create mode 100644 web/src/lib/api/hooks/app/placement/usePlacement.ts create mode 100644 web/src/lib/api/models/app/placement/Placement.ts diff --git a/docs/content/docs/guides/placement-tests.mdx b/docs/content/docs/guides/placement-tests.mdx index ce0e296e7..f73dbf9e2 100644 --- a/docs/content/docs/guides/placement-tests.mdx +++ b/docs/content/docs/guides/placement-tests.mdx @@ -55,9 +55,9 @@ A test runs against one panel of seed inboxes: | Panel | Whose seeds | Available | |-------|-------------|-----------| -| **Instance** | The panel the instance operator runs for every workspace. On the hosted product this is Warmbly's own | Whenever the operator has put seed inboxes on it. On a self-hosted instance, an administrator adds them from the admin panel | -| **Your seed inboxes** | Mailboxes of your own workspace that you marked as seeds | Once you mark at least one | -| **Warmbly Cloud** | Warmbly Cloud's panel | Only on a self-hosted instance [linked to Warmbly Cloud](/guides/warmbly-cloud/#placement-tests-on-warmbly-clouds-seed-panel) | +| **Shared panel** | The panel the instance operator runs for every workspace (`instance` in the API). On the hosted product this is Warmbly's own | Whenever the operator has put seed inboxes on it. On a self-hosted instance, an administrator adds them from the admin panel | +| **Your seed inboxes** | Mailboxes of your own workspace that you marked as seeds (`workspace` in the API) | Once you mark at least one | +| **Warmbly Cloud panel** | Warmbly Cloud's panel (`cloud` in the API) | Only on a self-hosted instance [linked to Warmbly Cloud](/guides/warmbly-cloud/#placement-tests-on-warmbly-clouds-seed-panel) | The page shows each panel's seed count and its mix of providers, and says why a panel is unavailable when it is. diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index 3b7b84bb2..f047d5a98 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -18,6 +18,7 @@ import { import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder"; import { FirstEmailSection } from "@/components/app/campaigns/preferences/CampaignFirstEmail"; import { GuardrailsSection } from "@/components/app/campaigns/preferences/CampaignGuardrails"; +import { PlacementMonitorSection } from "@/components/app/campaigns/preferences/CampaignPlacementMonitor"; import { guardrailValidationError } from "@/lib/helper/guardrail"; import CampaignFolderField from "@/components/app/campaigns/CampaignFolderField"; import CampaignDangerZone from "@/components/app/campaigns/preferences/CampaignDangerZone"; @@ -61,6 +62,11 @@ const SECTIONS = [ label: "Auto-pause", description: "Stop this campaign automatically when its bounce, complaint, or reply rate leaves the band you set.", }, + { + id: "placement", + label: "Placement monitor", + description: "Test where this campaign's first email lands on a schedule, and get told when it slips.", + }, { id: "first-email", label: "First email", @@ -382,6 +388,8 @@ export default function CampaignPreferences() { explicitAccounts={explicitAccounts} /> ); + case "placement": + return ; case "first-email": return ; case "leadflow": diff --git a/web/src/app/app/deliverability/page.tsx b/web/src/app/app/deliverability/page.tsx index 19aea364b..da16e1b8b 100644 --- a/web/src/app/app/deliverability/page.tsx +++ b/web/src/app/app/deliverability/page.tsx @@ -298,13 +298,26 @@ export default function DeliverabilityPage() { {show("providers") && ( <> - + + + Run a placement test + + + {q.isPending ? ( ) : (d?.by_provider?.length ?? 0) === 0 ? ( + Run a placement test + + } /> ) : (
@@ -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.

+ ) : ( +
+ + + + + + + + + + + + + {(test.families ?? []).map((f) => ( + + + + + + + + + + ))} + +
Provider + InboxTabsSpamMissingCopies
{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 ( + + ); + })} + + + {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 + + } + /> + ) : ( +
+ + + + + + + + + + + + + + {list.tests.map((t) => ( + navigate(`/app/placement/${t.id}`)} /> + ))} + +
Sender and subjectPanelTrackingStatusWhere it landedInboxStarted
+ {list.hasNextPage && ( +
+ +
+ )} +
+ )} + + {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 && } + + + } + /> + +
+
+
+ + { + if (v !== m?.interval_days) change({ interval_days: v }); + }} + suffix="days" + disabled={!canEdit} + className="w-36" + /> +
+
+ + { + if (v !== m?.alert_below) change({ alert_below: v }); + }} + suffix="%" + disabled={!canEdit} + className="w-36" + /> +
+
+ + { + 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 && ( + + )} +
+
+ ); +} 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 ( + <> + + 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 */} +
+ + 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" ? ( +
+
+ + patch({ campaignId: id, stepId: "", contact: null })} + /> +
+
+ + 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. +

+
+ ) : ( +
+
+ + patch({ subject: v })} + placeholder="Quick question, {{.FirstName}}" + /> +
+
+ + + 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")} + +
+ + 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 ( + + ); + })} +
+ )} + {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} +
+ + +
+ + + ); +} + +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: [