diff --git a/admin/src/app/dashboard/PlacementPage.tsx b/admin/src/app/dashboard/PlacementPage.tsx new file mode 100644 index 000000000..91195b8a5 --- /dev/null +++ b/admin/src/app/dashboard/PlacementPage.tsx @@ -0,0 +1,558 @@ +// Inbox placement seed panel. The instance panel is the set of mailboxes every +// workspace's placement tests send to; this page is where the operator builds +// it and watches the tests that run against it. No polling: the realtime +// spine's placement group invalidates ["admin","placement"] on +// PLACEMENT_TEST_UPDATED. + +import { useMemo, useState } from "react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; +import { Play, Plus, Search, Trash2 } from "lucide-react"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorState } from "@/components/ErrorState"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { DataTable, type Column } from "@/components/data/DataTable"; +import { useAdminPerm } from "@/hooks/useAdminPerm"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { cn } from "@/lib/utils"; +import { + listPlacementSeeds, + listPlacementTests, + searchPlacementSeedCandidates, + setPlacementSeed, + type AdminPlacementSeed, + type PlacementTestView, +} from "@/lib/api/client/admin/placement"; +import { absolute, relative } from "@/app/dashboard/jobs/format"; +import { StatusBadge } from "@/app/dashboard/placement/badges"; +import { describeError, ORIGIN_LABEL, PANEL_LABEL, pct } from "@/app/dashboard/placement/format"; +import { RunTestDialog } from "@/app/dashboard/placement/RunTestDialog"; +import { TestDetailSheet } from "@/app/dashboard/placement/TestDetailSheet"; +import { useDebounced } from "@/app/dashboard/placement/useDebounced"; + +// A provider family with fewer seeds than this gives a verdict one mailbox can swing. +const MIN_SEEDS_PER_FAMILY = 3; + +const SEED_STATUS_TONE: Record = { + active: "border-emerald-300 bg-emerald-50 text-emerald-700", + inactive: "border-amber-300 bg-amber-50 text-amber-700", + revoked: "border-red-300 bg-red-50 text-red-700", +}; + +export default function PlacementPage() { + const [params, setParams] = useSearchParams(); + const openTest = params.get("test"); + const [runOpen, setRunOpen] = useState(false); + const canManage = useAdminPerm(AdminPerm.ManageWarmupBans); + + function setOpenTest(id: string | null) { + setParams( + (p) => { + if (id) p.set("test", id); + else p.delete("test"); + return p; + }, + { replace: true }, + ); + } + + return ( +
+ + {canManage && ( + + )} + + + + {canManage && } + + + !open && setOpenTest(null)} + onOpenTest={setOpenTest} + /> + {canManage && ( + { + setRunOpen(false); + if (tests[0]) setOpenTest(tests[0].id); + }} + /> + )} +
+ ); +} + +function SectionTitle({ title, hint }: { title: string; hint?: string }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
+ ); +} + +function SeedsSection({ canManage }: { canManage: boolean }) { + const qc = useQueryClient(); + const confirm = useConfirm(); + const canViewOrgs = useAdminPerm(AdminPerm.ViewOrganizations); + const canViewWorkers = useAdminPerm(AdminPerm.ViewWorkers); + + const seedsQ = useQuery({ + queryKey: ["admin", "placement", "seeds"], + queryFn: listPlacementSeeds, + }); + const seeds = useMemo(() => seedsQ.data ?? [], [seedsQ.data]); + + const mix = useMemo(() => { + const counts = new Map(); + for (const s of seeds) { + const label = s.family_label || "Other provider"; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + }, [seeds]); + + const remove = useMutation({ + mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, false), + onSuccess: (seed) => { + toast.success(`${seed.email} removed from the panel`); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + }, + onError: (err) => toast.error(describeError(err).message || "Could not remove the seed"), + }); + + async function onRemove(seed: AdminPlacementSeed) { + const ok = await confirm({ + title: "Remove this seed?", + description: `${seed.email} stops receiving copies from new placement tests. The mailbox stays connected, and its warmup stays off until someone turns it back on.`, + confirmLabel: "Remove seed", + destructive: true, + }); + if (!ok) return; + remove.mutate(seed); + } + + const columns: Column[] = [ + { + id: "mailbox", + header: "Mailbox", + cell: (s) => ( +
+
{s.email}
+ {s.name &&
{s.name}
} +
+ ), + csv: (s) => s.email, + }, + { + id: "family", + header: "Provider", + cell: (s) => {s.family_label}, + csv: (s) => s.family_label, + }, + { + id: "status", + header: "Status", + cell: (s) => ( + + {s.status} + + ), + csv: (s) => s.status, + }, + { + id: "worker", + header: "Worker", + cell: (s) => + s.worker_id ? ( + canViewWorkers ? ( + + {s.worker_id.slice(0, 8)} + + ) : ( + {s.worker_id.slice(0, 8)} + ) + ) : ( + + unassigned + + ), + csv: (s) => s.worker_id ?? "", + }, + { + id: "organization", + header: "Organization", + cell: (s) => + s.organization_id ? ( + canViewOrgs ? ( + + {s.organization_id.slice(0, 8)} + + ) : ( + {s.organization_id.slice(0, 8)} + ) + ) : ( + — + ), + csv: (s) => s.organization_id ?? "", + }, + ]; + if (canManage) { + columns.push({ + id: "actions", + header: "", + align: "right", + cell: (s) => ( + + ), + }); + } + + return ( +
+ + +
+
+ Mix by provider +
+ {seedsQ.isLoading ? ( + + ) : mix.length === 0 ? ( +

The panel is empty, so no workspace can test on it yet.

+ ) : ( +
+ {mix.map(([label, n]) => ( + + {label} + {n} + + ))} +
+ )} +

+ A healthy panel is weighted toward Microsoft 365 and Google Workspace, where most B2B recipients + are, with a few Gmail, Outlook.com and Yahoo seeds, and at least {MIN_SEEDS_PER_FAMILY} per + provider. Seeds must be mailboxes nobody reads or engages with: opening, filing or replying to a + copy teaches the provider to trust the sender and skews every later result. +

+
+ + s.id} + loading={seedsQ.isLoading} + error={seedsQ.error} + onRetry={() => seedsQ.refetch()} + errorTitle="Failed to load the seed panel" + storageKey="admin.placement.seeds" + csvName="warmbly-placement-seeds" + noun="seeds" + emptyTitle="No seeds yet" + emptyHint={ + canManage + ? "Add connected mailboxes below to build the panel." + : "An admin with the manage warmup bans permission can add them." + } + /> +
+ ); +} + +function AddSeedsSection() { + const qc = useQueryClient(); + const confirm = useConfirm(); + const [search, setSearch] = useState(""); + const debounced = useDebounced(search.trim(), 250); + + const candidatesQ = useQuery({ + queryKey: ["admin", "placement", "candidates", debounced], + queryFn: () => searchPlacementSeedCandidates(debounced), + enabled: debounced.length >= 2, + staleTime: 30_000, + }); + const rows = candidatesQ.data ?? []; + + const add = useMutation({ + mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, true), + onSuccess: (seed) => { + toast.success(`${seed.email} added to the panel, with its warmup turned off`); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + }, + onError: (err) => toast.error(describeError(err).message || "Could not add the seed"), + }); + + async function onAdd(seed: AdminPlacementSeed) { + if (seed.seed_scope === "workspace") { + const ok = await confirm({ + title: "Move this workspace seed?", + description: `${seed.email} is one of its workspace's own test inboxes. Adding it here moves it to the instance panel, so its workspace can no longer test on it privately.`, + confirmLabel: "Add to the panel", + }); + if (!ok) return; + } + add.mutate(seed); + } + + return ( +
+ +
+ + setSearch(e.target.value)} + placeholder="Search connected mailboxes by address…" + className="h-8 pl-8 text-[12.5px]" + autoComplete="off" + /> +
+ + {debounced.length < 2 ? null : candidatesQ.isLoading ? ( + + ) : candidatesQ.error ? ( + candidatesQ.refetch()} /> + ) : rows.length === 0 ? ( +
+ No connected mailbox matches. +
+ ) : ( +
+
+ + + + + + + + + + {rows.map((m) => { + const onPanel = m.seed_scope === "instance"; + return ( + + + + + + + ); + })} + +
MailboxProviderStatus +
+
+ {m.email} + {m.seed_scope === "workspace" && ( + + workspace seed + + )} +
+
{m.family_label} + + {m.status} + + + +
+
+
+ )} +
+ ); +} + +function TestsSection({ onOpen }: { onOpen: (id: string) => void }) { + const testsQ = useInfiniteQuery({ + queryKey: ["admin", "placement", "tests"], + queryFn: ({ pageParam }) => listPlacementTests(pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => + last.pagination?.has_more ? (last.pagination.next_cursor ?? undefined) : undefined, + }); + const rows = useMemo(() => (testsQ.data?.pages ?? []).flatMap((p) => p.data ?? []), [testsQ.data]); + const total = testsQ.data?.pages[0]?.pagination?.total; + + const columns: Column[] = [ + { + id: "created", + header: "Created", + cell: (t) => ( + + {relative(t.created_at)} + + ), + csv: (t) => t.created_at, + }, + { + id: "sender", + header: "Sender", + cell: (t) => {t.sender_email || "—"}, + csv: (t) => t.sender_email, + }, + { + id: "subject", + header: "Subject", + cell: (t) => {t.subject || "—"}, + csv: (t) => t.subject, + }, + { + id: "panel", + header: "Panel", + cell: (t) => {PANEL_LABEL[t.panel] ?? t.panel}, + csv: (t) => t.panel, + }, + { + id: "origin", + header: "Origin", + cell: (t) => {ORIGIN_LABEL[t.origin] ?? t.origin}, + csv: (t) => t.origin, + }, + { + id: "status", + header: "Status", + cell: (t) => , + csv: (t) => t.status, + }, + { + id: "inbox", + header: "Inbox", + align: "right", + cell: (t) => {pct(t.summary?.inbox_rate)}, + csv: (t) => pct(t.summary?.inbox_rate), + }, + { + id: "spam", + header: "Spam", + align: "right", + cell: (t) => ( + 0 && "text-red-700")}> + {pct(t.summary?.spam_rate)} + + ), + csv: (t) => pct(t.summary?.spam_rate), + }, + { + id: "delivered", + header: "Delivered", + align: "right", + cell: (t) => ( + + {t.summary?.delivered ?? 0} + / {t.summary?.total ?? 0} + + ), + csv: (t) => t.summary?.delivered ?? 0, + }, + ]; + + return ( +
+ + t.id} + loading={testsQ.isLoading} + error={testsQ.error} + onRetry={() => testsQ.refetch()} + onRowClick={(t) => onOpen(t.id)} + errorTitle="Failed to load placement tests" + storageKey="admin.placement.tests" + csvName="warmbly-placement-tests" + noun="tests" + emptyTitle="No placement tests yet" + emptyHint="Tests appear here once a workspace, a campaign monitor or an admin runs one." + /> + {testsQ.hasNextPage && ( +
+ +
+ )} +
+ ); +} diff --git a/admin/src/app/dashboard/configuration/SettingsTab.tsx b/admin/src/app/dashboard/configuration/SettingsTab.tsx index f398f2a1d..7d77e39a8 100644 --- a/admin/src/app/dashboard/configuration/SettingsTab.tsx +++ b/admin/src/app/dashboard/configuration/SettingsTab.tsx @@ -188,6 +188,52 @@ const TRACKING_FIELDS = [ type TrackingFieldKey = (typeof TRACKING_FIELDS)[number]["key"]; +// Inbox placement test allowance and pacing, mirroring internal/config/constants.go. +const PLACEMENT_FIELDS = [ + { + key: "testsTrial", + setting: "tests_per_month_trial", + label: "Tests per month on trial", + min: 1, + max: 100000, + help: "How many tests a workspace without a paid plan may run on the metered panels each calendar month.", + }, + { + key: "testsPaid", + setting: "tests_per_month_paid", + label: "Tests per month on paid plans", + min: 1, + max: 100000, + help: "The same allowance for a workspace with an active subscription.", + }, + { + key: "seedsPerTest", + setting: "seeds_per_test", + label: "Seeds per test", + min: 1, + max: 100, + help: "The most seeds one test sends to, which is also how many sends it takes from the sending mailbox's daily limit.", + }, + { + key: "spacingSeconds", + setting: "spacing_seconds", + label: "Spacing between copies (seconds)", + min: 5, + max: 600, + help: "The gap between two copies from one mailbox, jittered, so a test never leaves as a burst.", + }, +] as const; + +type PlacementFieldKey = (typeof PLACEMENT_FIELDS)[number]["key"]; + +// A backend from before the placement section omits it; the form shows the compiled defaults. +const PLACEMENT_DEFAULTS: InstanceSettings["placement"] = { + tests_per_month_trial: 3, + tests_per_month_paid: 40, + seeds_per_test: 40, + spacing_seconds: 20, +}; + interface FormState { linksEnabled: boolean; ttlHours: string; @@ -195,11 +241,13 @@ interface FormState { sync: Record; retention: Record; tracking: Record; + placement: Record; enforceDomainAuth: boolean; authGraceHours: string; } function toForm(s: InstanceSettings): FormState { + const placement = s.placement ?? PLACEMENT_DEFAULTS; return { linksEnabled: s.invitations.links_enabled, ttlHours: String(s.invitations.ttl_hours), @@ -222,6 +270,12 @@ function toForm(s: InstanceSettings): FormState { machineWindowClick: String(s.tracking.machine_window_click_seconds), machineWindowProbable: String(s.tracking.machine_window_probable_seconds), }, + placement: { + testsTrial: String(placement.tests_per_month_trial), + testsPaid: String(placement.tests_per_month_paid), + seedsPerTest: String(placement.seeds_per_test), + spacingSeconds: String(placement.spacing_seconds), + }, enforceDomainAuth: s.deliverability.enforce_domain_auth, authGraceHours: String(s.deliverability.auth_grace_hours), }; @@ -280,6 +334,13 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { !!server && !!form && TRACKING_FIELDS.some((f) => form.tracking[f.key] !== String(server.tracking[f.setting])); + const placementDirty = + !!server && + !!form && + PLACEMENT_FIELDS.some( + (f) => + form.placement[f.key] !== String((server.placement ?? PLACEMENT_DEFAULTS)[f.setting]), + ); const dirty = !!server && !!form && @@ -290,6 +351,7 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { form.authGraceHours !== String(server.deliverability.auth_grace_hours) || retentionDirty || trackingDirty || + placementDirty || syncDirty); useEffect(() => { @@ -306,6 +368,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { form !== null && TRACKING_FIELDS.every((f) => syncFieldValid(form.tracking[f.key], f.min, f.max)); + const placementValid = + form !== null && + PLACEMENT_FIELDS.every((f) => syncFieldValid(form.placement[f.key], f.min, f.max)); + const authGrace = form ? Number(form.authGraceHours) : NaN; const authGraceValid = form !== null && @@ -346,6 +412,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { ); return; } + if (!placementValid) { + toast.error("Every placement test setting must be a whole number inside the range shown under it"); + return; + } if (!authGraceValid) { toast.error( `The authentication grace period must be a whole number of hours between ${AUTH_GRACE_MIN_HOURS} and ${AUTH_GRACE_MAX_HOURS}`, @@ -377,6 +447,12 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { enforce_domain_auth: form.enforceDomainAuth, auth_grace_hours: authGrace, }, + placement: { + tests_per_month_trial: Number(form.placement.testsTrial), + tests_per_month_paid: Number(form.placement.testsPaid), + seeds_per_test: Number(form.placement.seedsPerTest), + spacing_seconds: Number(form.placement.spacingSeconds), + }, }); } @@ -701,6 +777,59 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) { + + + Inbox placement tests + + A placement test sends one copy of a template to each seed on a + panel and reports where it landed. The monthly allowances count + tests on the instance panel and on Warmbly Cloud's; tests on a + workspace's own seed inboxes are never counted, and a + self-hosted instance does not meter tests at all. A tracking + comparison counts as two tests. The seeds themselves are managed on + the Seed panel page. + + + + {PLACEMENT_FIELDS.map((f) => { + const valid = syncFieldValid(form.placement[f.key], f.min, f.max); + return ( +
+ + + setForm({ + ...form, + placement: { + ...form.placement, + [f.key]: e.target.value, + }, + }) + } + aria-invalid={!valid} + className="mt-1" + /> +

+ {f.help} Between {f.min.toLocaleString()} and{" "} + {f.max.toLocaleString()}. +

+ {!valid && ( +

+ Enter a whole number between {f.min.toLocaleString()}{" "} + and {f.max.toLocaleString()}. +

+ )} +
+ ); + })} +
+
+ Sending-domain authentication diff --git a/admin/src/app/dashboard/placement/RunTestDialog.tsx b/admin/src/app/dashboard/placement/RunTestDialog.tsx new file mode 100644 index 000000000..d26fe29a2 --- /dev/null +++ b/admin/src/app/dashboard/placement/RunTestDialog.tsx @@ -0,0 +1,269 @@ +// Runs a placement test on the instance panel from any connected mailbox, +// outside every workspace allowance. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { X } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createPlacementTest, + searchPlacementSeedCandidates, + type AdminPlacementSeed, + type PlacementTestView, + type PlacementTracking, +} from "@/lib/api/client/admin/placement"; +import { describeError } from "./format"; +import { useDebounced } from "./useDebounced"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const TRACKING_OPTIONS: { value: PlacementTracking; label: string }[] = [ + { value: "campaign", label: "Default: untracked for an ad-hoc test" }, + { value: "off", label: "Off: no open pixel, links left as written" }, + { value: "on", label: "On: open pixel and click tracking" }, + { value: "compare", label: "Compare: one untracked and one tracked test" }, +]; + +export function RunTestDialog({ + open, + onOpenChange, + onStarted, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onStarted: (tests: PlacementTestView[]) => void; +}) { + const qc = useQueryClient(); + const [sender, setSender] = useState<{ id: string; email: string } | null>(null); + const [senderQuery, setSenderQuery] = useState(""); + const [subject, setSubject] = useState(""); + const [bodyPlain, setBodyPlain] = useState(""); + const [bodyHtml, setBodyHtml] = useState(""); + const [tracking, setTracking] = useState("campaign"); + const [failure, setFailure] = useState<{ message: string; code?: string } | null>(null); + + function reset() { + setSender(null); + setSenderQuery(""); + setSubject(""); + setBodyPlain(""); + setBodyHtml(""); + setTracking("campaign"); + setFailure(null); + } + + const debounced = useDebounced(senderQuery.trim(), 250); + const isId = UUID_RE.test(debounced); + const candidatesQ = useQuery({ + queryKey: ["admin", "placement", "candidates", "sender", debounced], + queryFn: () => searchPlacementSeedCandidates(debounced, 20), + enabled: open && !sender && debounced.length >= 2 && !isId, + staleTime: 30_000, + }); + // A seed cannot send a test, and an inactive mailbox cannot send at all. + const senders = (candidatesQ.data ?? []).filter((m) => m.seed_scope === "" && m.status === "active"); + + const senderId = sender?.id ?? (UUID_RE.test(senderQuery.trim()) ? senderQuery.trim() : ""); + + const create = useMutation({ + mutationFn: () => + createPlacementTest({ + sender_account_id: senderId, + subject: subject.trim(), + body_plain: bodyPlain.trim() || undefined, + body_html: bodyHtml.trim() || undefined, + tracking, + }), + onSuccess: (tests) => { + toast.success(tests.length > 1 ? "Tracking comparison started" : "Placement test started"); + qc.invalidateQueries({ queryKey: ["admin", "placement"] }); + reset(); + onStarted(tests); + }, + onError: (err) => setFailure(describeError(err)), + }); + + const problem = !senderId + ? "Pick the mailbox to send from, or paste its id." + : !subject.trim() + ? "Enter a subject." + : !bodyPlain.trim() && !bodyHtml.trim() + ? "Enter a plain or an HTML body." + : null; + + function pick(m: AdminPlacementSeed) { + setSender({ id: m.id, email: m.email }); + setSenderQuery(""); + setFailure(null); + } + + return ( + { + if (!v && !create.isPending) setFailure(null); + onOpenChange(v); + }} + > + + + Run a placement test + + Sends one copy to each seed on the instance panel from the mailbox you pick, spaced out, and + reports where each landed. Admin tests are never counted against the workspace's monthly + allowance, but every copy uses the mailbox's daily sending limit. + + + +
+
+ + {sender ? ( +
+ {sender.email} + +
+ ) : ( + <> + { + setSenderQuery(e.target.value); + setFailure(null); + }} + placeholder="Search by address, or paste a mailbox id" + className="mt-1 h-8 text-[12.5px]" + autoComplete="off" + /> + {debounced.length >= 2 && !isId && ( +
+ {candidatesQ.isLoading ? ( +
Searching…
+ ) : senders.length === 0 ? ( +
+ No active mailbox matches. Seeds cannot send a test. +
+ ) : ( + senders.map((m) => ( + + )) + )} +
+ )} + + )} +
+ +
+ + setSubject(e.target.value)} + className="mt-1 h-8 text-[12.5px]" + /> +
+ +
+ +