From d4467ce10e04bb06cee47e9a124d040a4065000f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 25 Sep 2026 20:56:11 -0700 Subject: [PATCH] feat: add the admin Seed panel page to manage the instance placement seed inboxes, run operator tests and read every workspace's results, plus the placement allowance and pacing settings on the Configuration page --- admin/src/app/dashboard/PlacementPage.tsx | 558 ++++++++++++++++++ .../dashboard/configuration/SettingsTab.tsx | 129 ++++ .../app/dashboard/placement/RunTestDialog.tsx | 269 +++++++++ .../dashboard/placement/TestDetailSheet.tsx | 279 +++++++++ admin/src/app/dashboard/placement/badges.tsx | 54 ++ admin/src/app/dashboard/placement/format.ts | 31 + .../app/dashboard/placement/useDebounced.ts | 11 + admin/src/components/layout/Sidebar.tsx | 2 + admin/src/lib/api/client/admin/instance.ts | 9 + admin/src/lib/api/client/admin/placement.ts | 177 ++++++ admin/src/lib/realtime/RealtimeManager.tsx | 5 + admin/src/main.tsx | 2 + 12 files changed, 1526 insertions(+) create mode 100644 admin/src/app/dashboard/PlacementPage.tsx create mode 100644 admin/src/app/dashboard/placement/RunTestDialog.tsx create mode 100644 admin/src/app/dashboard/placement/TestDetailSheet.tsx create mode 100644 admin/src/app/dashboard/placement/badges.tsx create mode 100644 admin/src/app/dashboard/placement/format.ts create mode 100644 admin/src/app/dashboard/placement/useDebounced.ts create mode 100644 admin/src/lib/api/client/admin/placement.ts 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]" + /> +
+ +
+ +