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: [