mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 08:02:34 +00:00
Merge pull request #693 from warmbly/feature/deliverability-check
feat: open inbox placement tests to workspaces with campaign-accurate probes, own and Warmbly Cloud seed panels, tracking comparison and scheduled campaign monitors, and default OpenAI models to gpt-6-luna
This commit is contained in:
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Seed panel"
|
||||
description="The mailboxes every workspace's inbox placement tests send to on this instance, and every test run against a panel."
|
||||
>
|
||||
{canManage && (
|
||||
<Button size="sm" onClick={() => setRunOpen(true)}>
|
||||
<Play className="size-4" /> Run a test
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SeedsSection canManage={canManage} />
|
||||
{canManage && <AddSeedsSection />}
|
||||
<TestsSection onOpen={setOpenTest} />
|
||||
|
||||
<TestDetailSheet
|
||||
testId={openTest}
|
||||
onOpenChange={(open) => !open && setOpenTest(null)}
|
||||
onOpenTest={setOpenTest}
|
||||
/>
|
||||
{canManage && (
|
||||
<RunTestDialog
|
||||
open={runOpen}
|
||||
onOpenChange={setRunOpen}
|
||||
onStarted={(tests) => {
|
||||
setRunOpen(false);
|
||||
if (tests[0]) setOpenTest(tests[0].id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title, hint }: { title: string; hint?: string }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{hint && <p className="mt-0.5 max-w-3xl text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, number>();
|
||||
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<AdminPlacementSeed>[] = [
|
||||
{
|
||||
id: "mailbox",
|
||||
header: "Mailbox",
|
||||
cell: (s) => (
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs">{s.email}</div>
|
||||
{s.name && <div className="text-[11px] text-muted-foreground">{s.name}</div>}
|
||||
</div>
|
||||
),
|
||||
csv: (s) => s.email,
|
||||
},
|
||||
{
|
||||
id: "family",
|
||||
header: "Provider",
|
||||
cell: (s) => <span className="text-xs">{s.family_label}</span>,
|
||||
csv: (s) => s.family_label,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: (s) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px]", SEED_STATUS_TONE[s.status] ?? "border-zinc-300 text-zinc-600")}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
),
|
||||
csv: (s) => s.status,
|
||||
},
|
||||
{
|
||||
id: "worker",
|
||||
header: "Worker",
|
||||
cell: (s) =>
|
||||
s.worker_id ? (
|
||||
canViewWorkers ? (
|
||||
<Link
|
||||
to={`/workers/${s.worker_id}`}
|
||||
className="font-mono text-[11px] text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{s.worker_id.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-[11px]">{s.worker_id.slice(0, 8)}</span>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-amber-50 text-[10px] text-amber-700"
|
||||
title="Nothing syncs this mailbox, so copies sent to it read as missing"
|
||||
>
|
||||
unassigned
|
||||
</Badge>
|
||||
),
|
||||
csv: (s) => s.worker_id ?? "",
|
||||
},
|
||||
{
|
||||
id: "organization",
|
||||
header: "Organization",
|
||||
cell: (s) =>
|
||||
s.organization_id ? (
|
||||
canViewOrgs ? (
|
||||
<Link
|
||||
to={`/organizations/${s.organization_id}`}
|
||||
className="font-mono text-[11px] text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{s.organization_id.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-[11px]">{s.organization_id.slice(0, 8)}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (s) => s.organization_id ?? "",
|
||||
},
|
||||
];
|
||||
if (canManage) {
|
||||
columns.push({
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (s) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={remove.isPending && remove.variables?.id === s.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onRemove(s);
|
||||
}}
|
||||
>
|
||||
<Trash2 /> Remove
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle
|
||||
title="Seeds"
|
||||
hint="Copies are spread round-robin across provider families, so every family on the panel is covered by each test. Seeds on the sender's own domain are always skipped."
|
||||
/>
|
||||
|
||||
<div className="mb-3 rounded-lg border border-border bg-card p-3">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Mix by provider
|
||||
</div>
|
||||
{seedsQ.isLoading ? (
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
) : mix.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">The panel is empty, so no workspace can test on it yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mix.map(([label, n]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs",
|
||||
n < MIN_SEEDS_PER_FAMILY
|
||||
? "border-amber-300 bg-amber-50 text-amber-800"
|
||||
: "border-border bg-muted/30",
|
||||
)}
|
||||
title={n < MIN_SEEDS_PER_FAMILY ? `Fewer than ${MIN_SEEDS_PER_FAMILY} seeds` : undefined}
|
||||
>
|
||||
{label}
|
||||
<span className="font-semibold tabular-nums">{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 max-w-3xl text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={seeds}
|
||||
getRowId={(s) => 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."
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="mt-6">
|
||||
<SectionTitle
|
||||
title="Add seeds"
|
||||
hint="Any connected mailbox can join the panel. Adding one turns its warmup off and keeps it out of campaign sending, so pick mailboxes that exist only to receive tests."
|
||||
/>
|
||||
<div className="relative mb-2 max-w-md">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search connected mailboxes by address…"
|
||||
className="h-8 pl-8 text-[12.5px]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{debounced.length < 2 ? null : candidatesQ.isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : candidatesQ.error ? (
|
||||
<ErrorState error={candidatesQ.error} title="Search failed" onRetry={() => candidatesQ.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No connected mailbox matches.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Mailbox</th>
|
||||
<th className="px-3 py-2 text-left">Provider</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((m) => {
|
||||
const onPanel = m.seed_scope === "instance";
|
||||
return (
|
||||
<tr key={m.id} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs">{m.email}</span>
|
||||
{m.seed_scope === "workspace" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-300 bg-sky-50 text-[10px] text-sky-700"
|
||||
>
|
||||
workspace seed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{m.family_label}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
SEED_STATUS_TONE[m.status] ?? "border-zinc-300 text-zinc-600",
|
||||
)}
|
||||
>
|
||||
{m.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={onPanel || (add.isPending && add.variables?.id === m.id)}
|
||||
onClick={() => void onAdd(m)}
|
||||
>
|
||||
<Plus /> {onPanel ? "On the panel" : "Add"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlacementTestView>[] = [
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground" title={absolute(t.created_at)}>
|
||||
{relative(t.created_at)}
|
||||
</span>
|
||||
),
|
||||
csv: (t) => t.created_at,
|
||||
},
|
||||
{
|
||||
id: "sender",
|
||||
header: "Sender",
|
||||
cell: (t) => <span className="font-mono text-xs">{t.sender_email || "—"}</span>,
|
||||
csv: (t) => t.sender_email,
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
cell: (t) => <span className="block max-w-72 truncate text-xs">{t.subject || "—"}</span>,
|
||||
csv: (t) => t.subject,
|
||||
},
|
||||
{
|
||||
id: "panel",
|
||||
header: "Panel",
|
||||
cell: (t) => <span className="text-xs">{PANEL_LABEL[t.panel] ?? t.panel}</span>,
|
||||
csv: (t) => t.panel,
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
header: "Origin",
|
||||
cell: (t) => <span className="text-xs">{ORIGIN_LABEL[t.origin] ?? t.origin}</span>,
|
||||
csv: (t) => t.origin,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: (t) => <StatusBadge status={t.status} />,
|
||||
csv: (t) => t.status,
|
||||
},
|
||||
{
|
||||
id: "inbox",
|
||||
header: "Inbox",
|
||||
align: "right",
|
||||
cell: (t) => <span className="text-xs font-medium tabular-nums">{pct(t.summary?.inbox_rate)}</span>,
|
||||
csv: (t) => pct(t.summary?.inbox_rate),
|
||||
},
|
||||
{
|
||||
id: "spam",
|
||||
header: "Spam",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className={cn("text-xs tabular-nums", (t.summary?.spam ?? 0) > 0 && "text-red-700")}>
|
||||
{pct(t.summary?.spam_rate)}
|
||||
</span>
|
||||
),
|
||||
csv: (t) => pct(t.summary?.spam_rate),
|
||||
},
|
||||
{
|
||||
id: "delivered",
|
||||
header: "Delivered",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className="text-xs tabular-nums">
|
||||
{t.summary?.delivered ?? 0}
|
||||
<span className="text-muted-foreground"> / {t.summary?.total ?? 0}</span>
|
||||
</span>
|
||||
),
|
||||
csv: (t) => t.summary?.delivered ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="mt-6">
|
||||
<SectionTitle
|
||||
title="Recent tests"
|
||||
hint={`Every placement test on this instance, from every workspace, newest first.${
|
||||
total !== undefined ? ` ${total.toLocaleString()} in total.` : ""
|
||||
}`}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(t) => 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 && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testsQ.fetchNextPage()}
|
||||
disabled={testsQ.isFetchingNextPage}
|
||||
>
|
||||
{testsQ.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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. A test is sized down to what the mailbox has left today, and refused below five seeds.",
|
||||
},
|
||||
{
|
||||
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: 20,
|
||||
spacing_seconds: 60,
|
||||
};
|
||||
|
||||
interface FormState {
|
||||
linksEnabled: boolean;
|
||||
ttlHours: string;
|
||||
@@ -195,11 +241,13 @@ interface FormState {
|
||||
sync: Record<SyncFieldKey, string>;
|
||||
retention: Record<RetentionFieldKey, string>;
|
||||
tracking: Record<TrackingFieldKey, string>;
|
||||
placement: Record<PlacementFieldKey, string>;
|
||||
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) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Inbox placement tests</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 pt-0 md:grid-cols-2">
|
||||
{PLACEMENT_FIELDS.map((f) => {
|
||||
const valid = syncFieldValid(form.placement[f.key], f.min, f.max);
|
||||
return (
|
||||
<div key={f.key}>
|
||||
<Label htmlFor={`placement-${f.key}`}>{f.label}</Label>
|
||||
<Input
|
||||
id={`placement-${f.key}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={form.placement[f.key]}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
placement: {
|
||||
...form.placement,
|
||||
[f.key]: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
aria-invalid={!valid}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{f.help} Between {f.min.toLocaleString()} and{" "}
|
||||
{f.max.toLocaleString()}.
|
||||
</p>
|
||||
{!valid && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
Enter a whole number between {f.min.toLocaleString()}{" "}
|
||||
and {f.max.toLocaleString()}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Sending-domain authentication</CardTitle>
|
||||
|
||||
@@ -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<PlacementTracking>("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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !create.isPending) setFailure(null);
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run a placement test</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Sending mailbox</Label>
|
||||
{sender ? (
|
||||
<div className="mt-1 flex h-8 items-center justify-between gap-2 rounded-md border border-border px-2.5 text-[12.5px]">
|
||||
<span className="truncate font-mono">{sender.email}</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
title="Change mailbox"
|
||||
onClick={() => setSender(null)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={senderQuery}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<div className="mt-1 max-h-44 overflow-y-auto rounded-md border border-border">
|
||||
{candidatesQ.isLoading ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">Searching…</div>
|
||||
) : senders.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No active mailbox matches. Seeds cannot send a test.
|
||||
</div>
|
||||
) : (
|
||||
senders.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => pick(m)}
|
||||
className="flex w-full items-center justify-between gap-2 border-b border-border px-2.5 py-1.5 text-left text-[12.5px] last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<span className="truncate font-mono">{m.email}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{m.family_label}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placement-subject" className="text-xs">
|
||||
Subject
|
||||
</Label>
|
||||
<Input
|
||||
id="placement-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="mt-1 h-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placement-plain" className="text-xs">
|
||||
Plain body
|
||||
</Label>
|
||||
<Textarea
|
||||
id="placement-plain"
|
||||
value={bodyPlain}
|
||||
onChange={(e) => setBodyPlain(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="placement-html" className="text-xs">
|
||||
HTML body
|
||||
</Label>
|
||||
<Textarea
|
||||
id="placement-html"
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Optional. Leave empty to send plain text only."
|
||||
className="mt-1 font-mono text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Tracking</Label>
|
||||
<Select value={tracking} onValueChange={(v) => setTracking(v as PlacementTracking)}>
|
||||
<SelectTrigger className="mt-1 h-8 w-full text-[12.5px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRACKING_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-[12.5px]">
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tracking === "compare" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Two tests to the same seeds, so twice the sends from the mailbox.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failure && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-700">
|
||||
{failure.message}
|
||||
{failure.code && <span className="ml-1.5 font-mono text-red-600/80">({failure.code})</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<span className="text-xs text-muted-foreground">{problem ?? ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={create.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={!!problem || create.isPending}>
|
||||
{create.isPending ? "Starting…" : "Start test"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// One placement test in full: where each copy landed, per host family and per
|
||||
// seed, and the rules pass over the copy. Kept live by the realtime spine's
|
||||
// placement group.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPlacementTest, type PlacementCounts } from "@/lib/api/client/admin/placement";
|
||||
import { absolute, relative } from "@/app/dashboard/jobs/format";
|
||||
import { FolderBadge, StatusBadge } from "./badges";
|
||||
import { ORIGIN_LABEL, PANEL_LABEL, pct } from "./format";
|
||||
|
||||
export function TestDetailSheet({
|
||||
testId,
|
||||
onOpenChange,
|
||||
onOpenTest,
|
||||
}: {
|
||||
testId: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenTest: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={testId !== null} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-2xl">
|
||||
{testId && <Detail id={testId} onOpenTest={onOpenTest} />}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ id, onOpenTest }: { id: string; onOpenTest: (id: string) => void }) {
|
||||
const q = useQuery({
|
||||
queryKey: ["admin", "placement", "test", id],
|
||||
queryFn: () => getPlacementTest(id),
|
||||
});
|
||||
const t = q.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="border-b border-border pr-10">
|
||||
<SheetTitle className="truncate">{t?.subject || "Placement test"}</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
{t ? (
|
||||
<>
|
||||
From <span className="font-mono">{t.sender_email || "unknown sender"}</span>, started{" "}
|
||||
<span title={absolute(t.created_at)}>{relative(t.created_at)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-mono">{id}</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-4">
|
||||
{q.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{q.error && <ErrorState error={q.error} title="Failed to load the test" onRetry={() => q.refetch()} />}
|
||||
|
||||
{t && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<StatusBadge status={t.status} />
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{PANEL_LABEL[t.panel] ?? t.panel} panel
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{ORIGIN_LABEL[t.origin] ?? t.origin}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t.open_tracking || t.link_tracking
|
||||
? `tracked (${[t.open_tracking && "opens", t.link_tracking && "clicks"].filter(Boolean).join(", ")})`
|
||||
: "untracked"}
|
||||
</Badge>
|
||||
{t.finished_at && (
|
||||
<span className="text-muted-foreground" title={absolute(t.finished_at)}>
|
||||
finished {relative(t.finished_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700">{t.error}</div>
|
||||
)}
|
||||
|
||||
{t.compare && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Half of a tracking comparison. The other half was sent{" "}
|
||||
{t.compare.open_tracking || t.compare.link_tracking ? "tracked" : "untracked"} to the same
|
||||
seeds and reached the primary inbox at{" "}
|
||||
<span className="font-medium text-foreground">{pct(t.compare.summary.inbox_rate)}</span>.
|
||||
</span>
|
||||
<Button size="xs" variant="outline" onClick={() => onOpenTest(t.compare!.id)}>
|
||||
<ArrowLeftRight /> Open
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryGrid counts={t.summary} />
|
||||
|
||||
<Section title="By provider">
|
||||
<FamiliesTable families={t.families ?? []} />
|
||||
</Section>
|
||||
|
||||
<Section title={`Content score ${t.content.score}/100`}>
|
||||
{(t.content.issues ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">The rules pass found nothing to flag in this copy.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{(t.content.issues ?? []).map((issue, i) => (
|
||||
<li key={`${issue.code}-${i}`} className="rounded-md border border-border p-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
issue.severity === "high"
|
||||
? "border-red-300 bg-red-50 text-red-700"
|
||||
: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
)}
|
||||
>
|
||||
{issue.severity}
|
||||
</Badge>
|
||||
<span>{issue.message}</span>
|
||||
</div>
|
||||
{issue.suggestion && (
|
||||
<p className="mt-1 text-muted-foreground">{issue.suggestion}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Copies">
|
||||
<ResultsTable results={t.results ?? []} />
|
||||
</Section>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t.campaign_id ? "A campaign step" : "An ad-hoc template"}. Test id{" "}
|
||||
<span className="font-mono select-text">{t.id}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryGrid({ counts }: { counts: PlacementCounts }) {
|
||||
const tabs = counts.promotions + counts.other;
|
||||
const cells: { label: string; value: number; sub?: string; tone?: string }[] = [
|
||||
{ label: "Delivered", value: counts.delivered, sub: `of ${counts.total}` },
|
||||
{ label: "Primary inbox", value: counts.inbox, sub: pct(counts.inbox_rate), tone: "text-emerald-700" },
|
||||
{ label: "Gmail tabs", value: tabs, sub: pct(counts.tabs_rate), tone: "text-sky-700" },
|
||||
{ label: "Spam", value: counts.spam, sub: pct(counts.spam_rate), tone: counts.spam > 0 ? "text-red-700" : undefined },
|
||||
{
|
||||
label: "Missing",
|
||||
value: counts.missing,
|
||||
sub: pct(counts.missing_rate),
|
||||
tone: counts.missing > 0 ? "text-amber-700" : undefined,
|
||||
},
|
||||
{
|
||||
label: "Pending",
|
||||
value: counts.pending,
|
||||
sub: counts.failed || counts.cancelled ? `${counts.failed} failed, ${counts.cancelled} cancelled` : undefined,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{cells.map((c) => (
|
||||
<div key={c.label} className="rounded-md border border-border bg-card p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{c.label}</div>
|
||||
<div className={cn("mt-0.5 text-lg font-semibold tabular-nums", c.tone)}>{c.value.toLocaleString()}</div>
|
||||
{c.sub && <div className="text-[11px] text-muted-foreground tabular-nums">{c.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FamiliesTable({ families }: { families: { family: string; label: string; counts: PlacementCounts }[] }) {
|
||||
if (families.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No copies yet.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Provider</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Delivered</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Inbox</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Tabs</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Spam</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Missing</th>
|
||||
<th className="px-2.5 py-1.5 text-right font-medium">Inbox rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{families.map((f) => (
|
||||
<tr key={f.family} className="border-t border-border tabular-nums">
|
||||
<td className="px-2.5 py-1.5">{f.label}</td>
|
||||
<td className="px-2.5 py-1.5 text-right">{f.counts.delivered}</td>
|
||||
<td className="px-2.5 py-1.5 text-right">{f.counts.inbox}</td>
|
||||
<td className="px-2.5 py-1.5 text-right">{f.counts.promotions + f.counts.other}</td>
|
||||
<td className={cn("px-2.5 py-1.5 text-right", f.counts.spam > 0 && "text-red-700")}>{f.counts.spam}</td>
|
||||
<td className={cn("px-2.5 py-1.5 text-right", f.counts.missing > 0 && "text-amber-700")}>
|
||||
{f.counts.missing}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 text-right font-medium">{pct(f.counts.inbox_rate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsTable({
|
||||
results,
|
||||
}: {
|
||||
results: { seed: string; family_label: string; folder: string; sent_at: string | null; detected_at: string | null; error?: string }[];
|
||||
}) {
|
||||
if (results.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No copies were scheduled.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Seed</th>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Provider</th>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Folder</th>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Sent</th>
|
||||
<th className="px-2.5 py-1.5 text-left font-medium">Found</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r, i) => (
|
||||
<tr key={`${r.seed}-${i}`} className="border-t border-border align-top">
|
||||
<td className="px-2.5 py-1.5 font-mono">{r.seed}</td>
|
||||
<td className="px-2.5 py-1.5">{r.family_label}</td>
|
||||
<td className="px-2.5 py-1.5">
|
||||
<FolderBadge folder={r.folder} />
|
||||
{r.error && <div className="mt-0.5 max-w-56 text-[11px] text-red-700">{r.error}</div>}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap text-muted-foreground" title={absolute(r.sent_at)}>
|
||||
{r.sent_at ? relative(r.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap text-muted-foreground" title={absolute(r.detected_at)}>
|
||||
{r.detected_at ? relative(r.detected_at) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Status and folder badges for placement tests.
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PlacementFolder, PlacementStatus } from "@/lib/api/client/admin/placement";
|
||||
|
||||
const STATUS_TONE: Record<PlacementStatus, string> = {
|
||||
running: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
cancelled: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
failed: "border-red-300 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px]", STATUS_TONE[status as PlacementStatus] ?? "border-zinc-300 text-zinc-600")}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const FOLDER_LABEL: Record<PlacementFolder, string> = {
|
||||
pending: "Pending",
|
||||
inbox: "Primary inbox",
|
||||
promotions: "Promotions",
|
||||
other: "Other tab",
|
||||
spam: "Spam",
|
||||
missing: "Missing",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const FOLDER_TONE: Record<PlacementFolder, string> = {
|
||||
pending: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
inbox: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
promotions: "border-sky-300 bg-sky-50 text-sky-700",
|
||||
other: "border-sky-300 bg-sky-50 text-sky-700",
|
||||
spam: "border-red-300 bg-red-50 text-red-700",
|
||||
missing: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
failed: "border-red-300 text-red-700",
|
||||
cancelled: "border-zinc-300 text-zinc-500",
|
||||
};
|
||||
|
||||
export function FolderBadge({ folder }: { folder: string }) {
|
||||
const f = folder as PlacementFolder;
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-[10px]", FOLDER_TONE[f] ?? "border-zinc-300 text-zinc-600")}>
|
||||
{FOLDER_LABEL[f] ?? folder}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Shared labels and helpers for the seed panel page and its test drawer.
|
||||
|
||||
import { APIError } from "@/lib/api/client";
|
||||
|
||||
export function pct(rate: number | null | undefined): string {
|
||||
if (rate === null || rate === undefined || !Number.isFinite(rate)) return "—";
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
export const PANEL_LABEL: Record<string, string> = {
|
||||
instance: "Instance",
|
||||
workspace: "Workspace",
|
||||
cloud: "Warmbly Cloud",
|
||||
};
|
||||
|
||||
export const ORIGIN_LABEL: Record<string, string> = {
|
||||
manual: "Workspace member",
|
||||
monitor: "Monitor",
|
||||
admin: "Admin",
|
||||
remote: "Linked instance",
|
||||
};
|
||||
|
||||
// The backend's `error` field is the HTTP status text; the sentence worth
|
||||
// showing is `message`.
|
||||
export function describeError(err: unknown): { message: string; code?: string } {
|
||||
if (err instanceof APIError) {
|
||||
const body = err.body as { message?: string } | undefined;
|
||||
return { message: body?.message || err.message, code: err.code };
|
||||
}
|
||||
return { message: err instanceof Error ? err.message : "Request failed" };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// The value, once it has stopped changing for `ms`.
|
||||
export function useDebounced<T>(value: T, ms: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return debounced;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Flame,
|
||||
Gauge,
|
||||
HeartPulse,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Mailbox,
|
||||
Megaphone,
|
||||
@@ -73,6 +74,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ to: "/warmup", label: "Warmup", icon: Flame, end: true, perm: AdminPerm.ViewWarmupPool },
|
||||
{ to: "/warmup/appeals", label: "Warmup Appeals", icon: ShieldCheck, perm: AdminPerm.ReviewAppeals },
|
||||
{ to: "/warmup-content", label: "Warmup Content", icon: Sparkles, perm: AdminPerm.ViewWarmupPool },
|
||||
{ to: "/placement", label: "Seed panel", icon: Inbox, perm: AdminPerm.ViewWarmupPool },
|
||||
{ to: "/campaigns", label: "Campaigns", icon: Megaphone, perm: AdminPerm.ViewCampaigns },
|
||||
{ to: "/sends", label: "Sends", icon: SendHorizonal, perm: AdminPerm.ViewCampaigns },
|
||||
],
|
||||
|
||||
@@ -151,6 +151,15 @@ export interface InstanceSettings {
|
||||
enforce_domain_auth: boolean;
|
||||
auth_grace_hours: number;
|
||||
};
|
||||
// Inbox placement tests. The monthly allowances count tests on the
|
||||
// instance and cloud panels only, and a self-hosted instance does not
|
||||
// meter at all. Zero or negative on write resolves to the default.
|
||||
placement: {
|
||||
tests_per_month_trial: number;
|
||||
tests_per_month_paid: number;
|
||||
seeds_per_test: number;
|
||||
spacing_seconds: number;
|
||||
};
|
||||
// Operator notification channels. Targets and secrets are redacted on
|
||||
// read: a chat webhook URL is a bearer credential, so the server returns a
|
||||
// recognisable preview and treats the preview (or an empty string) as
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// /admin/placement/*: the instance seed panel and every workspace's inbox
|
||||
// placement tests. Shapes mirror internal/app/placement/view.go and
|
||||
// internal/models/placement.go.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
export type PlacementPanel = "instance" | "workspace" | "cloud";
|
||||
export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote";
|
||||
export type PlacementStatus = "running" | "completed" | "cancelled" | "failed";
|
||||
export type PlacementTracking = "campaign" | "on" | "off" | "compare";
|
||||
export type PlacementFolder =
|
||||
| "pending"
|
||||
| "inbox"
|
||||
| "promotions"
|
||||
| "other"
|
||||
| "spam"
|
||||
| "missing"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface PlacementCounts {
|
||||
total: number;
|
||||
pending: number;
|
||||
inbox: number;
|
||||
promotions: number;
|
||||
other: number;
|
||||
spam: number;
|
||||
missing: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
delivered: number;
|
||||
// Fractions 0..1 of delivered; null until something is delivered.
|
||||
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 PlacementTestView {
|
||||
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;
|
||||
body_html?: string;
|
||||
body_plain?: string;
|
||||
open_tracking: boolean;
|
||||
link_tracking: boolean;
|
||||
compare_group_id: string | null;
|
||||
origin: PlacementOrigin;
|
||||
panel: PlacementPanel;
|
||||
status: PlacementStatus;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
finished_at: string | null;
|
||||
summary: PlacementCounts;
|
||||
families: PlacementFamilyCounts[] | null;
|
||||
}
|
||||
|
||||
export interface PlacementResultView {
|
||||
seed: string;
|
||||
family: string;
|
||||
family_label: string;
|
||||
folder: PlacementFolder;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
detected_at: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlacementContentIssue {
|
||||
severity: "warn" | "high";
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface PlacementTestDetail extends PlacementTestView {
|
||||
results: PlacementResultView[] | null;
|
||||
content: { score: number; issues: PlacementContentIssue[] | null };
|
||||
compare?: PlacementTestView;
|
||||
}
|
||||
|
||||
export type AdminSeedScope = "" | "instance" | "workspace";
|
||||
|
||||
export interface AdminPlacementSeed {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
family: string;
|
||||
family_label: string;
|
||||
status: string;
|
||||
worker_id: string | null;
|
||||
seed_scope: AdminSeedScope;
|
||||
}
|
||||
|
||||
export interface PlacementTestsPage {
|
||||
data: PlacementTestView[] | null;
|
||||
pagination: { total: number; has_more: boolean; next_cursor: string | null };
|
||||
}
|
||||
|
||||
export interface AdminCreatePlacementTestBody {
|
||||
sender_account_id: string;
|
||||
subject: string;
|
||||
body_html?: string;
|
||||
body_plain?: string;
|
||||
tracking?: PlacementTracking;
|
||||
}
|
||||
|
||||
export function listPlacementTests(cursor?: string, limit = 25): Promise<PlacementTestsPage> {
|
||||
const usp = new URLSearchParams();
|
||||
if (cursor) usp.set("cursor", cursor);
|
||||
usp.set("limit", String(limit));
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/placement/tests?${usp.toString()}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlacementTest(id: string): Promise<PlacementTestDetail> {
|
||||
return Request<{ data: PlacementTestDetail }>({
|
||||
method: "GET",
|
||||
url: `/admin/placement/tests/${id}`,
|
||||
authorization: true,
|
||||
}).then((r) => r.data);
|
||||
}
|
||||
|
||||
export function createPlacementTest(body: AdminCreatePlacementTestBody): Promise<PlacementTestView[]> {
|
||||
return Request<{ data: PlacementTestView[] | null }>({
|
||||
method: "POST",
|
||||
url: "/admin/placement/tests",
|
||||
data: body,
|
||||
authorization: true,
|
||||
}).then((r) => r.data ?? []);
|
||||
}
|
||||
|
||||
export function listPlacementSeeds(): Promise<AdminPlacementSeed[]> {
|
||||
return Request<{ data: AdminPlacementSeed[] | null }>({
|
||||
method: "GET",
|
||||
url: "/admin/placement/seeds",
|
||||
authorization: true,
|
||||
}).then((r) => r.data ?? []);
|
||||
}
|
||||
|
||||
export function searchPlacementSeedCandidates(search: string, limit = 50): Promise<AdminPlacementSeed[]> {
|
||||
const usp = new URLSearchParams();
|
||||
if (search) usp.set("search", search);
|
||||
usp.set("limit", String(limit));
|
||||
return Request<{ data: AdminPlacementSeed[] | null }>({
|
||||
method: "GET",
|
||||
url: `/admin/placement/seeds/candidates?${usp.toString()}`,
|
||||
authorization: true,
|
||||
}).then((r) => r.data ?? []);
|
||||
}
|
||||
|
||||
export function setPlacementSeed(id: string, seed: boolean): Promise<AdminPlacementSeed> {
|
||||
return Request<{ data: AdminPlacementSeed }>({
|
||||
method: "POST",
|
||||
url: `/admin/placement/seeds/${id}`,
|
||||
data: { seed },
|
||||
authorization: true,
|
||||
}).then((r) => r.data);
|
||||
}
|
||||
@@ -28,6 +28,11 @@ const SPINE: SpineGroup[] = [
|
||||
["admin", "mailboxes"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "placement",
|
||||
match: (n) => n.includes("PLACEMENT"),
|
||||
keys: [["admin", "placement"]],
|
||||
},
|
||||
{
|
||||
id: "campaigns",
|
||||
match: (n) => n.includes("CAMPAIGN"),
|
||||
|
||||
@@ -41,6 +41,7 @@ import WarmupContentLayout from "@/app/dashboard/warmup-content/WarmupContentLay
|
||||
import WarmupContentOverviewPage from "@/app/dashboard/warmup-content/OverviewPage";
|
||||
import WarmupContentLibraryPage from "@/app/dashboard/warmup-content/LibraryPage";
|
||||
import WarmupContentJobsPage from "@/app/dashboard/warmup-content/JobsPage";
|
||||
import PlacementPage from "@/app/dashboard/PlacementPage";
|
||||
import CampaignsPage from "@/app/dashboard/CampaignsPage";
|
||||
import SendsPage from "@/app/dashboard/SendsPage";
|
||||
import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
|
||||
@@ -153,6 +154,7 @@ const router = createBrowserRouter([
|
||||
{ path: "jobs", element: <WarmupContentJobsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "placement", element: gated(AdminPerm.ViewWarmupPool, <PlacementPage />) },
|
||||
{ path: "campaigns", element: gated(AdminPerm.ViewCampaigns, <CampaignsPage />) },
|
||||
{ path: "sends", element: gated(AdminPerm.ViewCampaigns, <SendsPage />) },
|
||||
|
||||
|
||||
+31
-8
@@ -1945,14 +1945,6 @@ func main() {
|
||||
emailVerificationScheduler := jobs.NewEmailVerificationScheduler(emailVerificationJob, time.Duration(config.VerificationIntervalSeconds)*time.Second)
|
||||
go emailVerificationScheduler.Start(ctx)
|
||||
|
||||
// Seed inbox-placement testing: send a tokenized copy of a template
|
||||
// through a real sender to the seed panel, then classify where it landed
|
||||
// by looking the token up in each seed's synced unibox entries.
|
||||
placementRepository = repository.NewPlacementRepository(primaryDB)
|
||||
placementService = placement.NewService(placementRepository, emailRepostory, emailSender)
|
||||
placementPoller := jobs.NewPlacementPoller(placementService, 2*time.Minute)
|
||||
go placementPoller.Start(ctx)
|
||||
|
||||
// Auto-pause guardrails: pause any active campaign whose bounce,
|
||||
// complaint, or reply rate has left the band its owner set. Fifteen
|
||||
// minutes is fast enough that a campaign cannot do much damage inside
|
||||
@@ -1968,6 +1960,37 @@ func main() {
|
||||
guardrailScheduler := jobs.NewGuardrailScheduler(guardrailJob, 15*time.Minute)
|
||||
go guardrailScheduler.Start(ctx)
|
||||
|
||||
// Inbox placement tests: a template or campaign step rendered as the
|
||||
// campaign would send it, one paced task per seed, read back from each
|
||||
// seed's synced mail by Message-ID.
|
||||
placementRepository = repository.NewPlacementRepository(primaryDB)
|
||||
placementDeps := placement.Deps{
|
||||
Repo: placementRepository,
|
||||
Emails: emailRepostory,
|
||||
Campaigns: campaignRepostory,
|
||||
Contacts: contactRepostory,
|
||||
Tasks: taskRepository,
|
||||
Scheduler: tasksClient,
|
||||
Policy: instanceSettings,
|
||||
Gate: featureGateService,
|
||||
Notifier: notificationService,
|
||||
Mailboxes: emailService,
|
||||
Pauser: guardrailService,
|
||||
}
|
||||
if streamingPublisher != nil {
|
||||
placementDeps.Publisher = streamingPublisher
|
||||
}
|
||||
// A self-hosted instance borrows Warmbly Cloud's panel through its link;
|
||||
// the hosted product is the cloud and runs its own.
|
||||
if config.SelfHosted() && cloudLinkService != nil {
|
||||
placementDeps.Cloud = cloudLinkService
|
||||
}
|
||||
placementService = placement.NewService(placementDeps)
|
||||
aitools.RegisterPlacementTools(aiToolRegistry, placementService, auditService)
|
||||
tasksService.SetPlacement(placementRepository)
|
||||
emailService.WireSeedScope(placementRepository)
|
||||
go jobs.NewPlacementPoller(placementService, 30*time.Second).Start(ctx)
|
||||
|
||||
// Advisor. Detection is deterministic Go over a per-org snapshot and
|
||||
// always runs; the narrator is optional and only rewrites the card copy,
|
||||
// so an install with no LLM provider still gets every recommendation.
|
||||
|
||||
+110
-1
@@ -35,7 +35,7 @@ func resourceSpecs() []resource {
|
||||
out = append(out, campaignSpec(), contactSpec(), suppressionSpec(), mailboxSpec(), inboxSpec())
|
||||
out = append(out, segmentSpec(), templateSpec(), automationSpec(), formSpec())
|
||||
out = append(out, dealSpec(), pipelineSpec(), taskSpec())
|
||||
out = append(out, analyticsSpec(), auditSpec(), advisorSpec())
|
||||
out = append(out, analyticsSpec(), placementSpec(), auditSpec(), advisorSpec())
|
||||
out = append(out, webhookSpec(), keySpec(), oauthAppSpec(), toolSpec())
|
||||
out = append(out, orgSpec(), teamSpec(), settingsSpec(), warmupRoutingSpec(), integrationSpec())
|
||||
return out
|
||||
@@ -1460,6 +1460,115 @@ func taskSpec() resource {
|
||||
}
|
||||
}
|
||||
|
||||
func placementSpec() resource {
|
||||
testTable := output.Table{
|
||||
Root: "data",
|
||||
Columns: []output.Column{
|
||||
col("ID", "id"),
|
||||
colt("SENDER", "sender_email", 30),
|
||||
colt("SUBJECT", "subject", 30),
|
||||
col("PANEL", "panel"),
|
||||
col("STATUS", "status"),
|
||||
col("INBOX", "summary.inbox"),
|
||||
col("TABS", "summary.promotions"),
|
||||
col("SPAM", "summary.spam"),
|
||||
col("MISSING", "summary.missing"),
|
||||
colf("CREATED", "created_at", "time"),
|
||||
},
|
||||
Empty: "No placement tests yet. Start one with `warmbly placement test --mailbox MAILBOX_ID --campaign CAMPAIGN_ID`.",
|
||||
}
|
||||
return resource{
|
||||
Name: "placement",
|
||||
Aliases: []string{"placement-test", "placement-tests"},
|
||||
Short: "Test where a template lands: inbox, a Gmail tab or spam",
|
||||
Group: groupData,
|
||||
Long: `Send a campaign step or a template from one of your mailboxes to a panel of
|
||||
seed inboxes and see where each copy landed.
|
||||
|
||||
Every copy is a real send from that mailbox, counted against its daily limit,
|
||||
so starting a test asks before it does it.`,
|
||||
Endpoints: []endpoint{
|
||||
{Name: "overview", Short: "The seed panels you can test on and this month's allowance", Method: http.MethodGet, Path: "/placement/overview"},
|
||||
{
|
||||
Name: "list", Aliases: []string{"ls"}, Short: "List placement tests",
|
||||
Method: http.MethodGet, Path: "/placement/tests", Paginate: true,
|
||||
Flag: withPaging(flagSpec{Name: "campaign", Help: "Only this campaign's tests", Query: true, Key: "campaign_id"}),
|
||||
Table: testTable,
|
||||
},
|
||||
{
|
||||
Name: "view", Aliases: []string{"get", "show"}, Short: "Show one test with where every copy landed",
|
||||
Method: http.MethodGet, Path: "/placement/tests/{id}",
|
||||
Args: []argSpec{{Name: "id", Help: "The test's id"}},
|
||||
},
|
||||
{
|
||||
Name: "test", Aliases: []string{"run", "start"}, Short: "Start a placement test",
|
||||
Method: http.MethodPost, Path: "/placement/tests", Body: bodyRequired, Sends: true, Idempotent: true,
|
||||
Example: " $ warmbly placement test --mailbox MAILBOX_ID --campaign CAMPAIGN_ID --step STEP_ID\n" +
|
||||
" $ warmbly placement test --mailbox MAILBOX_ID --subject \"Quick question\" --text \"Hi there\" --tracking compare",
|
||||
Flag: []flagSpec{
|
||||
{Name: "mailbox", Help: "The mailbox to send from (required)", Key: "sender_account_id"},
|
||||
{Name: "campaign", Help: "Test this campaign's copy", Key: "campaign_id"},
|
||||
{Name: "step", Help: "The step to test; with --campaign", Key: "sequence_id"},
|
||||
{Name: "contact", Help: "Render the copy for this contact's id; the campaign's first lead when omitted", Key: "contact_id"},
|
||||
{Name: "subject", Help: "Subject of an ad-hoc template", Key: "subject"},
|
||||
{Name: "text", Help: "Plain-text body of an ad-hoc template", Key: "body_plain"},
|
||||
{Name: "html", Help: "HTML body of an ad-hoc template", Key: "body_html"},
|
||||
{Name: "tracking", Help: "campaign, on, off or compare (sends twice, with and without)", Key: "tracking"},
|
||||
{Name: "panel", Help: "instance, workspace or cloud", Key: "panel"},
|
||||
},
|
||||
Table: testTable,
|
||||
},
|
||||
{
|
||||
Name: "cancel", Short: "Stop the copies of a running test that have not been sent",
|
||||
Method: http.MethodPost, Path: "/placement/tests/{id}/cancel", Body: bodyOptional,
|
||||
Args: []argSpec{{Name: "id", Help: "The test's id"}},
|
||||
Success: "Placement test cancelled.",
|
||||
},
|
||||
{
|
||||
Name: "seeds", Short: "List your mailboxes and which are seed inboxes",
|
||||
Method: http.MethodGet, Path: "/placement/seeds",
|
||||
Table: output.Table{Root: "data", Columns: []output.Column{
|
||||
col("ID", "email_account_id"), colt("MAILBOX", "email", 36), col("PROVIDER", "label"),
|
||||
colf("SEED", "seed", "bool"), colt("NOTE", "blocker", 40),
|
||||
}, Empty: "No mailboxes in this workspace."},
|
||||
},
|
||||
{
|
||||
Name: "set-seed", Short: "Make a mailbox a seed inbox, or an ordinary mailbox again",
|
||||
Method: http.MethodPut, Path: "/placement/seeds/{id}", Body: bodyRequired,
|
||||
Args: []argSpec{{Name: "id", Help: "The mailbox's id"}},
|
||||
Example: " $ warmbly placement set-seed MAILBOX_ID --seed\n $ warmbly placement set-seed MAILBOX_ID --seed=false",
|
||||
Flag: []flagSpec{{Name: "seed", Help: "true to make it a seed inbox, false to stop", Kind: flagBool, Key: "seed"}},
|
||||
Success: "Seed inbox updated.",
|
||||
},
|
||||
{
|
||||
Name: "monitor", Short: "Show a campaign's scheduled placement test",
|
||||
Method: http.MethodGet, Path: "/campaigns/{id}/placement-monitor",
|
||||
Args: []argSpec{{Name: "id", Help: "The campaign's id"}},
|
||||
},
|
||||
{
|
||||
Name: "set-monitor", Short: "Schedule or change a campaign's placement test",
|
||||
Method: http.MethodPut, Path: "/campaigns/{id}/placement-monitor", Body: bodyRequired,
|
||||
Args: []argSpec{{Name: "id", Help: "The campaign's id"}},
|
||||
Example: " $ warmbly placement set-monitor CAMPAIGN_ID --enabled --interval-days 7 --alert-below 70",
|
||||
Flag: []flagSpec{
|
||||
{Name: "enabled", Help: "Run the scheduled test", Kind: flagBool, Key: "enabled"},
|
||||
{Name: "interval-days", Help: "Days between tests, 1 to 30", Kind: flagInt, Key: "interval_days"},
|
||||
{Name: "alert-below", Help: "Alert when the inbox rate falls below this percent", Kind: flagInt, Key: "alert_below"},
|
||||
{Name: "panel", Help: "instance, workspace or cloud", Key: "panel"},
|
||||
{Name: "pause-on-alert", Help: "Pause the campaign when it alerts", Kind: flagBool, Key: "pause_on_alert"},
|
||||
},
|
||||
Success: "Placement monitor saved.",
|
||||
},
|
||||
{
|
||||
Name: "delete-monitor", Short: "Stop a campaign's scheduled placement test",
|
||||
Method: http.MethodDelete, Path: "/campaigns/{id}/placement-monitor",
|
||||
Args: []argSpec{{Name: "id", Help: "The campaign's id"}},
|
||||
Success: "Placement monitor removed.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func analyticsSpec() resource {
|
||||
return resource{
|
||||
Name: "analytics",
|
||||
|
||||
@@ -469,6 +469,7 @@ func main() {
|
||||
WarmupContentRepo: repository.NewWarmupContentRepository(primaryDB.Pool),
|
||||
WarmupEngagementRepo: repository.NewWarmupEngagementRepository(primaryDB.Pool),
|
||||
WarmupPlacementRepo: repository.NewWarmupPlacementRepository(primaryDB),
|
||||
PlacementRepo: repository.NewPlacementRepository(primaryDB),
|
||||
WarmupService: warmupService,
|
||||
WorkerRepo: workerRepo,
|
||||
FleetNodeRepo: repository.NewFleetNodeRepository(primaryDB),
|
||||
|
||||
@@ -227,7 +227,7 @@ $ warmbly campaign start 6f1c…
|
||||
! `warmbly campaign start` sends real mail. Continue? [y/N]
|
||||
```
|
||||
|
||||
`--yes` is the only way past it, which makes it the flag to grep for in a script review. The commands that behave this way are `campaign start`, `campaign test`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft`.
|
||||
`--yes` is the only way past it, which makes it the flag to grep for in a script review. The commands that behave this way are `campaign start`, `campaign test`, `placement test`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -249,6 +249,7 @@ Run `warmbly <command> --help` for the flags, and `warmbly <command> <subcommand
|
||||
| `form` | lead capture forms, submissions and stats |
|
||||
| `deal`, `pipeline`, `task` | the CRM |
|
||||
| `analytics` | dashboard, deliverability, warmup, per-mailbox and per-campaign numbers |
|
||||
| `placement` | [inbox placement tests](/guides/placement-tests/): overview, test, list, view, cancel, seed inboxes, a campaign's scheduled test |
|
||||
| `audit` | the workspace's audit trail |
|
||||
| `advisor` | recommendations, and applying or dismissing them |
|
||||
| `webhook` | endpoints, deliveries, redelivery, event types |
|
||||
|
||||
@@ -72,6 +72,9 @@ Campaigns, steps and activity logs are scoped to the selected organization for s
|
||||
| POST | `/campaigns/:id/test-email` | `SEND_CAMPAIGNS` |
|
||||
| POST | `/campaigns/:id/start` | `SEND_CAMPAIGNS` |
|
||||
| POST | `/campaigns/:id/stop` | `SEND_CAMPAIGNS` |
|
||||
| GET | `/campaigns/:id/placement-monitor` | `READ_CAMPAIGNS` |
|
||||
| PUT | `/campaigns/:id/placement-monitor` | `SEND_CAMPAIGNS` |
|
||||
| DELETE | `/campaigns/:id/placement-monitor` | `SEND_CAMPAIGNS` |
|
||||
| GET | `/campaigns/:id/leads/:contactId/hold` | `READ_CAMPAIGNS` |
|
||||
| POST | `/campaigns/:id/leads/:contactId/pause` | `WRITE_CAMPAIGNS` |
|
||||
| POST | `/campaigns/:id/leads/:contactId/resume` | `WRITE_CAMPAIGNS` |
|
||||
@@ -277,6 +280,24 @@ Analytics reads use the selected workspace for JWT callers and the key's workspa
|
||||
|
||||
`GET /analytics/inbox-tagging` accepts `limit`, opaque `cursor`, and `needs_review` query parameters and returns `data`, aggregate `summary`, and `pagination`. Each row carries `actions`, the list of what the verdict was allowed to do (`hold`, `stop`, `task`, `suppress`), and `summary.acted` counts the rows that did anything. Invalid cursors, limits, or booleans return `400`.
|
||||
|
||||
### Inbox placement tests
|
||||
|
||||
A [placement test](/guides/placement-tests/) sends real mail from one of the workspace's mailboxes, so starting and cancelling one takes the same permission as starting a campaign, while reading results is an analytics read. Everything here is scoped to the workspace: a test, a sending mailbox, a campaign, a step and a contact are each checked to belong to it before anything is sent.
|
||||
|
||||
| Method | Path | API Permission |
|
||||
|--------|------|----------------|
|
||||
| GET | `/placement/overview` | `READ_ANALYTICS` |
|
||||
| GET | `/placement/tests` | `READ_ANALYTICS` |
|
||||
| GET | `/placement/tests/:id` | `READ_ANALYTICS` |
|
||||
| POST | `/placement/tests` | `SEND_CAMPAIGNS` |
|
||||
| POST | `/placement/tests/:id/cancel` | `SEND_CAMPAIGNS` |
|
||||
| GET | `/placement/seeds` | `READ_EMAILS` |
|
||||
| PUT | `/placement/seeds/:email_account_id` | `WRITE_EMAILS` |
|
||||
|
||||
For session callers the matching organization permissions are **View analytics** for the three reads, **Send campaigns** for starting and cancelling, **View campaigns** for listing seed inboxes and **Manage mailboxes** for marking one. The campaign's placement monitor (`/campaigns/:id/placement-monitor`, listed under campaigns above) is read with **View campaigns** and changed with **Send campaigns**.
|
||||
|
||||
A key restricted to certain mailboxes can only start a test from one of them and only sees and marks those mailboxes as seeds. `POST /placement/tests` accepts an `Idempotency-Key`; `PUT /placement/seeds/:email_account_id` and `PUT /campaigns/:id/placement-monitor` state an absolute value, so a retry lands on the same state. `GET /placement/tests` takes `limit` (`1` to `100`, default `25`), an opaque `cursor` and an optional `campaign_id`, and returns `data` plus `pagination`; an invalid cursor or limit is a `400`. See the [placement endpoint reference](/api/reference/placement/).
|
||||
|
||||
### Advisor
|
||||
|
||||
Recommendations about deliverability, mailbox configuration, warmup, campaign performance, copy, and list quality. See the [Advisor guide](/guides/advisor/).
|
||||
|
||||
@@ -28,12 +28,12 @@ All errors follow this structure:
|
||||
|------|-------|-------------|
|
||||
| 400 | Bad Request | Invalid request syntax or parameters, or a quota that would be passed (`storage_limit_reached`) |
|
||||
| 401 | Unauthorized | Missing or invalid authentication |
|
||||
| 402 | Payment Required | Out of AI credits (`insufficient_credits`) |
|
||||
| 402 | Payment Required | Out of AI credits (`insufficient_credits`), or no placement tests left this month (`placement_quota_exceeded`, `placement_not_entitled`) |
|
||||
| 403 | Forbidden | Authenticated but lacks permission, or the workspace's mailbox allowance is full (`mailbox_allowance_reached`) |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 409 | Conflict | Resource already exists |
|
||||
| 422 | Unprocessable | Validation failed |
|
||||
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (`rate_limit_exceeded`, `usage_cap_exceeded`) |
|
||||
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (`rate_limit_exceeded`, `usage_cap_exceeded`), or too many placement tests running at once (`placement_too_many_running`) |
|
||||
|
||||
### Server errors (5xx)
|
||||
|
||||
@@ -226,6 +226,37 @@ The [inbox vendor](/guides/mailbox-import/#import-from-an-inbox-vendor) routes (
|
||||
| `app_password_invalid` | 400 | `app_password` is not 16 letters once spaces are removed. Create one at myaccount.google.com/apppasswords and send it as Google shows it |
|
||||
| `mailbox_not_google_signin` | 409 | The mailbox is not connected with per-mailbox Google sign-in: it is already on an app password or an admin grant, is an Outlook or SMTP/IMAP mailbox, or its sign-in is held by Warmbly Cloud. A repeat of a switch that succeeded answers this |
|
||||
|
||||
#### Placement test refusals
|
||||
|
||||
The [inbox placement test](/guides/placement-tests/) routes (`/placement/*` and `/campaigns/:id/placement-monitor`) answer these. A refused test sends nothing: every check runs before the first copy is scheduled.
|
||||
|
||||
| `code` | Status | Meaning |
|
||||
|--------|--------|---------|
|
||||
| `placement_not_entitled` | 402 | The workspace has no active trial or subscription, so it cannot start a test. Only the hosted product answers this |
|
||||
| `placement_quota_exceeded` | 402 | The workspace has used its monthly allowance of tests on the instance panel, or the linked Warmbly Cloud workspace has used its allowance on Warmbly Cloud's panel. A tracking comparison needs two. Tests on the workspace's own seed inboxes are never counted |
|
||||
| `placement_too_many_running` | 429 | The workspace already has `3` tests running, counting each half of a tracking comparison. Wait for one to finish |
|
||||
| `placement_sender_busy` | 409 | The sending mailbox still has copies of another test waiting to be sent. One test sends from a mailbox at a time |
|
||||
| `placement_sender_unavailable` | 409 | The sending mailbox is not connected and active, or it is itself a seed inbox, which receives tests and cannot send one |
|
||||
| `placement_daily_budget` | 409 | The mailbox has too little of its daily limit left today for a useful test (at least five seeds per test, ten for a comparison). A test that fits otherwise is sized down to what is left rather than refused. The `message` gives both numbers |
|
||||
| `placement_no_seeds` | 409 | The panel has no seed inbox the test could use: none were added, none is connected, or every one is on the sender's own domain, which is always skipped |
|
||||
| `placement_panel_unavailable` | 409 | `panel` is `cloud` on an instance that is not linked to Warmbly Cloud |
|
||||
| `placement_invalid_tracking` | 400 | `tracking` is `on` or `compare` for a campaign that sends plain text, which carries no tracking |
|
||||
| `placement_not_running` | 409 | `POST /placement/tests/:id/cancel` on a test that has already finished or been cancelled |
|
||||
| `placement_seed_unavailable` | 409 | The mailbox cannot be marked as a seed inbox: it is on the instance panel, or a placement test is still sending from it |
|
||||
| `placement_seed_limit` | 409 | The workspace already has `50` seed inboxes. Unmark one first |
|
||||
| `mailbox_is_seed` | 409 | Warmup was started or resumed on a seed inbox. A seed never warms up; unmark it first with `PUT /placement/seeds/:email_account_id` |
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Conflict",
|
||||
"message": "This test sends 40 emails from sales@yourdomain.com, which has 12 left of its daily limit today.",
|
||||
"code": "placement_daily_budget",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
```
|
||||
|
||||
A `400` without one of these codes is a malformed request: an unknown `panel` or `tracking`, a `sequence_id` without its `campaign_id`, a missing subject or body on an ad-hoc test, a monitor `interval_days` outside `1` to `30` or an `alert_below` outside `0` to `100`.
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
Returned when authentication fails.
|
||||
@@ -274,6 +305,8 @@ Returned when an AI action is requested but the organization is out of credits.
|
||||
- Wait for the monthly allowance to reset, or buy a top-up pack (see [AI credits](/guides/ai-credits/))
|
||||
- Related: a `429` with code `usage_cap_exceeded` means a short-term AI usage cap was hit; retry later
|
||||
|
||||
Placement tests answer `402` too, with `placement_not_entitled` or `placement_quota_exceeded`; see [placement test refusals](#placement-test-refusals).
|
||||
|
||||
### 403 Forbidden
|
||||
|
||||
Returned when authenticated but lacking necessary permissions.
|
||||
@@ -430,6 +463,7 @@ A few conflicts carry their own `code`. The mailbox import's are under [mailbox
|
||||
| `code` | Status | Meaning |
|
||||
|--------|--------|---------|
|
||||
| `contact_email_taken` | 409 | The address given to [update a contact](/api/reference/contacts/#update-a-contact) already belongs to another contact |
|
||||
| `mailbox_is_seed` | 409 | Warmup was started or resumed on a placement seed inbox. See [placement test refusals](#placement-test-refusals) |
|
||||
| `mailbox_not_google_signin` | 409 | `POST /emails/onboarding/app-password/:id` on a mailbox that is not connected with per-mailbox Google sign-in. See [app password switch refusals](#app-password-switch-refusals) |
|
||||
| `mailbox_cloud_unenroll_failed` | 409 | The mailbox is linked to [Warmbly Cloud](/guides/warmbly-cloud/) and its link could not be released, so `DELETE /emails/{id}` would leave Warmbly Cloud holding its credential or its claim on it. The mailbox record remains, and restoration onto its worker is attempted |
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
|
||||
| `set_lead_hold` | Hold or resume one contact's flow in one campaign, without unsubscribing them | `WRITE_CAMPAIGNS` |
|
||||
| `get_dashboard_analytics` | Org-wide sending stats for a period | `READ_ANALYTICS` |
|
||||
| `list_mailboxes` | Sender mailboxes with health + warmup state | `READ_ANALYTICS` |
|
||||
| `list_placement_tests` / `get_placement_test` | Inbox placement tests and where each copy landed | `READ_ANALYTICS` |
|
||||
| `run_placement_test` | Send a step or template to the seed inboxes from one mailbox | `SEND_CAMPAIGNS` |
|
||||
| `add_contact` / `delete_contact` | Create or delete a contact | `WRITE_CONTACTS` |
|
||||
| `bulk_edit_contacts` | Tag or subscribe many contacts at once | `BULK_CONTACTS` |
|
||||
| `get_contact_timeline` / `get_contact_sent_emails` | Read a contact's activity and sent mail | `READ_CONTACTS` |
|
||||
|
||||
@@ -36,7 +36,7 @@ These same permissions are the [OAuth](/api/oauth/) scopes, lowercased: `READ_EM
|
||||
| `AI_AGENT` | 22 | 4194304 | special | Run the AI assistant and MCP tools |
|
||||
| `AI_RESEARCH` | 23 | 8388608 | special | Run AI contact research |
|
||||
|
||||
`SEND_CAMPAIGNS` is intentionally separate from `WRITE_CAMPAIGNS`: editing a campaign draft and starting one that actually transmits mail are different blast radii, so a key can be granted the first without the second.
|
||||
`SEND_CAMPAIGNS` is intentionally separate from `WRITE_CAMPAIGNS`: editing a campaign draft and starting one that actually transmits mail are different blast radii, so a key can be granted the first without the second. An [inbox placement test](/guides/placement-tests/) sends real mail from a real mailbox too, so starting or cancelling one, and scheduling a campaign's placement monitor, takes `SEND_CAMPAIGNS`, while reading results takes `READ_ANALYTICS`.
|
||||
|
||||
## Categories
|
||||
|
||||
|
||||
@@ -41,13 +41,15 @@ After connecting, join one or more topics with a `phx_join` message:
|
||||
| `account:<account_id>` | One mailbox's sync and warmup events | Requires `manage_emails` |
|
||||
| `bulk:<operation_id>` | Progress of one bulk operation | Import/export progress; only the user who started the operation receives its events |
|
||||
|
||||
Events arrive as channel messages whose event name is the event type, for example `EMAIL_SENT`, `EMAIL_OPENED`, `EMAIL_REPLIED`, `EMAIL_RECEIVED`, `AI_DRAFT_READY`, `CAMPAIGN_COMPLETED`, `CAMPAIGN_IDLE` (a continuous campaign ran out of leads and is waiting), `TASK_PROGRESS`, `ACCOUNT_HEALTH_CHANGED`, `ACCOUNT_SYNC_STATE`, `WARMUP_PLACEMENT` (a partner found one of the workspace's warmup emails; `status` is `inbox`, `tabs` or `spam`), `AUDIT_CREATED`, `AUTOMATION_RUN`, `MEETING_BOOKED`, `NOTIFICATION_CREATED`, `PAGE_HIT`. Payloads always include `event_type` and a timestamp, plus the relevant ids (`campaign_id`, `contact_id`, `thread_id`, and so on). `EMAIL_OPENED` and `EMAIL_CLICKED` also carry `occurred_at` (when the tracking service saw it; `timestamp` is the publish time), `machine` (true for a mail client prefetch or a security scanner's fetch), and `client`, `client_type` (`app` or `webmail`), `device_hidden` (a mailbox provider's image proxy fetched it, so the device is unknowable), `device_type`, `os`, `browser`, `country_code` and `city` when the consumer could tell.
|
||||
Events arrive as channel messages whose event name is the event type, for example `EMAIL_SENT`, `EMAIL_OPENED`, `EMAIL_REPLIED`, `EMAIL_RECEIVED`, `AI_DRAFT_READY`, `CAMPAIGN_COMPLETED`, `CAMPAIGN_IDLE` (a continuous campaign ran out of leads and is waiting), `TASK_PROGRESS`, `ACCOUNT_HEALTH_CHANGED`, `ACCOUNT_SYNC_STATE`, `WARMUP_PLACEMENT` (a partner found one of the workspace's warmup emails; `status` is `inbox`, `tabs` or `spam`), `PLACEMENT_TEST_UPDATED`, `AUDIT_CREATED`, `AUTOMATION_RUN`, `MEETING_BOOKED`, `NOTIFICATION_CREATED`, `PAGE_HIT`. Payloads always include `event_type` and a timestamp, plus the relevant ids (`campaign_id`, `contact_id`, `thread_id`, and so on). `EMAIL_OPENED` and `EMAIL_CLICKED` also carry `occurred_at` (when the tracking service saw it; `timestamp` is the publish time), `machine` (true for a mail client prefetch or a security scanner's fetch), and `client`, `client_type` (`app` or `webmail`), `device_hidden` (a mailbox provider's image proxy fetched it, so the device is unknowable), `device_type`, `os`, `browser`, `country_code` and `city` when the consumer could tell.
|
||||
|
||||
`AI_DRAFT_READY` fires when the [inbox agent](/guides/inbox-agent/) drafts a suggested reply awaiting review; it carries `thread_id` and `draft_id` and requires `access_unibox`.
|
||||
|
||||
`MAILBOX_IMPORT_PROGRESS` fires on the org channel as a [mailbox import](/guides/mailbox-import/) moves: at most once a second per import while rows finish, and always when it completes, is cancelled, or a waiting sign-in row connects. It carries `org_id`, `import_id` and `status` (`running`, `completed` or `cancelled`), not the rows, so refetch `GET /emails/imports/:id` on receipt. It requires `manage_emails`.
|
||||
|
||||
Permission filtering happens per event on the org channel: for example inbox events require `access_unibox`, campaign pulses require `view_campaigns`, mailbox and mailbox import events require `manage_emails`, member changes require `manage_team`, and billing events require `manage_billing`. A member without the permission simply never receives the event.
|
||||
`PLACEMENT_TEST_UPDATED` fires on the org channel when an [inbox placement test](/guides/placement-tests/) starts, each time one of its copies gets a verdict, and when it finishes or is cancelled. It carries `org_id`, `test_id`, `status` (`running`, `completed`, `cancelled` or `failed`) and, for a test of a campaign step, `campaign_id`, not the results, so refetch `GET /placement/tests/:id` on receipt. It requires `view_analytics`, like the endpoints that read results.
|
||||
|
||||
Permission filtering happens per event on the org channel: for example inbox events require `access_unibox`, campaign pulses require `view_campaigns`, placement tests require `view_analytics`, mailbox and mailbox import events require `manage_emails`, member changes require `manage_team`, and billing events require `manage_billing`. A member without the permission simply never receives the event.
|
||||
|
||||
## Selecting events with intents
|
||||
|
||||
|
||||
@@ -541,6 +541,8 @@ Auth: Session only (not available to API keys).
|
||||
"billing_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"team_activity": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
|
||||
"campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
|
||||
"placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"email_digest_minutes": 30
|
||||
}
|
||||
}
|
||||
@@ -573,6 +575,8 @@ Auth: Session only (not available to API keys).
|
||||
"billing_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"team_activity": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
|
||||
"campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
|
||||
"placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
|
||||
"email_digest_minutes": 30
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +165,14 @@ Returns the organization's deliverability posture for a time window: bounce, com
|
||||
"by_provider": [
|
||||
{
|
||||
"provider": "gmail",
|
||||
"label": "Gmail",
|
||||
"samples": 24,
|
||||
"inbox": 22,
|
||||
"inbox": 21,
|
||||
"promotions": 1,
|
||||
"spam": 1,
|
||||
"other": 0,
|
||||
"inbox_rate": 91.67,
|
||||
"missing": 1,
|
||||
"inbox_rate": 87.5,
|
||||
"spam_rate": 4.17
|
||||
}
|
||||
],
|
||||
@@ -189,7 +191,7 @@ Returns the organization's deliverability posture for a time window: bounce, com
|
||||
|
||||
The `intent_*` counters and `reply_count` are two different things and do not add up to each other. `reply_count` counts `reply` deliverability events, which are the ones a provider or your own integration reports to [record an event](/api/reference/deliverability-ops/). The `intent_*` counters are the replies Warmbly classified as they arrived in a connected mailbox, one per reply, including the machine ones: a vacation notice lands in `intent_out_of_office` and an autoresponder, ticket acknowledgement or bounce notice in `intent_automated`. A workspace that reports no reply events sees `reply_count` at zero with intents counted normally.
|
||||
|
||||
`spam_placement_rate` and `inbox_placement_rate` are omitted when there are no seed samples in the window. `by_provider` rolls the same seed samples up per recipient provider; `warmup_placement` is the continuous warmup signal per recipient domain, where `delivered` counts verified warmup arrivals and `spam` the subset the recipient's provider filed into junk. `score` starts at `100` and subtracts saturating penalties for bounce rate (up to `40` points, maxed at `10%`), complaint rate (up to `30` points, maxed at `0.30%`), and spam placement (up to `40` points, maxed at `40%`).
|
||||
`spam_placement_rate` and `inbox_placement_rate` are omitted when there are no seed samples in the window. Seed samples are the copies of the workspace's [placement tests](/api/reference/placement/) that got a verdict in the window: `inbox`, `promotions`, `other` (another Gmail tab), `spam`, or `missing` (never arrived); copies that failed to send or were cancelled are not samples. `by_provider` rolls the same samples up per seed's provider family, where `provider` is the family id (`gmail`, `google_workspace`, `microsoft365`, `outlook`, `yahoo` and so on) and `label` its display name, and the rates are percentages of `samples`; `warmup_placement` is the continuous warmup signal per recipient domain, where `delivered` counts verified warmup arrivals and `spam` the subset the recipient's provider filed into junk. `score` starts at `100` and subtracts saturating penalties for bounce rate (up to `40` points, maxed at `10%`), complaint rate (up to `30` points, maxed at `0.30%`), and spam placement (up to `40` points, maxed at `40%`).
|
||||
|
||||
## Get warmup analytics
|
||||
|
||||
|
||||
@@ -812,7 +812,7 @@ At least one of the three must carry something, and the three together are cappe
|
||||
"score": 84,
|
||||
"issues": []
|
||||
},
|
||||
"model": "gpt-4o-mini",
|
||||
"model": "gpt-6-luna",
|
||||
"tokens_used": 812,
|
||||
"credits_remaining": 248,
|
||||
"credits_charged": 2
|
||||
|
||||
@@ -346,7 +346,7 @@ Same shape as Update the tracking domain.
|
||||
|
||||
`POST /emails/:id/warmup/start`
|
||||
|
||||
Enables warmup for a mailbox. When resuming from a paused state it preserves ramp progress and seeds the warmup task chain immediately rather than waiting for the next reconciler pass.
|
||||
Enables warmup for a mailbox. When resuming from a paused state it preserves ramp progress and seeds the warmup task chain immediately rather than waiting for the next reconciler pass. A [placement seed inbox](/api/reference/placement/#mark-a-seed-inbox) never warms up, so starting or resuming warmup on one answers `409` with `mailbox_is_seed`.
|
||||
|
||||
Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails`
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"unibox",
|
||||
"crm",
|
||||
"analytics",
|
||||
"placement",
|
||||
"api-keys",
|
||||
"webhooks",
|
||||
"integrations",
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
---
|
||||
title: Inbox placement tests
|
||||
description: Start placement tests, read where each copy landed, manage the workspace's seed inboxes, and schedule a campaign's placement monitor.
|
||||
---
|
||||
|
||||
An inbox placement test sends a template or a campaign step from one of the workspace's mailboxes to a panel of seed inboxes and reports where each copy landed: the primary inbox, a Gmail tab, spam, or nowhere. The [placement tests guide](/guides/placement-tests/) explains how copies are rendered, sent and found, and how to read a result.
|
||||
|
||||
Every route is scoped to the selected organization (API keys are always bound to one), and every id in a request is checked against it: a sending mailbox, campaign, step or contact from another workspace is a `404`. Errors follow the shared `{error, message, code, request_id}` envelope; the codes these routes add are listed under [placement test refusals](/api/error-codes/#placement-test-refusals).
|
||||
|
||||
Changes are pushed live as a `PLACEMENT_TEST_UPDATED` event on the organization's [realtime](/api/realtime/) channel when a test starts, on every new verdict, and when it finishes or is cancelled.
|
||||
|
||||
## Get the overview
|
||||
|
||||
`GET /placement/overview`
|
||||
|
||||
Returns the seed panels this workspace can test on, with each panel's seed count and provider mix, and the workspace's monthly allowance.
|
||||
|
||||
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"panels": [
|
||||
{
|
||||
"panel": "instance",
|
||||
"available": true,
|
||||
"seeds": 38,
|
||||
"families": [
|
||||
{ "family": "gmail", "label": "Gmail", "seeds": 10 },
|
||||
{ "family": "google_workspace", "label": "Google Workspace", "seeds": 8 },
|
||||
{ "family": "microsoft365", "label": "Microsoft 365", "seeds": 8 },
|
||||
{ "family": "outlook", "label": "Outlook.com", "seeds": 6 },
|
||||
{ "family": "yahoo", "label": "Yahoo Mail", "seeds": 6 }
|
||||
],
|
||||
"metered": true
|
||||
},
|
||||
{
|
||||
"panel": "workspace",
|
||||
"available": false,
|
||||
"reason": "Mark one of this workspace's mailboxes as a seed inbox to test on it.",
|
||||
"seeds": 0,
|
||||
"families": [],
|
||||
"metered": false
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"used": 4,
|
||||
"limit": 40,
|
||||
"period_start": "2026-09-01T00:00:00Z",
|
||||
"period_end": "2026-10-01T00:00:00Z"
|
||||
},
|
||||
"workspace_seeds": 0,
|
||||
"seeds_per_test": 20,
|
||||
"spacing_seconds": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `panels[].panel` | `instance` (the operator's panel), `workspace` (the workspace's own seed inboxes) or `cloud` (Warmbly Cloud's panel). `cloud` is listed on a self-hosted instance, and elsewhere only when it is available |
|
||||
| `panels[].available` | Whether a test can run on the panel now. `reason` says why not, in one sentence |
|
||||
| `panels[].families` | Seeds per provider family. `family` is a stable id (`gmail`, `google_workspace`, `microsoft365`, `outlook`, `yahoo`, `aol`, `icloud`, `zoho`, `fastmail`, `gmx`, `proton`, `yandex`, `other` and more); `label` is its display name |
|
||||
| `panels[].metered` | Whether a test on the panel counts against a monthly allowance |
|
||||
| `usage` | Metered tests this calendar month (UTC). `limit` is `null` when the instance does not meter tests, which is always the case on a self-hosted instance |
|
||||
| `seeds_per_test` | The most seeds one test sends to |
|
||||
| `spacing_seconds` | The average gap between two copies from one sender |
|
||||
|
||||
## Start a test
|
||||
|
||||
`POST /placement/tests`
|
||||
|
||||
Starts a test, or two linked tests for a tracking comparison. Every check (entitlement, allowance, tests in flight, the sender's state and daily limit, available seeds) runs before anything is scheduled, so a refused request sends nothing. Supports `Idempotency-Key`.
|
||||
|
||||
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `sender_account_id` | uuid | yes | The mailbox that sends every copy. It must be connected and active, cannot be a seed inbox, and must be one an API key with a mailbox allowlist may use |
|
||||
| `campaign_id` | uuid | no | Test with this campaign's settings: its opt-out line, tracking and attachments |
|
||||
| `sequence_id` | uuid | no | A step of `campaign_id`. Its subject and body are used when the request carries neither. Needs `campaign_id` |
|
||||
| `contact_id` | uuid | no | The contact merge fields and AI blocks are rendered for. Defaults to the campaign's first lead, except on the `cloud` panel, where it defaults to a placeholder so no lead's details leave the instance unless you choose one; AI blocks are left out when no contact is used |
|
||||
| `subject` | string | no | The subject. Required when there is no step to take it from |
|
||||
| `body_html` | string | no | HTML body. A test needs this or `body_plain` |
|
||||
| `body_plain` | string | no | Plain-text body |
|
||||
| `tracking` | string | no | `campaign` (default: the campaign's own open and click tracking, none for an ad-hoc test), `on`, `off`, or `compare` (one untracked and one tracked test to the same seeds) |
|
||||
| `panel` | string | no | `instance` (default), `workspace` or `cloud` |
|
||||
|
||||
```json
|
||||
{
|
||||
"sender_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
|
||||
"campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
|
||||
"sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
|
||||
"tracking": "compare"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
`201 Created` with the new tests: one, or two sharing a `compare_group_id` for a comparison. Their copies start going out within seconds.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "d1e2f3a4-0000-0000-0000-000000000010",
|
||||
"sender_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
|
||||
"sender_email": "sales@yourdomain.com",
|
||||
"created_by": "9c2a0000-0000-0000-0000-000000000001",
|
||||
"campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
|
||||
"sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
|
||||
"contact_id": "e5f6a7b8-0000-0000-0000-000000000004",
|
||||
"monitor_id": null,
|
||||
"subject": "Quick question about outbound at Acme",
|
||||
"open_tracking": false,
|
||||
"link_tracking": false,
|
||||
"compare_group_id": "f0f1f2f3-0000-0000-0000-000000000020",
|
||||
"origin": "manual",
|
||||
"panel": "instance",
|
||||
"status": "running",
|
||||
"created_at": "2026-09-25T14:00:00Z",
|
||||
"finished_at": null,
|
||||
"summary": {
|
||||
"total": 38, "pending": 38, "inbox": 0, "promotions": 0, "other": 0, "spam": 0,
|
||||
"missing": 0, "failed": 0, "cancelled": 0, "delivered": 0,
|
||||
"inbox_rate": null, "tabs_rate": null, "spam_rate": null, "missing_rate": null
|
||||
},
|
||||
"families": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The second element (omitted above) is the tracked half, with `open_tracking` and `link_tracking` set. A comparison counts as two tests against the allowance and the limit on tests in flight, and sends twice as many copies.
|
||||
|
||||
## List tests
|
||||
|
||||
`GET /placement/tests`
|
||||
|
||||
Returns the workspace's tests, newest first, each with its counts. The copy's bodies are left out of the list.
|
||||
|
||||
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
|
||||
|
||||
| Parameter | In | Type | Description |
|
||||
|-----------|----|------|-------------|
|
||||
| `limit` | query | integer | `1` to `100`, default `25`. Anything else is a `400` |
|
||||
| `cursor` | query | string | Opaque cursor from a previous `pagination.next_cursor`. One this API did not issue is a `400` |
|
||||
| `campaign_id` | query | uuid | Only tests of this campaign |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "d1e2f3a4-0000-0000-0000-000000000010",
|
||||
"sender_email": "sales@yourdomain.com",
|
||||
"status": "completed",
|
||||
"panel": "instance",
|
||||
"origin": "manual",
|
||||
"finished_at": "2026-09-25T16:20:00Z",
|
||||
"summary": {
|
||||
"total": 38, "pending": 0, "inbox": 27, "promotions": 4, "other": 1, "spam": 4,
|
||||
"missing": 2, "failed": 0, "cancelled": 0, "delivered": 38,
|
||||
"inbox_rate": 0.7105, "tabs_rate": 0.1316, "spam_rate": 0.1053, "missing_rate": 0.0526
|
||||
},
|
||||
"families": [
|
||||
{
|
||||
"family": "gmail",
|
||||
"label": "Gmail",
|
||||
"counts": {
|
||||
"total": 10, "pending": 0, "inbox": 5, "promotions": 4, "other": 1, "spam": 0,
|
||||
"missing": 0, "failed": 0, "cancelled": 0, "delivered": 10,
|
||||
"inbox_rate": 0.5, "tabs_rate": 0.5, "spam_rate": 0, "missing_rate": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": { "total": 12, "has_more": false, "next_cursor": null }
|
||||
}
|
||||
```
|
||||
|
||||
Each test carries the fields shown under [start a test](#start-a-test); the example trims them.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `status` | `running`, `completed`, `cancelled` or `failed`. A test fails when no copy was delivered at all, with the reason in `error` |
|
||||
| `origin` | `manual` (started by a person or a key), `monitor` (a campaign's placement monitor), `admin` (an operator) or `remote` |
|
||||
| `summary` | Counts over every copy. `delivered` is `inbox + promotions + other + spam + missing`: every copy that left the sender and got a verdict |
|
||||
| `summary.*_rate` | Fractions from `0` to `1` of `delivered`, `null` until something is delivered. `tabs_rate` covers `promotions` and `other` |
|
||||
| `families` | The same counts per provider family |
|
||||
|
||||
## Get a test
|
||||
|
||||
`GET /placement/tests/:id`
|
||||
|
||||
Returns one test with its copy, every seed's result, the content check of the copy and, for a tracking comparison, the other half.
|
||||
|
||||
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "d1e2f3a4-0000-0000-0000-000000000010",
|
||||
"subject": "Quick question about outbound at Acme",
|
||||
"body_html": "<p>Hi Dana,</p><p>...</p>",
|
||||
"body_plain": "Hi Dana,\n\n...",
|
||||
"status": "completed",
|
||||
"panel": "instance",
|
||||
"summary": { "total": 38, "inbox": 27, "delivered": 38, "inbox_rate": 0.7105 },
|
||||
"families": [],
|
||||
"results": [
|
||||
{
|
||||
"seed": "a***@gmail.com",
|
||||
"family": "gmail",
|
||||
"family_label": "Gmail",
|
||||
"folder": "promotions",
|
||||
"scheduled_at": "2026-09-25T14:00:05Z",
|
||||
"sent_at": "2026-09-25T14:00:06Z",
|
||||
"detected_at": "2026-09-25T14:01:30Z"
|
||||
}
|
||||
],
|
||||
"content": {
|
||||
"score": 84,
|
||||
"issues": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The test's own fields are the ones listed under [list tests](#list-tests), plus `body_html` and `body_plain`; the example trims them.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `results[].seed` | The seed's address, masked on the `instance` and `cloud` panels and shown in full on `workspace` |
|
||||
| `results[].folder` | `pending`, `inbox`, `promotions`, `other` (another Gmail tab: Updates, Social or Forums), `spam`, `missing` (not seen within 120 minutes of `sent_at`), `failed` (never left the sender; `error` says why) or `cancelled` |
|
||||
| `content` | The advisory content score of the test's copy, the same shape as [score template content](/api/reference/deliverability-ops/#score-template-content) |
|
||||
| `compare` | Present only on a tracking comparison: the other half, with its counts and without its results |
|
||||
|
||||
## Cancel a test
|
||||
|
||||
`POST /placement/tests/:id/cancel`
|
||||
|
||||
Stops the copies that have not been sent yet. Copies already sent keep being classified. Supports `Idempotency-Key`.
|
||||
|
||||
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
|
||||
|
||||
### Response
|
||||
|
||||
`200 OK` with the test, in the list shape. A test that has already finished answers `409` with `placement_not_running`.
|
||||
|
||||
## List seed inboxes
|
||||
|
||||
`GET /placement/seeds`
|
||||
|
||||
Returns every mailbox of the workspace with whether it is one of the workspace's seed inboxes. A key restricted to certain mailboxes sees only those.
|
||||
|
||||
Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"email_account_id": "a0a1a2a3-0000-0000-0000-000000000009",
|
||||
"email": "dana.seed@gmail.com",
|
||||
"family": "gmail",
|
||||
"label": "Gmail",
|
||||
"status": "active",
|
||||
"seed": true
|
||||
},
|
||||
{
|
||||
"email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
|
||||
"email": "sales@yourdomain.com",
|
||||
"family": "google_workspace",
|
||||
"label": "Google Workspace",
|
||||
"status": "active",
|
||||
"seed": false,
|
||||
"blocker": "A placement test is sending from this mailbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`blocker` is present when the mailbox cannot become a seed inbox right now, and says why.
|
||||
|
||||
## Mark a seed inbox
|
||||
|
||||
`PUT /placement/seeds/:email_account_id`
|
||||
|
||||
Makes a workspace mailbox one of its seed inboxes, or an ordinary mailbox again. A seed never warms up (its warmup is turned off here, and starting it again is refused with `mailbox_is_seed`), never sends campaign mail, and cannot send a placement test. The body states the value to hold, so a retry lands on the same state.
|
||||
|
||||
Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails`
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `seed` | boolean | yes | `true` to make the mailbox a seed inbox, `false` to make it an ordinary mailbox |
|
||||
|
||||
```json
|
||||
{ "seed": true }
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
`200 OK` with the mailbox, in the shape of [list seed inboxes](#list-seed-inboxes). A mailbox on the instance panel, or one a test is still sending from, answers `409` with `placement_seed_unavailable`; a workspace that already has `50` seed inboxes answers `placement_seed_limit`.
|
||||
|
||||
## Get a campaign's placement monitor
|
||||
|
||||
`GET /campaigns/:id/placement-monitor`
|
||||
|
||||
Returns the campaign's scheduled placement test, or `null` when it has none.
|
||||
|
||||
Auth: **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "a9b8c7d6-0000-0000-0000-000000000030",
|
||||
"campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
|
||||
"created_by": "9c2a0000-0000-0000-0000-000000000001",
|
||||
"enabled": true,
|
||||
"interval_days": 7,
|
||||
"panel": "instance",
|
||||
"alert_below": 70,
|
||||
"pause_on_alert": false,
|
||||
"next_run_at": "2026-10-02T14:05:00Z",
|
||||
"last_run_at": "2026-09-25T14:05:00Z",
|
||||
"last_test_id": "d1e2f3a4-0000-0000-0000-000000000010",
|
||||
"last_alert_at": null,
|
||||
"created_at": "2026-09-18T14:00:00Z",
|
||||
"updated_at": "2026-09-25T14:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`last_error` is present when the last run could not start a test, and says why (for example, the campaign was not running); the monitor then tries again a day later.
|
||||
|
||||
## Set a campaign's placement monitor
|
||||
|
||||
`PUT /campaigns/:id/placement-monitor`
|
||||
|
||||
Creates or updates the campaign's monitor. Every field is optional: an absent field keeps its current value, or its default on a new monitor. The body states values rather than changes, so a retry lands on the same state. A new or re-enabled monitor runs its first test within about five minutes; changing `interval_days` moves the next run to that interval after the last one.
|
||||
|
||||
Each run tests the campaign's first email step with a subject and a body, rendered for its first lead with the campaign's own tracking, from the next of the campaign's sending mailboxes in turn. It runs only while the campaign is `active`, and counts against the allowance and the mailbox's daily limit like any other test.
|
||||
|
||||
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | boolean | `true` | Whether the monitor runs |
|
||||
| `interval_days` | integer | `7` | Days between runs, `1` to `30` |
|
||||
| `panel` | string | `instance` | `instance`, `workspace` or `cloud` |
|
||||
| `alert_below` | integer | `70` | Inbox percentage, `0` to `100`. A run whose primary-inbox share lands below it notifies every member who can view campaigns with a `placement_alert` notification |
|
||||
| `pause_on_alert` | boolean | `false` | Also pause the campaign when a run alerts. The campaign moves to `paused_guardrail` with the reason in its activity log and stays paused until it is started again |
|
||||
|
||||
```json
|
||||
{ "interval_days": 3, "alert_below": 80, "pause_on_alert": true }
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
`200 OK` with the monitor, in the shape above.
|
||||
|
||||
## Remove a campaign's placement monitor
|
||||
|
||||
`DELETE /campaigns/:id/placement-monitor`
|
||||
|
||||
Removes the campaign's monitor. Tests it already ran stay in the list.
|
||||
|
||||
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
|
||||
|
||||
### Response
|
||||
|
||||
`204 No Content`. A campaign with no monitor answers `404`.
|
||||
@@ -38,6 +38,7 @@ The landing page. Instance-wide counters and their trends, acquisition by channe
|
||||
| **Warmup** | The pools, the blocked list, and the block and unblock history |
|
||||
| **Warmup Appeals** | Appeals from blocked mailboxes, to approve or reject |
|
||||
| **Warmup Content** | The generated and reviewed warmup conversation bank, its library and generation jobs |
|
||||
| **Seed panel** | The instance's placement seed inboxes by provider, adding and removing them, every workspace's recent [placement tests](/guides/placement-tests/) with their results, and an operator test that counts against no allowance |
|
||||
| **Campaigns** | Every campaign on the instance with its state and owner |
|
||||
| **Sends** | In-flight reservations no worker has answered, and the reclaimer that resolves them after 30 minutes; dead letters with replay; task failures; customer webhook delivery health, with reclaim |
|
||||
|
||||
@@ -92,7 +93,7 @@ Reading the list needs `view_organizations`; creating, editing and deleting need
|
||||
| Overview, Live Events | none beyond signing in as an admin |
|
||||
| Workers, Fleet | `view_workers` |
|
||||
| Mailboxes, Sync, Users | `view_users` |
|
||||
| Warmup, Warmup Content | `view_warmup_pool` |
|
||||
| Warmup, Warmup Content, Seed panel | `view_warmup_pool` (changing seeds or running a test needs `manage_warmup_bans`) |
|
||||
| Warmup Appeals | `review_appeals` |
|
||||
| Campaigns, Sends | `view_campaigns` |
|
||||
| Organizations, Limit requests, Promo codes, Outreach, Transfers | `view_organizations` |
|
||||
|
||||
@@ -185,7 +185,7 @@ Cold-email throughput is mailbox-first, not worker-first. A worker's safe outbou
|
||||
|
||||
Pool model: warmup traffic is segregated into `free` and `premium` pools (the `warmup_pools` tables in `internal/infrastructure/db/migrations/000001_baseline.up.sql`). Pool membership is a property of the mailbox, independent of which worker happens to be sending for it.
|
||||
|
||||
Warmup content generation is an offline control-plane workload. The autonomous controller runs every six hours, derives a shared-bank target from seven-day send demand, maintains a 200-thread floor, scales up to 5,000 threads, and submits at most 250 replacements per batch within a 1,000-thread daily cap. It deliberately does not split generation by customer mailbox tags, which would create unbounded queues and smaller, more repetitive content cohorts. Scheduled top-ups use `gpt-5-mini` through durable provider batch jobs rather than synchronous model calls, with a database uniqueness guard that prevents duplicate in-flight scheduled batches when several backend replicas are running. Content with at least 20 sampled sends is automatically archived when it has at least 3 spam placements and a placement rate of 15% or more. The send path atomically selects and increments a least-used active conversation through an indexed query, then falls back to the static reviewed library if no generated content is available. Model availability therefore never gates a warmup send.
|
||||
Warmup content generation is an offline control-plane workload. The autonomous controller runs every six hours, derives a shared-bank target from seven-day send demand, maintains a 200-thread floor, scales up to 5,000 threads, and submits at most 250 replacements per batch within a 1,000-thread daily cap. It deliberately does not split generation by customer mailbox tags, which would create unbounded queues and smaller, more repetitive content cohorts. Scheduled top-ups use `gpt-6-luna` through durable provider batch jobs rather than synchronous model calls, with a database uniqueness guard that prevents duplicate in-flight scheduled batches when several backend replicas are running. Content with at least 20 sampled sends is automatically archived when it has at least 3 spam placements and a placement rate of 15% or more. The send path atomically selects and increments a least-used active conversation through an indexed query, then falls back to the static reviewed library if no generated content is available. Model availability therefore never gates a warmup send.
|
||||
|
||||
## Anti-abuse layers
|
||||
|
||||
|
||||
@@ -791,6 +791,10 @@ These are the only settings a browser can change, and no environment variable ow
|
||||
| `tracking.machine_window_probable_seconds` | integer, 1 to 86400 | `600` | The window used instead of the two above when the tracking service recognised the source as a mail-security network that also renders clicked pages for people, which Proofpoint, Mimecast and Cisco do through browser isolation. Such a match cannot settle whether a person is behind the request, so it widens the window rather than deciding: inside it the event is the delivery-time scan, past it the recipient who got to the mail later. Never applied shorter than the window for the kind of event in hand, so naming a network can only ever catch more scans. Its ceiling reaches a day because how long a vendor takes to detonate a link is the vendor's property, not the instance's |
|
||||
| `deliverability.enforce_domain_auth` | boolean | `true` | Whether a sending domain that fails SPF or DMARC stops cold campaign sending and warmup sending from every mailbox on it. Off keeps the check running and still shows the state and the Advisor card, it just never blocks |
|
||||
| `deliverability.auth_grace_hours` | integer, 1 to 720 | `72` | How long a domain must stay failing before the gate applies. The clock starts when the background check first sees the failure, so this is also how much warning the owner gets |
|
||||
| `placement.tests_per_month_trial` | integer, 1 to 100000 | `3` | How many [placement tests](/guides/placement-tests/) a workspace on a free trial may run per calendar month on the instance seed panel. Only an instance running with `DEPLOYMENT_MODE=cloud` meters; a self-hosted instance never counts its own tests. Tests on a workspace's own seed inboxes are never counted |
|
||||
| `placement.tests_per_month_paid` | integer, 1 to 100000 | `40` | The same allowance for a workspace on a paid plan |
|
||||
| `placement.seeds_per_test` | integer, 1 to 100 | `20` | The most seed inboxes one test sends to. Every copy is a send from the sending mailbox's daily limit, so this is also how much of that limit one test takes. A test is sized down to what the mailbox has left that day, and refused below five seeds |
|
||||
| `placement.spacing_seconds` | integer, 5 to 600 | `60` | The average gap between two copies of a test from one mailbox, varied by up to a third either way |
|
||||
|
||||
The four `sync.*` values are read by the backend when a mailbox is loaded onto a worker (on connect, on reassignment, and by the reconciler's periodic republish), so a change reaches every mailbox within 30 minutes without a restart (the republish gives each mailbox one turn per 30 minutes, spread out so no worker gets them all at once). The fixed pacing numbers around them (burst per five minutes, hourly, backfill pace, the flood threshold and the chronic-overage rule) are compiled constants listed under **Instance > Configuration > Effective limits**; see [Mailboxes](/guides/mailboxes/#what-gets-synced) for how the budgets behave. The four `sync.*` values also have a companion read view on **Operations > Sync**, which shows each mailbox's backfill progress and fair-use throttle against them and can clear a throttle or restart a backfill.
|
||||
|
||||
@@ -806,6 +810,8 @@ It is applied only while the settings row has never been written, so from the fi
|
||||
|
||||
The two `deliverability.*` values are read on every scheduling pass and every warmup send, so turning the gate off releases blocked mailboxes within the 30 second cache window. Turning it back on does not stop anything retroactively: a domain still has to spend its whole grace window failing first. Only a sustained failure gates, so a domain reading `unknown` (never checked, DNS could not answer, or a special-use domain that cannot resolve) always sends. Manual sends and unibox replies are never gated; see [domain authentication](/guides/deliverability/#domain-authentication).
|
||||
|
||||
The four `placement.*` values are read when a test starts, so a change applies to the next test and never to one already sending. A value of zero or below resolves to the default, and one above the range to its ceiling. Which mailboxes form the instance seed panel is not a setting: an administrator marks them from the admin panel.
|
||||
|
||||
Changing them is audited, and every value is validated and clamped server side on write as well as on read, so a row written by an older version still resolves.
|
||||
|
||||
## Variables that do not do what their name suggests
|
||||
|
||||
@@ -675,7 +675,7 @@ AI_BASE_URL= # required for custom (any OpenAI-compatible endpoint)
|
||||
|
||||
| `AI_PROVIDER` | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `openai` | api.openai.com | also the only provider for warmup content batch generation |
|
||||
| `openai` | api.openai.com | defaults to `gpt-6-luna`; also the only provider for warmup content batch generation |
|
||||
| `openrouter` | openrouter.ai | one key fronts every vendor; switch via `AI_MODEL` |
|
||||
| `groq` | api.groq.com | fast, free tier |
|
||||
| `ollama` | localhost:11434 | no key, free local model (`AI_FREE` is automatically true) |
|
||||
|
||||
@@ -39,6 +39,7 @@ Two other entry points:
|
||||
- AI credits: the plan's monthly allowance plus a 5,000-credit purchased top-up, so the assistant and every other AI surface work out of the box (re-seeding refills both pools)
|
||||
- 20 sender mailboxes on `@sunrise.test` (a healthy warmup pool by the dashboard's own coverage bar), assigned to the worker `make sandbox` runs, spread across the whole warmup lifecycle: seven graduated senders that finished their ramp weeks ago and carry full (~50/day) cold volume, eight mid-ramp, three freshly connected, one with warmup paused, and one on reduced health with an open provider warning. Every account has matching warmup ramp stats and daily send counts backfilled, varied pool health scores, a personal signature, and a tag (VIP, Agency, or Cold); pacing is demo friendly (45s minimum gap, 100/day cap)
|
||||
- campaigns in every list bucket: two active ("Sunrise Q3 launch outreach", 3 steps, 48 contacts; "Agency partnerships", 2 steps, 24 contacts), one paused, one completed, and one draft, all with tracking enabled, every step connected to the next (the scheduler only follows explicit step connections), all 20 senders in the rotation, and a realistic funnel of sent, opened, clicked, replied, and bounced timestamps spread over three weeks
|
||||
- six placement seed inboxes on the instance panel, on domains of their own (`gmail-seeds.test`, `workspace-seeds.test`, `m365-seeds.test`, `outlook-seeds.test`, `yahoo-seeds.test`) with the provider each stands in for, so a [placement test](/guides/placement-tests/) from any Sunrise mailbox has somewhere to land
|
||||
- a live unified inbox: threaded inbound replies across several senders, including a meeting request, an out-of-office autoreply, a not-interested reply, and a bounce notice, with a mix of read and unread
|
||||
- a CRM pipeline with five stages, five deals across them, tasks, contact notes, and per-contact activity timelines
|
||||
- the analytics tables behind the deliverability and reporting views: deliverability events (opens, clicks, replies, bounces, complaints, unsubscribes), reply-intent classifications, a suppression list, and resolved and open mailbox errors
|
||||
@@ -69,7 +70,7 @@ Real mail servers stand in for the internet's mail infrastructure:
|
||||
- **Mailpit** is the SMTP sink. Every mailbox's outbound server points at it, so all sends (campaign and warmup) are captured and visible in its UI.
|
||||
- **Dovecot** hosts the mailboxes' inboxes. It accepts any username with the password `sandbox` and auto-creates the maildir on first use. The worker's real IMAP sync polls it every minute.
|
||||
|
||||
The simulator bridges the two and plays every human:
|
||||
The simulator bridges the two and plays every human. It also plays the spam filter for the seed inboxes: a placement copy the content check scores below 70 goes to Junk, and a fixed share of the rest does too, more on the Microsoft and Yahoo seeds and more again for tracked copy, so a test and its tracking comparison both show a spread:
|
||||
|
||||
1. It polls Mailpit's API for new messages (the read flag is its cursor).
|
||||
2. Mail addressed to a hosted mailbox (for example warmup mail between pool members) is appended into that user's Dovecot inbox. The recipient's worker sync picks it up, the warmup token verifies, and engagement actions run against the real IMAP folders.
|
||||
|
||||
@@ -331,6 +331,12 @@ The AI score is deliberately not sampled, so re-checking copy you did not touch
|
||||
|
||||
When the draft has moved on from the analysis on screen, the panel says so and highlights the button, rather than presenting stale findings as current.
|
||||
|
||||
## Testing placement
|
||||
|
||||
A content score reads the copy; a placement test sends it. Test any email step from **Placement tests** by choosing the campaign and the step: the copy is rendered the way this campaign sends it (merge fields for its first lead, its opt-out line, its tracking and attachments) and sent from one of its mailboxes to seed inboxes at the major providers, which report whether it reached the inbox, a Gmail tab or spam, or never arrived. Each copy is a send from that mailbox's daily limit.
|
||||
|
||||
A **placement monitor** repeats that on a schedule while the campaign runs, every `7` days by default, rotating through the campaign's mailboxes. When a run lands below its alert threshold (`70%` inbox by default), everyone who can view campaigns is notified, and the monitor can pause the campaign too, as an auto-pause you restart yourself. See [inbox placement tests](/guides/placement-tests/).
|
||||
|
||||
## Easing out of warmup
|
||||
|
||||
A mailbox that has been warming does not jump straight to its full cold cap. Going from 40 warmup emails a day to 50 cold emails the next morning is the volume jump mailbox providers penalise, so Warmbly ramps into it.
|
||||
@@ -389,6 +395,9 @@ Warmbly limits a campaign mailbox to at most five warmup messages per day, so ca
|
||||
<Card title="Deliverability" href="/guides/deliverability/">
|
||||
Warmup, authentication, tracking, and sender reputation.
|
||||
</Card>
|
||||
<Card title="Inbox placement tests" href="/guides/placement-tests/">
|
||||
Send a step to seed inboxes and see where it lands, once or on a schedule.
|
||||
</Card>
|
||||
<Card title="Sequences" href="/guides/sequences/">
|
||||
Multi-step follow-ups with delays and branching.
|
||||
</Card>
|
||||
|
||||
@@ -16,9 +16,9 @@ Scope everything to `7d`, `30d`, or `90d` with the range tabs. It updates live,
|
||||
| Headline rates | Deliverability score, bounce rate, complaint rate, spam placement, suppressed count |
|
||||
| Over time | One metric per day: bounces, complaints, opens, replies, or sent. Use it to find the day something spiked |
|
||||
| Window totals | Raw counts behind the rates |
|
||||
| Seed inbox placement | Inbox vs spam share from test deliveries, with the sample count |
|
||||
| Seed inbox placement | Inbox vs spam share across your [placement tests](/guides/placement-tests/) in the window, with the number of copies behind it |
|
||||
| Warmup inbox placement | Where warmup mail landed each day (inbox, Gmail tabs, spam), the inbox rate over time, the split per recipient provider, and every mailbox ranked by its seven-day inbox rate |
|
||||
| Seed test placement by provider | The seed split per Gmail, Outlook, and IMAP/SMTP |
|
||||
| Seed test placement by provider | Where placement test copies landed per provider family (Gmail, Google Workspace, Outlook.com, Microsoft 365, Yahoo Mail and the rest): inbox, Promotions, other Gmail tabs, spam, and copies that never arrived |
|
||||
| Warmup placement by domain | Continuous placement per recipient domain, amber at `10%` spam, red at `20%` |
|
||||
| At risk | Mailboxes and campaigns with bounce or complaint activity, worst first |
|
||||
|
||||
@@ -27,10 +27,10 @@ The **score** is a `0-100` composite starting at `100`: bounce rate subtracts up
|
||||
Use **Customize** to pick which sections show. Your choices and time window persist per browser.
|
||||
|
||||
<Callout type="info" title="Placement needs a sample">
|
||||
Placement figures only appear once seed data exists, and a small sample is a hint rather than a verdict. Warmbly waits for a meaningful sample before acting.
|
||||
Seed placement figures only appear once a placement test has results in the window, and a small sample is a hint rather than a verdict. Warmbly waits for a meaningful sample before acting.
|
||||
</Callout>
|
||||
|
||||
**Seed test placement by provider is the check before scaling a campaign.** An aggregate rate can look fine while one provider quietly junks everything: Gmail at `95%` inbox with Outlook at `40%` is a provider-specific problem, usually authentication or content, not volume.
|
||||
**Seed test placement by provider is the check before scaling a campaign.** An aggregate rate can look fine while one provider quietly junks everything: Gmail at `95%` inbox with Outlook.com at `40%` is a provider-specific problem, usually authentication or content, not volume. The rates are shares of every copy that left the sender, so a copy that never arrived counts against the inbox rate rather than dropping out of the sample; a provider with many missing copies is usually refusing or deferring your mail. Run a test from **Placement tests**, or let a campaign's monitor run one on a schedule; see [inbox placement tests](/guides/placement-tests/) for how copies are sent, found and classified, and how to read one test against several.
|
||||
|
||||
**Warmup placement is your continuous signal.** Seed tests are point-in-time probes, but every verified warmup delivery reports inbox, tab or spam, so drift appears here before it becomes complaints on a campaign. Click a mailbox in **Warmup inbox placement** to open its own history; see [seeing where warmup lands](/guides/warmup/#seeing-where-warmup-lands) for how the rate is counted.
|
||||
|
||||
@@ -199,6 +199,7 @@ The habit that matters: keep complaint rate and spam placement low. A clean repu
|
||||
|
||||
<Cards>
|
||||
<Card title="Warmup" href="/guides/warmup/" />
|
||||
<Card title="Inbox placement tests" href="/guides/placement-tests/" />
|
||||
<Card title="Personalization & expressions" href="/guides/expressions/" />
|
||||
<Card title="Error codes" href="/api/error-codes/" />
|
||||
</Cards>
|
||||
|
||||
@@ -314,6 +314,8 @@ The same tab decides where warmup mail lands **in your own mail client**: its ow
|
||||
|
||||
Keep warmup running after campaigns begin rather than switching it off once a mailbox looks ready. See [Warmup](/guides/warmup/).
|
||||
|
||||
A mailbox marked as a [placement seed inbox](/guides/placement-tests/#your-own-seed-inboxes) is the exception: it never warms up (starting warmup on it is refused until it is unmarked) and never sends campaign mail, because a test inbox has to stay a stranger to your senders.
|
||||
|
||||
<Callout type="info" title="Warmup is a paid feature">
|
||||
The model still supports separate free and premium pools, but access is gated to paid workspaces today.
|
||||
</Callout>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"sequences",
|
||||
"expressions",
|
||||
"deliverability",
|
||||
"placement-tests",
|
||||
"unsubscribe",
|
||||
"advisor",
|
||||
"---Contacts and inbox---",
|
||||
|
||||
@@ -19,6 +19,8 @@ The feed and the bell count update live, without refreshing.
|
||||
| Spam complaint | Health | On | A complaint lands on a campaign |
|
||||
| Worker downtime | Health | On | A sender worker stops responding |
|
||||
| Campaign auto-paused | Health | On, including email | An [auto-pause guardrail](/guides/campaigns/) stopped a campaign |
|
||||
| Placement monitor alert | Health | On, including email | A campaign's scheduled [placement test](/guides/placement-tests/#campaign-placement-monitors) landed less of its mail in the primary inbox than the monitor's threshold |
|
||||
| Placement test finished | Health | On | A [placement test](/guides/placement-tests/) you started has a verdict for every copy |
|
||||
| New sign-in | Security | On | Your account is accessed from a new device, with browser, OS, and rough location |
|
||||
| Billing alerts | Billing | On, including email | A trial expired or sending was paused for billing |
|
||||
| Teammate joined | Team | On | Someone accepted an invitation |
|
||||
@@ -28,7 +30,7 @@ The feed and the bell count update live, without refreshing.
|
||||
Health defaults **on** because those alerts are operationally important and low volume. Inbound defaults **off** because a large campaign would generate one notification per recipient and flood the feed. Turn **Reply received** on only for smaller, higher-touch campaigns.
|
||||
|
||||
<Callout type="info" title="Who gets notified">
|
||||
Workspace events never fan out to everyone. Reply and bounce alerts go to the owner of the mailbox or campaign, worker downtime to members who can manage mailboxes, billing to those who can manage billing, and team changes to those who can manage the team. The person who joined is not notified about themselves.
|
||||
Workspace events never fan out to everyone. Reply and bounce alerts go to the owner of the mailbox or campaign, worker downtime to members who can manage mailboxes, a finished placement test to whoever started it, a placement monitor alert to members who can view campaigns, billing to those who can manage billing, and team changes to those who can manage the team. The person who joined is not notified about themselves.
|
||||
</Callout>
|
||||
|
||||
## Reply notifications follow the message
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: "Inbox placement tests"
|
||||
description: "Send a campaign step or a template from one of your mailboxes to seed inboxes at the major providers, and see where each copy landed."
|
||||
---
|
||||
|
||||
A placement test answers one question before your prospects do: when this mailbox sends this email, where does it land? Warmbly sends a copy from a real mailbox of yours to a panel of seed inboxes spread across providers, then reads each seed's mail to see whether the copy reached the primary inbox, a Gmail tab, spam, or nowhere at all.
|
||||
|
||||
Tests live under **Placement tests** in the dashboard. Reading results needs the **View analytics** permission, and starting or cancelling a test needs **Send campaigns**, because a test sends real mail from a real mailbox.
|
||||
|
||||
## What a test measures
|
||||
|
||||
Every copy gets one of these verdicts:
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---------|---------|
|
||||
| Inbox | The primary inbox |
|
||||
| Promotions | Gmail's Promotions tab, which is where cold mail goes to be ignored |
|
||||
| Other tab | Another Gmail tab: Updates, Social or Forums |
|
||||
| Spam | The spam or junk folder |
|
||||
| Never arrived | The copy left your mailbox but was not seen in the seed within 120 minutes of being sent |
|
||||
| Failed | The copy never left your mailbox, for example because its worker was offline |
|
||||
| Cancelled | The test was cancelled before this copy was sent |
|
||||
|
||||
The rates are shares of the copies that were **delivered**, meaning every copy that left the sender and got a verdict: inbox, both kinds of tab, spam and never arrived. Failed and cancelled copies are not in the denominator, because they say nothing about the receiving side. The inbox rate counts the primary inbox only, so a copy in Promotions counts against it.
|
||||
|
||||
Results are broken down by provider family: Gmail, Google Workspace, Outlook.com, Microsoft 365, Yahoo Mail, iCloud, Zoho and the rest. Gmail and Google Workspace are kept apart, as are Outlook.com and Microsoft 365, because the consumer and business services filter differently and an email can do well at one and badly at the other.
|
||||
|
||||
## How a test runs
|
||||
|
||||
### The copy is the one your campaign sends
|
||||
|
||||
Each copy is rendered the way a campaign send is, in the same order: merge fields and spintax for a sample lead, AI blocks for that lead, form links, the mailbox's signature, the opt-out line, the `List-Unsubscribe` header, attachments, and the open pixel and wrapped links when tracking is on. What a test leaves out is what only a real conversation has: threading into an earlier message, the A/B variant choice, and the campaign's CC and BCC.
|
||||
|
||||
When you test a campaign step, the copy renders for the campaign's first lead unless you pick another contact (on the Warmbly Cloud panel it renders for a placeholder instead, so a lead's details leave your instance only when you pick one, and AI blocks are left out), and the step is copied when the test starts, so an edit halfway through does not change what the later seeds receive. A follow-up step that threads under an earlier one has no subject of its own, so the test uses the subject of the step it threads under, without the `Re:`. A template you write by hand renders the way a new campaign would, with the workspace's default opt-out line and the unsubscribe header on.
|
||||
|
||||
Nothing is added to the message for the test's sake: no marker in the subject, no special header, no hidden text. Each copy is found again by its Message-ID, so what the seed's filter judges is exactly what a prospect would receive.
|
||||
|
||||
The test also runs the same [content check](/guides/campaigns/#content-checks) the campaign editor uses and shows its score and issues next to the results.
|
||||
|
||||
### Sent one at a time from the real mailbox
|
||||
|
||||
Copies go out one by one, each as its own send from the mailbox you chose, through the worker that mailbox normally sends from. They are spaced about 60 seconds apart by default, varied by up to a third either way, and alternate between providers so no single provider sees a burst. A test with 20 seeds takes around 20 minutes to send.
|
||||
|
||||
The sending mailbox's own copy of each probe is kept out of your unibox.
|
||||
|
||||
### Found in the seed's mail
|
||||
|
||||
Seed inboxes are ordinary connected mailboxes that Warmbly syncs. As a seed's mail comes in, each copy is matched by its Message-ID and the folder and Gmail labels it arrived with become its verdict. Results appear on the test live, one copy at a time, for everyone looking at it.
|
||||
|
||||
A copy not seen within 120 minutes of being sent is marked **never arrived**. Copies on Warmbly Cloud's panel get half an hour more, because their verdict travels back from the cloud. A test finishes when every copy has a verdict. A test in which no copy was delivered at all ends as **failed**, with the reason.
|
||||
|
||||
## Seed panels
|
||||
|
||||
A test runs against one panel of seed inboxes:
|
||||
|
||||
| Panel | Whose seeds | Available |
|
||||
|-------|-------------|-----------|
|
||||
| **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.
|
||||
|
||||
Addresses on the instance and Warmbly Cloud panels are shown masked (`a***@gmail.com`), so a shared panel cannot be listed and added to a sender's allowlist. Your own seed inboxes are shown in full.
|
||||
|
||||
Each test uses up to 20 seeds by default (the operator can change this). They are picked round-robin across provider families, so even a small test covers every provider the panel has. Two kinds of seed are always skipped:
|
||||
|
||||
- **Seeds on the sender's own domain.** Mail between two mailboxes in the same tenant skips most of the filtering a test is there to observe, so a seed at `acme.com` never receives a test sent from `sales@acme.com`.
|
||||
- **The sending mailbox itself.**
|
||||
|
||||
If nothing is left after that, the test is refused with `placement_no_seeds` and nothing is sent.
|
||||
|
||||
## Your own seed inboxes
|
||||
|
||||
Any mailbox connected to your workspace can become a seed inbox. Mark mailboxes on the **Seed inboxes** tab of the placement tests page; it needs the **Manage mailboxes** permission, and a workspace can have up to 50.
|
||||
|
||||
A good seed inbox is one that behaves like a stranger's inbox:
|
||||
|
||||
- **A separate address on a different domain from your senders.** Seeds on a sender's domain are skipped, as described above.
|
||||
- **Personal accounts at the big providers.** A Gmail, an Outlook.com and a Yahoo account cover most consumer filtering. Add a Google Workspace or Microsoft 365 mailbox on another domain you own to see the business side.
|
||||
- **Left alone.** Do not add your senders to its contacts, mark their mail as not spam, or change its filtering. Every one of those teaches the provider that your mail is wanted, which is exactly what a prospect's inbox has not learned.
|
||||
|
||||
Marking a mailbox as a seed changes how Warmbly treats it:
|
||||
|
||||
- **It never warms up.** Its warmup is turned off when you mark it, and starting warmup on it is refused (`mailbox_is_seed`) until you unmark it. A seed that exchanged mail with the warmup pool would stop being a stranger to every sender in it.
|
||||
- **It never sends campaign mail.** It is left out of every campaign's sending mailboxes, and it cannot send a placement test itself.
|
||||
- **It syncs for tests.** It gets a large daily sync allowance, so copies arriving from many tests are never deferred past the 120 minute window, and it imports no history. The copies it receives are stored like any other mail it syncs.
|
||||
|
||||
A mailbox on the instance panel cannot also be one of your seeds, and a mailbox cannot be marked while a test is still sending from it. Unmarking a seed makes it an ordinary mailbox again; its warmup stays off until you start it.
|
||||
|
||||
## Starting a test
|
||||
|
||||
A test needs a sending mailbox, a panel, and a copy to send. The sending mailbox has to be connected and active, and cannot itself be a seed inbox.
|
||||
|
||||
- **Test a campaign step** by choosing the campaign and the step. The step's subject and body are filled in, and the campaign's opt-out line, tracking and attachments apply. Pick a contact if you want the merge fields and AI blocks rendered for someone other than the campaign's first lead.
|
||||
- **Test an ad-hoc template** by writing a subject and a body. This is the way to try a draft before it becomes a campaign.
|
||||
|
||||
Cancelling a running test stops the copies that have not been sent yet. Copies already sent keep being classified, so a cancelled test still shows where they landed.
|
||||
|
||||
### Tracking
|
||||
|
||||
| Choice | What the copies carry |
|
||||
|--------|----------------------|
|
||||
| Campaign | The campaign's own open and click tracking. An ad-hoc template has none. This is the default |
|
||||
| On | The open pixel and wrapped links |
|
||||
| Off | Neither |
|
||||
| Compare | Two linked tests to the same seeds, one without tracking and one with it |
|
||||
|
||||
A comparison sends each seed both versions back to back, in a random order per seed, so both see the same filter at the same moment. The difference between the two halves is what the pixel and the wrapped links cost you. It counts as two tests and sends twice as many copies. A campaign that sends plain text carries no tracking, so **On** and **Compare** are refused for it (`placement_invalid_tracking`).
|
||||
|
||||
## What a test costs
|
||||
|
||||
**Every copy is a send from the sending mailbox.** It is charged to the mailbox's daily campaign limit, and the mailbox's campaigns see those sends in their daily budget and in their minimum gap between sends. A test to 20 seeds uses 20 sends of that day; a comparison uses 40. Copies still waiting to go out already hold their place in the day, so a campaign cannot spend them while the test sends. When the mailbox has less left than a full test needs, the test goes to fewer seeds instead; below five seeds per test it is refused with `placement_daily_budget`, because a smaller test is noise. Test from a mailbox with headroom, or before its campaigns run for the day.
|
||||
|
||||
A mailbox runs one test at a time: until every copy of a test has been sent, another test from the same mailbox is refused with `placement_sender_busy`.
|
||||
|
||||
### Monthly allowance
|
||||
|
||||
On the hosted product, tests on the instance panel count against a monthly allowance:
|
||||
|
||||
| Workspace | Tests per month |
|
||||
|-----------|-----------------|
|
||||
| On a free trial | `3` |
|
||||
| On a paid plan | `40` |
|
||||
|
||||
These are the defaults, and the operator can change them. The month is the calendar month in UTC. A tracking comparison counts as two tests, and a monitor's scheduled tests count like any other. The placement tests page shows how many you have used. A workspace with no active trial or subscription cannot start a test (`placement_not_entitled`), and one that has used its allowance is refused with `placement_quota_exceeded` until the next month.
|
||||
|
||||
Tests on your own seed inboxes are never counted. A self-hosted instance does not meter its own panel at all. Tests on Warmbly Cloud's panel count against the allowance of the Warmbly Cloud workspace the instance is linked to.
|
||||
|
||||
## Campaign placement monitors
|
||||
|
||||
A monitor re-tests a running campaign on a schedule, so a drop in placement shows up before it shows up in your reply rate. Set it up from the campaign.
|
||||
|
||||
| Setting | Default | Range |
|
||||
|---------|---------|-------|
|
||||
| Interval | every `7` days | `1` to `30` days |
|
||||
| Panel | Instance | Instance, your seed inboxes, or Warmbly Cloud |
|
||||
| Alert below | `70%` inbox | `0` to `100` |
|
||||
| Pause on alert | off | on or off |
|
||||
|
||||
Each run tests the campaign's first email step that has both a subject and a body, rendered for its first lead, with the campaign's own tracking. The runs rotate through the campaign's sending mailboxes, one mailbox per run, because every mailbox has its own standing and the one that slipped is the one you want to find. A new or re-enabled monitor runs its first test within about five minutes.
|
||||
|
||||
A monitor only tests a campaign that is running. When a run cannot start (the campaign is paused, none of its mailboxes is connected, the allowance is used, or the mailbox has too little of its daily limit left), the monitor records why and tries again a day later rather than a whole interval later. Its tests count against the monthly allowance and the mailbox's daily limit like any other.
|
||||
|
||||
**When a monitor's test lands below its threshold**, everyone in the workspace who can view campaigns gets a **Placement monitor alert**, with the result and a link to the test. With **Pause on alert** on, the campaign is also paused automatically, with the reason in its activity log. Like every [auto-pause](/guides/campaigns/#auto-pause-guardrails), it does not resume on its own: find out what changed, then start the campaign again yourself.
|
||||
|
||||
## Reading the results honestly
|
||||
|
||||
A placement test is a signal, not a verdict. Read it with its limits in mind:
|
||||
|
||||
- **Seeds are not your prospects.** A seed inbox has never heard from you, which is what makes it useful, but it also has none of the history a real recipient's mailbox builds up, and providers weigh each recipient's own behaviour heavily.
|
||||
- **Corporate gateways are not covered.** Many business recipients sit behind a security gateway such as Mimecast, Proofpoint or Cisco before their Microsoft 365 or Google Workspace mailbox. A seed shows the provider's own filter, not what a gateway in front of it would do.
|
||||
- **One test is noise.** A panel holds a handful of seeds per provider, and filters do not treat every copy of the same email alike. One copy in spam is not a problem; the same provider junking most copies across several tests is.
|
||||
- **Compare over several tests and providers.** Test more than one sending mailbox, repeat the test on another day, and look for a pattern: a single provider going bad usually points at authentication or content at that provider, while every provider going bad points at the sender.
|
||||
- **Never arrived is its own signal.** A copy can go missing because the provider dropped or delayed it, or because the seed had not synced it yet. One missing copy means little; most copies missing at one provider usually means that provider is refusing or deferring your mail.
|
||||
|
||||
Tests also feed the seed placement figures on the [Deliverability](/guides/deliverability/#the-dashboard) page, which roll every test in the window up by provider.
|
||||
|
||||
## Notifications
|
||||
|
||||
| Notification | Who gets it | Default |
|
||||
|--------------|-------------|---------|
|
||||
| Placement test finished | Whoever started the test by hand, once every copy has a verdict. A tracking comparison reports once, with both halves | On, in-app and push, no email |
|
||||
| Placement monitor alert | Everyone who can view campaigns, when a monitor's test lands below its threshold | On, including email |
|
||||
|
||||
Both open the test. Change them under **Settings > Notifications**; see [notifications](/guides/notifications/).
|
||||
|
||||
## Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Tests running at once per workspace | `3` (a comparison counts as two) |
|
||||
| Tests sending from one mailbox at once | `1` |
|
||||
| Seeds per test | up to `40` by default, at most `100` (operator setting) |
|
||||
| Your own seed inboxes | `50` per workspace |
|
||||
| Time for a copy to arrive | `120` minutes after it was sent |
|
||||
| Monitor interval | `1` to `30` days |
|
||||
|
||||
## Moving a workspace
|
||||
|
||||
Placement tests and their results travel in a [workspace export](/guides/workspace-export-import/) as a record, under Delivery events, and campaign placement monitors travel with the campaigns. Which mailboxes are seed inboxes does not travel, so mark them again on the destination. A test that is still sending leaves its unsent copies behind.
|
||||
|
||||
## Related guides
|
||||
|
||||
<Cards>
|
||||
<Card title="Deliverability" href="/guides/deliverability/">
|
||||
Bounce, complaint and placement health, and the thresholds Warmbly acts on.
|
||||
</Card>
|
||||
<Card title="Campaigns" href="/guides/campaigns/">
|
||||
Content checks, tracking and auto-pause guardrails.
|
||||
</Card>
|
||||
<Card title="Placement tests API" href="/api/reference/placement/">
|
||||
Start tests, read results and manage seeds and monitors programmatically.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -90,6 +90,18 @@ Enrolled mailboxes are ordinary members of the hosted pool and are held to the s
|
||||
|
||||
For mailboxes signed in through Warmbly Cloud the enforcement reaches sending too. Every access token the instance draws is checked on the cloud: a mailbox the cloud has **blocked**, one that is no longer active there, one that was removed from the workspace, or an instance whose link was revoked gets no token, and the instance stops sending and syncing from that mailbox as soon as its cached token expires.
|
||||
|
||||
## Placement tests on Warmbly Cloud's seed panel
|
||||
|
||||
A self-hosted instance's own seed panel holds whatever its administrator added, often nothing. A linked instance can borrow Warmbly Cloud's instead: pick **Warmbly Cloud** as the panel when you start an [inbox placement test](/guides/placement-tests/), or for a campaign's placement monitor.
|
||||
|
||||
The split is the same as for warmup, only narrower:
|
||||
|
||||
- **Your instance renders and sends every copy.** The cloud stores no template and never gets the mailbox's credentials; its seed inboxes receive the rendered copies like any recipient. A campaign step renders for a placeholder contact here, not the campaign's first lead, so no lead's details reach the cloud's seed inboxes unless you pick a contact for the test. Copies go out from your mailbox through your own workers, paced and charged to the mailbox's daily limit exactly like a test on any other panel.
|
||||
- **The cloud only hands out seed addresses and reports where each copy landed.** It picks seeds for the test (never one on your sender's domain), your instance tells it the Message-ID of each copy it sent, and the cloud answers with the folder each seed found it in. Seed addresses are shown masked, as on any shared panel.
|
||||
- **Tests count against the linked cloud workspace's allowance**, the same monthly allowance a hosted workspace on the same plan gets, and a tracking comparison counts as two. Tests on your instance's own panel and on your own seed inboxes are never counted.
|
||||
|
||||
The panel is offered only while the instance is linked. Without a link a test on it is refused with `placement_panel_unavailable`.
|
||||
|
||||
## Unlinking
|
||||
|
||||
On your instance, **Settings > Warmbly Cloud > Disconnect** removes every enrolled mailbox from the pool, deletes their credentials on the cloud and lets local warmup take over. On Warmbly Cloud, **Settings > Linked instances** lists the instances linked to a workspace and can unlink any of them from the other side.
|
||||
|
||||
@@ -50,6 +50,8 @@ Every draw also favours the inbox that is owed the most. A mailbox that has sent
|
||||
|
||||
Warmbly spreads sends across many partners and recipient domains rather than looping the same two mailboxes, since tight reciprocal pairs are easy for providers to spot. **Pool quality matters more than pool size.**
|
||||
|
||||
A [placement seed inbox](/guides/placement-tests/#your-own-seed-inboxes) never joins a pool: marking a mailbox as a seed turns its warmup off, and starting warmup on a seed is refused, because a seed that exchanged mail with the pool would stop being a stranger to every sender in it. It never sends campaign mail either.
|
||||
|
||||
Self-hosted instances warm only among their own mailboxes unless they are linked to Warmbly Cloud, which puts their enrolled mailboxes into these same pools. See [Warmbly Cloud for self-hosted instances](/guides/warmbly-cloud/).
|
||||
|
||||
### Choosing partners
|
||||
|
||||
@@ -19,14 +19,14 @@ The data is split into groups. Every export includes **Workspace**; the rest are
|
||||
|-------|----------|
|
||||
| Workspace | The organization, members, roles, teams, mailboxes, mailbox tags, the column mappings saved by [mailbox imports](/guides/mailbox-import/), inbox vendor connections, [root redirects](/guides/sending-domains/#root-redirects), API keys, webhooks, and settings, including the website tracking site key. Always included |
|
||||
| Contacts | Contacts, categories, segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
|
||||
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
|
||||
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
|
||||
| CRM | Pipelines, deals, tasks, and meeting bookings |
|
||||
| Automations | Automations, connected integrations, and lead sync sources |
|
||||
| Assistant | Assistant sessions and messages, skills, MCP servers, and AI settings |
|
||||
| Warmup | Warmup participation, routing rules, statistics and inbox placement history, appeals, and the standing of every penalised address, current or removed, so a move is not a way past a block |
|
||||
| Inbox | Unified inbox threads, message bodies, conversation labels, mailbox sync state, and completed automatic-tagging verdicts with their raw probabilities |
|
||||
| Send history | Queued and completed send tasks with their payloads |
|
||||
| Delivery events | Bounces, complaints, opens, clicks, placement tests, and website page views with the browser records that tie them to contacts |
|
||||
| Delivery events | Bounces, complaints, opens, clicks, [placement tests](/guides/placement-tests/) with where each copy landed, and website page views with the browser records that tie them to contacts |
|
||||
| Verification evidence | What real mail showed about each contact's address (deliveries, opens, replies, bounces), so verdicts and confidence survive the move |
|
||||
| Logs | Audit log, campaign logs, and notifications |
|
||||
| Billing history | Subscription, credit ledger, and referral records |
|
||||
@@ -96,6 +96,8 @@ Some things belong to an instance rather than to a workspace, so they are not ap
|
||||
| Risk and review status | A workspace's abuse posture is one platform's verdict about a tenant on its own infrastructure, reached from evidence the destination never saw. An archive can neither carry a restriction nor clear one |
|
||||
| Cold rotation state | Whether a mailbox is resting or held in reserve is this instance's decision about sending it watched. Every mailbox arrives in normal rotation and earns its way out again |
|
||||
| Cold sending ramps | How far a mailbox had eased into cold volume raises its cap, and the destination never watched it send. Mailboxes re-graduate from their warmup maturity, which costs a few days and errs toward sending less |
|
||||
| Seed inboxes | Which mailboxes are placement seed inboxes is this instance's choice of test inboxes, and an archive must not add mailboxes to another instance's seed panel. Every mailbox arrives as an ordinary one; mark your own seed inboxes again on the destination |
|
||||
| Placement test sends | Copies of a test that had not been sent yet stay behind, so the destination never sends to the source instance's seeds. Tests and their results arrive as a record. A campaign's placement monitor keeps its settings and next run, but not the record of its last run |
|
||||
| Scheduled deletions | A pending deletion from the source must never follow the workspace to its new home |
|
||||
| Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent |
|
||||
| Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history |
|
||||
|
||||
@@ -17,7 +17,7 @@ A message goes through three gates after you hand it to a mailbox provider. **Ac
|
||||
|
||||
"Delivery rate" almost always reports gate one only. A Gmail or Microsoft endpoint will accept 99% of validly-formed mail and then quietly route it to Spam if reputation is poor. The SMTP transaction never tells you that happened. Postfix logs the 250. Your tool shows a green check.
|
||||
|
||||
The only way to know placement after the fact is to probe it: warmup pool partners reporting where mail landed (`X-Warmbly-Seed: probe`), Gmail Postmaster Tools showing the IP and domain reputation buckets, or a seed-list service like GlockApps/MailGenius. None of those are inferable from the SMTP response.
|
||||
The only way to know placement after the fact is to probe it: warmup pool partners reporting where mail landed, a [placement test](/guides/placement-tests/) sending a copy to seed inboxes at each provider, Gmail Postmaster Tools showing the IP and domain reputation buckets, or a seed-list service like GlockApps/MailGenius. None of those are inferable from the SMTP response.
|
||||
|
||||
<Callout type="info" title="Working definition">
|
||||
**Deliverability = P(placement = Inbox | acceptance).** Everything in this handbook moves that conditional probability.
|
||||
|
||||
@@ -25,6 +25,8 @@ Some teams maintain a "seed list": a fixed set of mailboxes across providers tha
|
||||
|
||||
Seed lists are useful but not free of bias. They tend to be older, more engaged inboxes than your real cold recipients. Treat them as a directional signal.
|
||||
|
||||
Warmbly's [inbox placement tests](/guides/placement-tests/) work this way: a campaign step or a draft goes from one of your mailboxes to seed inboxes across providers, and each copy is found again by its Message-ID and classified as inbox, a Gmail tab, spam, or never arrived.
|
||||
|
||||
### Recipient feedback
|
||||
|
||||
If a recipient moves a message from Spam to Inbox, replies, or adds you to their address book, the provider sometimes tells you (through FBL feeds or workspace integrations). These are the strongest individual signals.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+527
-202
@@ -2,101 +2,95 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/app/placement"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// placementEntity tags placement actions in the admin audit trail.
|
||||
const placementEntity models.AuditEntityType = "placement_test"
|
||||
|
||||
// --- DTOs --------------------------------------------------------------
|
||||
|
||||
type placementTestRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID *uuid.UUID `json:"organization_id"`
|
||||
SenderAccountID uuid.UUID `json:"sender_account_id"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
func toPlacementTestRow(t repository.PlacementTest) placementTestRow {
|
||||
return placementTestRow{
|
||||
ID: t.ID,
|
||||
OrganizationID: t.OrganizationID,
|
||||
SenderAccountID: t.SenderAccountID,
|
||||
Subject: t.Subject,
|
||||
Status: t.Status,
|
||||
CreatedAt: t.CreatedAt,
|
||||
FinishedAt: t.FinishedAt,
|
||||
func (h *Handler) placementReady(c *gin.Context) bool {
|
||||
if h.PlacementService == nil {
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "placement testing is not configured"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type placementResultRow struct {
|
||||
SeedAccountID uuid.UUID `json:"seed_account_id"`
|
||||
Provider string `json:"provider"`
|
||||
Folder string `json:"folder"`
|
||||
DetectedAt *time.Time `json:"detected_at"`
|
||||
RawFlags string `json:"raw_flags"`
|
||||
}
|
||||
|
||||
// providerRollup aggregates per-provider folder counts for a test.
|
||||
type providerRollup struct {
|
||||
Provider string `json:"provider"`
|
||||
Inbox int `json:"inbox"`
|
||||
Promotions int `json:"promotions"`
|
||||
Spam int `json:"spam"`
|
||||
Other int `json:"other"`
|
||||
Pending int `json:"pending"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type seedAccountRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
WorkerID *uuid.UUID `json:"worker_id"`
|
||||
IsSeed bool `json:"is_seed"`
|
||||
}
|
||||
|
||||
func toSeedRow(s repository.SeedAccount) seedAccountRow {
|
||||
return seedAccountRow{
|
||||
ID: s.ID,
|
||||
Email: s.Email,
|
||||
Name: s.Name,
|
||||
Provider: s.Provider,
|
||||
Status: s.Status,
|
||||
WorkerID: s.WorkerID,
|
||||
IsSeed: s.IsSeed,
|
||||
// placementLimit parses ?limit into [1, 100], 400 on anything else.
|
||||
func placementLimit(c *gin.Context) (int, bool) {
|
||||
raw := c.Query("limit")
|
||||
if raw == "" {
|
||||
return 25, true
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 100 {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid limit"))
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------
|
||||
func optionalUUID(c *gin.Context, raw, field string) (*uuid.UUID, bool) {
|
||||
if raw == "" {
|
||||
return nil, true
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid "+field))
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
// --- Workspace ----------------------------------------------------------
|
||||
|
||||
// GetPlacementOverview lists the seed panels this workspace can test on and
|
||||
// its monthly allowance.
|
||||
func (h *Handler) GetPlacementOverview(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
out, xerr := h.PlacementService.Overview(c.Request.Context(), *orgID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
type createPlacementTestRequest struct {
|
||||
SenderAccountID string `json:"sender_account_id"`
|
||||
CampaignID string `json:"campaign_id"`
|
||||
SequenceID string `json:"sequence_id"`
|
||||
ContactID string `json:"contact_id"`
|
||||
Subject string `json:"subject"`
|
||||
BodyPlain string `json:"body_plain"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
BodyPlain string `json:"body_plain"`
|
||||
Tracking string `json:"tracking"`
|
||||
Panel string `json:"panel"`
|
||||
}
|
||||
|
||||
// AdminCreatePlacementTest sends a tokenized copy of a template through a chosen
|
||||
// sender to every active seed mailbox, recording one pending result per seed.
|
||||
func (h *Handler) AdminCreatePlacementTest(c *gin.Context) {
|
||||
if h.PlacementService == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
// CreatePlacementTest starts a test: one test, or two for a tracking
|
||||
// comparison.
|
||||
func (h *Handler) CreatePlacementTest(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req createPlacementTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
@@ -107,24 +101,346 @@ func (h *Handler) AdminCreatePlacementTest(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid sender_account_id"))
|
||||
return
|
||||
}
|
||||
|
||||
test, serr := h.PlacementService.CreateTest(c.Request.Context(), nil, senderID, req.Subject, req.BodyPlain, req.BodyHTML)
|
||||
if serr != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, serr.Error()))
|
||||
if !middleware.APIKeyAllowsEmailAccount(c, senderID) {
|
||||
errx.JSON(c, errx.New(errx.Forbidden, "this API key cannot send from that mailbox"))
|
||||
return
|
||||
}
|
||||
campaignID, ok := optionalUUID(c, req.CampaignID, "campaign_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sequenceID, ok := optionalUUID(c, req.SequenceID, "sequence_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contactID, ok := optionalUUID(c, req.ContactID, "contact_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var userID *uuid.UUID
|
||||
if id, err := middleware.GetUserUUID(c); err == nil && id != uuid.Nil {
|
||||
userID = &id
|
||||
}
|
||||
|
||||
h.audit(c, models.AuditActionCreate, placementEntity, &test.ID, map[string]string{
|
||||
"sender_account_id": senderID.String(),
|
||||
"subject": test.Subject,
|
||||
tests, xerr := h.PlacementService.CreateTests(c.Request.Context(), placement.CreateInput{
|
||||
OrgID: *orgID,
|
||||
UserID: userID,
|
||||
SenderAccountID: senderID,
|
||||
CampaignID: campaignID,
|
||||
SequenceID: sequenceID,
|
||||
ContactID: contactID,
|
||||
Subject: req.Subject,
|
||||
BodyHTML: req.BodyHTML,
|
||||
BodyPlain: req.BodyPlain,
|
||||
Tracking: req.Tracking,
|
||||
Panel: req.Panel,
|
||||
Origin: models.PlacementOriginManual,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"data": toPlacementTestRow(*test)})
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
for _, t := range tests {
|
||||
id := t.ID
|
||||
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityPlacementTest, &id, nil, map[string]string{
|
||||
"sender": t.SenderEmail,
|
||||
"panel": t.Panel,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": tests})
|
||||
}
|
||||
|
||||
// AdminListPlacementTests lists placement tests, newest first.
|
||||
func (h *Handler) AdminListPlacementTests(c *gin.Context) {
|
||||
// ListPlacementTests lists the workspace's tests, newest first.
|
||||
func (h *Handler) ListPlacementTests(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
offset, ok := decodeOffsetCursor(c.Query("cursor"))
|
||||
if !ok {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
|
||||
return
|
||||
}
|
||||
limit, ok := placementLimit(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
campaignID, ok := optionalUUID(c, c.Query("campaign_id"), "campaign_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tests, total, xerr := h.PlacementService.ListTests(c.Request.Context(), orgID, campaignID, limit, offset)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": tests, "pagination": pageMetaFor(offset, limit, len(tests), total)})
|
||||
}
|
||||
|
||||
// GetPlacementTest returns one test with every copy, the content check and,
|
||||
// for a tracking comparison, the other half.
|
||||
func (h *Handler) GetPlacementTest(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
detail, xerr := h.PlacementService.GetTest(c.Request.Context(), orgID, id)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": detail})
|
||||
}
|
||||
|
||||
// CancelPlacementTest stops the copies that have not been sent yet.
|
||||
func (h *Handler) CancelPlacementTest(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
view, xerr := h.PlacementService.CancelTest(c.Request.Context(), *orgID, id)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionStop, models.AuditEntityPlacementTest, &id, nil, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"data": view})
|
||||
}
|
||||
|
||||
// ListPlacementSeeds lists the workspace's mailboxes and which are its seed
|
||||
// inboxes.
|
||||
func (h *Handler) ListPlacementSeeds(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
seeds, xerr := h.PlacementService.ListWorkspaceSeeds(c.Request.Context(), *orgID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
allowed := seeds[:0]
|
||||
for _, s := range seeds {
|
||||
if middleware.APIKeyAllowsEmailAccount(c, s.EmailAccountID) {
|
||||
allowed = append(allowed, s)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": allowed})
|
||||
}
|
||||
|
||||
type setPlacementSeedRequest struct {
|
||||
Seed *bool `json:"seed"`
|
||||
}
|
||||
|
||||
// SetPlacementSeed makes a workspace mailbox a seed inbox, or not.
|
||||
func (h *Handler) SetPlacementSeed(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
var req setPlacementSeedRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
if req.Seed == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "seed is required"))
|
||||
return
|
||||
}
|
||||
seed, xerr := h.PlacementService.SetWorkspaceSeed(c.Request.Context(), *orgID, id, *req.Seed)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityEmailAccount, &id, map[string]string{
|
||||
"placement_seed": strconv.FormatBool(*req.Seed),
|
||||
}, map[string]string{"email": seed.Email})
|
||||
c.JSON(http.StatusOK, gin.H{"data": seed})
|
||||
}
|
||||
|
||||
// GetPlacementMonitor returns a campaign's scheduled placement test, null when
|
||||
// it has none.
|
||||
func (h *Handler) GetPlacementMonitor(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
m, xerr := h.PlacementService.GetMonitor(c.Request.Context(), *orgID, campaignID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": m})
|
||||
}
|
||||
|
||||
// PutPlacementMonitor creates or updates a campaign's scheduled test. A PUT of
|
||||
// the same body lands on the same state, so it needs no Idempotency-Key.
|
||||
func (h *Handler) PutPlacementMonitor(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
var in placement.MonitorInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
userID, _ := middleware.GetUserUUID(c)
|
||||
m, xerr := h.PlacementService.PutMonitor(c.Request.Context(), *orgID, userID, campaignID, in)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
id := m.ID
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityPlacementMonitor, &id, nil, map[string]string{
|
||||
"campaign_id": campaignID.String(),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"data": m})
|
||||
}
|
||||
|
||||
// DeletePlacementMonitor removes a campaign's scheduled test.
|
||||
func (h *Handler) DeletePlacementMonitor(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
if xerr := h.PlacementService.DeleteMonitor(c.Request.Context(), *orgID, campaignID); xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityPlacementMonitor, nil, nil, map[string]string{
|
||||
"campaign_id": campaignID.String(),
|
||||
})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Admin ----------------------------------------------------------------
|
||||
|
||||
type adminCreatePlacementTestRequest struct {
|
||||
SenderAccountID string `json:"sender_account_id"`
|
||||
Subject string `json:"subject"`
|
||||
BodyPlain string `json:"body_plain"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
Tracking string `json:"tracking"`
|
||||
}
|
||||
|
||||
// AdminCreatePlacementTest runs a test from any mailbox on the instance panel,
|
||||
// outside every workspace allowance.
|
||||
func (h *Handler) AdminCreatePlacementTest(c *gin.Context) {
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
var req adminCreatePlacementTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
senderID, err := uuid.Parse(req.SenderAccountID)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid sender_account_id"))
|
||||
return
|
||||
}
|
||||
if h.PlacementRepo == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "placement testing is not configured"))
|
||||
return
|
||||
}
|
||||
sender, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), senderID)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
return
|
||||
}
|
||||
if sender == nil || sender.OrganizationID == nil {
|
||||
errx.JSON(c, errx.New(errx.NotFound, "sending mailbox not found"))
|
||||
return
|
||||
}
|
||||
tests, xerr := h.PlacementService.CreateTests(c.Request.Context(), placement.CreateInput{
|
||||
OrgID: *sender.OrganizationID,
|
||||
SenderAccountID: senderID,
|
||||
Subject: req.Subject,
|
||||
BodyHTML: req.BodyHTML,
|
||||
BodyPlain: req.BodyPlain,
|
||||
Tracking: req.Tracking,
|
||||
Panel: models.PlacementPanelInstance,
|
||||
Origin: models.PlacementOriginAdmin,
|
||||
})
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
for _, t := range tests {
|
||||
id := t.ID
|
||||
h.audit(c, models.AuditActionCreate, models.AuditEntityPlacementTest, &id, map[string]string{
|
||||
"sender_account_id": senderID.String(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": tests})
|
||||
}
|
||||
|
||||
// AdminListPlacementTests lists every workspace's tests, newest first.
|
||||
func (h *Handler) AdminListPlacementTests(c *gin.Context) {
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
offset, ok := decodeOffsetCursor(c.Query("cursor"))
|
||||
@@ -133,24 +449,17 @@ func (h *Handler) AdminListPlacementTests(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 25)
|
||||
|
||||
tests, total, err := h.PlacementRepo.ListTests(c.Request.Context(), nil, limit, offset)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
tests, total, xerr := h.PlacementService.ListTests(c.Request.Context(), nil, nil, limit, offset)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
rows := make([]placementTestRow, 0, len(tests))
|
||||
for _, t := range tests {
|
||||
rows = append(rows, toPlacementTestRow(t))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows, "pagination": pageMetaFor(offset, limit, len(tests), total)})
|
||||
c.JSON(http.StatusOK, gin.H{"data": tests, "pagination": pageMetaFor(offset, limit, len(tests), total)})
|
||||
}
|
||||
|
||||
// AdminGetPlacementTest returns a test with its per-provider rollup and the
|
||||
// per-seed detail rows.
|
||||
// AdminGetPlacementTest returns any test in full.
|
||||
func (h *Handler) AdminGetPlacementTest(c *gin.Context) {
|
||||
if h.PlacementRepo == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
@@ -158,113 +467,43 @@ func (h *Handler) AdminGetPlacementTest(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
|
||||
test, results, err := h.PlacementRepo.GetTestWithResults(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
detail, xerr := h.PlacementService.GetTest(c.Request.Context(), nil, id)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
if test == nil {
|
||||
errx.JSON(c, errx.New(errx.NotFound, "placement test not found"))
|
||||
return
|
||||
}
|
||||
|
||||
rollups := map[string]*providerRollup{}
|
||||
resultRows := make([]placementResultRow, 0, len(results))
|
||||
for _, r := range results {
|
||||
provider := r.Provider
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
ru, ok := rollups[provider]
|
||||
if !ok {
|
||||
ru = &providerRollup{Provider: provider}
|
||||
rollups[provider] = ru
|
||||
}
|
||||
ru.Total++
|
||||
switch r.Folder {
|
||||
case repository.PlacementFolderInbox:
|
||||
ru.Inbox++
|
||||
case repository.PlacementFolderPromotions:
|
||||
ru.Promotions++
|
||||
case repository.PlacementFolderSpam:
|
||||
ru.Spam++
|
||||
case repository.PlacementFolderOther:
|
||||
ru.Other++
|
||||
default:
|
||||
ru.Pending++
|
||||
}
|
||||
resultRows = append(resultRows, placementResultRow{
|
||||
SeedAccountID: r.SeedAccountID,
|
||||
Provider: r.Provider,
|
||||
Folder: r.Folder,
|
||||
DetectedAt: r.DetectedAt,
|
||||
RawFlags: r.RawFlags,
|
||||
})
|
||||
}
|
||||
|
||||
rollup := make([]providerRollup, 0, len(rollups))
|
||||
for _, ru := range rollups {
|
||||
rollup = append(rollup, *ru)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"test": toPlacementTestRow(*test),
|
||||
"rollup": rollup,
|
||||
"results": resultRows,
|
||||
},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"data": detail})
|
||||
}
|
||||
|
||||
// --- Seeds -------------------------------------------------------------
|
||||
|
||||
// AdminListSeedMailboxes lists the configured seed panel.
|
||||
// AdminListSeedMailboxes lists the instance panel.
|
||||
func (h *Handler) AdminListSeedMailboxes(c *gin.Context) {
|
||||
if h.PlacementRepo == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
seeds, err := h.PlacementRepo.ListSeedAccounts(c.Request.Context(), false)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
seeds, xerr := h.PlacementService.AdminListSeeds(c.Request.Context())
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
rows := make([]seedAccountRow, 0, len(seeds))
|
||||
for _, s := range seeds {
|
||||
rows = append(rows, toSeedRow(s))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
c.JSON(http.StatusOK, gin.H{"data": seeds})
|
||||
}
|
||||
|
||||
// AdminListSeedCandidates lists connected mailboxes an admin can flag as seeds,
|
||||
// optionally filtered by an email substring.
|
||||
// AdminListSeedCandidates searches connected mailboxes to add to the panel.
|
||||
func (h *Handler) AdminListSeedCandidates(c *gin.Context) {
|
||||
if h.PlacementRepo == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
limit := parseLimit(c.Query("limit"), 50)
|
||||
candidates, err := h.PlacementRepo.ListSeedCandidates(c.Request.Context(), c.Query("search"), limit)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
seeds, xerr := h.PlacementService.AdminSeedCandidates(c.Request.Context(), c.Query("search"), parseLimit(c.Query("limit"), 50))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
rows := make([]seedAccountRow, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
rows = append(rows, toSeedRow(s))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
c.JSON(http.StatusOK, gin.H{"data": seeds})
|
||||
}
|
||||
|
||||
type setSeedRequest struct {
|
||||
IsSeed bool `json:"is_seed"`
|
||||
}
|
||||
|
||||
// AdminSetSeedMailbox toggles is_seed on a mailbox (register/unregister a seed).
|
||||
// AdminSetSeedMailbox adds a mailbox to the instance panel or takes it off.
|
||||
func (h *Handler) AdminSetSeedMailbox(c *gin.Context) {
|
||||
if h.PlacementRepo == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
@@ -272,31 +511,117 @@ func (h *Handler) AdminSetSeedMailbox(c *gin.Context) {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
||||
return
|
||||
}
|
||||
var req setSeedRequest
|
||||
var req setPlacementSeedRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
|
||||
seed, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
if req.Seed == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "seed is required"))
|
||||
return
|
||||
}
|
||||
if seed == nil {
|
||||
errx.JSON(c, errx.New(errx.NotFound, "mailbox not found"))
|
||||
seed, xerr := h.PlacementService.AdminSetSeed(c.Request.Context(), id, *req.Seed)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.PlacementRepo.SetIsSeed(c.Request.Context(), id, req.IsSeed); err != nil {
|
||||
errx.JSON(c, errx.InternalError())
|
||||
return
|
||||
}
|
||||
|
||||
action := models.AuditActionUpdate
|
||||
h.audit(c, action, placementEntity, &id, map[string]string{
|
||||
"is_seed": map[bool]string{true: "true", false: "false"}[req.IsSeed],
|
||||
"email": seed.Email,
|
||||
h.audit(c, models.AuditActionUpdate, models.AuditEntityEmailAccount, &id, map[string]string{
|
||||
"seed": strconv.FormatBool(*req.Seed),
|
||||
"email": seed.Email,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
c.JSON(http.StatusOK, gin.H{"data": seed})
|
||||
}
|
||||
|
||||
// --- Warmbly Cloud, for linked instances -----------------------------------
|
||||
|
||||
// PoolLinkPlacementPanel is the cloud seed panel as a linked instance sees it.
|
||||
func (h *Handler) PoolLinkPlacementPanel(c *gin.Context) {
|
||||
inst := middleware.GetPoolLinkInstance(c)
|
||||
if inst == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
out, xerr := h.PlacementService.RemotePanel(c.Request.Context(), inst)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// PoolLinkStartPlacement hands a linked instance the seeds for a test.
|
||||
func (h *Handler) PoolLinkStartPlacement(c *gin.Context) {
|
||||
inst := middleware.GetPoolLinkInstance(c)
|
||||
if inst == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
var req models.PlacementCloudStartRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
out, xerr := h.PlacementService.RemoteStart(c.Request.Context(), inst, req)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// PoolLinkPlacementSends records the Message-IDs of the copies an instance
|
||||
// sent.
|
||||
func (h *Handler) PoolLinkPlacementSends(c *gin.Context) {
|
||||
inst := middleware.GetPoolLinkInstance(c)
|
||||
if inst == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
testID, err := uuid.Parse(c.Param("testId"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid test id"))
|
||||
return
|
||||
}
|
||||
var req models.PlacementCloudSends
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errx.JSON(c, errx.InvalidBody(err))
|
||||
return
|
||||
}
|
||||
if xerr := h.PlacementService.RemoteSends(c.Request.Context(), inst, testID, req.Sends); xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PoolLinkPlacementVerdicts reports where each copy landed so far.
|
||||
func (h *Handler) PoolLinkPlacementVerdicts(c *gin.Context) {
|
||||
inst := middleware.GetPoolLinkInstance(c)
|
||||
if inst == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !h.placementReady(c) {
|
||||
return
|
||||
}
|
||||
testID, err := uuid.Parse(c.Param("testId"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid test id"))
|
||||
return
|
||||
}
|
||||
out, xerr := h.PlacementService.RemoteGet(c.Request.Context(), inst, testID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -662,6 +662,11 @@ func Run(
|
||||
campaigns.POST("/:id/preflight", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.RunCampaignPreflight)
|
||||
campaigns.GET("/:id/ab-analysis", m.RequireOrganization(), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetCampaignABAnalysis)
|
||||
campaigns.POST("/:id/test-email", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.SendTestEmail)
|
||||
// The campaign's scheduled placement test. PUT is a full-state
|
||||
// write, so a retry lands on the same monitor.
|
||||
campaigns.GET("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetPlacementMonitor)
|
||||
campaigns.PUT("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.PutPlacementMonitor)
|
||||
campaigns.DELETE("/:id/placement-monitor", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.DeletePlacementMonitor)
|
||||
|
||||
// Campaign start/stop
|
||||
campaigns.POST("/:id/start", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StartCampaign)
|
||||
@@ -980,6 +985,21 @@ func Run(
|
||||
analytics.GET("/usage", h.GetUsageOverview)
|
||||
}
|
||||
|
||||
// Inbox placement tests: a template or campaign step sent to a seed
|
||||
// panel from a real mailbox. Reads are analytics; starting one sends
|
||||
// mail, so it takes the campaign-sending gate like a test email.
|
||||
placementTests := protected.Group("/placement")
|
||||
placementTests.Use(m.RequireOrganization())
|
||||
{
|
||||
placementTests.GET("/overview", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementOverview)
|
||||
placementTests.GET("/tests", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.ListPlacementTests)
|
||||
placementTests.GET("/tests/:id", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementTest)
|
||||
placementTests.POST("/tests", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CreatePlacementTest)
|
||||
placementTests.POST("/tests/:id/cancel", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CancelPlacementTest)
|
||||
placementTests.GET("/seeds", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.ListPlacementSeeds)
|
||||
placementTests.PUT("/seeds/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SetPlacementSeed)
|
||||
}
|
||||
|
||||
// Audit logs
|
||||
auditLogs := protected.Group("/audit-logs")
|
||||
auditLogs.Use(m.RateLimitMiddleware(models.RateLimitRead), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAuditLogs))
|
||||
@@ -1482,6 +1502,11 @@ func Run(
|
||||
poolLinkInstance.POST("/mailboxes/:remoteId/warmup-deliveries", h.PoolLinkVerifyWarmupDelivery)
|
||||
poolLinkInstance.GET("/workspace-mailboxes", h.PoolLinkWorkspaceMailboxes)
|
||||
poolLinkInstance.POST("/mailboxes/adopt", h.PoolLinkAdopt)
|
||||
// The placement seed panel lent to linked instances.
|
||||
poolLinkInstance.GET("/placement/panel", h.PoolLinkPlacementPanel)
|
||||
poolLinkInstance.POST("/placement/tests", h.PoolLinkStartPlacement)
|
||||
poolLinkInstance.GET("/placement/tests/:testId", h.PoolLinkPlacementVerdicts)
|
||||
poolLinkInstance.POST("/placement/tests/:testId/sends", h.PoolLinkPlacementSends)
|
||||
}
|
||||
|
||||
// Self-hosted side: Settings > Warmbly Cloud.
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/audit"
|
||||
"github.com/warmbly/warmbly/internal/app/placement"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/generation"
|
||||
)
|
||||
|
||||
// PlacementTests is the slice of placement.Service the tools use.
|
||||
type PlacementTests interface {
|
||||
CreateTests(ctx context.Context, in placement.CreateInput) ([]placement.TestView, *errx.Error)
|
||||
ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]placement.TestView, int, *errx.Error)
|
||||
GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*placement.TestDetail, *errx.Error)
|
||||
}
|
||||
|
||||
type placementTools struct {
|
||||
svc PlacementTests
|
||||
audit audit.AuditService
|
||||
}
|
||||
|
||||
// RegisterPlacementTools adds the inbox placement tools once the placement
|
||||
// service exists, which is after the registry is built.
|
||||
func RegisterPlacementTools(r *Registry, svc PlacementTests, auditSvc audit.AuditService) {
|
||||
if r == nil || svc == nil {
|
||||
return
|
||||
}
|
||||
p := placementTools{svc: svc, audit: auditSvc}
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "list_placement_tests",
|
||||
Description: "List the workspace's inbox placement tests, newest first, with where each test's copies landed (primary inbox, Gmail tabs, spam, never arrived). Use this to answer whether copy is landing in the inbox before or during a campaign.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"campaign_id": strProp("Only this campaign's tests (UUID)."),
|
||||
"limit": intProp("Maximum tests to return (default 10, max 50)."),
|
||||
}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewAnalytics,
|
||||
RequiredAPIPerm: models.APIPermReadAnalytics,
|
||||
Handler: p.list,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_placement_test",
|
||||
Description: "Read one inbox placement test in full: where every copy landed per mail provider, the content check of the tested copy, and for a tracking comparison the untracked and tracked halves side by side.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"test_id": strProp("The placement test's UUID."),
|
||||
}, "test_id"),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermViewAnalytics,
|
||||
RequiredAPIPerm: models.APIPermReadAnalytics,
|
||||
Handler: p.get,
|
||||
})
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "run_placement_test",
|
||||
Description: "Start an inbox placement test: send a campaign step (or an ad-hoc subject and body) from one of the workspace's mailboxes to a panel of seed inboxes and report where each copy lands. " +
|
||||
"Every copy is a real send counted against that mailbox's daily limit, so only run it when the user asked for a placement test. Results arrive over the next minutes; read them with get_placement_test.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"sender_account_id": strProp("The mailbox to send from (UUID)."),
|
||||
"campaign_id": strProp("Test this campaign's copy (UUID)."),
|
||||
"sequence_id": strProp("The campaign step to test (UUID); requires campaign_id."),
|
||||
"subject": strProp("Subject of an ad-hoc template, when not testing a campaign step."),
|
||||
"body_plain": strProp("Plain-text body of an ad-hoc template."),
|
||||
"tracking": enumProp("Open and click tracking on the copies (default campaign).", "campaign", "on", "off", "compare"),
|
||||
"panel": enumProp("Which seed inboxes to test on (default instance).", "instance", "workspace", "cloud"),
|
||||
}, "sender_account_id"),
|
||||
Risk: generation.RiskSend,
|
||||
RequiredOrgPerm: models.PermSendCampaigns,
|
||||
RequiredAPIPerm: models.APIPermSendCampaigns,
|
||||
Handler: p.run,
|
||||
})
|
||||
}
|
||||
|
||||
func placementSummary(v placement.TestView) map[string]any {
|
||||
out := map[string]any{
|
||||
"test_id": v.ID.String(),
|
||||
"status": v.Status,
|
||||
"sender": v.SenderEmail,
|
||||
"subject": v.Subject,
|
||||
"panel": v.Panel,
|
||||
"open_tracking": v.OpenTracking,
|
||||
"link_tracking": v.LinkTracking,
|
||||
"created_at": v.CreatedAt,
|
||||
"summary": v.Summary,
|
||||
"by_provider": v.Families,
|
||||
}
|
||||
if v.CampaignID != nil {
|
||||
out["campaign_id"] = v.CampaignID.String()
|
||||
}
|
||||
if v.Error != "" {
|
||||
out["error"] = v.Error
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p placementTools) list(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
CampaignID string `json:"campaign_id"`
|
||||
Limit int `json:"limit"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var campaignID *uuid.UUID
|
||||
if in.CampaignID != "" {
|
||||
id, err := parseUUIDArg(in.CampaignID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
campaignID = &id
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
limit = min(limit, 50)
|
||||
org := inv.OrgID
|
||||
tests, total, xerr := p.svc.ListTests(ctx, &org, campaignID, limit, 0)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(tests))
|
||||
for _, t := range tests {
|
||||
out = append(out, placementSummary(t))
|
||||
}
|
||||
return jsonResult(map[string]any{"tests": out, "total": total})
|
||||
}
|
||||
|
||||
func (p placementTools) get(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
TestID string `json:"test_id"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := parseUUIDArg(in.TestID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
org := inv.OrgID
|
||||
detail, xerr := p.svc.GetTest(ctx, &org, id)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
out := placementSummary(detail.TestView)
|
||||
out["content_score"] = detail.Content.Score
|
||||
out["content_issues"] = detail.Content.Issues
|
||||
results := make([]map[string]any, 0, len(detail.Results))
|
||||
for _, r := range detail.Results {
|
||||
row := map[string]any{"provider": r.FamilyLabel, "folder": r.Folder}
|
||||
if r.Error != "" {
|
||||
row["error"] = r.Error
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
out["copies"] = results
|
||||
if detail.Compare != nil {
|
||||
out["comparison"] = placementSummary(*detail.Compare)
|
||||
}
|
||||
return jsonResult(out)
|
||||
}
|
||||
|
||||
func (p placementTools) run(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
||||
in, err := decodeArgs[struct {
|
||||
SenderAccountID string `json:"sender_account_id"`
|
||||
CampaignID string `json:"campaign_id"`
|
||||
SequenceID string `json:"sequence_id"`
|
||||
Subject string `json:"subject"`
|
||||
BodyPlain string `json:"body_plain"`
|
||||
Tracking string `json:"tracking"`
|
||||
Panel string `json:"panel"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sender, err := parseUUIDArg(in.SenderAccountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
optional := func(s string) (*uuid.UUID, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := parseUUIDArg(s)
|
||||
return &id, err
|
||||
}
|
||||
campaignID, err := optional(in.CampaignID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sequenceID, err := optional(in.SequenceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var userID *uuid.UUID
|
||||
if inv.UserID != uuid.Nil {
|
||||
u := inv.UserID
|
||||
userID = &u
|
||||
}
|
||||
tests, xerr := p.svc.CreateTests(ctx, placement.CreateInput{
|
||||
OrgID: inv.OrgID,
|
||||
UserID: userID,
|
||||
SenderAccountID: sender,
|
||||
CampaignID: campaignID,
|
||||
SequenceID: sequenceID,
|
||||
Subject: in.Subject,
|
||||
BodyPlain: in.BodyPlain,
|
||||
Tracking: in.Tracking,
|
||||
Panel: in.Panel,
|
||||
Origin: models.PlacementOriginManual,
|
||||
})
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(tests))
|
||||
for _, t := range tests {
|
||||
id := t.ID
|
||||
if p.audit != nil {
|
||||
p.audit.LogAction(ctx, inv.OrgID, inv.UserID, models.AuditActionCreate, models.AuditEntityPlacementTest, &id,
|
||||
inv.IP, inv.UserAgent, nil, map[string]string{"sender": t.SenderEmail, "panel": t.Panel})
|
||||
}
|
||||
out = append(out, placementSummary(t))
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
"tests": out,
|
||||
"note": "Copies go out one at a time; read the result with get_placement_test in a few minutes.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cloudlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func (s *service) PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error) {
|
||||
l, xerr := s.link(ctx)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
var out models.PlacementCloudPanel
|
||||
if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/placement/panel", nil, &out); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *service) StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) {
|
||||
l, xerr := s.link(ctx)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
var out models.PlacementCloudStart
|
||||
if xerr := s.clientFor(l).do(ctx, http.MethodPost, "/instance/placement/tests", req, &out); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *service) ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error {
|
||||
l, xerr := s.link(ctx)
|
||||
if xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
return s.clientFor(l).do(ctx, http.MethodPost, "/instance/placement/tests/"+testID.String()+"/sends",
|
||||
models.PlacementCloudSends{Sends: sends}, nil)
|
||||
}
|
||||
|
||||
func (s *service) PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) {
|
||||
l, xerr := s.link(ctx)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
var out models.PlacementCloudTest
|
||||
if xerr := s.clientFor(l).do(ctx, http.MethodGet, "/instance/placement/tests/"+testID.String(), nil, &out); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -121,6 +121,14 @@ type Service interface {
|
||||
// IsCloudWarmupThreadReply asks by ancestry: whether what a tokenless
|
||||
// message answers is a turn of one of the cloud's warmup conversations.
|
||||
IsCloudWarmupThreadReply(ctx context.Context, accountID uuid.UUID, messageID string, inReplyTo []string) (bool, error)
|
||||
|
||||
// The placement seed panel the cloud lends a linked instance. The
|
||||
// instance renders and sends every copy; the cloud only hands out seed
|
||||
// addresses and reports where each copy landed.
|
||||
PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error)
|
||||
StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error)
|
||||
ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error
|
||||
PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
|
||||
@@ -83,6 +83,14 @@ func (s *JobsService) ingestNewEmail(ctx context.Context, e *models.JobEventNewE
|
||||
return nil
|
||||
}
|
||||
|
||||
// A placement test's copies are the seeds' business; the sender's own
|
||||
// sent copy of each stays out of its unibox.
|
||||
if s.PlacementRepo != nil && e.Message.Folder == models.FolderSent && e.Message.MessageID != "" {
|
||||
if probe, perr := s.PlacementRepo.IsProbeSentCopy(ctx, e.Message.EmailID, e.Message.MessageID); perr == nil && probe {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// A pool-linked mailbox is warmup-only: everything else is dropped unread.
|
||||
if s.PoolLinkRepo != nil {
|
||||
if linked, lerr := s.PoolLinkRepo.GetMailboxByAccount(ctx, e.Message.EmailID); lerr == nil && linked != nil {
|
||||
|
||||
@@ -74,6 +74,13 @@ func (s *JobsService) HandleEmailSent(ctx context.Context, result models.SendEma
|
||||
log.Warn().Err(err).Str("email_account_id", task.EmailAccountID.String()).Msg("could not anchor the cold ramp")
|
||||
}
|
||||
}
|
||||
case "placement":
|
||||
// The seed is searched for the Message-ID the provider actually sent.
|
||||
if s.PlacementRepo != nil && result.MessageID != "" {
|
||||
if err := s.PlacementRepo.SetProbeMessageIDByTask(ctx, task.ID, result.MessageID); err != nil {
|
||||
log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("could not record a placement probe's message id")
|
||||
}
|
||||
}
|
||||
case "warmup":
|
||||
// Without this the recipient has nothing to match a warmup email
|
||||
// against when the verify header did not survive delivery.
|
||||
@@ -163,6 +170,10 @@ func (s *JobsService) HandleEmailFailed(ctx context.Context, result models.SendE
|
||||
return s.failCampaignSend(ctx, task, reason, code, nil)
|
||||
case "email":
|
||||
s.notifyUserSendFailed(ctx, task, reason)
|
||||
case "placement":
|
||||
if s.PlacementRepo != nil {
|
||||
return s.PlacementRepo.FailProbeByTask(ctx, task.ID, reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,9 +51,12 @@ type JobsService struct {
|
||||
WarmupEngagementRepo repository.WarmupEngagementRepository
|
||||
// WarmupPlacementRepo keeps each sender's daily placement history. Optional.
|
||||
WarmupPlacementRepo repository.WarmupPlacementRepository
|
||||
WarmupService warmupapp.Service
|
||||
WorkerRepo repository.WorkerRepository
|
||||
FleetNodeRepo repository.FleetNodeRepository
|
||||
// PlacementRepo resolves placement test probes from worker send results.
|
||||
// Optional.
|
||||
PlacementRepo repository.PlacementRepository
|
||||
WarmupService warmupapp.Service
|
||||
WorkerRepo repository.WorkerRepository
|
||||
FleetNodeRepo repository.FleetNodeRepository
|
||||
// LifecycleRepo moves mailboxes in and out of cold rotation. Nil disables
|
||||
// the lifecycle rebalancer entirely.
|
||||
LifecycleRepo repository.SendLifecycleRepository
|
||||
|
||||
@@ -109,6 +109,15 @@ func (s *emailService) BulkUpdateTags(ctx context.Context, orgID string, emailID
|
||||
// of an organization's mailbox, and the route's gate is already the
|
||||
// organization's manage-emails permission.
|
||||
func (s *emailService) SetWarmupLifecycle(ctx context.Context, orgID, emailAccountID, action string) (*models.Email, *errx.Error) {
|
||||
// A placement seed stays a stranger to every sender, so it never warms.
|
||||
if (action == "start" || action == "resume") && s.seedScope != nil {
|
||||
if id, perr := uuid.Parse(emailAccountID); perr == nil {
|
||||
if scope, err := s.seedScope.SeedScope(ctx, id); err == nil && scope != "" {
|
||||
return nil, errx.NewWithIdentifier(errx.Conflict, "mailbox_is_seed",
|
||||
"This mailbox is a placement seed inbox, which never warms up. Remove it from the seed inboxes first.")
|
||||
}
|
||||
}
|
||||
}
|
||||
account, err := s.emailRepository.SetWarmupLifecycle(ctx, orgID, emailAccountID, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -463,6 +472,13 @@ func (s *emailService) syncWarmupPoolMembership(ctx context.Context, account *mo
|
||||
s.removeFromAllWarmupPools(ctx, account)
|
||||
return
|
||||
}
|
||||
// A placement seed must stay a stranger to every sender, recipient role included.
|
||||
if s.seedScope != nil {
|
||||
if scope, err := s.seedScope.SeedScope(ctx, account.ID); err == nil && scope != "" {
|
||||
s.removeFromAllWarmupPools(ctx, account)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
role := "recipient_only"
|
||||
if account.Warmup != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/warmbly/warmbly/internal/app/instancesettings"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
@@ -468,6 +469,17 @@ func (s *emailService) syncDataFor(ctx context.Context, emailID uuid.UUID) (*mod
|
||||
data.Policy.BackfillMessages = 25
|
||||
}
|
||||
}
|
||||
// A seed receives every workspace's test copies, so the per-mailbox budget
|
||||
// that fits one person's mail would defer them past the classify window.
|
||||
// It never needs its history.
|
||||
if s.seedScope != nil {
|
||||
if scope, err := s.seedScope.SeedScope(ctx, emailID); err == nil && scope != "" {
|
||||
data.Policy.DailyMessages = max(data.Policy.DailyMessages, config.PlacementSeedDailySyncMessages)
|
||||
data.Policy.OrgDailyMessages = max(data.Policy.OrgDailyMessages, config.PlacementSeedDailySyncMessages)
|
||||
data.Policy.BackfillDays = 1
|
||||
data.Policy.BackfillMessages = 25
|
||||
}
|
||||
}
|
||||
if s.syncState != nil {
|
||||
if saved, err := s.syncState.Get(ctx, emailID); err == nil {
|
||||
data.State = saved
|
||||
|
||||
@@ -116,6 +116,9 @@ type EmailService interface {
|
||||
WireMailboxes(repo repository.MailboxRepository)
|
||||
WireUnibox(repo repository.UniboxRepository)
|
||||
WireSyncBudget(src SyncBudgetSource)
|
||||
// WireSeedScope lets the loader give a placement seed mailbox the sync
|
||||
// allowance a whole instance's test traffic needs.
|
||||
WireSeedScope(src SeedScopeSource)
|
||||
WirePoolLink(repo repository.PoolLinkRepository)
|
||||
// WireCloudLink marks managed mailboxes, which ship to the worker without a credential.
|
||||
WireCloudLink(repo repository.CloudLinkRepository)
|
||||
@@ -178,6 +181,7 @@ type emailService struct {
|
||||
syncState repository.EmailSyncStateRepository
|
||||
mailboxes repository.MailboxRepository
|
||||
syncBudget SyncBudgetSource
|
||||
seedScope SeedScopeSource
|
||||
// poolLink marks linked warmup-only mailboxes, which sync with no history.
|
||||
poolLink repository.PoolLinkRepository
|
||||
// cloudLink marks managed mailboxes whose credential the cloud holds.
|
||||
@@ -261,6 +265,16 @@ func (s *emailService) WireSyncBudget(src SyncBudgetSource) {
|
||||
s.syncBudget = src
|
||||
}
|
||||
|
||||
// SeedScopeSource reports whether a mailbox is a placement seed.
|
||||
type SeedScopeSource interface {
|
||||
SeedScope(ctx context.Context, accountID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
// WireSeedScope attaches the seed lookup the loader reads.
|
||||
func (s *emailService) WireSeedScope(src SeedScopeSource) {
|
||||
s.seedScope = src
|
||||
}
|
||||
|
||||
// WirePoolLink attaches the pool-link repository so linked mailboxes get the
|
||||
// warmup-only sync policy.
|
||||
func (s *emailService) WireCloudLink(repo repository.CloudLinkRepository) {
|
||||
|
||||
@@ -28,6 +28,9 @@ const (
|
||||
RuleComplaintRate Rule = "complaint_rate"
|
||||
RuleBounceRate Rule = "bounce_rate"
|
||||
RuleReplyRate Rule = "reply_rate"
|
||||
// RulePlacement is a scheduled inbox placement test below its alert
|
||||
// threshold, on a monitor set to pause the campaign.
|
||||
RulePlacement Rule = "placement"
|
||||
)
|
||||
|
||||
// Breach is one rule's verdict on one campaign.
|
||||
|
||||
@@ -22,6 +22,10 @@ type Service interface {
|
||||
Sweep(ctx context.Context) (int, error)
|
||||
// Clear wipes a campaign's tripped marker. Called when it is started again.
|
||||
Clear(ctx context.Context, campaignID uuid.UUID) error
|
||||
// PausePlacementAlert auto-pauses a campaign whose scheduled placement
|
||||
// test landed below its threshold. The placement alert is the
|
||||
// notification, so this only pauses and records it.
|
||||
PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -75,6 +79,28 @@ func (s *service) Sweep(ctx context.Context) (int, error) {
|
||||
return paused, nil
|
||||
}
|
||||
|
||||
func (s *service) PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error {
|
||||
tripped, err := s.repo.TripGuardrail(ctx, campaignID, reason)
|
||||
if err != nil || !tripped {
|
||||
return err
|
||||
}
|
||||
if s.logRepo != nil {
|
||||
_ = s.logRepo.CreateLog(ctx, &repository.CampaignLogEntry{
|
||||
CampaignID: campaignID,
|
||||
EventType: "guardrail_paused",
|
||||
Message: reason,
|
||||
Metadata: map[string]interface{}{"rule": string(RulePlacement)},
|
||||
})
|
||||
}
|
||||
if s.audit != nil {
|
||||
s.audit.LogAction(ctx, orgID, uuid.Nil,
|
||||
models.AuditActionPause, models.AuditEntityCampaign, &campaignID, "", "",
|
||||
map[string]string{"status": "active -> paused_guardrail"},
|
||||
map[string]string{"reason": string(RulePlacement)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Clear(ctx context.Context, campaignID uuid.UUID) error {
|
||||
return s.repo.ClearGuardrail(ctx, campaignID)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,56 @@ type Deliverability struct {
|
||||
AuthGraceHours int `json:"auth_grace_hours"`
|
||||
}
|
||||
|
||||
// Placement holds the inbox placement test knobs.
|
||||
type Placement struct {
|
||||
// TestsPerMonthTrial and TestsPerMonthPaid are how many tests one workspace
|
||||
// may run on the instance and cloud seed panels per calendar month. Tests
|
||||
// on a workspace's own seed mailboxes are not counted.
|
||||
TestsPerMonthTrial int `json:"tests_per_month_trial"`
|
||||
TestsPerMonthPaid int `json:"tests_per_month_paid"`
|
||||
// SeedsPerTest caps how many seed mailboxes one test sends to, which is
|
||||
// also how many sends it takes from the sender's day.
|
||||
SeedsPerTest int `json:"seeds_per_test"`
|
||||
// SpacingSeconds is the gap between two probes from one sender.
|
||||
SpacingSeconds int `json:"spacing_seconds"`
|
||||
}
|
||||
|
||||
// DefaultPlacement is the compiled placement test allowance and pacing.
|
||||
func DefaultPlacement() Placement {
|
||||
return Placement{
|
||||
TestsPerMonthTrial: config.PlacementTestsPerMonthTrialDefault,
|
||||
TestsPerMonthPaid: config.PlacementTestsPerMonthPaidDefault,
|
||||
SeedsPerTest: config.PlacementSeedsPerTestDefault,
|
||||
SpacingSeconds: config.PlacementSpacingSecondsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize clamps every knob; zero and negative resolve to the compiled
|
||||
// default, so a document written before this section existed keeps it.
|
||||
func (p *Placement) Normalize() {
|
||||
month := func(v, def int) int {
|
||||
if v <= 0 {
|
||||
return def
|
||||
}
|
||||
return min(v, config.PlacementTestsPerMonthMax)
|
||||
}
|
||||
p.TestsPerMonthTrial = month(p.TestsPerMonthTrial, config.PlacementTestsPerMonthTrialDefault)
|
||||
p.TestsPerMonthPaid = month(p.TestsPerMonthPaid, config.PlacementTestsPerMonthPaidDefault)
|
||||
if p.SeedsPerTest <= 0 {
|
||||
p.SeedsPerTest = config.PlacementSeedsPerTestDefault
|
||||
}
|
||||
p.SeedsPerTest = min(p.SeedsPerTest, config.PlacementSeedsPerTestMax)
|
||||
if p.SpacingSeconds <= 0 {
|
||||
p.SpacingSeconds = config.PlacementSpacingSecondsDefault
|
||||
}
|
||||
p.SpacingSeconds = max(config.PlacementSpacingSecondsMin, min(p.SpacingSeconds, config.PlacementSpacingSecondsMax))
|
||||
}
|
||||
|
||||
// Spacing is the gap between two probes as a duration.
|
||||
func (p Placement) Spacing() time.Duration {
|
||||
return time.Duration(p.SpacingSeconds) * time.Second
|
||||
}
|
||||
|
||||
// Document is the whole Tier B settings document. Every key here is one no
|
||||
// environment variable owns, so there is no precedence to resolve.
|
||||
type Document struct {
|
||||
@@ -196,6 +246,7 @@ type Document struct {
|
||||
Retention Retention `json:"retention"`
|
||||
Tracking Tracking `json:"tracking"`
|
||||
Deliverability Deliverability `json:"deliverability"`
|
||||
Placement Placement `json:"placement"`
|
||||
Notifications Notifications `json:"notifications"`
|
||||
}
|
||||
|
||||
@@ -213,6 +264,7 @@ func Defaults() Document {
|
||||
Retention: DefaultRetention(),
|
||||
Tracking: DefaultTracking(),
|
||||
Deliverability: DefaultDeliverability(),
|
||||
Placement: DefaultPlacement(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +343,7 @@ func (d *Document) Normalize() {
|
||||
d.Retention.Normalize()
|
||||
d.Tracking.Normalize()
|
||||
d.Deliverability.Normalize()
|
||||
d.Placement.Normalize()
|
||||
d.Notifications.Normalize()
|
||||
}
|
||||
|
||||
@@ -371,6 +424,12 @@ type Patch struct {
|
||||
EnforceDomainAuth *bool `json:"enforce_domain_auth"`
|
||||
AuthGraceHours *int `json:"auth_grace_hours"`
|
||||
} `json:"deliverability"`
|
||||
Placement *struct {
|
||||
TestsPerMonthTrial *int `json:"tests_per_month_trial"`
|
||||
TestsPerMonthPaid *int `json:"tests_per_month_paid"`
|
||||
SeedsPerTest *int `json:"seeds_per_test"`
|
||||
SpacingSeconds *int `json:"spacing_seconds"`
|
||||
} `json:"placement"`
|
||||
// Channels replaces the whole list when present. A channel that comes back
|
||||
// with a masked target or secret keeps the stored value, so the admin panel
|
||||
// can round-trip a redacted read without wiping credentials.
|
||||
@@ -452,6 +511,20 @@ func (p Patch) Apply(doc Document) Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Placement != nil {
|
||||
if p.Placement.TestsPerMonthTrial != nil {
|
||||
doc.Placement.TestsPerMonthTrial = *p.Placement.TestsPerMonthTrial
|
||||
}
|
||||
if p.Placement.TestsPerMonthPaid != nil {
|
||||
doc.Placement.TestsPerMonthPaid = *p.Placement.TestsPerMonthPaid
|
||||
}
|
||||
if p.Placement.SeedsPerTest != nil {
|
||||
doc.Placement.SeedsPerTest = *p.Placement.SeedsPerTest
|
||||
}
|
||||
if p.Placement.SpacingSeconds != nil {
|
||||
doc.Placement.SpacingSeconds = *p.Placement.SpacingSeconds
|
||||
}
|
||||
}
|
||||
if p.Notifications != nil && p.Notifications.Channels != nil {
|
||||
doc.Notifications.Channels = mergeChannels(doc.Notifications.Channels, *p.Notifications.Channels)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ type Service interface {
|
||||
// DomainAuth is the sending-domain authentication gate: whether it is
|
||||
// enforced at all, and how long a domain must stay failing first.
|
||||
DomainAuth(ctx context.Context) (enforce bool, grace time.Duration)
|
||||
// PlacementPolicy is the inbox placement test section, already normalized.
|
||||
PlacementPolicy(ctx context.Context) Placement
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -156,6 +158,12 @@ func (s *service) TrackingPolicy(ctx context.Context) Tracking {
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *service) PlacementPolicy(ctx context.Context) Placement {
|
||||
p := s.Get(ctx).Placement
|
||||
p.Normalize()
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *service) DomainAuth(ctx context.Context) (bool, time.Duration) {
|
||||
d := s.Get(ctx).Deliverability
|
||||
d.Normalize()
|
||||
|
||||
@@ -227,6 +227,10 @@ func digestTitle(category models.NotificationCategory, n int) string {
|
||||
return fmt.Sprintf("%d campaigns paused automatically", n)
|
||||
case models.NotifDomainAuth:
|
||||
return fmt.Sprintf("%d domains failing authentication", n)
|
||||
case models.NotifPlacementFinished:
|
||||
return fmt.Sprintf("%d placement tests finished", n)
|
||||
case models.NotifPlacementAlert:
|
||||
return fmt.Sprintf("%d placement alerts", n)
|
||||
default:
|
||||
return fmt.Sprintf("%d new notifications", n)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const (
|
||||
orgMailboxes = `(SELECT id FROM email_accounts WHERE organization_id = $1)`
|
||||
orgCampaigns = `(SELECT id FROM campaigns WHERE organization_id = $1)`
|
||||
orgContacts = `(SELECT id FROM contacts WHERE organization_id = $1)`
|
||||
orgTasks = `(SELECT id FROM tasks WHERE email_account_id IN ` + orgMailboxes + `)`
|
||||
orgTasks = `(SELECT id FROM tasks WHERE email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement')`
|
||||
orgThreads = `(SELECT DISTINCT thread_id FROM unibox_emails WHERE email_id IN ` + orgMailboxes + `)`
|
||||
orgPipelines = `(SELECT id FROM pipelines WHERE organization_id = $1)`
|
||||
orgInvitations = `(SELECT id FROM organization_invitations WHERE organization_id = $1)`
|
||||
@@ -195,9 +195,12 @@ var Tables = []Table{
|
||||
// mailbox it watched send. Importing "resting" would silence a mailbox
|
||||
// on the destination for a reason nothing there observed; importing
|
||||
// "active" would assert readiness the destination has not seen.
|
||||
// seed_scope is the operator's choice of test inboxes on this
|
||||
// instance; an archive must not add mailboxes to another instance's
|
||||
// seed panel.
|
||||
ResetOnImport: []string{
|
||||
"worker_id", "auth_checked_at", "auth_failing_since", "cold_ramp_started_at",
|
||||
"send_lifecycle", "send_lifecycle_since", "send_lifecycle_reason",
|
||||
"send_lifecycle", "send_lifecycle_since", "send_lifecycle_reason", "seed_scope",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -426,6 +429,13 @@ var Tables = []Table{
|
||||
Name: "campaign_leads", Group: models.OrgDataGroupCampaigns,
|
||||
Scope: `campaign_id IN ` + orgCampaigns,
|
||||
},
|
||||
{
|
||||
// A campaign's scheduled placement test. Its run history names this
|
||||
// instance's tests, which import later in the list.
|
||||
Name: "placement_monitors", Group: models.OrgDataGroupCampaigns,
|
||||
Scope: scopeOrg,
|
||||
ResetOnImport: []string{"last_test_id", "last_run_at", "last_alert_at", "last_error"},
|
||||
},
|
||||
{
|
||||
// The recipient's opt-out address. It travels because an unsubscribe
|
||||
// link a recipient already holds is a commitment for as long as it
|
||||
@@ -662,7 +672,9 @@ var Tables = []Table{
|
||||
// ---------- send pipeline ----------
|
||||
{
|
||||
Name: "tasks", Group: models.OrgDataGroupSending,
|
||||
Scope: `email_account_id IN ` + orgMailboxes,
|
||||
// A placement probe's task stays behind: a pending one would send from
|
||||
// the destination to the source instance's seeds.
|
||||
Scope: `email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement'`,
|
||||
// The handle belongs to the source instance's queue.
|
||||
ResetOnImport: []string{"cloud_task_name"},
|
||||
},
|
||||
@@ -748,12 +760,16 @@ var Tables = []Table{
|
||||
Scope: scopeOrg,
|
||||
},
|
||||
{
|
||||
// The results travel as a record. The link to a cloud-run test and the
|
||||
// seeds on the source instance's panel do not.
|
||||
Name: "placement_tests", Group: models.OrgDataGroupEvents,
|
||||
Scope: scopeOrg,
|
||||
Scope: scopeOrg,
|
||||
ResetOnImport: []string{"remote_instance_id", "remote_test_id"},
|
||||
},
|
||||
{
|
||||
Name: "placement_results", Group: models.OrgDataGroupEvents,
|
||||
Scope: `test_id IN ` + orgPlacements,
|
||||
Scope: `test_id IN ` + orgPlacements,
|
||||
ResetOnImport: []string{"seed_account_id", "remote_seed_id", "task_id", "remote_synced_at"},
|
||||
},
|
||||
{
|
||||
Name: "webhook_deliveries", Group: models.OrgDataGroupEvents,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// MonitorInput configures a campaign's scheduled placement test. Nil fields
|
||||
// keep the stored value, or the default on a new monitor.
|
||||
type MonitorInput struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
IntervalDays *int `json:"interval_days"`
|
||||
Panel *string `json:"panel"`
|
||||
AlertBelow *int `json:"alert_below"`
|
||||
PauseOnAlert *bool `json:"pause_on_alert"`
|
||||
}
|
||||
|
||||
func (s *service) ownedCampaign(ctx context.Context, orgID, campaignID uuid.UUID) (*models.Campaign, *errx.Error) {
|
||||
c, err := s.Campaigns.GetByID(ctx, campaignID)
|
||||
if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != orgID {
|
||||
return nil, errx.New(errx.NotFound, "campaign not found")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetMonitor is a campaign's monitor, nil when it has none.
|
||||
func (s *service) GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, *errx.Error) {
|
||||
if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
m, err := s.Repo.GetMonitor(ctx, orgID, campaignID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// PutMonitor creates or updates a campaign's monitor. A new or re-enabled
|
||||
// monitor runs its first test within minutes, not an interval from now.
|
||||
func (s *service) PutMonitor(ctx context.Context, orgID, userID, campaignID uuid.UUID, in MonitorInput) (*models.PlacementMonitor, *errx.Error) {
|
||||
if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
current, err := s.Repo.GetMonitor(ctx, orgID, campaignID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
// An API key has no user behind it.
|
||||
var createdBy *uuid.UUID
|
||||
if userID != uuid.Nil {
|
||||
createdBy = &userID
|
||||
}
|
||||
m := models.PlacementMonitor{
|
||||
OrganizationID: orgID,
|
||||
CampaignID: campaignID,
|
||||
CreatedBy: createdBy,
|
||||
Enabled: true,
|
||||
IntervalDays: config.PlacementMonitorIntervalDaysDef,
|
||||
Panel: models.PlacementPanelInstance,
|
||||
AlertBelow: config.PlacementMonitorAlertBelowDefault,
|
||||
NextRunAt: s.now().Add(5 * time.Minute),
|
||||
}
|
||||
if current != nil {
|
||||
m = *current
|
||||
}
|
||||
wasEnabled := current != nil && current.Enabled
|
||||
if in.Enabled != nil {
|
||||
m.Enabled = *in.Enabled
|
||||
}
|
||||
if in.IntervalDays != nil {
|
||||
if *in.IntervalDays < config.PlacementMonitorIntervalDaysMin || *in.IntervalDays > config.PlacementMonitorIntervalDaysMax {
|
||||
return nil, errx.New(errx.BadRequest, fmt.Sprintf("interval_days must be between %d and %d",
|
||||
config.PlacementMonitorIntervalDaysMin, config.PlacementMonitorIntervalDaysMax))
|
||||
}
|
||||
m.IntervalDays = *in.IntervalDays
|
||||
}
|
||||
if in.Panel != nil {
|
||||
if !models.ValidPlacementPanel(*in.Panel) {
|
||||
return nil, errx.New(errx.BadRequest, "panel must be instance, workspace or cloud")
|
||||
}
|
||||
m.Panel = *in.Panel
|
||||
}
|
||||
if in.AlertBelow != nil {
|
||||
if *in.AlertBelow < 0 || *in.AlertBelow > 100 {
|
||||
return nil, errx.New(errx.BadRequest, "alert_below must be between 0 and 100")
|
||||
}
|
||||
m.AlertBelow = *in.AlertBelow
|
||||
}
|
||||
if in.PauseOnAlert != nil {
|
||||
m.PauseOnAlert = *in.PauseOnAlert
|
||||
}
|
||||
if m.Enabled && !wasEnabled {
|
||||
m.NextRunAt = s.now().Add(5 * time.Minute)
|
||||
} else if current != nil && in.IntervalDays != nil && current.LastRunAt != nil {
|
||||
m.NextRunAt = current.LastRunAt.Add(time.Duration(m.IntervalDays) * 24 * time.Hour)
|
||||
}
|
||||
if err := s.Repo.UpsertMonitor(ctx, &m); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// DeleteMonitor removes a campaign's monitor.
|
||||
func (s *service) DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) *errx.Error {
|
||||
if _, xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
ok, err := s.Repo.DeleteMonitor(ctx, orgID, campaignID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if !ok {
|
||||
return errx.New(errx.NotFound, "this campaign has no placement monitor")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMonitors starts the test of every due monitor: the campaign's first
|
||||
// email step, from the next of its mailboxes in turn, because every mailbox
|
||||
// the campaign sends from has its own standing.
|
||||
func (s *service) runMonitors(ctx context.Context) {
|
||||
now := s.now()
|
||||
due, err := s.Repo.ClaimDueMonitors(ctx, now, 20)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return
|
||||
}
|
||||
for _, m := range due {
|
||||
next := now.Add(time.Duration(m.IntervalDays) * 24 * time.Hour)
|
||||
testID, senderID, reason := s.runMonitor(ctx, m)
|
||||
if reason != "" && testID == nil {
|
||||
// Nothing started: look again in a day rather than a whole interval.
|
||||
next = now.Add(24 * time.Hour)
|
||||
}
|
||||
if err := s.Repo.MarkMonitorRun(ctx, m.ID, next, testID, senderID, reason); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) runMonitor(ctx context.Context, m models.PlacementMonitor) (*uuid.UUID, *uuid.UUID, string) {
|
||||
campaign, xerr := s.ownedCampaign(ctx, m.OrganizationID, m.CampaignID)
|
||||
if xerr != nil {
|
||||
return nil, nil, "The campaign no longer exists."
|
||||
}
|
||||
if campaign.Status != "active" {
|
||||
return nil, nil, "The campaign is not running, so nothing was tested."
|
||||
}
|
||||
seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaign.ID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, nil, "The campaign's steps could not be read."
|
||||
}
|
||||
sort.Slice(seqs, func(i, j int) bool { return seqs[i].Position < seqs[j].Position })
|
||||
var step *models.Sequence
|
||||
for i := range seqs {
|
||||
if strings.TrimSpace(seqs[i].Subject) != "" && (mailhtml.HasContent(seqs[i].BodyHTML) || strings.TrimSpace(seqs[i].BodyPlain) != "") {
|
||||
step = &seqs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if step == nil {
|
||||
return nil, nil, "The campaign has no email step to test."
|
||||
}
|
||||
sender := s.nextSender(ctx, campaign, m.LastSenderID)
|
||||
if sender == nil {
|
||||
return nil, nil, "None of the campaign's mailboxes is connected and running."
|
||||
}
|
||||
views, xerr := s.CreateTests(ctx, CreateInput{
|
||||
OrgID: m.OrganizationID,
|
||||
UserID: m.CreatedBy,
|
||||
SenderAccountID: *sender,
|
||||
CampaignID: &campaign.ID,
|
||||
SequenceID: &step.ID,
|
||||
Tracking: models.PlacementTrackingCampaign,
|
||||
Panel: m.Panel,
|
||||
Origin: models.PlacementOriginMonitor,
|
||||
MonitorID: &m.ID,
|
||||
})
|
||||
if xerr != nil {
|
||||
return nil, sender, xerr.Message
|
||||
}
|
||||
id := views[0].ID
|
||||
return &id, sender, ""
|
||||
}
|
||||
|
||||
// nextSender is the campaign's mailbox after the one tested last, in a
|
||||
// stable order, skipping any that cannot send.
|
||||
func (s *service) nextSender(ctx context.Context, campaign *models.Campaign, last *uuid.UUID) *uuid.UUID {
|
||||
pool, xerr := repository.ResolveCampaignSenderPool(ctx, s.Emails, campaign)
|
||||
if xerr != nil {
|
||||
return nil
|
||||
}
|
||||
var ids []uuid.UUID
|
||||
for _, a := range pool.Accounts {
|
||||
if a.Status == "active" && a.WorkerID != nil {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() })
|
||||
pick := ids[0]
|
||||
if last != nil {
|
||||
for i, id := range ids {
|
||||
if id == *last {
|
||||
pick = ids[(i+1)%len(ids)]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return &pick
|
||||
}
|
||||
|
||||
// checkMonitorAlert raises the alert when a monitor's test lands below its
|
||||
// threshold, and pauses the campaign when the monitor is set to.
|
||||
func (s *service) checkMonitorAlert(ctx context.Context, monitorID, orgID uuid.UUID, campaignID *uuid.UUID, c models.PlacementCounts, body, link string) {
|
||||
m, err := s.Repo.GetMonitorByID(ctx, monitorID)
|
||||
if err != nil || m == nil || m.OrganizationID != orgID {
|
||||
return
|
||||
}
|
||||
rate := percent(c.InboxRate)
|
||||
if rate < 0 || rate >= m.AlertBelow {
|
||||
return
|
||||
}
|
||||
title := fmt.Sprintf("Inbox placement dropped to %d%%", rate)
|
||||
if m.PauseOnAlert && s.Pauser != nil && campaignID != nil {
|
||||
reason := fmt.Sprintf("Scheduled placement test landed %d%% in the inbox, below the %d%% alert threshold", rate, m.AlertBelow)
|
||||
if err := s.Pauser.PausePlacementAlert(ctx, orgID, *campaignID, reason); err != nil {
|
||||
errs.CaptureException(err)
|
||||
} else {
|
||||
title += "; campaign paused"
|
||||
}
|
||||
}
|
||||
if s.Notifier != nil {
|
||||
s.Notifier.NotifyOrg(ctx, orgID, models.PermViewCampaigns, uuid.Nil, models.NotifPlacementAlert, title, body, link,
|
||||
map[string]any{"campaign_id": m.CampaignID.String()}, "placement_alert:"+m.ID.String())
|
||||
}
|
||||
if err := s.Repo.MarkMonitorAlert(ctx, m.ID, s.now()); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// The cloud side: Warmbly Cloud lends its instance panel to a linked
|
||||
// self-hosted instance. The instance renders and sends every copy itself, so
|
||||
// the cloud stores no template; its seed inboxes receive the copies like any
|
||||
// recipient. It hands out seed addresses, takes the Message-IDs back, and
|
||||
// answers with where each copy landed.
|
||||
|
||||
// RemotePanel is the cloud panel and the linked workspace's allowance.
|
||||
func (s *service) RemotePanel(ctx context.Context, inst *models.PoolLinkInstance) (*models.PlacementCloudPanel, *errx.Error) {
|
||||
rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
usage, xerr := s.usage(ctx, inst.OrganizationID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
panel := panelInfo(models.PlacementPanelCloud, familySeeds(rows), true)
|
||||
if !panel.Available {
|
||||
panel.Reason = "Warmbly Cloud has no seed inboxes available right now."
|
||||
}
|
||||
return &models.PlacementCloudPanel{Panel: panel, Usage: usage}, nil
|
||||
}
|
||||
|
||||
// RemoteStart opens one test per requested variant against the same seeds,
|
||||
// charged to the linked workspace's allowance.
|
||||
func (s *service) RemoteStart(ctx context.Context, inst *models.PoolLinkInstance, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error) {
|
||||
if req.Tests < 1 || req.Tests > 2 {
|
||||
return nil, errx.New(errx.BadRequest, "tests must be 1 or 2")
|
||||
}
|
||||
usage, xerr := s.usage(ctx, inst.OrganizationID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if usage.Limit != nil && usage.Used+req.Tests > *usage.Limit {
|
||||
return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded",
|
||||
"This Warmbly Cloud workspace has used its placement tests for the month.")
|
||||
}
|
||||
running, err := s.Repo.CountRunning(ctx, inst.OrganizationID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if running+req.Tests > config.PlacementRunningPerOrgMax {
|
||||
return nil, placementErr(errx.TooManyRequests, "placement_too_many_running",
|
||||
"This workspace already has placement tests running. Wait for one to finish.")
|
||||
}
|
||||
rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
limit := s.policy(ctx).SeedsPerTest
|
||||
if req.MaxSeeds > 0 {
|
||||
limit = min(limit, req.MaxSeeds)
|
||||
}
|
||||
seeds := pickSeeds(rows, uuid.Nil, req.SenderDomain, limit)
|
||||
if len(seeds) == 0 {
|
||||
return nil, placementErr(errx.Conflict, "placement_no_seeds", noSeedsMessage(models.PlacementPanelCloud))
|
||||
}
|
||||
|
||||
org := inst.OrganizationID
|
||||
instanceID := inst.ID
|
||||
out := &models.PlacementCloudStart{Usage: usage}
|
||||
for _, seed := range seeds {
|
||||
out.Seeds = append(out.Seeds, models.PlacementCloudSeed{ID: *seed.AccountID, Address: seed.Address, Family: seed.Family})
|
||||
}
|
||||
bundles := make([]repository.PlacementBundle, 0, req.Tests)
|
||||
for range req.Tests {
|
||||
test := &models.PlacementTest{
|
||||
ID: uuid.New(),
|
||||
OrganizationID: &org,
|
||||
Origin: models.PlacementOriginRemote,
|
||||
Panel: models.PlacementPanelInstance,
|
||||
Status: models.PlacementStatusRunning,
|
||||
RemoteInstanceID: &instanceID,
|
||||
}
|
||||
results := make([]models.PlacementResult, 0, len(seeds))
|
||||
for _, seed := range seeds {
|
||||
results = append(results, models.PlacementResult{
|
||||
SeedAccountID: seed.AccountID,
|
||||
SeedAddress: seed.Address,
|
||||
Family: seed.Family,
|
||||
Folder: models.PlacementFolderPending,
|
||||
})
|
||||
}
|
||||
bundles = append(bundles, repository.PlacementBundle{Test: test, Results: results})
|
||||
out.TestIDs = append(out.TestIDs, test.ID)
|
||||
}
|
||||
if err := s.Repo.CreateTests(ctx, bundles); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out.Usage.Used += req.Tests
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteSends records the Message-IDs an instance put on its copies.
|
||||
func (s *service) RemoteSends(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error {
|
||||
if len(sends) > config.PlacementSeedsPerTestMax {
|
||||
return errx.New(errx.BadRequest, "too many sends in one report")
|
||||
}
|
||||
test, _, err := s.Repo.GetRemoteTest(ctx, inst.ID, testID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if test == nil {
|
||||
return errx.New(errx.NotFound, "placement test not found")
|
||||
}
|
||||
for _, snd := range sends {
|
||||
if snd.Error == "" && snd.MessageID == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.Repo.RecordRemoteSent(ctx, inst.ID, testID, snd.SeedID, snd.MessageID, snd.SentAt, snd.Error); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoteGet answers with the verdicts so far.
|
||||
func (s *service) RemoteGet(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error) {
|
||||
test, results, err := s.Repo.GetRemoteTest(ctx, inst.ID, testID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if test == nil {
|
||||
return nil, errx.New(errx.NotFound, "placement test not found")
|
||||
}
|
||||
out := &models.PlacementCloudTest{TestID: test.ID, Status: test.Status, Verdicts: []models.PlacementCloudVerdict{}}
|
||||
for _, r := range results {
|
||||
if r.SeedAccountID == nil || !models.PlacementFolderResolved(r.Folder) {
|
||||
continue
|
||||
}
|
||||
out.Verdicts = append(out.Verdicts, models.PlacementCloudVerdict{SeedID: *r.SeedAccountID, Folder: r.Folder, DetectedAt: r.DetectedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// syncCloud is the instance side: report what left, pull back what landed.
|
||||
// It returns the local tests that got a verdict.
|
||||
func (s *service) syncCloud(ctx context.Context) map[uuid.UUID]bool {
|
||||
touched := map[uuid.UUID]bool{}
|
||||
if s.Cloud == nil {
|
||||
return touched
|
||||
}
|
||||
reports, err := s.Repo.ListRemoteReports(ctx, 200)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return touched
|
||||
}
|
||||
byTest := map[uuid.UUID][]int{}
|
||||
for i, r := range reports {
|
||||
byTest[r.RemoteTestID] = append(byTest[r.RemoteTestID], i)
|
||||
}
|
||||
for remoteTest, idx := range byTest {
|
||||
sends := make([]models.PlacementCloudSend, 0, len(idx))
|
||||
ids := make([]uuid.UUID, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
r := reports[i]
|
||||
snd := models.PlacementCloudSend{SeedID: r.RemoteSeedID, MessageID: r.MessageID, SentAt: r.SentAt}
|
||||
if r.Folder == models.PlacementFolderFailed || r.Folder == models.PlacementFolderCancelled {
|
||||
snd = models.PlacementCloudSend{SeedID: r.RemoteSeedID, Error: firstNonEmpty(r.Error, "Not sent")}
|
||||
}
|
||||
sends = append(sends, snd)
|
||||
ids = append(ids, r.ResultID)
|
||||
}
|
||||
if xerr := s.Cloud.ReportPlacementSends(ctx, remoteTest, sends); xerr != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Repo.MarkRemoteReported(ctx, ids); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
}
|
||||
|
||||
open, err := s.Repo.ListRemoteOpenTests(ctx, 50)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return touched
|
||||
}
|
||||
for _, t := range open {
|
||||
if t.RemoteTestID == nil {
|
||||
continue
|
||||
}
|
||||
answer, xerr := s.Cloud.PlacementVerdicts(ctx, *t.RemoteTestID)
|
||||
if xerr != nil || answer == nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range answer.Verdicts {
|
||||
if !models.PlacementFolderResolved(v.Folder) {
|
||||
continue
|
||||
}
|
||||
if err := s.Repo.RecordRemoteVerdict(ctx, t.ID, v.SeedID, v.Folder, v.DetectedAt); err != nil {
|
||||
errs.CaptureException(err)
|
||||
continue
|
||||
}
|
||||
touched[t.ID] = true
|
||||
}
|
||||
}
|
||||
return touched
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
func workspaceSeed(r repository.SeedAccount, blocker string) models.PlacementWorkspaceSeed {
|
||||
fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email))
|
||||
return models.PlacementWorkspaceSeed{
|
||||
EmailAccountID: r.ID,
|
||||
Email: r.Email,
|
||||
Family: fam,
|
||||
Label: familyLabel(fam),
|
||||
Status: r.Status,
|
||||
Seed: r.SeedScope == models.SeedScopeWorkspace,
|
||||
Blocker: blocker,
|
||||
}
|
||||
}
|
||||
|
||||
// ListWorkspaceSeeds lists a workspace's mailboxes with which are its seed
|
||||
// inboxes and which cannot become one.
|
||||
func (s *service) ListWorkspaceSeeds(ctx context.Context, orgID uuid.UUID) ([]models.PlacementWorkspaceSeed, *errx.Error) {
|
||||
rows, blockers, err := s.Repo.ListOrgMailboxes(ctx, orgID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out := make([]models.PlacementWorkspaceSeed, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, workspaceSeed(r, blockers[r.ID]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetWorkspaceSeed makes one of the workspace's mailboxes a seed inbox, or an
|
||||
// ordinary mailbox again. A seed stops warming and stops sending campaigns:
|
||||
// a test inbox that talks to the pool or to prospects learns its senders and
|
||||
// stops being a stranger's inbox.
|
||||
func (s *service) SetWorkspaceSeed(ctx context.Context, orgID, accountID uuid.UUID, enabled bool) (*models.PlacementWorkspaceSeed, *errx.Error) {
|
||||
row, err := s.Repo.GetSeedAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if row == nil || row.OrganizationID == nil || *row.OrganizationID != orgID {
|
||||
return nil, errx.New(errx.NotFound, "mailbox not found")
|
||||
}
|
||||
if row.SeedScope == models.SeedScopeInstance {
|
||||
return nil, placementErr(errx.Conflict, "placement_seed_unavailable", "This mailbox is on the instance seed panel.")
|
||||
}
|
||||
if enabled == (row.SeedScope == models.SeedScopeWorkspace) {
|
||||
v := workspaceSeed(*row, "")
|
||||
return &v, nil
|
||||
}
|
||||
if enabled {
|
||||
own, err := s.Repo.ListSeeds(ctx, models.SeedScopeWorkspace, &orgID, false)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if len(own) >= config.PlacementSeedsPerWorkspaceMax {
|
||||
return nil, placementErr(errx.Conflict, "placement_seed_limit", "This workspace has the most seed inboxes it can have.")
|
||||
}
|
||||
if busy, err := s.Repo.SenderBusy(ctx, accountID); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
} else if busy {
|
||||
return nil, placementErr(errx.Conflict, "placement_seed_unavailable", "A placement test is sending from this mailbox.")
|
||||
}
|
||||
}
|
||||
scope := ""
|
||||
if enabled {
|
||||
scope = models.SeedScopeWorkspace
|
||||
}
|
||||
if xerr := s.applySeedScope(ctx, orgID, accountID, scope); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
row.SeedScope = scope
|
||||
v := workspaceSeed(*row, "")
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// applySeedScope writes the scope, stops warmup on a new seed and reloads the
|
||||
// mailbox so its worker syncs it under the seed allowance.
|
||||
func (s *service) applySeedScope(ctx context.Context, orgID, accountID uuid.UUID, scope string) *errx.Error {
|
||||
if err := s.Repo.SetSeedScope(ctx, accountID, scope); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if s.Mailboxes == nil {
|
||||
return nil
|
||||
}
|
||||
if scope != "" {
|
||||
if _, xerr := s.Mailboxes.SetWarmupLifecycle(ctx, orgID.String(), accountID.String(), "disable"); xerr != nil && xerr.Code != errx.NotFound {
|
||||
errs.CaptureException(xerr)
|
||||
}
|
||||
}
|
||||
if err := s.Mailboxes.LoadAccountOntoWorker(ctx, accountID); err != nil {
|
||||
// The next reconcile pass reloads it; the scope is already written.
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adminSeed(r repository.SeedAccount) AdminSeed {
|
||||
fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email))
|
||||
return AdminSeed{
|
||||
ID: r.ID,
|
||||
OrganizationID: r.OrganizationID,
|
||||
Email: r.Email,
|
||||
Name: r.Name,
|
||||
Provider: r.Provider,
|
||||
Family: fam,
|
||||
FamilyLabel: familyLabel(fam),
|
||||
Status: r.Status,
|
||||
WorkerID: r.WorkerID,
|
||||
SeedScope: r.SeedScope,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListSeeds is the instance panel.
|
||||
func (s *service) AdminListSeeds(ctx context.Context) ([]AdminSeed, *errx.Error) {
|
||||
rows, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, false)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out := make([]AdminSeed, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, adminSeed(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminSeedCandidates searches connected mailboxes an operator may add.
|
||||
func (s *service) AdminSeedCandidates(ctx context.Context, search string, limit int) ([]AdminSeed, *errx.Error) {
|
||||
rows, err := s.Repo.ListSeedCandidates(ctx, search, limit)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out := make([]AdminSeed, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, adminSeed(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminSetSeed adds a mailbox to the instance panel or takes it off.
|
||||
func (s *service) AdminSetSeed(ctx context.Context, accountID uuid.UUID, enabled bool) (*AdminSeed, *errx.Error) {
|
||||
row, err := s.Repo.GetSeedAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if row == nil {
|
||||
return nil, errx.New(errx.NotFound, "mailbox not found")
|
||||
}
|
||||
if !enabled && row.SeedScope != models.SeedScopeInstance {
|
||||
v := adminSeed(*row)
|
||||
return &v, nil
|
||||
}
|
||||
scope := ""
|
||||
if enabled {
|
||||
scope = models.SeedScopeInstance
|
||||
}
|
||||
orgID := uuid.Nil
|
||||
if row.OrganizationID != nil {
|
||||
orgID = *row.OrganizationID
|
||||
}
|
||||
if xerr := s.applySeedScope(ctx, orgID, accountID, scope); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
row.SeedScope = scope
|
||||
v := adminSeed(*row)
|
||||
return &v, nil
|
||||
}
|
||||
+667
-191
@@ -1,259 +1,735 @@
|
||||
// Package placement implements seed inbox-placement testing: send a tokenized
|
||||
// copy of a real template through a real sender to a panel of Warmbly-controlled
|
||||
// SEED mailboxes, then classify where each landed (Inbox / Spam / Promotions /
|
||||
// other) per provider.
|
||||
// Package placement runs inbox placement tests: a template or a campaign step
|
||||
// is sent from a real sender mailbox to a panel of seed mailboxes, each copy
|
||||
// rendered the way the campaign would render it and paced as its own task, and
|
||||
// where each copy landed (inbox, a Gmail tab, spam, never arrived) is read
|
||||
// back from the seed's synced mail by Message-ID.
|
||||
//
|
||||
// Classification reuses the warmup landing-folder detection (the same `\Junk`
|
||||
// /spam-flag logic in the consumer) and reads it out of the UNIBOX — the
|
||||
// received-mail store the worker syncs every mailbox into. A seed is an
|
||||
// ordinary connected + synced email_account flagged is_seed, so its received
|
||||
// mail lands in the unibox like any other mailbox; the poller (ClassifyPending)
|
||||
// looks up the test token in the seed's unibox entries and reads the folder
|
||||
// flags. No consumer hot-path hook is added.
|
||||
// Three panels exist: the instance panel the operator runs for everyone, a
|
||||
// workspace's own test inboxes, and Warmbly Cloud's panel for a linked
|
||||
// self-hosted instance.
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/instancesettings"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
"github.com/warmbly/warmbly/internal/tasks"
|
||||
"github.com/warmbly/warmbly/internal/tasks/proto"
|
||||
"github.com/warmbly/warmbly/internal/tasksched"
|
||||
)
|
||||
|
||||
// classifyTimeout bounds how long a result stays pending before the poller
|
||||
// gives up and records it as "other" (the message never showed up in the
|
||||
// seed's unibox — dropped, blocked at the gateway, or sync lag past the
|
||||
// window). Tests are marked completed once every result resolves or this
|
||||
// elapses.
|
||||
const classifyTimeout = 2 * time.Hour
|
||||
// Policy is the operator's placement section, satisfied by
|
||||
// instancesettings.Service.
|
||||
type Policy interface {
|
||||
PlacementPolicy(ctx context.Context) instancesettings.Placement
|
||||
}
|
||||
|
||||
// subjectTokenMarker prefixes the token embedded in the test subject. The
|
||||
// worker injects only the warmup verify header (config.WarmupVerifyHeader),
|
||||
// which this control-plane package can't change, so the subject is the token
|
||||
// carrier that reliably survives sync into the unibox. Format:
|
||||
//
|
||||
// <subject> [wmpl:<token>]
|
||||
//
|
||||
// The marker is matched verbatim by the repo's FindTokenInUnibox subject LIKE.
|
||||
const subjectTokenMarker = "wmpl:"
|
||||
// Entitlements answers who may test and on which allowance. feature.Gate
|
||||
// satisfies it.
|
||||
type Entitlements interface {
|
||||
CanSendCampaignEmail(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
||||
IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
|
||||
}
|
||||
|
||||
// Service is the seed inbox-placement testing service.
|
||||
// Publisher tells the dashboard a test moved. pubsub.StreamingPublisher
|
||||
// satisfies it.
|
||||
type Publisher interface {
|
||||
PublishPlacementTest(ctx context.Context, orgID, testID uuid.UUID, campaignID *uuid.UUID, status string)
|
||||
}
|
||||
|
||||
// Notifier reaches people about a finished test or a monitor alert.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any)
|
||||
NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
|
||||
}
|
||||
|
||||
// MailboxControl is what a seed toggle needs from the mailbox service: stop
|
||||
// its warmup and reload it onto its worker with the seed sync allowance.
|
||||
type MailboxControl interface {
|
||||
SetWarmupLifecycle(ctx context.Context, orgID, emailAccountID, action string) (*models.Email, *errx.Error)
|
||||
LoadAccountOntoWorker(ctx context.Context, accountID uuid.UUID) error
|
||||
}
|
||||
|
||||
// CampaignPauser pauses a campaign whose monitor alerted, when asked to.
|
||||
type CampaignPauser interface {
|
||||
PausePlacementAlert(ctx context.Context, orgID, campaignID uuid.UUID, reason string) error
|
||||
}
|
||||
|
||||
// CloudPanel is the instance side of Warmbly Cloud's seed panel. The
|
||||
// cloudlink service satisfies it; nil when the instance is not linked.
|
||||
type CloudPanel interface {
|
||||
PlacementPanel(ctx context.Context) (*models.PlacementCloudPanel, *errx.Error)
|
||||
StartPlacement(ctx context.Context, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error)
|
||||
ReportPlacementSends(ctx context.Context, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error
|
||||
PlacementVerdicts(ctx context.Context, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error)
|
||||
}
|
||||
|
||||
// Deps are the service's collaborators. Repo, Emails, Campaigns, Tasks and
|
||||
// Scheduler are required; the rest are optional.
|
||||
type Deps struct {
|
||||
Repo repository.PlacementRepository
|
||||
Emails repository.EmailRepository
|
||||
Campaigns repository.CampaignRepository
|
||||
Contacts repository.ContactRepository
|
||||
Tasks repository.TaskRepository
|
||||
Scheduler tasksched.Scheduler
|
||||
Policy Policy
|
||||
Gate Entitlements
|
||||
Publisher Publisher
|
||||
Notifier Notifier
|
||||
Mailboxes MailboxControl
|
||||
Pauser CampaignPauser
|
||||
Cloud CloudPanel
|
||||
}
|
||||
|
||||
// Service is the placement test surface.
|
||||
type Service interface {
|
||||
// CreateTest generates a unique token, persists the test plus one pending
|
||||
// result per active seed, and sends a tokenized copy of the template from
|
||||
// the sender to every active seed address.
|
||||
CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error)
|
||||
// ClassifyPending resolves pending results by looking up each test's token
|
||||
// in the seed's unibox entries and classifying the folder from flags. It
|
||||
// marks a test completed once all its results resolve or the timeout passes.
|
||||
ClassifyPending(ctx context.Context) error
|
||||
Overview(ctx context.Context, orgID uuid.UUID) (*Overview, *errx.Error)
|
||||
CreateTests(ctx context.Context, in CreateInput) ([]TestView, *errx.Error)
|
||||
ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]TestView, int, *errx.Error)
|
||||
GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*TestDetail, *errx.Error)
|
||||
CancelTest(ctx context.Context, orgID, id uuid.UUID) (*TestView, *errx.Error)
|
||||
|
||||
ListWorkspaceSeeds(ctx context.Context, orgID uuid.UUID) ([]models.PlacementWorkspaceSeed, *errx.Error)
|
||||
SetWorkspaceSeed(ctx context.Context, orgID, accountID uuid.UUID, enabled bool) (*models.PlacementWorkspaceSeed, *errx.Error)
|
||||
|
||||
GetMonitor(ctx context.Context, orgID, campaignID uuid.UUID) (*models.PlacementMonitor, *errx.Error)
|
||||
PutMonitor(ctx context.Context, orgID, userID, campaignID uuid.UUID, in MonitorInput) (*models.PlacementMonitor, *errx.Error)
|
||||
DeleteMonitor(ctx context.Context, orgID, campaignID uuid.UUID) *errx.Error
|
||||
|
||||
AdminListSeeds(ctx context.Context) ([]AdminSeed, *errx.Error)
|
||||
AdminSeedCandidates(ctx context.Context, search string, limit int) ([]AdminSeed, *errx.Error)
|
||||
AdminSetSeed(ctx context.Context, accountID uuid.UUID, enabled bool) (*AdminSeed, *errx.Error)
|
||||
|
||||
RemotePanel(ctx context.Context, inst *models.PoolLinkInstance) (*models.PlacementCloudPanel, *errx.Error)
|
||||
RemoteStart(ctx context.Context, inst *models.PoolLinkInstance, req models.PlacementCloudStartRequest) (*models.PlacementCloudStart, *errx.Error)
|
||||
RemoteSends(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error
|
||||
RemoteGet(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error)
|
||||
|
||||
// Tick classifies delivered probes, closes finished tests, syncs cloud
|
||||
// tests and runs due monitors. The poller calls it.
|
||||
Tick(ctx context.Context) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo repository.PlacementRepository
|
||||
emailRepo repository.EmailRepository
|
||||
sender tasks.EmailSender
|
||||
Deps
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires the placement service. emailRepo resolves sender/seed
|
||||
// mailbox rows; sender is the existing send primitive (publishes a SendEmail
|
||||
// event to the assigned worker over Kafka — no Cloud Task / task row needed
|
||||
// for a one-off probe).
|
||||
func NewService(repo repository.PlacementRepository, emailRepo repository.EmailRepository, sender tasks.EmailSender) Service {
|
||||
return &service{repo: repo, emailRepo: emailRepo, sender: sender}
|
||||
// NewService wires the placement service.
|
||||
func NewService(d Deps) Service {
|
||||
return &service{Deps: d, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *service) CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error) {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return nil, errors.New("subject is required")
|
||||
// CreateInput is one request to test a template.
|
||||
type CreateInput struct {
|
||||
OrgID uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
SenderAccountID uuid.UUID
|
||||
CampaignID *uuid.UUID
|
||||
SequenceID *uuid.UUID
|
||||
ContactID *uuid.UUID
|
||||
Subject string
|
||||
BodyHTML string
|
||||
BodyPlain string
|
||||
Tracking string
|
||||
Panel string
|
||||
Origin string
|
||||
MonitorID *uuid.UUID
|
||||
}
|
||||
|
||||
func (s *service) policy(ctx context.Context) instancesettings.Placement {
|
||||
if s.Policy == nil {
|
||||
p := instancesettings.DefaultPlacement()
|
||||
return p
|
||||
}
|
||||
if bodyPlain == "" && bodyHTML == "" {
|
||||
return nil, errors.New("a plaintext or HTML body is required")
|
||||
return s.Policy.PlacementPolicy(ctx)
|
||||
}
|
||||
|
||||
func placementErr(code errx.Code, id, msg string) *errx.Error {
|
||||
return errx.NewWithIdentifier(code, id, msg)
|
||||
}
|
||||
|
||||
// CreateTests validates a request, picks the seeds, writes the test (two for
|
||||
// a tracking comparison) with one task per probe, and schedules the tasks.
|
||||
func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView, *errx.Error) {
|
||||
if in.Panel == "" {
|
||||
in.Panel = models.PlacementPanelInstance
|
||||
}
|
||||
if !models.ValidPlacementPanel(in.Panel) {
|
||||
return nil, errx.New(errx.BadRequest, "panel must be instance, workspace or cloud")
|
||||
}
|
||||
if in.Origin == "" {
|
||||
in.Origin = models.PlacementOriginManual
|
||||
}
|
||||
if in.Tracking == "" {
|
||||
in.Tracking = models.PlacementTrackingCampaign
|
||||
}
|
||||
switch in.Tracking {
|
||||
case models.PlacementTrackingCampaign, models.PlacementTrackingOn, models.PlacementTrackingOff, models.PlacementTrackingCompare:
|
||||
default:
|
||||
return nil, errx.New(errx.BadRequest, "tracking must be campaign, on, off or compare")
|
||||
}
|
||||
|
||||
sender, xerr := s.emailRepo.GetByID(ctx, senderAccountID)
|
||||
if xerr != nil || sender == nil {
|
||||
return nil, fmt.Errorf("sender account not found")
|
||||
}
|
||||
if sender.WorkerID == nil {
|
||||
return nil, fmt.Errorf("sender account %s has no assigned worker", senderAccountID)
|
||||
if s.Gate != nil && !config.SelfHosted() {
|
||||
if ok, _ := s.Gate.CanSendCampaignEmail(ctx, in.OrgID); !ok {
|
||||
return nil, placementErr(errx.PaymentRequired, "placement_not_entitled", "Placement tests need an active trial or subscription.")
|
||||
}
|
||||
}
|
||||
|
||||
seeds, err := s.repo.ListSeedAccounts(ctx, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list seed accounts: %w", err)
|
||||
// The sender: an active mailbox of this workspace, on a worker, not itself
|
||||
// a seed.
|
||||
sender, xerr := s.Emails.GetByID(ctx, in.SenderAccountID)
|
||||
if xerr != nil || sender == nil || sender.OrganizationID == nil || *sender.OrganizationID != in.OrgID {
|
||||
return nil, errx.New(errx.NotFound, "sending mailbox not found")
|
||||
}
|
||||
if sender.Status != "active" || sender.WorkerID == nil {
|
||||
return nil, placementErr(errx.Conflict, "placement_sender_unavailable", "The sending mailbox is not connected and running, so it cannot send a test.")
|
||||
}
|
||||
if scope, err := s.Repo.SeedScope(ctx, sender.ID); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
} else if scope != "" {
|
||||
return nil, placementErr(errx.Conflict, "placement_sender_unavailable", "A seed mailbox receives tests; it cannot send one.")
|
||||
}
|
||||
|
||||
// The copy: a campaign step (snapshotted now, so an edit mid-test does not
|
||||
// change what the later seeds get) or an ad-hoc template.
|
||||
var campaign *models.Campaign
|
||||
if in.CampaignID != nil {
|
||||
c, err := s.Campaigns.GetByID(ctx, *in.CampaignID)
|
||||
if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != in.OrgID {
|
||||
return nil, errx.New(errx.NotFound, "campaign not found")
|
||||
}
|
||||
campaign = c
|
||||
if in.SequenceID != nil {
|
||||
seq := s.campaignStep(ctx, c.ID, *in.SequenceID)
|
||||
if seq == nil {
|
||||
return nil, errx.New(errx.NotFound, "campaign step not found")
|
||||
}
|
||||
if strings.TrimSpace(in.Subject) == "" && in.BodyHTML == "" && in.BodyPlain == "" {
|
||||
in.Subject, in.BodyHTML, in.BodyPlain = seq.Subject, seq.BodyHTML, seq.BodyPlain
|
||||
}
|
||||
if strings.TrimSpace(in.Subject) == "" {
|
||||
in.Subject = s.threadSubject(ctx, c.ID, seq)
|
||||
}
|
||||
}
|
||||
} else if in.SequenceID != nil {
|
||||
return nil, errx.New(errx.BadRequest, "sequence_id needs campaign_id")
|
||||
}
|
||||
// A campaign test renders for the campaign's first lead unless told
|
||||
// otherwise, so merge fields and AI blocks read as a lead would get them.
|
||||
// Not on the cloud panel: those copies land in another operator's inboxes,
|
||||
// so a real lead's details go there only when someone chose that lead.
|
||||
if in.ContactID == nil && campaign != nil && in.Panel != models.PlacementPanelCloud {
|
||||
if lead, err := s.Repo.SampleLead(ctx, campaign.ID); err == nil {
|
||||
in.ContactID = lead
|
||||
}
|
||||
}
|
||||
if in.ContactID != nil {
|
||||
found, xerr := s.contactsInOrg(ctx, in.OrgID, *in.ContactID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if !found {
|
||||
return nil, errx.New(errx.NotFound, "contact not found")
|
||||
}
|
||||
}
|
||||
in.Subject = strings.TrimSpace(in.Subject)
|
||||
if in.Subject == "" {
|
||||
return nil, errx.New(errx.BadRequest, "subject is required")
|
||||
}
|
||||
if !mailhtml.HasContent(in.BodyHTML) && strings.TrimSpace(in.BodyPlain) == "" {
|
||||
return nil, errx.New(errx.BadRequest, "a plain-text or HTML body is required")
|
||||
}
|
||||
if len(in.Subject) > config.SequenceSubjectLimit*4 || len(in.BodyHTML)+len(in.BodyPlain) > config.SequenceBodyLimit*4 {
|
||||
return nil, errx.New(errx.BadRequest, "the template is too long")
|
||||
}
|
||||
|
||||
// Tracking, resolved to what each test's copies carry.
|
||||
textOnly := campaign != nil && campaign.TextOnly
|
||||
campOpen, campLink := false, false
|
||||
if campaign != nil {
|
||||
campOpen, campLink = campaign.OpenTracking, campaign.LinkTracking
|
||||
}
|
||||
type variant struct{ open, link bool }
|
||||
var variants []variant
|
||||
switch in.Tracking {
|
||||
case models.PlacementTrackingCampaign:
|
||||
variants = []variant{{campOpen, campLink}}
|
||||
case models.PlacementTrackingOn:
|
||||
variants = []variant{{true, true}}
|
||||
case models.PlacementTrackingOff:
|
||||
variants = []variant{{false, false}}
|
||||
case models.PlacementTrackingCompare:
|
||||
tracked := variant{campOpen, campLink}
|
||||
if !tracked.open && !tracked.link {
|
||||
tracked = variant{true, true}
|
||||
}
|
||||
variants = []variant{{false, false}, tracked}
|
||||
}
|
||||
if textOnly && (in.Tracking == models.PlacementTrackingOn || in.Tracking == models.PlacementTrackingCompare) {
|
||||
return nil, placementErr(errx.BadRequest, "placement_invalid_tracking", "This campaign sends plain text, which carries no tracking to compare.")
|
||||
}
|
||||
if textOnly {
|
||||
variants = []variant{{false, false}}
|
||||
}
|
||||
|
||||
// Limits: tests in flight, the sender's own queue, the monthly allowance.
|
||||
if in.Origin == models.PlacementOriginManual || in.Origin == models.PlacementOriginMonitor {
|
||||
running, err := s.Repo.CountRunning(ctx, in.OrgID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if running+len(variants) > config.PlacementRunningPerOrgMax {
|
||||
return nil, placementErr(errx.TooManyRequests, "placement_too_many_running",
|
||||
"This workspace already has placement tests running. Wait for one to finish.")
|
||||
}
|
||||
}
|
||||
if busy, err := s.Repo.SenderBusy(ctx, sender.ID); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
} else if busy {
|
||||
return nil, placementErr(errx.Conflict, "placement_sender_busy", "A placement test is still sending from this mailbox.")
|
||||
}
|
||||
metered := in.Panel == models.PlacementPanelInstance && in.Origin != models.PlacementOriginAdmin
|
||||
if metered {
|
||||
usage, xerr := s.usage(ctx, in.OrgID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if usage.Limit != nil && usage.Used+len(variants) > *usage.Limit {
|
||||
return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded",
|
||||
"This workspace has used its placement tests for the month.")
|
||||
}
|
||||
}
|
||||
|
||||
pol := s.policy(ctx)
|
||||
senderDomain := domainOf(sender.SendFrom())
|
||||
|
||||
// Every probe is a send from the sender's day, so the test is sized to
|
||||
// what is left of it rather than refused for a few sends short.
|
||||
left, xerr := s.remainingSends(ctx, sender)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
perVariant := min(pol.SeedsPerTest, left/len(variants))
|
||||
budgetShort := func(need int) *errx.Error {
|
||||
return placementErr(errx.Conflict, "placement_daily_budget",
|
||||
"This test needs at least "+strconv.Itoa(need*len(variants))+" sends from "+sender.Email+
|
||||
", which has "+strconv.Itoa(max(0, left))+" left of its daily limit today.")
|
||||
}
|
||||
|
||||
// Seeds, per panel.
|
||||
var seeds []models.PlacementSeed
|
||||
var cloudStart *models.PlacementCloudStart
|
||||
switch in.Panel {
|
||||
case models.PlacementPanelInstance, models.PlacementPanelWorkspace:
|
||||
scope, orgFilter := models.SeedScopeInstance, (*uuid.UUID)(nil)
|
||||
if in.Panel == models.PlacementPanelWorkspace {
|
||||
scope, orgFilter = models.SeedScopeWorkspace, &in.OrgID
|
||||
}
|
||||
rows, err := s.Repo.ListSeeds(ctx, scope, orgFilter, true)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
wanted := len(pickSeeds(rows, sender.ID, senderDomain, pol.SeedsPerTest))
|
||||
if floor := min(wanted, config.PlacementSeedsPerTestMin); wanted > 0 && perVariant < floor {
|
||||
return nil, budgetShort(floor)
|
||||
}
|
||||
seeds = pickSeeds(rows, sender.ID, senderDomain, perVariant)
|
||||
case models.PlacementPanelCloud:
|
||||
if s.Cloud == nil {
|
||||
return nil, placementErr(errx.Conflict, "placement_panel_unavailable", "Link this instance to Warmbly Cloud to test on its seed panel.")
|
||||
}
|
||||
// The cloud charges its allowance when it opens the test, so what can
|
||||
// be refused is refused before that, and the cloud is told how many
|
||||
// seeds the sender's day can pay for.
|
||||
if perVariant < config.PlacementSeedsPerTestMin {
|
||||
return nil, budgetShort(config.PlacementSeedsPerTestMin)
|
||||
}
|
||||
if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr == nil && panel != nil {
|
||||
if lim := panel.Usage.Limit; lim != nil && panel.Usage.Used+len(variants) > *lim {
|
||||
return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded",
|
||||
"The linked Warmbly Cloud workspace has used its placement tests for the month.")
|
||||
}
|
||||
}
|
||||
start, xerr := s.Cloud.StartPlacement(ctx, models.PlacementCloudStartRequest{
|
||||
SenderDomain: senderDomain, Tests: len(variants), MaxSeeds: perVariant,
|
||||
})
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if len(start.TestIDs) != len(variants) {
|
||||
return nil, errx.New(errx.ServiceUnavailable, "Warmbly Cloud did not open the test")
|
||||
}
|
||||
cloudStart = start
|
||||
for _, cs := range start.Seeds {
|
||||
id := cs.ID
|
||||
seeds = append(seeds, models.PlacementSeed{RemoteSeedID: &id, Address: cs.Address, Family: cs.Family})
|
||||
}
|
||||
}
|
||||
if len(seeds) == 0 {
|
||||
return nil, errors.New("no active seed mailboxes are configured")
|
||||
return nil, placementErr(errx.Conflict, "placement_no_seeds", noSeedsMessage(in.Panel))
|
||||
}
|
||||
|
||||
token := uuid.NewString()
|
||||
test := &repository.PlacementTest{
|
||||
ID: uuid.New(),
|
||||
OrganizationID: orgID,
|
||||
SenderAccountID: senderAccountID,
|
||||
Subject: subject,
|
||||
BodyPlain: bodyPlain,
|
||||
BodyHTML: bodyHTML,
|
||||
Token: token,
|
||||
Status: repository.PlacementStatusPending,
|
||||
}
|
||||
if err := s.repo.CreateTest(ctx, test); err != nil {
|
||||
return nil, fmt.Errorf("create test: %w", err)
|
||||
// A cloud that predates MaxSeeds may hand back more than was asked for.
|
||||
if len(seeds)*len(variants) > left {
|
||||
seeds = seeds[:max(0, left/len(variants))]
|
||||
}
|
||||
|
||||
// Tokenized subject: the token lives in a marker the unibox subject search
|
||||
// can match. Keeping the original subject first means the seed inbox shows
|
||||
// the real template subject for classification fidelity.
|
||||
taggedSubject := fmt.Sprintf("%s [%s%s]", subject, subjectTokenMarker, token)
|
||||
|
||||
sentCount := 0
|
||||
// Build the tests, their probes and one task per probe.
|
||||
now := s.now()
|
||||
var group *uuid.UUID
|
||||
if len(variants) > 1 {
|
||||
g := uuid.New()
|
||||
group = &g
|
||||
}
|
||||
type built struct {
|
||||
test models.PlacementTest
|
||||
results []models.PlacementResult
|
||||
tasks []repository.Task
|
||||
}
|
||||
out := make([]built, len(variants))
|
||||
for i, v := range variants {
|
||||
senderID := sender.ID
|
||||
out[i].test = models.PlacementTest{
|
||||
ID: uuid.New(),
|
||||
OrganizationID: &in.OrgID,
|
||||
SenderAccountID: &senderID,
|
||||
SenderEmail: sender.SendFrom(),
|
||||
CreatedBy: in.UserID,
|
||||
CampaignID: in.CampaignID,
|
||||
SequenceID: in.SequenceID,
|
||||
ContactID: in.ContactID,
|
||||
MonitorID: in.MonitorID,
|
||||
Subject: in.Subject,
|
||||
BodyHTML: in.BodyHTML,
|
||||
BodyPlain: in.BodyPlain,
|
||||
OpenTracking: v.open,
|
||||
LinkTracking: v.link,
|
||||
CompareGroupID: group,
|
||||
Origin: in.Origin,
|
||||
Panel: in.Panel,
|
||||
Status: models.PlacementStatusRunning,
|
||||
}
|
||||
if cloudStart != nil {
|
||||
remote := cloudStart.TestIDs[i]
|
||||
out[i].test.RemoteTestID = &remote
|
||||
}
|
||||
}
|
||||
// One probe every spacing, jittered, alternating variants per seed in a
|
||||
// random order so both see the same conditions.
|
||||
at := now.Add(5 * time.Second)
|
||||
spacing := pol.Spacing()
|
||||
for _, seed := range seeds {
|
||||
if err := s.repo.CreatePendingResult(ctx, test.ID, seed.ID, seed.Provider); err != nil {
|
||||
log.Warn().Err(err).
|
||||
Str("test_id", test.ID.String()).
|
||||
Str("seed_id", seed.ID.String()).
|
||||
Msg("placement: failed to create pending result")
|
||||
continue
|
||||
order := rand.Perm(len(variants))
|
||||
for _, vi := range order {
|
||||
taskID := uuid.New()
|
||||
slot := at
|
||||
out[vi].tasks = append(out[vi].tasks, repository.Task{
|
||||
ID: taskID, TaskType: "placement", EmailAccountID: sender.ID, Status: "pending", ScheduledAt: &slot,
|
||||
})
|
||||
seed := seed
|
||||
out[vi].results = append(out[vi].results, models.PlacementResult{
|
||||
ID: uuid.New(),
|
||||
SeedAccountID: seed.AccountID,
|
||||
SeedAddress: seed.Address,
|
||||
Family: seed.Family,
|
||||
RemoteSeedID: seed.RemoteSeedID,
|
||||
TaskID: &taskID,
|
||||
Folder: models.PlacementFolderPending,
|
||||
ScheduledAt: &slot,
|
||||
})
|
||||
at = at.Add(jitter(spacing))
|
||||
}
|
||||
|
||||
msg := tasks.EmailMessage{
|
||||
From: sender.Email,
|
||||
To: []string{seed.Email},
|
||||
Subject: taggedSubject,
|
||||
BodyPlain: bodyPlain,
|
||||
BodyHTML: bodyHTML,
|
||||
MessageID: fmt.Sprintf("<%s@%s>", uuid.NewString(), domainOf(sender.Email)),
|
||||
// We also pass the token through the warmup verify header lane via
|
||||
// WarmupToken so that, IF a future worker change starts persisting
|
||||
// that header into unibox flags, the same token is already present.
|
||||
// Today the header is consumed by the warmup detector and not stored,
|
||||
// so the subject marker remains the authoritative carrier.
|
||||
WarmupToken: token,
|
||||
}
|
||||
|
||||
if err := s.sender.Send(ctx, uuid.New(), msg, *sender); err != nil {
|
||||
log.Warn().Err(err).
|
||||
Str("test_id", test.ID.String()).
|
||||
Str("seed_id", seed.ID.String()).
|
||||
Msg("placement: failed to publish test send to worker")
|
||||
continue
|
||||
}
|
||||
sentCount++
|
||||
}
|
||||
|
||||
if sentCount == 0 {
|
||||
// Nothing went out — mark the test completed immediately so it doesn't
|
||||
// dangle pending forever. Results remain "pending" for visibility.
|
||||
now := time.Now()
|
||||
_ = s.repo.SetTestStatus(ctx, test.ID, repository.PlacementStatusCompleted, &now)
|
||||
return nil, errors.New("failed to send any placement probes")
|
||||
// Both halves of a comparison are written together, then scheduled.
|
||||
bundles := make([]repository.PlacementBundle, len(out))
|
||||
for i := range out {
|
||||
bundles[i] = repository.PlacementBundle{Test: &out[i].test, Results: out[i].results, Tasks: out[i].tasks}
|
||||
}
|
||||
|
||||
return test, nil
|
||||
if err := s.Repo.CreateTests(ctx, bundles); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
views := make([]TestView, 0, len(out))
|
||||
for i := range out {
|
||||
b := &out[i]
|
||||
s.enqueue(ctx, b.tasks, b.results)
|
||||
s.publish(ctx, &b.test)
|
||||
views = append(views, s.view(b.test, b.results, false))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *service) ClassifyPending(ctx context.Context) error {
|
||||
jobs, err := s.repo.ListPendingResults(ctx, 200)
|
||||
// remainingSends is what is left of the sender's daily campaign limit,
|
||||
// counting campaign sends and probes already queued today.
|
||||
func (s *service) remainingSends(ctx context.Context, sender *models.Email) (int, *errx.Error) {
|
||||
if s.Tasks == nil {
|
||||
return sender.CampaignLimit, nil
|
||||
}
|
||||
sent, err := s.Tasks.CountCampaignEmailsSentToday(ctx, sender.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list pending results: %w", err)
|
||||
errs.CaptureException(err)
|
||||
return 0, errx.InternalError()
|
||||
}
|
||||
return max(0, sender.CampaignLimit-sent), nil
|
||||
}
|
||||
|
||||
touchedTests := map[uuid.UUID]struct{}{}
|
||||
|
||||
for _, j := range jobs {
|
||||
touchedTests[j.TestID] = struct{}{}
|
||||
|
||||
match, err := s.repo.FindTokenInUnibox(ctx, j.SeedUserID, j.SeedAccountID, j.Token, j.TestCreatedAt)
|
||||
// enqueue hands each probe's task to the scheduler. The local scheduler picks
|
||||
// due pending rows on its own; Cloud Tasks needs the handle stored.
|
||||
func (s *service) enqueue(ctx context.Context, tasks []repository.Task, results []models.PlacementResult) {
|
||||
if s.Scheduler == nil {
|
||||
return
|
||||
}
|
||||
for i, t := range tasks {
|
||||
name, err := s.Scheduler.CreateTask(ctx, &proto.ProcessTask{TaskId: t.ID.String()}, *t.ScheduledAt)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: unibox lookup failed")
|
||||
errs.CaptureException(err)
|
||||
_ = s.Repo.FailProbe(ctx, results[i].ID, "The send could not be scheduled")
|
||||
continue
|
||||
}
|
||||
|
||||
if match != nil {
|
||||
folder := classifyFolder(match.Flags)
|
||||
rawFlags := strings.Join(match.Flags, ",")
|
||||
if err := s.repo.RecordResult(ctx, j.ResultID, folder, rawFlags, time.Now()); err != nil {
|
||||
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to record result")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Not found yet. If the test is older than the classify timeout, the
|
||||
// probe almost certainly never landed in this seat — record "other"
|
||||
// (could be a gateway block, a hard bounce, or a sync gap) so the test
|
||||
// can complete instead of hanging pending forever.
|
||||
if time.Since(j.TestCreatedAt) > classifyTimeout {
|
||||
if err := s.repo.RecordResult(ctx, j.ResultID, repository.PlacementFolderOther, "timeout", time.Now()); err != nil {
|
||||
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to timeout result")
|
||||
}
|
||||
if name != "" && s.Tasks != nil {
|
||||
_ = s.Tasks.UpdateTaskScheduledAt(ctx, t.ID, *t.ScheduledAt, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any touched test completed once it has no pending results left.
|
||||
for testID := range touchedTests {
|
||||
pending, err := s.repo.CountPendingForTest(ctx, testID)
|
||||
if err != nil {
|
||||
continue
|
||||
func (s *service) publish(ctx context.Context, t *models.PlacementTest) {
|
||||
if s.Publisher == nil || t.OrganizationID == nil {
|
||||
return
|
||||
}
|
||||
s.Publisher.PublishPlacementTest(ctx, *t.OrganizationID, t.ID, t.CampaignID, t.Status)
|
||||
}
|
||||
|
||||
// threadSubject is the subject a threading follow-up inherits: the subject of
|
||||
// the closest earlier step that has one. A probe is a standalone message, so
|
||||
// it goes without the "Re:" a real reply would carry.
|
||||
func (s *service) threadSubject(ctx context.Context, campaignID uuid.UUID, step *models.Sequence) string {
|
||||
seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(seqs, func(i, j int) bool { return seqs[i].Position < seqs[j].Position })
|
||||
subject := ""
|
||||
for _, q := range seqs {
|
||||
if q.Position >= step.Position {
|
||||
break
|
||||
}
|
||||
if pending == 0 {
|
||||
now := time.Now()
|
||||
_ = s.repo.SetTestStatus(ctx, testID, repository.PlacementStatusCompleted, &now)
|
||||
if strings.TrimSpace(q.Subject) != "" {
|
||||
subject = q.Subject
|
||||
}
|
||||
}
|
||||
return subject
|
||||
}
|
||||
|
||||
// campaignStep is one step of a campaign, nil when the id names another
|
||||
// campaign's step.
|
||||
func (s *service) campaignStep(ctx context.Context, campaignID, sequenceID uuid.UUID) *models.Sequence {
|
||||
seqs, err := s.Campaigns.GetSequencesByCampaignID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range seqs {
|
||||
if seqs[i].ID == sequenceID {
|
||||
return &seqs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyFolder maps a synced message's flags/labels to a placement folder.
|
||||
// It reuses the SAME spam-flag detection the warmup consumer uses
|
||||
// (containsSpamFlag: \Junk / \Spam / SPAM / Junk).
|
||||
//
|
||||
// Promotions detection requires a Gmail CATEGORY_PROMOTIONS label to be present
|
||||
// in the flags. NOTE: the current Gmail sync only maps UNREAD/STARRED/IMPORTANT
|
||||
// /DRAFT into flags (internal/client/goog/message.go GmailMessageToEmailData)
|
||||
// and does not capture CATEGORY_* tab labels. Capturing those needs a Gmail
|
||||
// category-label sync change in the worker — a deliberate follow-up. Until then
|
||||
// a Gmail-tab message reads as "inbox" here, which is the correct conservative
|
||||
// default (it did reach the inbox, just a tab).
|
||||
func classifyFolder(flags []string) string {
|
||||
if containsSpamFlag(flags) {
|
||||
return repository.PlacementFolderSpam
|
||||
func (s *service) contactsInOrg(ctx context.Context, orgID, contactID uuid.UUID) (bool, *errx.Error) {
|
||||
if s.Contacts == nil {
|
||||
return false, nil
|
||||
}
|
||||
if hasPromotionsLabel(flags) {
|
||||
return repository.PlacementFolderPromotions
|
||||
found, xerr := s.Contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{contactID})
|
||||
if xerr != nil {
|
||||
return false, xerr
|
||||
}
|
||||
return repository.PlacementFolderInbox
|
||||
return len(found) == 1, nil
|
||||
}
|
||||
|
||||
// containsSpamFlag reads the same list as warmup spam-placement detection, so
|
||||
// placement classification and warmup agree on what "landed in spam" means.
|
||||
func containsSpamFlag(flags []string) bool {
|
||||
return models.HasSpamFlag(flags)
|
||||
}
|
||||
|
||||
// hasPromotionsLabel reports whether a Gmail CATEGORY_PROMOTIONS label is
|
||||
// present. See classifyFolder for the sync caveat.
|
||||
func hasPromotionsLabel(flags []string) bool {
|
||||
for _, f := range flags {
|
||||
if strings.EqualFold(f, "CATEGORY_PROMOTIONS") {
|
||||
return true
|
||||
// pickSeeds chooses up to n seeds, never the sender and never one on the
|
||||
// sender's domain, round-robin across host families so a small test still
|
||||
// covers every provider.
|
||||
func pickSeeds(rows []repository.SeedAccount, senderID uuid.UUID, senderDomain string, n int) []models.PlacementSeed {
|
||||
byFamily := map[string][]models.PlacementSeed{}
|
||||
var families []string
|
||||
for _, r := range rows {
|
||||
if r.ID == senderID || strings.EqualFold(domainOf(r.Email), senderDomain) {
|
||||
continue
|
||||
}
|
||||
fam := string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email))
|
||||
if _, ok := byFamily[fam]; !ok {
|
||||
families = append(families, fam)
|
||||
}
|
||||
id := r.ID
|
||||
byFamily[fam] = append(byFamily[fam], models.PlacementSeed{AccountID: &id, Address: r.Email, Family: fam, OrganizationID: r.OrganizationID})
|
||||
}
|
||||
sort.Strings(families)
|
||||
for _, f := range families {
|
||||
rand.Shuffle(len(byFamily[f]), func(i, j int) { byFamily[f][i], byFamily[f][j] = byFamily[f][j], byFamily[f][i] })
|
||||
}
|
||||
var out []models.PlacementSeed
|
||||
for len(out) < n {
|
||||
added := false
|
||||
for _, f := range families {
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
if len(byFamily[f]) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, byFamily[f][0])
|
||||
byFamily[f] = byFamily[f][1:]
|
||||
added = true
|
||||
}
|
||||
if !added {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
return out
|
||||
}
|
||||
|
||||
func domainOf(email string) string {
|
||||
if at := strings.LastIndex(email, "@"); at >= 0 && at < len(email)-1 {
|
||||
return email[at+1:]
|
||||
func noSeedsMessage(panel string) string {
|
||||
switch panel {
|
||||
case models.PlacementPanelWorkspace:
|
||||
return "Add a test inbox under Placement tests > Seed inboxes first. It has to be on a different domain from the sender."
|
||||
case models.PlacementPanelCloud:
|
||||
return "Warmbly Cloud has no seed inboxes available for this sender right now."
|
||||
}
|
||||
return "localhost"
|
||||
return "This instance has no seed inboxes on a different domain from the sender yet."
|
||||
}
|
||||
|
||||
// jitter spreads a gap by up to a third either way.
|
||||
func jitter(d time.Duration) time.Duration {
|
||||
third := int64(d) / 3
|
||||
if third <= 0 {
|
||||
return d
|
||||
}
|
||||
return time.Duration(int64(d) - third + rand.Int64N(2*third+1))
|
||||
}
|
||||
|
||||
func domainOf(address string) string {
|
||||
if at := strings.LastIndex(address, "@"); at >= 0 && at < len(address)-1 {
|
||||
return strings.ToLower(strings.TrimSpace(address[at+1:]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CancelTest stops the probes of a running test that have not left yet.
|
||||
func (s *service) CancelTest(ctx context.Context, orgID, id uuid.UUID) (*TestView, *errx.Error) {
|
||||
ok, err := s.Repo.CancelTest(ctx, orgID, id)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
test, err := s.Repo.GetOrgTest(ctx, orgID, id)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if test == nil {
|
||||
return nil, errx.New(errx.NotFound, "placement test not found")
|
||||
}
|
||||
if !ok {
|
||||
return nil, placementErr(errx.Conflict, "placement_not_running", "This placement test is no longer running.")
|
||||
}
|
||||
results, err := s.Repo.ListResults(ctx, []uuid.UUID{id})
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
s.publish(ctx, test)
|
||||
v := s.view(*test, results[id], false)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// ListTests lists tests newest first; a nil orgID lists every workspace's.
|
||||
func (s *service) ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]TestView, int, *errx.Error) {
|
||||
tests, total, err := s.Repo.ListTests(ctx, repository.PlacementTestFilter{
|
||||
OrganizationID: orgID, CampaignID: campaignID, Limit: limit, Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, 0, errx.InternalError()
|
||||
}
|
||||
ids := make([]uuid.UUID, len(tests))
|
||||
for i, t := range tests {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
results, err := s.Repo.ListResults(ctx, ids)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, 0, errx.InternalError()
|
||||
}
|
||||
out := make([]TestView, 0, len(tests))
|
||||
for _, t := range tests {
|
||||
out = append(out, s.view(t, results[t.ID], false))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetTest is one test with every probe, the content check of its copy and,
|
||||
// for a tracking comparison, the other half.
|
||||
func (s *service) GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*TestDetail, *errx.Error) {
|
||||
var test *models.PlacementTest
|
||||
var err error
|
||||
if orgID != nil {
|
||||
test, err = s.Repo.GetOrgTest(ctx, *orgID, id)
|
||||
} else {
|
||||
test, err = s.Repo.GetTest(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if test == nil {
|
||||
return nil, errx.New(errx.NotFound, "placement test not found")
|
||||
}
|
||||
ids := []uuid.UUID{test.ID}
|
||||
var sibling *models.PlacementTest
|
||||
if test.CompareGroupID != nil && test.OrganizationID != nil {
|
||||
group, err := s.Repo.ListGroup(ctx, *test.OrganizationID, *test.CompareGroupID)
|
||||
if err == nil {
|
||||
for i := range group {
|
||||
if group[i].ID != test.ID {
|
||||
sibling = &group[i]
|
||||
ids = append(ids, sibling.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results, err := s.Repo.ListResults(ctx, ids)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
detail := &TestDetail{
|
||||
TestView: s.view(*test, results[test.ID], true),
|
||||
Results: resultViews(test.Panel, results[test.ID]),
|
||||
Content: contentCheck(*test),
|
||||
}
|
||||
if sibling != nil {
|
||||
v := s.view(*sibling, results[sibling.ID], false)
|
||||
detail.Compare = &v
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/instancesettings"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
"github.com/warmbly/warmbly/internal/tasks/proto"
|
||||
)
|
||||
|
||||
// Each fake embeds the interface it stands in for, so a call the test does
|
||||
// not expect panics instead of passing silently.
|
||||
|
||||
type fakeRepo struct {
|
||||
repository.PlacementRepository
|
||||
seeds []repository.SeedAccount
|
||||
scopes map[uuid.UUID]string
|
||||
running int
|
||||
busy bool
|
||||
metered int
|
||||
created []models.PlacementTest
|
||||
results [][]models.PlacementResult
|
||||
tasks [][]repository.Task
|
||||
failures []uuid.UUID
|
||||
}
|
||||
|
||||
func (f *fakeRepo) SeedScope(_ context.Context, id uuid.UUID) (string, error) {
|
||||
return f.scopes[id], nil
|
||||
}
|
||||
func (f *fakeRepo) CountRunning(context.Context, uuid.UUID) (int, error) { return f.running, nil }
|
||||
func (f *fakeRepo) SenderBusy(context.Context, uuid.UUID) (bool, error) { return f.busy, nil }
|
||||
func (f *fakeRepo) CountMeteredTests(context.Context, uuid.UUID, time.Time) (int, error) {
|
||||
return f.metered, nil
|
||||
}
|
||||
func (f *fakeRepo) SampleLead(context.Context, uuid.UUID) (*uuid.UUID, error) { return nil, nil }
|
||||
func (f *fakeRepo) ListSeeds(_ context.Context, scope string, org *uuid.UUID, _ bool) ([]repository.SeedAccount, error) {
|
||||
var out []repository.SeedAccount
|
||||
for _, s := range f.seeds {
|
||||
if s.SeedScope == scope && (org == nil || (s.OrganizationID != nil && *s.OrganizationID == *org)) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeRepo) CreateTest(_ context.Context, t *models.PlacementTest, results []models.PlacementResult, tasks []repository.Task) error {
|
||||
f.created = append(f.created, *t)
|
||||
f.results = append(f.results, results)
|
||||
f.tasks = append(f.tasks, tasks)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) CreateTests(ctx context.Context, bundles []repository.PlacementBundle) error {
|
||||
for _, b := range bundles {
|
||||
_ = f.CreateTest(ctx, b.Test, b.Results, b.Tasks)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) FailProbe(_ context.Context, id uuid.UUID, _ string) error {
|
||||
f.failures = append(f.failures, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEmails struct {
|
||||
repository.EmailRepository
|
||||
accounts map[uuid.UUID]*models.Email
|
||||
}
|
||||
|
||||
func (f *fakeEmails) GetByID(_ context.Context, id uuid.UUID) (*models.Email, *errx.Error) {
|
||||
if a, ok := f.accounts[id]; ok {
|
||||
return a, nil
|
||||
}
|
||||
return nil, errx.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeTasks struct {
|
||||
repository.TaskRepository
|
||||
sentToday int
|
||||
}
|
||||
|
||||
func (f *fakeTasks) CountCampaignEmailsSentToday(context.Context, uuid.UUID) (int, error) {
|
||||
return f.sentToday, nil
|
||||
}
|
||||
|
||||
type fakeScheduler struct{ scheduled int }
|
||||
|
||||
func (f *fakeScheduler) CreateTask(context.Context, *proto.ProcessTask, time.Time) (string, error) {
|
||||
f.scheduled++
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeScheduler) DeleteTask(context.Context, string) error { return nil }
|
||||
|
||||
type fakePolicy struct{ p instancesettings.Placement }
|
||||
|
||||
func (f fakePolicy) PlacementPolicy(context.Context) instancesettings.Placement { return f.p }
|
||||
|
||||
type harness struct {
|
||||
svc *service
|
||||
repo *fakeRepo
|
||||
tasks *fakeTasks
|
||||
sched *fakeScheduler
|
||||
org uuid.UUID
|
||||
sender uuid.UUID
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
t.Setenv("DEPLOYMENT_MODE", "self_hosted")
|
||||
org, operator, sender, worker := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
h := &harness{org: org, sender: sender, repo: &fakeRepo{scopes: map[uuid.UUID]string{}}, tasks: &fakeTasks{}, sched: &fakeScheduler{}}
|
||||
seed := func(addr, host string) repository.SeedAccount {
|
||||
return repository.SeedAccount{ID: uuid.New(), OrganizationID: &operator, Email: addr, Provider: "smtp_imap",
|
||||
MailHost: host, Status: "active", WorkerID: &worker, SeedScope: models.SeedScopeInstance}
|
||||
}
|
||||
h.repo.seeds = []repository.SeedAccount{
|
||||
seed("a@gmail.com", "gmail"), seed("b@gmail.com", "gmail"), seed("c@gmail.com", "gmail"),
|
||||
seed("d@contoso.test", "microsoft365"), seed("e@yahoo.com", "yahoo"),
|
||||
// On the sender's own domain: never a seed for this sender.
|
||||
seed("f@acme.test", "google_workspace"),
|
||||
}
|
||||
emails := &fakeEmails{accounts: map[uuid.UUID]*models.Email{
|
||||
sender: {ID: sender, OrganizationID: &org, Email: "rep@acme.test", Status: "active", WorkerID: &worker, CampaignLimit: 50},
|
||||
}}
|
||||
policy := instancesettings.DefaultPlacement()
|
||||
policy.SeedsPerTest = 4
|
||||
h.svc = &service{Deps: Deps{
|
||||
Repo: h.repo, Emails: emails, Tasks: h.tasks, Scheduler: h.sched, Policy: fakePolicy{policy},
|
||||
}, now: time.Now}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *harness) input() CreateInput {
|
||||
return CreateInput{OrgID: h.org, SenderAccountID: h.sender, Subject: "Quick question", BodyPlain: "Hi there"}
|
||||
}
|
||||
|
||||
func TestCreateTestsPicksAcrossFamiliesAndSkipsTheSendersDomain(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
views, xerr := h.svc.CreateTests(context.Background(), h.input())
|
||||
if xerr != nil {
|
||||
t.Fatalf("CreateTests: %v", xerr)
|
||||
}
|
||||
if len(views) != 1 || len(h.repo.results[0]) != 4 {
|
||||
t.Fatalf("got %d tests and %d probes; want one test with the 4-seed cap", len(views), len(h.repo.results[0]))
|
||||
}
|
||||
families := map[string]int{}
|
||||
for _, r := range h.repo.results[0] {
|
||||
if strings.HasSuffix(r.SeedAddress, "@acme.test") {
|
||||
t.Fatalf("seed %s is on the sender's domain", r.SeedAddress)
|
||||
}
|
||||
families[r.Family]++
|
||||
}
|
||||
if families["gmail"] != 2 || families["microsoft365"] != 1 || families["yahoo"] != 1 {
|
||||
t.Fatalf("families = %v; want every family before a second gmail", families)
|
||||
}
|
||||
if h.sched.scheduled != 4 {
|
||||
t.Fatalf("scheduled %d tasks, want 4", h.sched.scheduled)
|
||||
}
|
||||
// One task per probe, all from the sender, in order and spaced out.
|
||||
tasks := h.repo.tasks[0]
|
||||
for i, task := range tasks {
|
||||
if task.TaskType != "placement" || task.EmailAccountID != h.sender || *h.repo.results[0][i].TaskID != task.ID {
|
||||
t.Fatalf("task %d = %+v; want the sender's placement task behind probe %d", i, task, i)
|
||||
}
|
||||
if i > 0 && !task.ScheduledAt.After(*tasks[i-1].ScheduledAt) {
|
||||
t.Fatalf("probes %d and %d are not spaced", i-1, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestsCompareSendsBothVariantsToTheSameSeeds(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
in := h.input()
|
||||
in.Tracking = models.PlacementTrackingCompare
|
||||
views, xerr := h.svc.CreateTests(context.Background(), in)
|
||||
if xerr != nil {
|
||||
t.Fatalf("CreateTests: %v", xerr)
|
||||
}
|
||||
if len(views) != 2 || views[0].CompareGroupID == nil || views[1].CompareGroupID == nil || *views[0].CompareGroupID != *views[1].CompareGroupID {
|
||||
t.Fatalf("want two tests in one comparison group, got %+v", views)
|
||||
}
|
||||
if views[0].Tracked() == views[1].Tracked() {
|
||||
t.Fatalf("want one tracked and one untracked test")
|
||||
}
|
||||
seeds := func(i int) string {
|
||||
var out []string
|
||||
for _, r := range h.repo.results[i] {
|
||||
out = append(out, r.SeedAddress)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
if seeds(0) != seeds(1) {
|
||||
t.Fatalf("variants went to different seeds: %s vs %s", seeds(0), seeds(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestsRefusals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(*harness, *CreateInput)
|
||||
wantID string
|
||||
}{
|
||||
{"sender in another workspace", func(h *harness, in *CreateInput) { in.OrgID = uuid.New() }, ""},
|
||||
{"sender is a seed", func(h *harness, in *CreateInput) { h.repo.scopes[h.sender] = models.SeedScopeWorkspace }, "placement_sender_unavailable"},
|
||||
{"sender still sending a test", func(h *harness, in *CreateInput) { h.repo.busy = true }, "placement_sender_busy"},
|
||||
{"too many running", func(h *harness, in *CreateInput) { h.repo.running = 3 }, "placement_too_many_running"},
|
||||
{"daily limit spent", func(h *harness, in *CreateInput) { h.tasks.sentToday = 48 }, "placement_daily_budget"},
|
||||
{"no own seeds", func(h *harness, in *CreateInput) { in.Panel = models.PlacementPanelWorkspace }, "placement_no_seeds"},
|
||||
{"no cloud link", func(h *harness, in *CreateInput) { in.Panel = models.PlacementPanelCloud }, "placement_panel_unavailable"},
|
||||
{"empty body", func(h *harness, in *CreateInput) { in.BodyPlain, in.BodyHTML = "", "<div></div>" }, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
in := h.input()
|
||||
c.setup(h, &in)
|
||||
_, xerr := h.svc.CreateTests(context.Background(), in)
|
||||
if xerr == nil {
|
||||
t.Fatalf("CreateTests succeeded; want a refusal")
|
||||
}
|
||||
if c.wantID != "" && xerr.Identifier != c.wantID {
|
||||
t.Fatalf("refused with %q (%s); want %q", xerr.Identifier, xerr.Message, c.wantID)
|
||||
}
|
||||
if len(h.repo.created) != 0 || h.sched.scheduled != 0 {
|
||||
t.Fatalf("a refused test wrote %d tests and scheduled %d tasks", len(h.repo.created), h.sched.scheduled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestsMetersTheInstancePanelOnTheHostedProduct(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
t.Setenv("DEPLOYMENT_MODE", "cloud")
|
||||
h.repo.metered = 3 // the trial allowance
|
||||
_, xerr := h.svc.CreateTests(context.Background(), h.input())
|
||||
if xerr == nil || xerr.Identifier != "placement_quota_exceeded" {
|
||||
t.Fatalf("got %v; want placement_quota_exceeded", xerr)
|
||||
}
|
||||
in := h.input()
|
||||
in.Origin = models.PlacementOriginAdmin
|
||||
if _, xerr := h.svc.CreateTests(context.Background(), in); xerr != nil {
|
||||
t.Fatalf("an operator's test is not metered: %v", xerr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryAndMasking(t *testing.T) {
|
||||
var c models.PlacementCounts
|
||||
for _, f := range []string{"inbox", "inbox", "promotions", "spam", "missing", "failed", "pending"} {
|
||||
c.Add(f)
|
||||
}
|
||||
c.Finish()
|
||||
if c.Delivered != 5 || percent(c.InboxRate) != 40 || percent(c.TabsRate) != 20 {
|
||||
t.Fatalf("counts = %+v", c)
|
||||
}
|
||||
if got := summaryLine(c); !strings.HasPrefix(got, "2 of 5 copies reached the inbox") {
|
||||
t.Fatalf("summary = %q", got)
|
||||
}
|
||||
if got := maskAddress("seed.one@gmail.com"); got != "s***@gmail.com" {
|
||||
t.Fatalf("mask = %q", got)
|
||||
}
|
||||
for range 200 {
|
||||
if d := jitter(30 * time.Second); d < 20*time.Second || d > 40*time.Second {
|
||||
t.Fatalf("jitter %v outside a third of 30s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestsShrinksToTheSendersDayAndRefusesBelowTheFloor(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
operator, worker := uuid.New(), uuid.New()
|
||||
for _, addr := range []string{"g@gmail.com", "h@gmail.com", "i@gmail.com"} {
|
||||
h.repo.seeds = append(h.repo.seeds, repository.SeedAccount{ID: uuid.New(), OrganizationID: &operator, Email: addr,
|
||||
Provider: "smtp_imap", MailHost: "gmail", Status: "active", WorkerID: &worker, SeedScope: models.SeedScopeInstance})
|
||||
}
|
||||
policy := instancesettings.DefaultPlacement()
|
||||
policy.SeedsPerTest = 8
|
||||
h.svc.Policy = fakePolicy{policy}
|
||||
|
||||
h.tasks.sentToday = 44 // six sends left of fifty
|
||||
if _, xerr := h.svc.CreateTests(context.Background(), h.input()); xerr != nil {
|
||||
t.Fatalf("CreateTests: %v", xerr)
|
||||
}
|
||||
if got := len(h.repo.results[0]); got != 6 {
|
||||
t.Fatalf("sent to %d seeds; want the six the day can pay for", got)
|
||||
}
|
||||
|
||||
in := h.input()
|
||||
in.Tracking = models.PlacementTrackingCompare // three per half is under the five-seed floor
|
||||
h.repo.created = nil
|
||||
if _, xerr := h.svc.CreateTests(context.Background(), in); xerr == nil || xerr.Identifier != "placement_daily_budget" {
|
||||
t.Fatalf("got %v; want placement_daily_budget", xerr)
|
||||
}
|
||||
if len(h.repo.created) != 0 {
|
||||
t.Fatalf("a refused comparison wrote %d tests", len(h.repo.created))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// Tick is one pass of the poller: read verdicts, expire what never arrived,
|
||||
// sync the cloud's panel, close finished tests and start due monitors.
|
||||
func (s *service) Tick(ctx context.Context) error {
|
||||
now := s.now()
|
||||
touched := map[uuid.UUID]bool{}
|
||||
|
||||
landings, err := s.Repo.FindLandings(ctx, 500)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return err
|
||||
}
|
||||
for _, l := range landings {
|
||||
folder := models.ClassifyPlacementLanding(l.Folder, l.Flags)
|
||||
if err := s.Repo.RecordLanding(ctx, l.ResultID, folder, strings.Join(l.Flags, ","), now); err != nil {
|
||||
errs.CaptureException(err)
|
||||
continue
|
||||
}
|
||||
touched[l.TestID] = true
|
||||
}
|
||||
|
||||
timeout := now.Add(-time.Duration(config.PlacementClassifyTimeoutMinutes) * time.Minute)
|
||||
// An instance reports failed and cancelled copies itself; this only
|
||||
// catches one that went away. The longest test the settings allow (100
|
||||
// seeds, twice, 600 seconds apart) sends in about 33 hours.
|
||||
if err := s.Repo.ExpireProbes(ctx, timeout, timeout, now.Add(-48*time.Hour)); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
|
||||
for id := range s.syncCloud(ctx) {
|
||||
touched[id] = true
|
||||
}
|
||||
|
||||
finished, err := s.Repo.FinishTests(ctx)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
notifiedGroups := map[uuid.UUID]bool{}
|
||||
for _, f := range finished {
|
||||
delete(touched, f.ID)
|
||||
if f.OrganizationID != nil && s.Publisher != nil {
|
||||
s.Publisher.PublishPlacementTest(ctx, *f.OrganizationID, f.ID, f.CampaignID, f.Status)
|
||||
}
|
||||
s.onFinished(ctx, f, notifiedGroups)
|
||||
}
|
||||
for id := range touched {
|
||||
if t, err := s.Repo.GetTest(ctx, id); err == nil && t != nil {
|
||||
s.publish(ctx, t)
|
||||
}
|
||||
}
|
||||
|
||||
s.runMonitors(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// onFinished tells whoever started a test where it landed, and checks a
|
||||
// monitor's test against its threshold. A tracking comparison reports once,
|
||||
// when both halves are done.
|
||||
func (s *service) onFinished(ctx context.Context, f repository.PlacementFinished, notifiedGroups map[uuid.UUID]bool) {
|
||||
if f.OrganizationID == nil || f.Status == models.PlacementStatusCancelled {
|
||||
return
|
||||
}
|
||||
ids := []uuid.UUID{f.ID}
|
||||
var tests []models.PlacementTest
|
||||
if f.CompareGroupID != nil {
|
||||
if notifiedGroups[*f.CompareGroupID] {
|
||||
return
|
||||
}
|
||||
group, err := s.Repo.ListGroup(ctx, *f.OrganizationID, *f.CompareGroupID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, t := range group {
|
||||
if t.FinishedAt == nil && t.ID != f.ID {
|
||||
return
|
||||
}
|
||||
}
|
||||
notifiedGroups[*f.CompareGroupID] = true
|
||||
tests = group
|
||||
ids = ids[:0]
|
||||
for _, t := range group {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
} else {
|
||||
t, err := s.Repo.GetTest(ctx, f.ID)
|
||||
if err != nil || t == nil {
|
||||
return
|
||||
}
|
||||
tests = []models.PlacementTest{*t}
|
||||
}
|
||||
results, err := s.Repo.ListResults(ctx, ids)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var headline models.PlacementCounts
|
||||
lines := make([]string, 0, len(tests))
|
||||
for _, t := range tests {
|
||||
v := s.view(t, results[t.ID], false)
|
||||
if len(tests) > 1 {
|
||||
label := "Without tracking"
|
||||
if t.Tracked() {
|
||||
label = "With tracking"
|
||||
}
|
||||
lines = append(lines, label+": "+summaryLine(v.Summary))
|
||||
} else {
|
||||
lines = append(lines, summaryLine(v.Summary))
|
||||
}
|
||||
// The alert reads the copy the campaign really sends.
|
||||
if len(tests) == 1 || t.Tracked() {
|
||||
headline = v.Summary
|
||||
}
|
||||
}
|
||||
link := "/app/placement/" + f.ID.String()
|
||||
body := strings.Join(lines, "\n")
|
||||
|
||||
switch f.Origin {
|
||||
case models.PlacementOriginManual:
|
||||
if f.CreatedBy != nil && s.Notifier != nil {
|
||||
s.Notifier.Notify(ctx, *f.CreatedBy, f.OrganizationID, models.NotifPlacementFinished,
|
||||
"Placement test finished", body, link, map[string]any{"placement_test_id": f.ID.String()})
|
||||
}
|
||||
case models.PlacementOriginMonitor:
|
||||
if f.MonitorID != nil {
|
||||
s.checkMonitorAlert(ctx, *f.MonitorID, *f.OrganizationID, f.CampaignID, headline, body, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// summaryLine reads a test in one sentence.
|
||||
func summaryLine(c models.PlacementCounts) string {
|
||||
if c.Delivered == 0 {
|
||||
return "No copy reached a seed inbox."
|
||||
}
|
||||
return fmt.Sprintf("%d of %d copies reached the inbox, %d a Gmail tab, %d spam and %d never arrived.",
|
||||
c.Inbox, c.Delivered, c.Promotions+c.Other, c.Spam, c.Missing)
|
||||
}
|
||||
|
||||
// percent is a rate as a whole percentage, -1 when there is none.
|
||||
func percent(r *float64) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
return int(math.Round(*r * 100))
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package placement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
||||
"github.com/warmbly/warmbly/internal/pkg/warmlint"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// TestView is a test with where its copies landed, overall and per host
|
||||
// family. The copy itself is left out of lists.
|
||||
type TestView struct {
|
||||
models.PlacementTest
|
||||
Summary models.PlacementCounts `json:"summary"`
|
||||
Families []models.PlacementFamilyCounts `json:"families"`
|
||||
}
|
||||
|
||||
// ResultView is one probe as the dashboard shows it.
|
||||
type ResultView struct {
|
||||
// Seed is the seed's address, masked on the shared panels so the panel
|
||||
// cannot be listed and whitelisted.
|
||||
Seed string `json:"seed"`
|
||||
Family string `json:"family"`
|
||||
FamilyLabel string `json:"family_label"`
|
||||
Folder string `json:"folder"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
SentAt *time.Time `json:"sent_at"`
|
||||
DetectedAt *time.Time `json:"detected_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ContentCheck is the rules pass over the test's copy.
|
||||
type ContentCheck struct {
|
||||
Score int `json:"score"`
|
||||
Issues []warmlint.Issue `json:"issues"`
|
||||
}
|
||||
|
||||
// TestDetail is one test in full.
|
||||
type TestDetail struct {
|
||||
TestView
|
||||
Results []ResultView `json:"results"`
|
||||
Content ContentCheck `json:"content"`
|
||||
Compare *TestView `json:"compare,omitempty"`
|
||||
}
|
||||
|
||||
// Overview is what a workspace can test on and how much of its allowance is
|
||||
// left.
|
||||
type Overview struct {
|
||||
Panels []models.PlacementPanelInfo `json:"panels"`
|
||||
Usage models.PlacementUsage `json:"usage"`
|
||||
WorkspaceSeeds int `json:"workspace_seeds"`
|
||||
SeedsPerTest int `json:"seeds_per_test"`
|
||||
SpacingSeconds int `json:"spacing_seconds"`
|
||||
}
|
||||
|
||||
// AdminSeed is a mailbox on (or offered for) the instance panel.
|
||||
type AdminSeed struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID *uuid.UUID `json:"organization_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Family string `json:"family"`
|
||||
FamilyLabel string `json:"family_label"`
|
||||
Status string `json:"status"`
|
||||
WorkerID *uuid.UUID `json:"worker_id"`
|
||||
SeedScope string `json:"seed_scope"`
|
||||
}
|
||||
|
||||
func familyLabel(f string) string {
|
||||
if l := mailhost.Host(f).Label(); l != "" {
|
||||
return l
|
||||
}
|
||||
return "Other provider"
|
||||
}
|
||||
|
||||
// view counts a test's probes overall and per family. withBody keeps the copy.
|
||||
func (s *service) view(t models.PlacementTest, results []models.PlacementResult, withBody bool) TestView {
|
||||
if !withBody {
|
||||
t.BodyHTML, t.BodyPlain = "", ""
|
||||
}
|
||||
v := TestView{PlacementTest: t, Families: []models.PlacementFamilyCounts{}}
|
||||
byFamily := map[string]*models.PlacementCounts{}
|
||||
for _, r := range results {
|
||||
v.Summary.Add(r.Folder)
|
||||
c, ok := byFamily[r.Family]
|
||||
if !ok {
|
||||
c = &models.PlacementCounts{}
|
||||
byFamily[r.Family] = c
|
||||
}
|
||||
c.Add(r.Folder)
|
||||
}
|
||||
v.Summary.Finish()
|
||||
for fam, c := range byFamily {
|
||||
c.Finish()
|
||||
v.Families = append(v.Families, models.PlacementFamilyCounts{Family: fam, Label: familyLabel(fam), Counts: *c})
|
||||
}
|
||||
sort.Slice(v.Families, func(i, j int) bool { return v.Families[i].Label < v.Families[j].Label })
|
||||
return v
|
||||
}
|
||||
|
||||
func resultViews(panel string, results []models.PlacementResult) []ResultView {
|
||||
out := make([]ResultView, 0, len(results))
|
||||
for _, r := range results {
|
||||
seed := r.SeedAddress
|
||||
if panel != models.PlacementPanelWorkspace {
|
||||
seed = maskAddress(seed)
|
||||
}
|
||||
out = append(out, ResultView{
|
||||
Seed: seed,
|
||||
Family: r.Family,
|
||||
FamilyLabel: familyLabel(r.Family),
|
||||
Folder: r.Folder,
|
||||
ScheduledAt: r.ScheduledAt,
|
||||
SentAt: r.SentAt,
|
||||
DetectedAt: r.DetectedAt,
|
||||
Error: r.Error,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maskAddress keeps the first letter of the local part and the domain.
|
||||
func maskAddress(address string) string {
|
||||
at := strings.LastIndex(address, "@")
|
||||
if at <= 0 {
|
||||
return "***"
|
||||
}
|
||||
return address[:1] + "***" + address[at:]
|
||||
}
|
||||
|
||||
func contentCheck(t models.PlacementTest) ContentCheck {
|
||||
res := warmlint.Score(t.Subject, t.BodyHTML, t.BodyPlain)
|
||||
return ContentCheck{Score: res.Score, Issues: res.Issues}
|
||||
}
|
||||
|
||||
// usage is a workspace's count against the monthly allowance on the metered
|
||||
// panel. A self-hosted instance does not meter its own seeds.
|
||||
func (s *service) usage(ctx context.Context, orgID uuid.UUID) (models.PlacementUsage, *errx.Error) {
|
||||
now := s.now().UTC()
|
||||
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
u := models.PlacementUsage{PeriodStart: start, PeriodEnd: start.AddDate(0, 1, 0)}
|
||||
used, err := s.Repo.CountMeteredTests(ctx, orgID, start)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return u, errx.InternalError()
|
||||
}
|
||||
u.Used = used
|
||||
if config.SelfHosted() {
|
||||
return u, nil
|
||||
}
|
||||
pol := s.policy(ctx)
|
||||
limit := pol.TestsPerMonthTrial
|
||||
if s.Gate != nil {
|
||||
if paid, _ := s.Gate.IsPaidOrganization(ctx, orgID); paid {
|
||||
limit = pol.TestsPerMonthPaid
|
||||
}
|
||||
}
|
||||
u.Limit = &limit
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func panelInfo(panel string, seeds []models.PlacementSeed, metered bool) models.PlacementPanelInfo {
|
||||
info := models.PlacementPanelInfo{Panel: panel, Seeds: len(seeds), Metered: metered, Families: []models.PlacementPanelFamily{}}
|
||||
counts := map[string]int{}
|
||||
for _, s := range seeds {
|
||||
counts[s.Family]++
|
||||
}
|
||||
for fam, n := range counts {
|
||||
info.Families = append(info.Families, models.PlacementPanelFamily{Family: fam, Label: familyLabel(fam), Seeds: n})
|
||||
}
|
||||
sort.Slice(info.Families, func(i, j int) bool { return info.Families[i].Label < info.Families[j].Label })
|
||||
info.Available = len(seeds) > 0
|
||||
return info
|
||||
}
|
||||
|
||||
// Overview lists the three panels with their seed mix, and the allowance.
|
||||
func (s *service) Overview(ctx context.Context, orgID uuid.UUID) (*Overview, *errx.Error) {
|
||||
pol := s.policy(ctx)
|
||||
out := &Overview{SeedsPerTest: pol.SeedsPerTest, SpacingSeconds: pol.SpacingSeconds}
|
||||
usage, xerr := s.usage(ctx, orgID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
out.Usage = usage
|
||||
|
||||
instance, err := s.Repo.ListSeeds(ctx, models.SeedScopeInstance, nil, true)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
inst := panelInfo(models.PlacementPanelInstance, familySeeds(instance), !config.SelfHosted())
|
||||
if !inst.Available {
|
||||
inst.Reason = "No seed inboxes are on this instance's panel yet."
|
||||
if config.SelfHosted() {
|
||||
inst.Reason = "No seed inboxes are on this instance's panel yet. An administrator adds them in the admin panel."
|
||||
}
|
||||
}
|
||||
out.Panels = append(out.Panels, inst)
|
||||
|
||||
own, err := s.Repo.ListSeeds(ctx, models.SeedScopeWorkspace, &orgID, true)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
ws := panelInfo(models.PlacementPanelWorkspace, familySeeds(own), false)
|
||||
if !ws.Available {
|
||||
ws.Reason = "Mark one of this workspace's mailboxes as a seed inbox to test on it."
|
||||
}
|
||||
out.Panels = append(out.Panels, ws)
|
||||
out.WorkspaceSeeds = len(own)
|
||||
|
||||
cloud := models.PlacementPanelInfo{Panel: models.PlacementPanelCloud, Metered: true, Families: []models.PlacementPanelFamily{}}
|
||||
switch {
|
||||
case !config.SelfHosted():
|
||||
// The hosted product's own panel is the instance panel.
|
||||
cloud.Reason = "Warmbly Cloud's panel is the instance panel here."
|
||||
case s.Cloud == nil:
|
||||
cloud.Reason = "Link this instance to Warmbly Cloud to test on its seed panel."
|
||||
default:
|
||||
if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr != nil {
|
||||
cloud.Reason = xerr.Message
|
||||
} else if panel != nil {
|
||||
cloud = panel.Panel
|
||||
cloud.Panel = models.PlacementPanelCloud
|
||||
cloud.Metered = true
|
||||
}
|
||||
}
|
||||
if config.SelfHosted() || cloud.Available {
|
||||
out.Panels = append(out.Panels, cloud)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func familySeeds(rows []repository.SeedAccount) []models.PlacementSeed {
|
||||
out := make([]models.PlacementSeed, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, models.PlacementSeed{Address: r.Email, Family: string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -102,6 +102,27 @@ const (
|
||||
// window keeps double coverage. Operator-editable under Instance settings.
|
||||
FormEventsRetentionDaysDefault = 180
|
||||
|
||||
// Inbox placement tests. The monthly allowance covers tests on the
|
||||
// instance and cloud seed panels only; a workspace's own seeds cost the
|
||||
// operator nothing. Operator-editable under Instance settings.
|
||||
PlacementTestsPerMonthTrialDefault = 3
|
||||
PlacementTestsPerMonthPaidDefault = 40
|
||||
PlacementTestsPerMonthMax = 100_000
|
||||
PlacementSeedsPerTestDefault = 20
|
||||
PlacementSeedsPerTestMax = 100
|
||||
PlacementSeedsPerTestMin = 5 // below this a test is noise, so it is refused rather than shrunk
|
||||
PlacementSpacingSecondsDefault = 60 // gap between two probes from one sender
|
||||
PlacementSpacingSecondsMin = 5
|
||||
PlacementSpacingSecondsMax = 600
|
||||
PlacementClassifyTimeoutMinutes = 120 // after the send, a copy not seen in the seed is missing
|
||||
PlacementRunningPerOrgMax = 3 // tests one workspace may have in flight at once
|
||||
PlacementSeedsPerWorkspaceMax = 50 // a workspace's own seed mailboxes
|
||||
PlacementSeedDailySyncMessages = 50_000
|
||||
PlacementMonitorIntervalDaysMin = 1
|
||||
PlacementMonitorIntervalDaysMax = 30
|
||||
PlacementMonitorIntervalDaysDef = 7
|
||||
PlacementMonitorAlertBelowDefault = 70 // inbox rate, percent
|
||||
|
||||
// Sequences. Empty by default so the editor shows a smart, position-based
|
||||
// label (e.g. "Email 1") until the user names the step themselves.
|
||||
SequenceDefaultName = ""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- task_type keeps 'placement': Postgres cannot drop an enum value.
|
||||
DROP INDEX IF EXISTS idx_placement_results_task;
|
||||
DROP INDEX IF EXISTS idx_placement_results_test_address;
|
||||
|
||||
-- Results of tests that never had a local seed cannot go back to a NOT NULL
|
||||
-- seed column.
|
||||
DELETE FROM placement_results WHERE seed_account_id IS NULL;
|
||||
ALTER TABLE placement_results
|
||||
DROP CONSTRAINT placement_results_seed_account_id_fkey,
|
||||
ADD CONSTRAINT placement_results_seed_account_id_fkey
|
||||
FOREIGN KEY (seed_account_id) REFERENCES email_accounts (id) ON DELETE CASCADE;
|
||||
UPDATE placement_results SET folder = 'other', raw_flags = 'timeout' WHERE folder = 'missing';
|
||||
UPDATE placement_results SET folder = 'other' WHERE folder IN ('failed', 'cancelled');
|
||||
|
||||
ALTER TABLE placement_results
|
||||
DROP CONSTRAINT IF EXISTS placement_results_folder_check,
|
||||
DROP COLUMN IF EXISTS seed_address,
|
||||
DROP COLUMN IF EXISTS remote_seed_id,
|
||||
DROP COLUMN IF EXISTS task_id,
|
||||
DROP COLUMN IF EXISTS message_id,
|
||||
DROP COLUMN IF EXISTS scheduled_at,
|
||||
DROP COLUMN IF EXISTS sent_at,
|
||||
DROP COLUMN IF EXISTS remote_synced_at,
|
||||
DROP COLUMN IF EXISTS error,
|
||||
ALTER COLUMN seed_account_id SET NOT NULL,
|
||||
ADD CONSTRAINT placement_results_folder_check
|
||||
CHECK (folder IN ('inbox', 'promotions', 'spam', 'other', 'pending'));
|
||||
|
||||
ALTER TABLE placement_monitors DROP CONSTRAINT IF EXISTS placement_monitors_last_test_fkey;
|
||||
|
||||
DROP INDEX IF EXISTS idx_placement_tests_running;
|
||||
DROP INDEX IF EXISTS idx_placement_tests_campaign;
|
||||
DROP INDEX IF EXISTS idx_placement_tests_group;
|
||||
|
||||
DELETE FROM placement_tests WHERE sender_account_id IS NULL;
|
||||
ALTER TABLE placement_tests
|
||||
DROP CONSTRAINT placement_tests_sender_account_id_fkey,
|
||||
ADD CONSTRAINT placement_tests_sender_account_id_fkey
|
||||
FOREIGN KEY (sender_account_id) REFERENCES email_accounts (id) ON DELETE CASCADE;
|
||||
UPDATE placement_tests SET status = 'pending' WHERE status = 'running';
|
||||
UPDATE placement_tests SET status = 'completed' WHERE status IN ('cancelled', 'failed');
|
||||
|
||||
ALTER TABLE placement_tests
|
||||
DROP CONSTRAINT IF EXISTS placement_tests_status_check,
|
||||
DROP CONSTRAINT IF EXISTS placement_tests_origin_check,
|
||||
DROP CONSTRAINT IF EXISTS placement_tests_panel_check,
|
||||
ALTER COLUMN status SET DEFAULT 'pending',
|
||||
DROP COLUMN IF EXISTS created_by,
|
||||
DROP COLUMN IF EXISTS campaign_id,
|
||||
DROP COLUMN IF EXISTS sequence_id,
|
||||
DROP COLUMN IF EXISTS contact_id,
|
||||
DROP COLUMN IF EXISTS monitor_id,
|
||||
DROP COLUMN IF EXISTS open_tracking,
|
||||
DROP COLUMN IF EXISTS link_tracking,
|
||||
DROP COLUMN IF EXISTS compare_group_id,
|
||||
DROP COLUMN IF EXISTS origin,
|
||||
DROP COLUMN IF EXISTS panel,
|
||||
DROP COLUMN IF EXISTS sender_email,
|
||||
DROP COLUMN IF EXISTS remote_instance_id,
|
||||
DROP COLUMN IF EXISTS remote_test_id,
|
||||
DROP COLUMN IF EXISTS error,
|
||||
ALTER COLUMN sender_account_id SET NOT NULL,
|
||||
ADD COLUMN token text NOT NULL UNIQUE DEFAULT gen_random_uuid()::text;
|
||||
|
||||
DROP TABLE IF EXISTS placement_monitors;
|
||||
|
||||
ALTER TABLE email_accounts ADD COLUMN is_seed boolean NOT NULL DEFAULT false;
|
||||
UPDATE email_accounts SET is_seed = true WHERE seed_scope = 'instance';
|
||||
DROP INDEX IF EXISTS idx_email_accounts_seed_scope;
|
||||
ALTER TABLE email_accounts
|
||||
DROP CONSTRAINT IF EXISTS email_accounts_seed_scope_check,
|
||||
DROP COLUMN IF EXISTS seed_scope;
|
||||
CREATE INDEX idx_email_accounts_is_seed ON email_accounts (is_seed) WHERE is_seed = true;
|
||||
@@ -0,0 +1,128 @@
|
||||
-- Inbox placement tests for workspaces. A test renders a template (or a
|
||||
-- campaign step) the way a campaign would, sends one copy per seed mailbox
|
||||
-- from a real sender mailbox, paced as scheduled tasks, and reads where each
|
||||
-- copy landed from the seed's synced mail, matched on Message-ID.
|
||||
|
||||
-- One probe send per seed is a task of its own, so it shares the min-gap clock
|
||||
-- and the daily counts every other send from the mailbox uses. Postgres cannot
|
||||
-- drop an enum value, so the down migration leaves it.
|
||||
ALTER TYPE public.task_type ADD VALUE IF NOT EXISTS 'placement';
|
||||
|
||||
-- Who a seed serves: the instance panel every workspace tests against, or one
|
||||
-- workspace's own test inboxes. NULL is an ordinary mailbox.
|
||||
ALTER TABLE email_accounts ADD COLUMN seed_scope text;
|
||||
UPDATE email_accounts SET seed_scope = 'instance' WHERE is_seed;
|
||||
ALTER TABLE email_accounts
|
||||
ADD CONSTRAINT email_accounts_seed_scope_check
|
||||
CHECK (seed_scope IS NULL OR seed_scope IN ('instance', 'workspace'));
|
||||
DROP INDEX IF EXISTS idx_email_accounts_is_seed;
|
||||
ALTER TABLE email_accounts DROP COLUMN is_seed;
|
||||
CREATE INDEX idx_email_accounts_seed_scope
|
||||
ON email_accounts (seed_scope, organization_id)
|
||||
WHERE seed_scope IS NOT NULL;
|
||||
|
||||
-- Scheduled re-tests of an active campaign's first step, rotating through its
|
||||
-- sending mailboxes, with an alert when the inbox rate drops.
|
||||
CREATE TABLE placement_monitors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
campaign_id uuid NOT NULL UNIQUE REFERENCES campaigns (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
interval_days integer NOT NULL DEFAULT 7 CHECK (interval_days BETWEEN 1 AND 30),
|
||||
panel text NOT NULL DEFAULT 'instance' CHECK (panel IN ('instance', 'workspace', 'cloud')),
|
||||
alert_below integer NOT NULL DEFAULT 70 CHECK (alert_below BETWEEN 0 AND 100),
|
||||
pause_on_alert boolean NOT NULL DEFAULT false,
|
||||
next_run_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
last_run_at timestamptz,
|
||||
last_test_id uuid,
|
||||
last_sender_id uuid REFERENCES email_accounts (id) ON DELETE SET NULL,
|
||||
last_alert_at timestamptz,
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_placement_monitors_due ON placement_monitors (next_run_at) WHERE enabled;
|
||||
CREATE INDEX idx_placement_monitors_org ON placement_monitors (organization_id);
|
||||
|
||||
-- A copy is matched on its Message-ID now, so the subject token goes. A test
|
||||
-- the cloud runs for a linked instance has no local sender.
|
||||
ALTER TABLE placement_tests
|
||||
DROP COLUMN token,
|
||||
ALTER COLUMN sender_account_id DROP NOT NULL,
|
||||
ADD COLUMN created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
ADD COLUMN campaign_id uuid REFERENCES campaigns (id) ON DELETE SET NULL,
|
||||
ADD COLUMN sequence_id uuid REFERENCES sequences (id) ON DELETE SET NULL,
|
||||
ADD COLUMN contact_id uuid REFERENCES contacts (id) ON DELETE SET NULL,
|
||||
ADD COLUMN monitor_id uuid REFERENCES placement_monitors (id) ON DELETE SET NULL,
|
||||
ADD COLUMN open_tracking boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN link_tracking boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN compare_group_id uuid,
|
||||
-- Existing rows were all run from the admin panel.
|
||||
ADD COLUMN origin text NOT NULL DEFAULT 'admin',
|
||||
ADD COLUMN panel text NOT NULL DEFAULT 'instance',
|
||||
ADD COLUMN sender_email text NOT NULL DEFAULT '',
|
||||
ADD COLUMN remote_instance_id uuid,
|
||||
ADD COLUMN remote_test_id uuid,
|
||||
ADD COLUMN error text NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE placement_tests SET status = 'running' WHERE status = 'pending';
|
||||
|
||||
ALTER TABLE placement_tests
|
||||
ALTER COLUMN status SET DEFAULT 'running',
|
||||
ALTER COLUMN origin SET DEFAULT 'manual',
|
||||
ADD CONSTRAINT placement_tests_status_check
|
||||
CHECK (status IN ('running', 'completed', 'cancelled', 'failed')),
|
||||
ADD CONSTRAINT placement_tests_origin_check
|
||||
CHECK (origin IN ('manual', 'monitor', 'admin', 'remote')),
|
||||
ADD CONSTRAINT placement_tests_panel_check
|
||||
CHECK (panel IN ('instance', 'workspace', 'cloud'));
|
||||
|
||||
ALTER TABLE placement_monitors
|
||||
ADD CONSTRAINT placement_monitors_last_test_fkey
|
||||
FOREIGN KEY (last_test_id) REFERENCES placement_tests (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_placement_tests_running ON placement_tests (created_at) WHERE status = 'running';
|
||||
CREATE INDEX idx_placement_tests_campaign ON placement_tests (campaign_id, created_at DESC) WHERE campaign_id IS NOT NULL;
|
||||
CREATE INDEX idx_placement_tests_group ON placement_tests (compare_group_id) WHERE compare_group_id IS NOT NULL;
|
||||
|
||||
-- A result names its seed by address and host family as well as by id, so a
|
||||
-- cloud seed (no local mailbox) and an archive import (the seed stays behind)
|
||||
-- still read. 'missing' is a copy that never arrived, 'failed' one that never
|
||||
-- left, 'other' a Gmail tab other than Promotions.
|
||||
ALTER TABLE placement_results
|
||||
DROP CONSTRAINT IF EXISTS placement_results_folder_check,
|
||||
ALTER COLUMN seed_account_id DROP NOT NULL,
|
||||
ADD COLUMN seed_address text NOT NULL DEFAULT '',
|
||||
ADD COLUMN remote_seed_id uuid,
|
||||
ADD COLUMN task_id uuid REFERENCES tasks (id) ON DELETE SET NULL,
|
||||
ADD COLUMN message_id text NOT NULL DEFAULT '',
|
||||
ADD COLUMN scheduled_at timestamptz,
|
||||
ADD COLUMN sent_at timestamptz,
|
||||
ADD COLUMN remote_synced_at timestamptz,
|
||||
ADD COLUMN error text NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE placement_results SET folder = 'missing' WHERE folder = 'other' AND raw_flags = 'timeout';
|
||||
UPDATE placement_results pr SET seed_address = ea.email
|
||||
FROM email_accounts ea WHERE ea.id = pr.seed_account_id;
|
||||
|
||||
ALTER TABLE placement_results
|
||||
ADD CONSTRAINT placement_results_folder_check
|
||||
CHECK (folder IN ('pending', 'inbox', 'promotions', 'other', 'spam', 'missing', 'failed', 'cancelled'));
|
||||
|
||||
-- A result keeps the seed's address and a test the sender's, so removing a
|
||||
-- mailbox leaves the history in place instead of deleting it.
|
||||
ALTER TABLE placement_results
|
||||
DROP CONSTRAINT placement_results_seed_account_id_fkey,
|
||||
ADD CONSTRAINT placement_results_seed_account_id_fkey
|
||||
FOREIGN KEY (seed_account_id) REFERENCES email_accounts (id) ON DELETE SET NULL;
|
||||
ALTER TABLE placement_tests
|
||||
DROP CONSTRAINT placement_tests_sender_account_id_fkey,
|
||||
ADD CONSTRAINT placement_tests_sender_account_id_fkey
|
||||
FOREIGN KEY (sender_account_id) REFERENCES email_accounts (id) ON DELETE SET NULL;
|
||||
UPDATE placement_tests pt SET sender_email = ea.email
|
||||
FROM email_accounts ea WHERE ea.id = pt.sender_account_id AND pt.sender_email = '';
|
||||
|
||||
CREATE UNIQUE INDEX idx_placement_results_test_address ON placement_results (test_id, lower(seed_address));
|
||||
CREATE UNIQUE INDEX idx_placement_results_task ON placement_results (task_id) WHERE task_id IS NOT NULL;
|
||||
@@ -51,6 +51,9 @@ const (
|
||||
// or stopped holding it. Status carries the backfill status, Reason the
|
||||
// throttle reason (empty when released).
|
||||
EventAccountSyncState EventType = "ACCOUNT_SYNC_STATE"
|
||||
// EventPlacementTest: an inbox placement test started, got a verdict for
|
||||
// a copy, or finished.
|
||||
EventPlacementTest EventType = "PLACEMENT_TEST_UPDATED"
|
||||
|
||||
// Bulk operation events
|
||||
EventBulkStarted EventType = "BULK_STARTED"
|
||||
@@ -592,6 +595,37 @@ func (p *StreamingPublisher) PublishFormSubmission(ctx context.Context, orgID, f
|
||||
}
|
||||
}
|
||||
|
||||
// PlacementTestEvent is the org-scoped placement test signal. Ids and status
|
||||
// only: where the copies landed stays behind the read endpoint's permission.
|
||||
type PlacementTestEvent struct {
|
||||
BaseEvent
|
||||
OrgID string `json:"org_id"`
|
||||
TestID string `json:"test_id"`
|
||||
CampaignID string `json:"campaign_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PublishPlacementTest emits an org-scoped placement test signal.
|
||||
func (p *StreamingPublisher) PublishPlacementTest(ctx context.Context, orgID, testID uuid.UUID, campaignID *uuid.UUID, status string) {
|
||||
if p == nil || p.client == nil || orgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
event := &PlacementTestEvent{
|
||||
BaseEvent: BaseEvent{EventType: EventPlacementTest, Timestamp: time.Now()},
|
||||
OrgID: orgID.String(),
|
||||
TestID: testID.String(),
|
||||
Status: status,
|
||||
}
|
||||
if campaignID != nil {
|
||||
event.CampaignID = campaignID.String()
|
||||
}
|
||||
attrs := map[string]string{
|
||||
"org_id": orgID.String(),
|
||||
"event_type": string(EventPlacementTest),
|
||||
}
|
||||
_ = p.client.Publish(ctx, TopicUserEvents, event, attrs)
|
||||
}
|
||||
|
||||
// AutomationEvent is an org-scoped automation lifecycle/run signal. The web
|
||||
// client invalidates the ['automations'] queries on any "AUTOMATION" event.
|
||||
type AutomationEvent struct {
|
||||
|
||||
@@ -10,13 +10,10 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/jobrun"
|
||||
)
|
||||
|
||||
// PlacementPoller reconciles pending seed inbox-placement results: each tick it
|
||||
// looks up each in-flight test's token in the receiving seed's unibox entries
|
||||
// and classifies where the probe landed (Inbox / Spam / Promotions / other),
|
||||
// completing the test once every result resolves or the classify timeout
|
||||
// passes. It is a thin scheduler around placement.Service.ClassifyPending; all
|
||||
// policy lives in the service. A ~2-minute tick balances responsiveness against
|
||||
// the latency of mailbox sync delivering the probe into the unibox.
|
||||
// PlacementPoller drives placement.Service.Tick: it reads where delivered
|
||||
// probes landed, closes finished tests, syncs Warmbly Cloud's panel and starts
|
||||
// due monitors. All policy lives in the service; a short tick keeps a running
|
||||
// test's panel live without waiting on mailbox sync any longer than it takes.
|
||||
type PlacementPoller struct {
|
||||
svc placement.Service
|
||||
interval time.Duration
|
||||
@@ -26,7 +23,7 @@ type PlacementPoller struct {
|
||||
// NewPlacementPoller creates the poller.
|
||||
func NewPlacementPoller(svc placement.Service, interval time.Duration) *PlacementPoller {
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Minute
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
return &PlacementPoller{
|
||||
svc: svc,
|
||||
@@ -35,13 +32,13 @@ func NewPlacementPoller(svc placement.Service, interval time.Duration) *Placemen
|
||||
}
|
||||
}
|
||||
|
||||
// Run performs one classification pass. Safe to call frequently — ClassifyPending
|
||||
// no-ops when there are no pending results.
|
||||
// Run performs one pass. Safe to call often: an idle tick is a handful of
|
||||
// indexed reads.
|
||||
func (p *PlacementPoller) Run(ctx context.Context) error {
|
||||
if p.svc == nil {
|
||||
return nil
|
||||
}
|
||||
if err := p.svc.ClassifyPending(ctx); err != nil {
|
||||
if err := p.svc.Tick(ctx); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -925,14 +925,18 @@ type CampaignDeliverability struct {
|
||||
// ProviderPlacement is one recipient provider's seed placement rollup in the
|
||||
// window (from placement_results; folders mirror the table CHECK constraint).
|
||||
type ProviderPlacement struct {
|
||||
Provider string `json:"provider"`
|
||||
Samples int `json:"samples"`
|
||||
Inbox int `json:"inbox"`
|
||||
Promotions int `json:"promotions"`
|
||||
Spam int `json:"spam"`
|
||||
Other int `json:"other"`
|
||||
InboxRate float64 `json:"inbox_rate"`
|
||||
SpamRate float64 `json:"spam_rate"`
|
||||
// Provider is the seed's host family (a mailhost value), Label its name.
|
||||
Provider string `json:"provider"`
|
||||
Label string `json:"label"`
|
||||
Samples int `json:"samples"`
|
||||
Inbox int `json:"inbox"`
|
||||
Promotions int `json:"promotions"`
|
||||
Spam int `json:"spam"`
|
||||
Other int `json:"other"`
|
||||
// Missing is copies that never arrived.
|
||||
Missing int `json:"missing"`
|
||||
InboxRate float64 `json:"inbox_rate"`
|
||||
SpamRate float64 `json:"spam_rate"`
|
||||
}
|
||||
|
||||
// WarmupDomainPlacement is one recipient domain's warmup placement rollup:
|
||||
|
||||
@@ -146,6 +146,11 @@ const (
|
||||
// spine so the settings page and mailbox lists refresh for every member.
|
||||
AuditEntityPoolLink AuditEntityType = "pool_link"
|
||||
AuditEntityCloudLink AuditEntityType = "cloud_link"
|
||||
|
||||
// Inbox placement: a test started or cancelled, and a campaign's
|
||||
// scheduled test set up, changed or removed.
|
||||
AuditEntityPlacementTest AuditEntityType = "placement_test"
|
||||
AuditEntityPlacementMonitor AuditEntityType = "placement_monitor"
|
||||
)
|
||||
|
||||
// AuditActor is the minimal identity of the member who performed an action,
|
||||
|
||||
@@ -28,6 +28,12 @@ const (
|
||||
// It is the warning shot before the send/warmup gate applies, so it must
|
||||
// reach the owner while there is still time to fix the DNS records.
|
||||
NotifDomainAuth NotificationCategory = "health_domain_auth"
|
||||
// NotifPlacementFinished tells whoever started an inbox placement test
|
||||
// where its copies landed.
|
||||
NotifPlacementFinished NotificationCategory = "placement_finished"
|
||||
// NotifPlacementAlert fires when a campaign's scheduled placement test
|
||||
// comes back below its alert threshold, so it emails by default.
|
||||
NotifPlacementAlert NotificationCategory = "placement_alert"
|
||||
)
|
||||
|
||||
// ChannelPrefs is the per-category delivery toggles: in-app feed, account
|
||||
@@ -58,6 +64,9 @@ type NotificationPreferences struct {
|
||||
TeamActivity CategoryPref `json:"team_activity"`
|
||||
CampaignPaused CategoryPref `json:"campaign_paused"`
|
||||
DomainAuth CategoryPref `json:"health_domain_auth"`
|
||||
// PlacementFinished and PlacementAlert are the inbox placement test pair.
|
||||
PlacementFinished CategoryPref `json:"placement_finished"`
|
||||
PlacementAlert CategoryPref `json:"placement_alert"`
|
||||
|
||||
// EmailDigestMinutes is the email-channel bundling window: pending
|
||||
// notification emails hold this long, then flush as one email. Bounded
|
||||
@@ -95,6 +104,8 @@ func DefaultNotificationPreferences() NotificationPreferences {
|
||||
TeamActivity: on,
|
||||
CampaignPaused: campaignPaused,
|
||||
DomainAuth: domainAuth,
|
||||
PlacementFinished: on,
|
||||
PlacementAlert: domainAuth,
|
||||
EmailDigestMinutes: 30,
|
||||
}
|
||||
}
|
||||
@@ -122,6 +133,10 @@ func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPr
|
||||
return p.CampaignPaused
|
||||
case NotifDomainAuth:
|
||||
return p.DomainAuth
|
||||
case NotifPlacementFinished:
|
||||
return p.PlacementFinished
|
||||
case NotifPlacementAlert:
|
||||
return p.PlacementAlert
|
||||
default:
|
||||
return CategoryPref{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Seed panels a placement test can send to.
|
||||
const (
|
||||
// PlacementPanelInstance is the seed panel the operator runs for every
|
||||
// workspace on the instance (on the hosted product, Warmbly's own).
|
||||
PlacementPanelInstance = "instance"
|
||||
// PlacementPanelWorkspace is the workspace's own test inboxes.
|
||||
PlacementPanelWorkspace = "workspace"
|
||||
// PlacementPanelCloud is Warmbly Cloud's panel, for a linked self-hosted
|
||||
// instance.
|
||||
PlacementPanelCloud = "cloud"
|
||||
)
|
||||
|
||||
// ValidPlacementPanel reports whether p names a seed panel.
|
||||
func ValidPlacementPanel(p string) bool {
|
||||
switch p {
|
||||
case PlacementPanelInstance, PlacementPanelWorkspace, PlacementPanelCloud:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Seed scopes, stored as email_accounts.seed_scope.
|
||||
const (
|
||||
SeedScopeInstance = "instance"
|
||||
SeedScopeWorkspace = "workspace"
|
||||
)
|
||||
|
||||
// Placement test statuses.
|
||||
const (
|
||||
PlacementStatusRunning = "running"
|
||||
PlacementStatusCompleted = "completed"
|
||||
PlacementStatusCancelled = "cancelled"
|
||||
PlacementStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// What started a placement test.
|
||||
const (
|
||||
PlacementOriginManual = "manual"
|
||||
PlacementOriginMonitor = "monitor"
|
||||
PlacementOriginAdmin = "admin"
|
||||
// PlacementOriginRemote is a test the cloud runs for a linked instance.
|
||||
PlacementOriginRemote = "remote"
|
||||
)
|
||||
|
||||
// Where one probe landed, stored as placement_results.folder.
|
||||
const (
|
||||
PlacementFolderPending = "pending"
|
||||
PlacementFolderInbox = "inbox"
|
||||
PlacementFolderPromotions = "promotions"
|
||||
// PlacementFolderOther is a Gmail tab other than Promotions (Updates,
|
||||
// Social, Forums).
|
||||
PlacementFolderOther = "other"
|
||||
PlacementFolderSpam = "spam"
|
||||
// PlacementFolderMissing is a copy that left the sender and never showed
|
||||
// up in the seed within the classify window.
|
||||
PlacementFolderMissing = "missing"
|
||||
// PlacementFolderFailed is a copy that never left the sender.
|
||||
PlacementFolderFailed = "failed"
|
||||
PlacementFolderCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// PlacementFolderResolved reports whether a folder is a final answer about
|
||||
// where a delivered copy went (not pending, failed or cancelled).
|
||||
func PlacementFolderResolved(folder string) bool {
|
||||
switch folder {
|
||||
case PlacementFolderInbox, PlacementFolderPromotions, PlacementFolderOther,
|
||||
PlacementFolderSpam, PlacementFolderMissing:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClassifyPlacementLanding reads where an arriving probe sits from its
|
||||
// canonical folder and provider flags, splitting Gmail's Promotions tab from
|
||||
// the other tabs because Promotions is where cold mail goes to be ignored.
|
||||
func ClassifyPlacementLanding(folder string, flags []string) string {
|
||||
if folder == FolderSpam || HasSpamFlag(flags) {
|
||||
return PlacementFolderSpam
|
||||
}
|
||||
tab := ""
|
||||
for _, f := range flags {
|
||||
switch strings.ToUpper(f) {
|
||||
case "CATEGORY_PROMOTIONS":
|
||||
return PlacementFolderPromotions
|
||||
case "CATEGORY_UPDATES", "CATEGORY_SOCIAL", "CATEGORY_FORUMS":
|
||||
tab = PlacementFolderOther
|
||||
}
|
||||
}
|
||||
if tab != "" {
|
||||
return tab
|
||||
}
|
||||
return PlacementFolderInbox
|
||||
}
|
||||
|
||||
// Tracking choices when a test is created.
|
||||
const (
|
||||
// PlacementTrackingCampaign copies the campaign's own open and click
|
||||
// tracking; an ad-hoc test has none.
|
||||
PlacementTrackingCampaign = "campaign"
|
||||
PlacementTrackingOn = "on"
|
||||
PlacementTrackingOff = "off"
|
||||
// PlacementTrackingCompare runs the same copy twice, once tracked and once
|
||||
// not, interleaved per seed so both see the same conditions.
|
||||
PlacementTrackingCompare = "compare"
|
||||
)
|
||||
|
||||
// PlacementTest is one run: a template sent from one sender to a seed panel.
|
||||
type PlacementTest struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID *uuid.UUID `json:"-"`
|
||||
SenderAccountID *uuid.UUID `json:"sender_account_id"`
|
||||
SenderEmail string `json:"sender_email"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
CampaignID *uuid.UUID `json:"campaign_id"`
|
||||
SequenceID *uuid.UUID `json:"sequence_id"`
|
||||
ContactID *uuid.UUID `json:"contact_id"`
|
||||
MonitorID *uuid.UUID `json:"monitor_id"`
|
||||
Subject string `json:"subject"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
BodyPlain string `json:"body_plain,omitempty"`
|
||||
OpenTracking bool `json:"open_tracking"`
|
||||
LinkTracking bool `json:"link_tracking"`
|
||||
CompareGroupID *uuid.UUID `json:"compare_group_id"`
|
||||
Origin string `json:"origin"`
|
||||
Panel string `json:"panel"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// RemoteInstanceID is set on the cloud's copy of a test it runs for a
|
||||
// linked instance; RemoteTestID on the instance's copy, naming the cloud's.
|
||||
RemoteInstanceID *uuid.UUID `json:"-"`
|
||||
RemoteTestID *uuid.UUID `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
// Tracked reports whether the test's copies carry any tracking.
|
||||
func (t PlacementTest) Tracked() bool { return t.OpenTracking || t.LinkTracking }
|
||||
|
||||
// PlacementResult is one probe: one copy to one seed.
|
||||
type PlacementResult struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TestID uuid.UUID `json:"test_id"`
|
||||
SeedAccountID *uuid.UUID `json:"-"`
|
||||
SeedAddress string `json:"seed_address"`
|
||||
// Family is who hosts the seed, a mailhost value (google_workspace,
|
||||
// gmail, microsoft365, outlook, yahoo, ...). Stored as provider.
|
||||
Family string `json:"family"`
|
||||
RemoteSeedID *uuid.UUID `json:"-"`
|
||||
TaskID *uuid.UUID `json:"-"`
|
||||
MessageID string `json:"-"`
|
||||
Folder string `json:"folder"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
SentAt *time.Time `json:"sent_at"`
|
||||
DetectedAt *time.Time `json:"detected_at"`
|
||||
RawFlags string `json:"-"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RemoteSyncedAt *time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// PlacementCounts is where a set of probes landed.
|
||||
type PlacementCounts struct {
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
Inbox int `json:"inbox"`
|
||||
Promotions int `json:"promotions"`
|
||||
Other int `json:"other"`
|
||||
Spam int `json:"spam"`
|
||||
Missing int `json:"missing"`
|
||||
Failed int `json:"failed"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
// Delivered is every copy that left and got a verdict:
|
||||
// inbox + promotions + other + spam + missing.
|
||||
Delivered int `json:"delivered"`
|
||||
// InboxRate is the primary inbox share of Delivered, TabsRate the Gmail
|
||||
// tabs, SpamRate spam and MissingRate the copies never seen. Nil until
|
||||
// something is delivered.
|
||||
InboxRate *float64 `json:"inbox_rate"`
|
||||
TabsRate *float64 `json:"tabs_rate"`
|
||||
SpamRate *float64 `json:"spam_rate"`
|
||||
MissingRate *float64 `json:"missing_rate"`
|
||||
}
|
||||
|
||||
// Add counts one probe.
|
||||
func (c *PlacementCounts) Add(folder string) {
|
||||
c.Total++
|
||||
switch folder {
|
||||
case PlacementFolderInbox:
|
||||
c.Inbox++
|
||||
case PlacementFolderPromotions:
|
||||
c.Promotions++
|
||||
case PlacementFolderOther:
|
||||
c.Other++
|
||||
case PlacementFolderSpam:
|
||||
c.Spam++
|
||||
case PlacementFolderMissing:
|
||||
c.Missing++
|
||||
case PlacementFolderFailed:
|
||||
c.Failed++
|
||||
case PlacementFolderCancelled:
|
||||
c.Cancelled++
|
||||
default:
|
||||
c.Pending++
|
||||
}
|
||||
}
|
||||
|
||||
// Finish derives Delivered and the rates.
|
||||
func (c *PlacementCounts) Finish() {
|
||||
c.Delivered = c.Inbox + c.Promotions + c.Other + c.Spam + c.Missing
|
||||
c.InboxRate, c.TabsRate, c.SpamRate, c.MissingRate = nil, nil, nil, nil
|
||||
if c.Delivered == 0 {
|
||||
return
|
||||
}
|
||||
rate := func(n int) *float64 {
|
||||
r := float64(n) / float64(c.Delivered)
|
||||
return &r
|
||||
}
|
||||
c.InboxRate = rate(c.Inbox)
|
||||
c.TabsRate = rate(c.Promotions + c.Other)
|
||||
c.SpamRate = rate(c.Spam)
|
||||
c.MissingRate = rate(c.Missing)
|
||||
}
|
||||
|
||||
// PlacementFamilyCounts is one host family's share of a test.
|
||||
type PlacementFamilyCounts struct {
|
||||
Family string `json:"family"`
|
||||
Label string `json:"label"`
|
||||
Counts PlacementCounts `json:"counts"`
|
||||
}
|
||||
|
||||
// PlacementSeed is a seed mailbox as a test sees it.
|
||||
type PlacementSeed struct {
|
||||
AccountID *uuid.UUID
|
||||
RemoteSeedID *uuid.UUID
|
||||
Address string
|
||||
Family string
|
||||
OrganizationID *uuid.UUID
|
||||
}
|
||||
|
||||
// PlacementPanelFamily counts the seeds one host family contributes.
|
||||
type PlacementPanelFamily struct {
|
||||
Family string `json:"family"`
|
||||
Label string `json:"label"`
|
||||
Seeds int `json:"seeds"`
|
||||
}
|
||||
|
||||
// PlacementPanelInfo says whether a workspace can test on a panel.
|
||||
type PlacementPanelInfo struct {
|
||||
Panel string `json:"panel"`
|
||||
Available bool `json:"available"`
|
||||
// Reason explains an unavailable panel in one sentence.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Seeds int `json:"seeds"`
|
||||
Families []PlacementPanelFamily `json:"families"`
|
||||
// Metered panels count against the monthly allowance.
|
||||
Metered bool `json:"metered"`
|
||||
}
|
||||
|
||||
// PlacementUsage is a workspace's monthly allowance on the metered panels.
|
||||
type PlacementUsage struct {
|
||||
Used int `json:"used"`
|
||||
// Limit is nil when the instance does not meter tests (self-hosted).
|
||||
Limit *int `json:"limit"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
}
|
||||
|
||||
// Remaining is how many metered tests are left, -1 when unmetered.
|
||||
func (u PlacementUsage) Remaining() int {
|
||||
if u.Limit == nil {
|
||||
return -1
|
||||
}
|
||||
return max(0, *u.Limit-u.Used)
|
||||
}
|
||||
|
||||
// PlacementWorkspaceSeed is one of a workspace's own mailboxes as a seed
|
||||
// candidate.
|
||||
type PlacementWorkspaceSeed struct {
|
||||
EmailAccountID uuid.UUID `json:"email_account_id"`
|
||||
Email string `json:"email"`
|
||||
Family string `json:"family"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"`
|
||||
Seed bool `json:"seed"`
|
||||
// Blocker is why the mailbox cannot become a seed, empty when it can.
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementMonitor re-tests a campaign's first step on a schedule.
|
||||
type PlacementMonitor struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"-"`
|
||||
CampaignID uuid.UUID `json:"campaign_id"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
Panel string `json:"panel"`
|
||||
AlertBelow int `json:"alert_below"`
|
||||
PauseOnAlert bool `json:"pause_on_alert"`
|
||||
NextRunAt time.Time `json:"next_run_at"`
|
||||
LastRunAt *time.Time `json:"last_run_at"`
|
||||
LastTestID *uuid.UUID `json:"last_test_id"`
|
||||
LastSenderID *uuid.UUID `json:"-"`
|
||||
LastAlertAt *time.Time `json:"last_alert_at"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PlacementCloudStartRequest is a linked instance asking Warmbly Cloud for
|
||||
// seeds to run a test against.
|
||||
type PlacementCloudStartRequest struct {
|
||||
// SenderDomain keeps seeds on the sender's own domain out of the panel:
|
||||
// mail inside one tenant skips the filtering the test is there to see.
|
||||
SenderDomain string `json:"sender_domain"`
|
||||
// Tests is how many tests the request covers (two for a tracking
|
||||
// comparison), each charged against the cloud workspace's allowance.
|
||||
Tests int `json:"tests"`
|
||||
// MaxSeeds is how many seeds the sender's day can pay for; zero means the
|
||||
// cloud's own cap.
|
||||
MaxSeeds int `json:"max_seeds,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementCloudSeed is one cloud seed a linked instance sends a copy to.
|
||||
type PlacementCloudSeed struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Family string `json:"family"`
|
||||
}
|
||||
|
||||
// PlacementCloudStart is the cloud's answer: the seeds, once per test.
|
||||
type PlacementCloudStart struct {
|
||||
TestIDs []uuid.UUID `json:"test_ids"`
|
||||
Seeds []PlacementCloudSeed `json:"seeds"`
|
||||
Usage PlacementUsage `json:"usage"`
|
||||
}
|
||||
|
||||
// PlacementCloudSend reports one copy the instance sent, or failed to send.
|
||||
type PlacementCloudSend struct {
|
||||
SeedID uuid.UUID `json:"seed_id"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementCloudSends is the batch an instance reports.
|
||||
type PlacementCloudSends struct {
|
||||
Sends []PlacementCloudSend `json:"sends"`
|
||||
}
|
||||
|
||||
// PlacementCloudVerdict is where one cloud seed saw its copy land.
|
||||
type PlacementCloudVerdict struct {
|
||||
SeedID uuid.UUID `json:"seed_id"`
|
||||
Folder string `json:"folder"`
|
||||
DetectedAt *time.Time `json:"detected_at,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementCloudTest is the cloud's view of one test it runs for an instance.
|
||||
type PlacementCloudTest struct {
|
||||
TestID uuid.UUID `json:"test_id"`
|
||||
Status string `json:"status"`
|
||||
Verdicts []PlacementCloudVerdict `json:"verdicts"`
|
||||
}
|
||||
|
||||
// PlacementCloudPanel is the cloud panel as a linked instance sees it.
|
||||
type PlacementCloudPanel struct {
|
||||
Panel PlacementPanelInfo `json:"panel"`
|
||||
Usage PlacementUsage `json:"usage"`
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func DefaultWarmupGenerationSettings() WarmupGenerationSettings {
|
||||
CadenceHours: 6,
|
||||
RefreshEnabled: true,
|
||||
RefreshPerRun: 10,
|
||||
Model: "gpt-5-mini",
|
||||
Model: "gpt-6-luna",
|
||||
MaxMessagesPerThread: 5,
|
||||
DailyGenerationCap: 1000,
|
||||
AISelectionShare: 70,
|
||||
@@ -174,7 +174,7 @@ func (s *WarmupGenerationSettings) Normalize() {
|
||||
s.RefreshPerRun = 25
|
||||
s.DailyGenerationCap = 1000
|
||||
s.AISelectionShare = 70
|
||||
s.Model = "gpt-5-mini"
|
||||
s.Model = "gpt-6-luna"
|
||||
s.MaxMessagesPerThread = 5
|
||||
s.Engagement.SpamRescueRate = clampPct(s.Engagement.SpamRescueRate)
|
||||
s.Engagement.MarkImportantRate = clampPct(s.Engagement.MarkImportantRate)
|
||||
|
||||
@@ -27,8 +27,8 @@ const (
|
||||
// OpenAI models. These are the PRIMARY writing/agent models for Warmbly's
|
||||
// hosted product (OpenAI-first) and the default for any OpenAI-compatible
|
||||
// self-hosted endpoint.
|
||||
ModelWritingFreeOpenAI = "gpt-4o-mini"
|
||||
ModelWritingPaidOpenAI = "gpt-4o"
|
||||
ModelWritingFreeOpenAI = "gpt-6-luna"
|
||||
ModelWritingPaidOpenAI = "gpt-6-luna"
|
||||
|
||||
// Agent-loop models for the Anthropic connector (self-host only): haiku for
|
||||
// trial orgs, sonnet for paid.
|
||||
|
||||
@@ -97,7 +97,7 @@ func conversationResponseFormat() openai.ChatCompletionNewParamsResponseFormatUn
|
||||
// thread. Both GenerateConversation (sync) and SubmitBatch (Batch API) use this
|
||||
// so the request bodies are identical; the only difference is the transport.
|
||||
func buildConversationParams(theme, model string, maxMessages int) openai.ChatCompletionNewParams {
|
||||
chatModel := openai.ChatModelGPT4oMini
|
||||
chatModel := openai.ChatModel(ModelWritingFreeOpenAI)
|
||||
if model != "" {
|
||||
chatModel = openai.ChatModel(model)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func buildConversationParams(theme, model string, maxMessages int) openai.ChatCo
|
||||
// The prompt is tuned for warmup deliverability: plaintext, a few short
|
||||
// sentences, one natural question, and explicitly NO links, phone numbers,
|
||||
// attachments, emoji, or marketing language (all of which raise spam scores).
|
||||
// Empty model falls back to gpt-4o-mini for direct callers.
|
||||
// Empty model falls back to ModelWritingFreeOpenAI for direct callers.
|
||||
func (c *GenerationClient) GenerateConversation(ctx context.Context, theme, model string, maxMessages int) (*Conversation, error) {
|
||||
req := buildConversationParams(theme, model, normalizeMaxMessages(maxMessages))
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ const defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
// defaultLocalModel is the fall-back model tag for a free/local backend
|
||||
// (AI_FREE) when no explicit model id is set, so a dev who forgets
|
||||
// AI_MODEL does not 404 on "gpt-4o-mini". llama3.1 has reliable
|
||||
// AI_MODEL does not 404 on "gpt-6-luna". llama3.1 has reliable
|
||||
// tool-calling in Ollama; override with AI_MODEL (or AI_MODEL_TRIAL / _PAID).
|
||||
const defaultLocalModel = "llama3.1"
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ type ProviderConfig struct {
|
||||
OpenAIBaseURL string
|
||||
// OpenAIModelTrial / OpenAIModelPaid override the per-tier model ids (so a
|
||||
// self-hoster can route to whatever their endpoint serves). Empty uses the
|
||||
// built-in gpt-4o-mini / gpt-4o defaults (or the local default when Local).
|
||||
// built-in gpt-6-luna default (or the local default when Local).
|
||||
OpenAIModelTrial string
|
||||
OpenAIModelPaid string
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ type providerPreset struct {
|
||||
func presetFor(name string) (providerPreset, bool) {
|
||||
switch name {
|
||||
case "openai":
|
||||
// Model default handled by newOpenAIProvider (gpt-4o-mini / gpt-4o).
|
||||
// Model default handled by newOpenAIProvider (gpt-6-luna).
|
||||
return providerPreset{baseURL: defaultOpenAIBaseURL, needsKey: true}, true
|
||||
case "openrouter":
|
||||
return providerPreset{baseURL: "https://openrouter.ai/api/v1", defaultModel: "meta-llama/llama-3.1-8b-instruct:free", needsKey: true}, true
|
||||
|
||||
@@ -91,6 +91,29 @@ func (h Host) Google() bool { return h == GoogleWorkspace || h == Gmail }
|
||||
// Microsoft reports whether h is either Microsoft product.
|
||||
func (h Host) Microsoft() bool { return h == Microsoft365 || h == Outlook }
|
||||
|
||||
// ForMailbox is who hosts a connected mailbox: its stored mail_host when the
|
||||
// connect path recorded one, else read from how it connects and its address.
|
||||
// provider is the email_provider value (gmail, outlook, smtp_imap).
|
||||
func ForMailbox(stored, provider, address string) Host {
|
||||
domain := ""
|
||||
if at := strings.LastIndex(address, "@"); at >= 0 {
|
||||
domain = NormalizeDomain(address[at+1:])
|
||||
}
|
||||
if h := Host(stored); stored != "" && Valid(stored) {
|
||||
return Refine(h, domain)
|
||||
}
|
||||
switch provider {
|
||||
case "gmail":
|
||||
return Refine(Gmail, domain)
|
||||
case "outlook":
|
||||
return Refine(Microsoft365, domain)
|
||||
}
|
||||
if h, ok := knownDomain(domain); ok {
|
||||
return h
|
||||
}
|
||||
return Other
|
||||
}
|
||||
|
||||
// PasswordAuth says how a password signs in on a host.
|
||||
type PasswordAuth string
|
||||
|
||||
|
||||
@@ -558,3 +558,25 @@ func TestValidAndLabels(t *testing.T) {
|
||||
t.Fatal("Label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForMailbox(t *testing.T) {
|
||||
cases := []struct {
|
||||
stored, provider, address string
|
||||
want Host
|
||||
}{
|
||||
{"", "gmail", "a@gmail.com", Gmail},
|
||||
{"", "gmail", "a@acme.io", GoogleWorkspace},
|
||||
{"", "outlook", "a@hotmail.com", Outlook},
|
||||
{"", "outlook", "a@acme.io", Microsoft365},
|
||||
{"", "smtp_imap", "a@yahoo.com", Yahoo},
|
||||
{"", "smtp_imap", "a@acme.io", Other},
|
||||
{"zoho", "smtp_imap", "a@acme.io", Zoho},
|
||||
{"google_workspace", "smtp_imap", "a@gmail.com", Gmail},
|
||||
{"bogus", "smtp_imap", "a@acme.io", Other},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ForMailbox(c.stored, c.provider, c.address); got != c.want {
|
||||
t.Errorf("ForMailbox(%q, %q, %q) = %q, want %q", c.stored, c.provider, c.address, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhost"
|
||||
)
|
||||
|
||||
type AdvancedOutreachRepository interface {
|
||||
@@ -743,7 +744,9 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
|
||||
placementQuery := `
|
||||
SELECT pr.provider, pr.folder, COUNT(*)
|
||||
FROM placement_results pr JOIN placement_tests pt ON pt.id = pr.test_id
|
||||
WHERE pt.organization_id = $1 AND pr.detected_at >= $2 AND pr.detected_at <= $3 AND pr.folder <> 'pending'
|
||||
WHERE pt.organization_id = $1 AND pr.detected_at >= $2 AND pr.detected_at <= $3
|
||||
AND pr.folder IN ('inbox', 'promotions', 'other', 'spam', 'missing')
|
||||
AND pt.origin <> 'remote'
|
||||
GROUP BY pr.provider, pr.folder`
|
||||
if rows, perr := r.db.Query(ctx, placementQuery, organizationID, from, to); perr == nil {
|
||||
for rows.Next() {
|
||||
@@ -753,7 +756,7 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
|
||||
placementTotal += n
|
||||
p := byProvider[provider]
|
||||
if p == nil {
|
||||
p = &models.ProviderPlacement{Provider: provider}
|
||||
p = &models.ProviderPlacement{Provider: provider, Label: mailhost.Host(provider).Label()}
|
||||
byProvider[provider] = p
|
||||
}
|
||||
p.Samples += n
|
||||
@@ -766,6 +769,8 @@ func (r *advancedOutreachRepository) GetDeliverabilityDashboard(ctx context.Cont
|
||||
p.Inbox += n
|
||||
case "promotions":
|
||||
p.Promotions += n
|
||||
case "missing":
|
||||
p.Missing += n
|
||||
default:
|
||||
p.Other += n
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func (r *behaviorRepository) CountSendsBetween(ctx context.Context, accountID uu
|
||||
SELECT COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.email_account_id = $1
|
||||
AND t.task_type = $2::task_type
|
||||
AND (t.task_type = $2::task_type OR ($2 = 'campaign' AND t.task_type = 'placement'))
|
||||
AND t.status = 'completed'
|
||||
AND t.completed_at >= $3
|
||||
AND t.completed_at < $4
|
||||
|
||||
@@ -2215,6 +2215,8 @@ func (r *emailRepository) GetByTags(ctx context.Context, scope AccountScope, tag
|
||||
WHERE ea.organization_id = $1
|
||||
AND eat.tag_id = ANY($2)
|
||||
AND ea.status = 'active'
|
||||
-- A seed mailbox is a placement test inbox and never sends campaign mail.
|
||||
AND ea.seed_scope IS NULL
|
||||
ORDER BY ea.id
|
||||
`
|
||||
|
||||
@@ -2268,6 +2270,8 @@ func (r *emailRepository) GetAllActiveInScope(ctx context.Context, scope Account
|
||||
FROM email_accounts ea
|
||||
WHERE ea.organization_id = $1
|
||||
AND ea.status = 'active'
|
||||
-- A seed mailbox is a placement test inbox and never sends campaign mail.
|
||||
AND ea.seed_scope IS NULL
|
||||
ORDER BY ea.id
|
||||
`
|
||||
|
||||
@@ -2337,6 +2341,8 @@ func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope Accoun
|
||||
AND cs.enabled
|
||||
AND ea.organization_id = $1
|
||||
AND ea.status = 'active'
|
||||
-- A seed mailbox is a placement test inbox and never sends campaign mail.
|
||||
AND ea.seed_scope IS NULL
|
||||
ORDER BY ea.id
|
||||
`
|
||||
|
||||
|
||||
+871
-282
File diff suppressed because it is too large
Load Diff
@@ -453,16 +453,21 @@ const taskDispatchedEmail = `(t.task_type <> 'campaign' OR t.message_id <> '' OR
|
||||
SELECT 1 FROM campaign_contact_progress ccp WHERE ccp.dispatch_task_id = t.id))`
|
||||
|
||||
// CountCampaignEmailsSentToday counts the campaign emails a mailbox dispatched
|
||||
// today (excludes warmup, and the campaign chain's own wake-ups).
|
||||
// today (excludes warmup, and the campaign chain's own wake-ups). Placement
|
||||
// test probes count too: they are cold mail from the same daily budget.
|
||||
func (r *taskRepository) CountCampaignEmailsSentToday(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
// Two counts so the campaign half keeps its partial index; the probe half
|
||||
// is a range on (email_account_id, completed_at).
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.email_account_id = $1
|
||||
AND t.status = 'completed'
|
||||
AND t.task_type = 'campaign'
|
||||
AND DATE(t.completed_at) = CURRENT_DATE
|
||||
AND ` + taskDispatchedEmail + `
|
||||
SELECT (
|
||||
SELECT COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.email_account_id = $1
|
||||
AND t.status = 'completed'
|
||||
AND t.task_type = 'campaign'
|
||||
AND DATE(t.completed_at) = CURRENT_DATE
|
||||
AND ` + taskDispatchedEmail + `
|
||||
) + (` + placementSentTodaySQL + ` AND t.email_account_id = $1)
|
||||
`
|
||||
|
||||
var count int
|
||||
@@ -517,6 +522,14 @@ func (r *taskRepository) CountCampaignEmailsSentTodayByAccounts(ctx context.Cont
|
||||
AND DATE(t.completed_at) = CURRENT_DATE
|
||||
AND ` + taskDispatchedEmail + `
|
||||
GROUP BY t.email_account_id
|
||||
UNION ALL
|
||||
SELECT t.email_account_id, COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.email_account_id = ANY($1)
|
||||
AND t.task_type = 'placement'
|
||||
AND ((t.status = 'completed' AND t.completed_at >= CURRENT_DATE AND t.completed_at < CURRENT_DATE + 1)
|
||||
OR (t.status IN ('pending', 'active') AND t.scheduled_at < CURRENT_DATE + 1))
|
||||
GROUP BY t.email_account_id
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, accountIDs)
|
||||
if err != nil {
|
||||
@@ -529,11 +542,20 @@ func (r *taskRepository) CountCampaignEmailsSentTodayByAccounts(ctx context.Cont
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = n
|
||||
out[id] += n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// placementSentTodaySQL counts today's placement probes, sent or still
|
||||
// queued: a queued probe already holds its place in the day, so a campaign
|
||||
// cannot spend it while the test is sending. Callers add the mailbox predicate.
|
||||
const placementSentTodaySQL = `
|
||||
SELECT COUNT(*) FROM tasks t
|
||||
WHERE t.task_type = 'placement'
|
||||
AND ((t.status = 'completed' AND t.completed_at >= CURRENT_DATE AND t.completed_at < CURRENT_DATE + 1)
|
||||
OR (t.status IN ('pending', 'active') AND t.scheduled_at < CURRENT_DATE + 1))`
|
||||
|
||||
// CountCampaignSendsTodayBySender is CountCampaignEmailsSentToday for one
|
||||
// campaign, split by mailbox. Same ledger and the same day boundary, so the
|
||||
// two agree on what a mailbox has spent.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Run against a migrated scratch database:
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/<db>?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LivePlacement -v
|
||||
|
||||
type placementFixture struct {
|
||||
t *testing.T
|
||||
repo PlacementRepository
|
||||
tasks TaskRepository
|
||||
exec func(string, ...any)
|
||||
owner uuid.UUID
|
||||
org uuid.UUID
|
||||
sender uuid.UUID
|
||||
opOwner uuid.UUID
|
||||
opOrg uuid.UUID
|
||||
seeds []uuid.UUID
|
||||
sameHost uuid.UUID
|
||||
}
|
||||
|
||||
func newPlacementFixture(t *testing.T) *placementFixture {
|
||||
t.Helper()
|
||||
handle, pool := liveContactDB(t)
|
||||
requireSchemaVersion(t, pool, 217)
|
||||
ctx := context.Background()
|
||||
f := &placementFixture{
|
||||
t: t,
|
||||
repo: NewPlacementRepository(handle),
|
||||
tasks: NewTaskRepository(pool),
|
||||
owner: uuid.New(), org: uuid.New(), sender: uuid.New(),
|
||||
opOwner: uuid.New(), opOrg: uuid.New(), sameHost: uuid.New(),
|
||||
}
|
||||
f.exec = func(query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, query, args...); err != nil {
|
||||
t.Fatalf("fixture: %v", err)
|
||||
}
|
||||
}
|
||||
tag := uuid.NewString()[:8]
|
||||
for _, u := range []struct {
|
||||
id uuid.UUID
|
||||
org uuid.UUID
|
||||
}{{f.owner, f.org}, {f.opOwner, f.opOrg}} {
|
||||
f.exec(`INSERT INTO users (id, first_name, last_name, email) VALUES ($1, 'Placement', 'Live', $2)`,
|
||||
u.id, "placement-"+uuid.NewString()+"@example.test")
|
||||
f.exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Placement live', $2)`, u.org, u.id)
|
||||
}
|
||||
mailbox := func(id, user, org uuid.UUID, address, provider string) {
|
||||
f.exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider, campaign_limit)
|
||||
VALUES ($1, $2, $3, $4, 'Placement', '', '', $5, 50)`, id, user, org, address, provider)
|
||||
}
|
||||
mailbox(f.sender, f.owner, f.org, "sender-"+tag+"@acme-"+tag+".test", "gmail")
|
||||
for i := range 2 {
|
||||
id := uuid.New()
|
||||
f.seeds = append(f.seeds, id)
|
||||
mailbox(id, f.opOwner, f.opOrg, "seed"+string(rune('a'+i))+"-"+tag+"@gmail.com", "gmail")
|
||||
}
|
||||
mailbox(f.sameHost, f.opOwner, f.opOrg, "seed-"+tag+"@acme-"+tag+".test", "gmail")
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
arg uuid.UUID
|
||||
}{
|
||||
{`DELETE FROM placement_monitors WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM placement_tests WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM tasks WHERE email_account_id = $1`, f.sender},
|
||||
{`DELETE FROM unibox_emails WHERE user_id = $1`, f.opOwner},
|
||||
{`DELETE FROM email_accounts WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM email_accounts WHERE organization_id = $1`, f.opOrg},
|
||||
{`DELETE FROM organizations WHERE id = $1`, f.org},
|
||||
{`DELETE FROM organizations WHERE id = $1`, f.opOrg},
|
||||
{`DELETE FROM users WHERE id = $1`, f.owner},
|
||||
{`DELETE FROM users WHERE id = $1`, f.opOwner},
|
||||
} {
|
||||
if _, err := pool.Exec(c, q.sql, q.arg); err != nil {
|
||||
t.Errorf("cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// newTest writes a running test with one scheduled probe per seed.
|
||||
func (f *placementFixture) newTest(seeds []uuid.UUID, scheduled time.Time) (models.PlacementTest, []models.PlacementResult, []Task) {
|
||||
f.t.Helper()
|
||||
sender := f.sender
|
||||
test := models.PlacementTest{
|
||||
ID: uuid.New(), OrganizationID: &f.org, SenderAccountID: &sender, SenderEmail: "sender@example.test",
|
||||
Subject: "Quick question", BodyPlain: "Hi there", Origin: models.PlacementOriginManual,
|
||||
Panel: models.PlacementPanelInstance, Status: models.PlacementStatusRunning,
|
||||
}
|
||||
var results []models.PlacementResult
|
||||
var tasks []Task
|
||||
for i, seed := range seeds {
|
||||
seed := seed
|
||||
taskID := uuid.New()
|
||||
at := scheduled.Add(time.Duration(i) * time.Second)
|
||||
tasks = append(tasks, Task{ID: taskID, TaskType: "placement", EmailAccountID: f.sender, Status: "pending", ScheduledAt: &at})
|
||||
results = append(results, models.PlacementResult{
|
||||
ID: uuid.New(), SeedAccountID: &seed, SeedAddress: seed.String() + "@gmail.com", Family: "gmail",
|
||||
TaskID: &taskID, Folder: models.PlacementFolderPending, ScheduledAt: &at,
|
||||
})
|
||||
}
|
||||
if err := f.repo.CreateTest(context.Background(), &test, results, tasks); err != nil {
|
||||
f.t.Fatalf("CreateTest: %v", err)
|
||||
}
|
||||
return test, results, tasks
|
||||
}
|
||||
|
||||
func TestLivePlacementProbesResolveByMessageID(t *testing.T) {
|
||||
f := newPlacementFixture(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
for _, id := range append([]uuid.UUID{f.sameHost}, f.seeds...) {
|
||||
if err := f.repo.SetSeedScope(ctx, id, models.SeedScopeInstance); err != nil {
|
||||
t.Fatalf("SetSeedScope: %v", err)
|
||||
}
|
||||
}
|
||||
if scope, err := f.repo.SeedScope(ctx, f.seeds[0]); err != nil || scope != models.SeedScopeInstance {
|
||||
t.Fatalf("SeedScope = %q, %v", scope, err)
|
||||
}
|
||||
all, err := f.repo.ListSeeds(ctx, models.SeedScopeInstance, &f.opOrg, false)
|
||||
if err != nil || len(all) != 3 {
|
||||
t.Fatalf("ListSeeds = %d, %v; want 3", len(all), err)
|
||||
}
|
||||
// None is on a worker, so none can take a test.
|
||||
if live, err := f.repo.ListSeeds(ctx, models.SeedScopeInstance, &f.opOrg, true); err != nil || len(live) != 0 {
|
||||
t.Fatalf("active ListSeeds = %d, %v; want 0 without a worker", len(live), err)
|
||||
}
|
||||
|
||||
test, results, tasks := f.newTest(f.seeds, now.Add(-3*time.Hour))
|
||||
probe, err := f.repo.GetProbeByTask(ctx, tasks[0].ID)
|
||||
if err != nil || probe == nil || probe.Result.ID != results[0].ID || probe.Test.ID != test.ID {
|
||||
t.Fatalf("GetProbeByTask = %+v, %v", probe, err)
|
||||
}
|
||||
if busy, err := f.repo.SenderBusy(ctx, f.sender); err != nil || !busy {
|
||||
t.Fatalf("SenderBusy = %v, %v; want true with unsent probes", busy, err)
|
||||
}
|
||||
|
||||
// Probe one arrived in spam with brackets stripped on the seed's side;
|
||||
// probe two left three hours ago and never showed up.
|
||||
if err := f.repo.MarkProbeSent(ctx, results[0].ID, "<one-"+test.ID.String()+"@acme.test>", now.Add(-10*time.Minute)); err != nil {
|
||||
t.Fatalf("MarkProbeSent: %v", err)
|
||||
}
|
||||
if err := f.repo.MarkProbeSent(ctx, results[1].ID, "<two-"+test.ID.String()+"@acme.test>", now.Add(-3*time.Hour)); err != nil {
|
||||
t.Fatalf("MarkProbeSent: %v", err)
|
||||
}
|
||||
f.exec(`INSERT INTO unibox_emails (id, user_id, email_id, folder, provider_folder, message_id, thread_id,
|
||||
from_addr, subject, body_text, in_reply_to, internal_date, flags)
|
||||
VALUES ($1, $2, $3, 'spam', 'spam', $4, $5, ARRAY['sender@acme.test'], 'Quick question', 'Hi there', '{}', $6, ARRAY['CATEGORY_PROMOTIONS'])`,
|
||||
uuid.New(), f.opOwner, f.seeds[0], "one-"+test.ID.String()+"@acme.test", uuid.NewString(), now.Add(-9*time.Minute))
|
||||
|
||||
landings, err := f.repo.FindLandings(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("FindLandings: %v", err)
|
||||
}
|
||||
var mine []PlacementLanding
|
||||
for _, l := range landings {
|
||||
if l.TestID == test.ID {
|
||||
mine = append(mine, l)
|
||||
}
|
||||
}
|
||||
if len(mine) != 1 || mine[0].ResultID != results[0].ID || mine[0].Folder != "spam" {
|
||||
t.Fatalf("landings = %+v; want probe one in spam", mine)
|
||||
}
|
||||
folder := models.ClassifyPlacementLanding(mine[0].Folder, mine[0].Flags)
|
||||
if folder != models.PlacementFolderSpam {
|
||||
t.Fatalf("classified %q, want spam over the Promotions label", folder)
|
||||
}
|
||||
if err := f.repo.RecordLanding(ctx, mine[0].ResultID, folder, "", now); err != nil {
|
||||
t.Fatalf("RecordLanding: %v", err)
|
||||
}
|
||||
if err := f.repo.ExpireProbes(ctx, now.Add(-2*time.Hour), now.Add(-2*time.Hour), now.Add(-6*time.Hour)); err != nil {
|
||||
t.Fatalf("ExpireProbes: %v", err)
|
||||
}
|
||||
|
||||
finished, err := f.repo.FinishTests(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("FinishTests: %v", err)
|
||||
}
|
||||
var closed *PlacementFinished
|
||||
for i := range finished {
|
||||
if finished[i].ID == test.ID {
|
||||
closed = &finished[i]
|
||||
}
|
||||
}
|
||||
if closed == nil || closed.Status != models.PlacementStatusCompleted {
|
||||
t.Fatalf("FinishTests closed %+v; want the test completed", closed)
|
||||
}
|
||||
byTest, err := f.repo.ListResults(ctx, []uuid.UUID{test.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListResults: %v", err)
|
||||
}
|
||||
var counts models.PlacementCounts
|
||||
for _, r := range byTest[test.ID] {
|
||||
counts.Add(r.Folder)
|
||||
}
|
||||
counts.Finish()
|
||||
if counts.Spam != 1 || counts.Missing != 1 || counts.Delivered != 2 {
|
||||
t.Fatalf("counts = %+v; want one spam and one missing", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLivePlacementProbesChargeTheSendersDay(t *testing.T) {
|
||||
f := newPlacementFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, results, tasks := f.newTest(f.seeds[:1], time.Now())
|
||||
f.exec(`UPDATE tasks SET status = 'completed', completed_at = NOW(), message_id = '<probe@acme.test>' WHERE id = $1`, tasks[0].ID)
|
||||
if err := f.repo.MarkProbeSent(ctx, results[0].ID, "<probe@acme.test>", time.Now()); err != nil {
|
||||
t.Fatalf("MarkProbeSent: %v", err)
|
||||
}
|
||||
|
||||
sent, err := f.tasks.CountCampaignEmailsSentToday(ctx, f.sender)
|
||||
if err != nil || sent != 1 {
|
||||
t.Fatalf("CountCampaignEmailsSentToday = %d, %v; want the probe counted", sent, err)
|
||||
}
|
||||
byAccount, err := f.tasks.CountCampaignEmailsSentTodayByAccounts(ctx, []uuid.UUID{f.sender})
|
||||
if err != nil || byAccount[f.sender] != 1 {
|
||||
t.Fatalf("CountCampaignEmailsSentTodayByAccounts = %v, %v; want 1", byAccount, err)
|
||||
}
|
||||
if copy, err := f.repo.IsProbeSentCopy(ctx, f.sender, "probe@acme.test"); err != nil || !copy {
|
||||
t.Fatalf("IsProbeSentCopy = %v, %v; want the sender's own copy recognised", copy, err)
|
||||
}
|
||||
if copy, err := f.repo.IsProbeSentCopy(ctx, f.seeds[0], "probe@acme.test"); err != nil || copy {
|
||||
t.Fatalf("IsProbeSentCopy on the seed = %v, %v; a seed's copy must be kept", copy, err)
|
||||
}
|
||||
if n, err := f.repo.CountMeteredTests(ctx, f.org, time.Now().Add(-time.Hour)); err != nil || n != 1 {
|
||||
t.Fatalf("CountMeteredTests = %d, %v; want 1", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLivePlacementCancelStopsUnsentProbes(t *testing.T) {
|
||||
f := newPlacementFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
test, results, tasks := f.newTest(f.seeds, time.Now().Add(time.Hour))
|
||||
if err := f.repo.MarkProbeSent(ctx, results[0].ID, "<sent@acme.test>", time.Now()); err != nil {
|
||||
t.Fatalf("MarkProbeSent: %v", err)
|
||||
}
|
||||
if ok, err := f.repo.CancelTest(ctx, f.opOrg, test.ID); err != nil || ok {
|
||||
t.Fatalf("CancelTest from another workspace = %v, %v; want refused", ok, err)
|
||||
}
|
||||
if ok, err := f.repo.CancelTest(ctx, f.org, test.ID); err != nil || !ok {
|
||||
t.Fatalf("CancelTest = %v, %v", ok, err)
|
||||
}
|
||||
byTest, err := f.repo.ListResults(ctx, []uuid.UUID{test.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListResults: %v", err)
|
||||
}
|
||||
folders := map[uuid.UUID]string{}
|
||||
for _, r := range byTest[test.ID] {
|
||||
folders[r.ID] = r.Folder
|
||||
}
|
||||
if folders[results[0].ID] != models.PlacementFolderPending || folders[results[1].ID] != models.PlacementFolderCancelled {
|
||||
t.Fatalf("folders = %v; want the sent probe still pending and the unsent one cancelled", folders)
|
||||
}
|
||||
task, err := f.tasks.GetTask(ctx, tasks[1].ID)
|
||||
if err != nil || task == nil || task.Status != "cancelled" {
|
||||
t.Fatalf("unsent task = %+v, %v; want cancelled", task, err)
|
||||
}
|
||||
if running, err := f.repo.CountRunning(ctx, f.org); err != nil || running != 0 {
|
||||
t.Fatalf("CountRunning = %d, %v; want 0 after cancel", running, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLivePlacementMonitorRoundTrip(t *testing.T) {
|
||||
f := newPlacementFixture(t)
|
||||
ctx := context.Background()
|
||||
campaignID := uuid.New()
|
||||
f.exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, status, updated_at, created_at)
|
||||
VALUES ($1, $2, $3, 'Placement live', '', 62, 'active', NOW(), NOW())`, campaignID, f.owner, f.org)
|
||||
|
||||
m := &models.PlacementMonitor{
|
||||
OrganizationID: f.org, CampaignID: campaignID, CreatedBy: &f.owner, Enabled: true,
|
||||
IntervalDays: 7, Panel: models.PlacementPanelInstance, AlertBelow: 70, NextRunAt: time.Now().Add(-time.Minute),
|
||||
}
|
||||
if err := f.repo.UpsertMonitor(ctx, m); err != nil {
|
||||
t.Fatalf("UpsertMonitor: %v", err)
|
||||
}
|
||||
// Another workspace cannot take the campaign's monitor over.
|
||||
stolen := *m
|
||||
stolen.ID, stolen.OrganizationID = uuid.New(), f.opOrg
|
||||
if err := f.repo.UpsertMonitor(ctx, &stolen); err == nil {
|
||||
t.Fatalf("UpsertMonitor from another workspace succeeded")
|
||||
}
|
||||
due, err := f.repo.ClaimDueMonitors(ctx, time.Now(), 500)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimDueMonitors: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range due {
|
||||
found = found || d.ID == m.ID
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("monitor not due")
|
||||
}
|
||||
// A second replica asking at the same moment gets nothing: the claim moved it.
|
||||
again, err := f.repo.ClaimDueMonitors(ctx, time.Now(), 500)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimDueMonitors: %v", err)
|
||||
}
|
||||
for _, d := range again {
|
||||
if d.ID == m.ID {
|
||||
t.Fatalf("monitor claimed twice")
|
||||
}
|
||||
}
|
||||
next := time.Now().Add(7 * 24 * time.Hour)
|
||||
if err := f.repo.MarkMonitorRun(ctx, m.ID, next, nil, &f.sender, ""); err != nil {
|
||||
t.Fatalf("MarkMonitorRun: %v", err)
|
||||
}
|
||||
got, err := f.repo.GetMonitor(ctx, f.org, campaignID)
|
||||
if err != nil || got == nil || got.LastSenderID == nil || *got.LastSenderID != f.sender || got.LastRunAt == nil {
|
||||
t.Fatalf("GetMonitor = %+v, %v", got, err)
|
||||
}
|
||||
if ok, err := f.repo.DeleteMonitor(ctx, f.opOrg, campaignID); err != nil || ok {
|
||||
t.Fatalf("DeleteMonitor from another workspace = %v, %v", ok, err)
|
||||
}
|
||||
if ok, err := f.repo.DeleteMonitor(ctx, f.org, campaignID); err != nil || !ok {
|
||||
t.Fatalf("DeleteMonitor = %v, %v", ok, err)
|
||||
}
|
||||
f.exec(`DELETE FROM campaigns WHERE id = $1`, campaignID)
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
// placed where the worker's real IMAP sync will find it. One short-lived
|
||||
// connection per delivery keeps the client trivially correct.
|
||||
func deliverToInbox(imapAddr, user, password string, raw []byte) error {
|
||||
return deliverToFolder(imapAddr, user, password, "INBOX", raw)
|
||||
}
|
||||
|
||||
// deliverToFolder is deliverToInbox for any folder, created on first use.
|
||||
func deliverToFolder(imapAddr, user, password, folder string, raw []byte) error {
|
||||
c, err := imapclient.DialTLS(imapAddr, &imapclient.Options{
|
||||
// Dovecot's cert is self-signed; this client only ever talks to the
|
||||
// local sandbox container.
|
||||
@@ -29,7 +34,11 @@ func deliverToInbox(imapAddr, user, password string, raw []byte) error {
|
||||
return fmt.Errorf("imap login %s: %w", user, err)
|
||||
}
|
||||
|
||||
cmd := c.Append("INBOX", int64(len(raw)), nil)
|
||||
if folder != "INBOX" {
|
||||
// Already there on every delivery after the first.
|
||||
_ = c.Create(folder, nil).Wait()
|
||||
}
|
||||
cmd := c.Append(folder, int64(len(raw)), nil)
|
||||
if _, err := cmd.Write(raw); err != nil {
|
||||
return fmt.Errorf("imap append write: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/pkg/warmlint"
|
||||
)
|
||||
|
||||
// sandboxSeeds are the instance placement panel: test inboxes on domains of
|
||||
// their own, so a sandbox placement test has somewhere to land. The mail host
|
||||
// is stored so results group by provider as they would against real seeds.
|
||||
var sandboxSeeds = []struct {
|
||||
id uuid.UUID
|
||||
email string
|
||||
name string
|
||||
host string
|
||||
}{
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000001"), "panel.one@gmail-seeds.test", "Gmail seed 1", "gmail"},
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000002"), "panel.two@gmail-seeds.test", "Gmail seed 2", "gmail"},
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000003"), "panel@workspace-seeds.test", "Google Workspace seed", "google_workspace"},
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000004"), "panel@m365-seeds.test", "Microsoft 365 seed", "microsoft365"},
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000005"), "panel@outlook-seeds.test", "Outlook.com seed", "outlook"},
|
||||
{uuid.MustParse("5eed0000-aaaa-0000-0000-000000000006"), "panel@yahoo-seeds.test", "Yahoo seed", "yahoo"},
|
||||
}
|
||||
|
||||
// seedPlacementPanel adds the seed inboxes. They never warm and never send,
|
||||
// like any seed; the credential repair points them at dovecot.
|
||||
func seedPlacementPanel(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
for _, s := range sandboxSeeds {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO email_accounts (
|
||||
id, user_id, organization_id, worker_id, email, name,
|
||||
signature_plain, signature_html, provider, status, mail_host, seed_scope, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, '', '', 'smtp_imap', 'active', $7, 'instance', NOW() - INTERVAL '90 days')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
worker_id = EXCLUDED.worker_id,
|
||||
status = 'active',
|
||||
mail_host = EXCLUDED.mail_host,
|
||||
seed_scope = 'instance',
|
||||
warmup = NULL,
|
||||
updated_at = NOW()`,
|
||||
s.id, sandboxUser, sandboxOrg, sandboxWorker, s.email, s.name, s.host); err != nil {
|
||||
return fmt.Errorf("seed inbox %s: %w", s.email, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedFolder plays the recipient's spam filter for a copy landing in a seed:
|
||||
// copy the rules pass dislikes goes to Junk, and a fixed share of the rest
|
||||
// does too, harder on Microsoft and Yahoo and harder again for tracked copy,
|
||||
// so a test and its tracking comparison both have something to show.
|
||||
func seedFolder(host string, msg *mailpitMessage) string {
|
||||
score := warmlint.Score(msg.Subject, msg.HTML, msg.Text).Score
|
||||
if score < 70 {
|
||||
return "Junk"
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(msg.MessageID))
|
||||
roll := h.Sum32() % 100
|
||||
threshold := uint32(10)
|
||||
switch host {
|
||||
case "microsoft365", "outlook":
|
||||
threshold = 25
|
||||
case "yahoo":
|
||||
threshold = 20
|
||||
}
|
||||
if strings.Contains(msg.HTML, "/t/o/") || strings.Contains(msg.HTML, "/c/") {
|
||||
threshold += 15
|
||||
}
|
||||
if roll < threshold {
|
||||
return "Junk"
|
||||
}
|
||||
return "INBOX"
|
||||
}
|
||||
@@ -265,6 +265,9 @@ func Seed(ctx context.Context, pool *pgxpool.Pool, cfg Config) error {
|
||||
if err := seedMailboxes(ctx, pool); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedPlacementPanel(ctx, pool); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedSubscription(ctx, pool); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -303,6 +306,7 @@ func Seed(ctx context.Context, pool *pgxpool.Pool, cfg Config) error {
|
||||
fmt.Println("sandbox seeded:")
|
||||
fmt.Printf(" dashboard %s / %s (org: Sunrise Labs)\n", SandboxLoginEmail, SandboxLoginPassword)
|
||||
fmt.Printf(" mailboxes %d senders on @sunrise.test (SMTP -> mailpit, IMAP -> dovecot), signatures + tags on all\n", len(sandboxMailboxes))
|
||||
fmt.Printf(" placement %d seed inboxes on the instance panel; the simulator files each test copy into Inbox or Junk\n", len(sandboxSeeds))
|
||||
fmt.Println(" campaigns active, paused, completed, and draft - every list bucket filled")
|
||||
fmt.Println(" warmup enabled on all senders, premium pool, 10 days of ramp stats")
|
||||
fmt.Println(" history funnel progress, unified inbox, CRM pipeline, templates, notifications, chart rollups")
|
||||
|
||||
@@ -36,6 +36,9 @@ type simulator struct {
|
||||
type hostedMailbox struct {
|
||||
Email string
|
||||
Name string
|
||||
// Seed and Host mark a placement seed inbox and who it stands in for.
|
||||
Seed bool
|
||||
Host string
|
||||
}
|
||||
|
||||
type contactInfo struct {
|
||||
@@ -97,13 +100,13 @@ func Simulate(ctx context.Context, pool *pgxpool.Pool, cfg Config) error {
|
||||
// accounts, which the seeder pointed at the local stack) and seeded contacts.
|
||||
func (s *simulator) refreshDirectory(ctx context.Context) error {
|
||||
hosted := map[string]hostedMailbox{}
|
||||
rows, err := s.pool.Query(ctx, `SELECT email, name FROM email_accounts WHERE provider = 'smtp_imap'`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT email, name, seed_scope IS NOT NULL, mail_host FROM email_accounts WHERE provider = 'smtp_imap'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var m hostedMailbox
|
||||
if err := rows.Scan(&m.Email, &m.Name); err != nil {
|
||||
if err := rows.Scan(&m.Email, &m.Name, &m.Seed, &m.Host); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -186,11 +189,17 @@ func (s *simulator) handleMessage(ctx context.Context, summary mailpitSummary) e
|
||||
// Hosted recipient (another sandbox/warmup mailbox): final delivery
|
||||
// into its dovecot INBOX; the worker's real IMAP sync takes it from
|
||||
// there (warmup token verification, unibox, engagement actions).
|
||||
if _, ok := hosted[addr]; ok {
|
||||
if err := deliverToInbox(s.cfg.IMAPAddr, addr, s.cfg.IMAPPassword, raw); err != nil {
|
||||
if m, ok := hosted[addr]; ok {
|
||||
// A seed inbox stands in for a real provider, so something has to
|
||||
// play that provider's spam filter.
|
||||
folder := "INBOX"
|
||||
if m.Seed {
|
||||
folder = seedFolder(m.Host, detail)
|
||||
}
|
||||
if err := deliverToFolder(s.cfg.IMAPAddr, addr, s.cfg.IMAPPassword, folder, raw); err != nil {
|
||||
return fmt.Errorf("deliver to %s: %w", addr, err)
|
||||
}
|
||||
fmt.Printf("delivered %-34s %q\n", addr, detail.Subject)
|
||||
fmt.Printf("delivered %-34s %q (%s)\n", addr, detail.Subject, folder)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ func (s *tasksService) HandleTask(task *proto.ProcessTask) *errx.Error {
|
||||
// anywhere in the database.
|
||||
case "email":
|
||||
return s.HandleUserEmailTask(task)
|
||||
case "placement":
|
||||
return s.HandlePlacementTask(task)
|
||||
default:
|
||||
log.Warn().Str("task_id", taskID.String()).Str("task_type", rec.TaskType).Msg("task dispatch: unknown task type")
|
||||
return errx.New(errx.BadRequest, "unknown task type")
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/mailhtml"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
"github.com/warmbly/warmbly/internal/tasks/proto"
|
||||
)
|
||||
|
||||
// SetPlacement wires the placement test store the probe handler reads.
|
||||
func (s *tasksService) SetPlacement(repo repository.PlacementRepository) {
|
||||
s.placementRepo = repo
|
||||
}
|
||||
|
||||
// HandlePlacementTask sends one placement probe: the test's copy, rendered the
|
||||
// way the campaign would render it, from the test's sender to one seed. The
|
||||
// copy carries nothing a real send would not (no marker, no warmup header,
|
||||
// no CC or BCC); the probe is found again by its Message-ID.
|
||||
func (s *tasksService) HandlePlacementTask(task *proto.ProcessTask) *errx.Error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
taskID, err := uuid.Parse(task.TaskId)
|
||||
if err != nil {
|
||||
return errx.New(errx.BadRequest, "invalid task ID")
|
||||
}
|
||||
if s.placementRepo == nil {
|
||||
return errx.New(errx.Internal, "placement tests are not configured")
|
||||
}
|
||||
record, err := s.taskRepo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if record == nil || record.Status != "pending" {
|
||||
return nil
|
||||
}
|
||||
probe, err := s.placementRepo.GetProbeByTask(ctx, taskID)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if probe == nil || probe.Result.Folder != models.PlacementFolderPending || probe.Test.Status != models.PlacementStatusRunning {
|
||||
_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
|
||||
return nil
|
||||
}
|
||||
fail := func(reason string) *errx.Error {
|
||||
_ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Placement probe not sent", reason)
|
||||
_ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled")
|
||||
if err := s.placementRepo.FailProbe(ctx, probe.Result.ID, reason); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
test := probe.Test
|
||||
if test.SenderAccountID == nil || test.OrganizationID == nil {
|
||||
return fail("The test has no sender")
|
||||
}
|
||||
account, xerr := s.emailRepo.GetByID(ctx, *test.SenderAccountID)
|
||||
if xerr != nil || account == nil {
|
||||
return fail("The sending mailbox no longer exists")
|
||||
}
|
||||
// Tenancy: the sender, and everything rendered below, belongs to the test's
|
||||
// workspace.
|
||||
if account.OrganizationID == nil || *account.OrganizationID != *test.OrganizationID {
|
||||
return fail("The sending mailbox is not in this workspace")
|
||||
}
|
||||
if account.Status != "active" {
|
||||
return fail("The sending mailbox is not active")
|
||||
}
|
||||
// A probe is outbound mail from the same domains a suspension protects.
|
||||
if s.orgBlocksSending(ctx, account.OrganizationID) {
|
||||
return fail("Sending is suspended for this workspace")
|
||||
}
|
||||
|
||||
msg, reason := s.renderPlacementProbe(ctx, taskID, &test, account, probe.Result.SeedAddress)
|
||||
if reason != "" {
|
||||
return fail(reason)
|
||||
}
|
||||
|
||||
// The Message-ID is stored before the send, so the worker's answer (Gmail
|
||||
// and Graph restamp it) can only ever correct it, never be overwritten.
|
||||
if err := s.taskRepo.UpdateTaskMessageID(ctx, taskID, msg.MessageID); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := s.placementRepo.SetProbeMessageID(ctx, probe.Result.ID, msg.MessageID); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := s.emailSender.Send(ctx, taskID, msg, *account); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrSendDispatchUnknown):
|
||||
// The bus may have taken it: leave the probe to the worker's answer
|
||||
// or to the classify window.
|
||||
_ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed")
|
||||
_ = s.placementRepo.MarkProbeSent(ctx, probe.Result.ID, msg.MessageID, time.Now())
|
||||
return nil
|
||||
case errors.Is(err, ErrWorkerOffline), errors.Is(err, ErrWorkerUnconfirmed):
|
||||
return fail("The sending mailbox's worker is offline")
|
||||
default:
|
||||
return fail("The send could not be handed to a worker")
|
||||
}
|
||||
}
|
||||
if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
if err := s.placementRepo.MarkProbeSent(ctx, probe.Result.ID, msg.MessageID, time.Now()); err != nil {
|
||||
errs.CaptureException(err)
|
||||
}
|
||||
log.Debug().Str("task_id", taskID.String()).Str("test_id", test.ID.String()).Msg("placement probe sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderPlacementProbe builds one probe the way HandleCampaignTask builds a
|
||||
// send, in the same order: form links, merge fields and spintax, AI blocks,
|
||||
// plain-text rule, unsubscribe link, tracking, signature, opt-out footer,
|
||||
// CSS inlining. What it leaves out is what only a real lead has: threading,
|
||||
// A/B arm selection and the campaign's CC and BCC. The second result is why
|
||||
// the probe cannot be sent, empty when it can.
|
||||
func (s *tasksService) renderPlacementProbe(ctx context.Context, taskID uuid.UUID, test *models.PlacementTest, account *models.Email, recipient string) (EmailMessage, string) {
|
||||
orgID := *test.OrganizationID
|
||||
|
||||
// An ad-hoc test renders against a campaign with the platform defaults, so
|
||||
// the opt-out and the List-Unsubscribe header match a new campaign.
|
||||
campaign := &models.Campaign{ID: uuid.Nil, OrganizationID: &orgID, UnsubscribeHeader: true}
|
||||
var sequenceID uuid.UUID
|
||||
if test.CampaignID != nil {
|
||||
c, err := s.campaignRepo.GetByID(ctx, *test.CampaignID)
|
||||
if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != orgID {
|
||||
return EmailMessage{}, "The campaign no longer exists"
|
||||
}
|
||||
campaign = c
|
||||
}
|
||||
if test.SequenceID != nil {
|
||||
sequenceID = *test.SequenceID
|
||||
}
|
||||
|
||||
contact := testContact(recipient)
|
||||
realContact := false
|
||||
if test.ContactID != nil && s.contactRepo != nil {
|
||||
if found, xerr := s.contactRepo.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{*test.ContactID}); xerr == nil && len(found) == 1 {
|
||||
contact = found[0]
|
||||
realContact = true
|
||||
}
|
||||
}
|
||||
|
||||
rawSubject, rawHTML, rawPlain := test.Subject, test.BodyHTML, test.BodyPlain
|
||||
if test.CampaignID != nil && realContact {
|
||||
s.resolveFormLinks(ctx, orgID, campaign, &contact, &rawSubject, &rawHTML, &rawPlain)
|
||||
} else {
|
||||
// No lead to personalise a form for: drop the markers, as the send path
|
||||
// does when a form cannot be resolved.
|
||||
for _, part := range []*string{&rawSubject, &rawHTML, &rawPlain} {
|
||||
*part = models.FormLinkMarkerRE.ReplaceAllString(*part, "")
|
||||
}
|
||||
}
|
||||
|
||||
optOut := s.resolveOptOut(ctx, orgID, campaign)
|
||||
var unsubscribeURL string
|
||||
if s.unsubLinks != nil && s.unsubLinks.Enabled() {
|
||||
// No contact: clicking it can never suppress anyone.
|
||||
unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, uuid.Nil)
|
||||
}
|
||||
extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL}
|
||||
subject := expandSpintax(RenderTemplateWith(rawSubject, contact, extra))
|
||||
bodyHTML := expandSpintax(RenderTemplateWith(rawHTML, contact, extra))
|
||||
bodyPlain := expandSpintax(RenderTemplateWith(rawPlain, contact, extra))
|
||||
if bodyPlain == "" && bodyHTML != "" {
|
||||
bodyPlain = ExtractPlainTextFromHTML(bodyHTML)
|
||||
}
|
||||
|
||||
// AI blocks resolve for the chosen lead through the send path's own cache,
|
||||
// so every probe reads the copy that lead would get.
|
||||
if realContact && test.CampaignID != nil && sequenceID != uuid.Nil && s.aiProvider != nil && s.aiCredits != nil {
|
||||
var err error
|
||||
subject, bodyHTML, bodyPlain, err = s.resolveAIVariables(ctx, campaign, &contact, sequenceID, subject, bodyHTML, bodyPlain)
|
||||
if err != nil {
|
||||
return EmailMessage{}, "The AI blocks in the copy could not be generated"
|
||||
}
|
||||
}
|
||||
|
||||
// With no lead there is nothing to generate for, so the blocks drop out
|
||||
// the way a block that cannot be generated does.
|
||||
if !realContact {
|
||||
subject = aiVarTokenRE.ReplaceAllString(subject, "")
|
||||
bodyHTML = aiVarTokenRE.ReplaceAllString(aiVarSpanRE.ReplaceAllString(bodyHTML, ""), "")
|
||||
bodyPlain = aiVarTokenRE.ReplaceAllString(bodyPlain, "")
|
||||
}
|
||||
|
||||
if campaign.TextOnly {
|
||||
bodyHTML = ""
|
||||
}
|
||||
bodyHTML = dropBlankHTMLPart(bodyHTML, bodyPlain)
|
||||
if !mailhtml.HasContent(bodyHTML) && bodyPlain == "" {
|
||||
return EmailMessage{}, "The copy rendered empty"
|
||||
}
|
||||
bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubscribeURL, optOut.LinkText)
|
||||
|
||||
trackingDomain, _ := resolveTrackingHost(config.TrackingHost(), account, campaign)
|
||||
openTracking := test.OpenTracking && bodyHTML != "" && trackingDomain != ""
|
||||
linkTracking := test.LinkTracking && bodyHTML != "" && trackingDomain != ""
|
||||
if openTracking {
|
||||
bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, trackingDomain)
|
||||
}
|
||||
if bodyHTML != "" && (linkTracking || campaign.UTMTracking) {
|
||||
opts := LinkTracking{
|
||||
TaskID: taskID,
|
||||
CampaignID: campaign.ID,
|
||||
TrackingDomain: trackingDomain,
|
||||
Wrap: linkTracking,
|
||||
UTM: CampaignUTM(campaign),
|
||||
}
|
||||
tracked, links := TrackLinks(bodyHTML, opts)
|
||||
switch {
|
||||
case len(links) == 0:
|
||||
bodyHTML = tracked
|
||||
case s.trackedLinkRepo == nil:
|
||||
bodyHTML, _ = TrackLinks(bodyHTML, LinkTracking{TrackingDomain: trackingDomain, UTM: opts.UTM})
|
||||
default:
|
||||
if err := s.trackedLinkRepo.CreateBatch(ctx, links); err != nil {
|
||||
bodyHTML, _ = TrackLinks(bodyHTML, LinkTracking{TrackingDomain: trackingDomain, UTM: opts.UTM})
|
||||
} else {
|
||||
bodyHTML = tracked
|
||||
}
|
||||
}
|
||||
}
|
||||
if campaign.UTMTracking && bodyPlain != "" {
|
||||
bodyPlain = TagPlainTextLinks(bodyPlain, CampaignUTM(campaign), trackingDomain)
|
||||
}
|
||||
|
||||
if account.SignatureSync {
|
||||
if bodyHTML != "" {
|
||||
bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true)
|
||||
}
|
||||
if bodyPlain != "" {
|
||||
bodyPlain = AddSignature(bodyPlain, account.SignaturePlain, false)
|
||||
}
|
||||
}
|
||||
bodyHTML, bodyPlain = appendOptOut(bodyHTML, bodyPlain, optOut, unsubscribeURL)
|
||||
bodyHTML = mailhtml.InlineCSS(bodyHTML)
|
||||
|
||||
if s.cipherService != nil {
|
||||
if _, err := s.cipherService.Cipher(ctx, orgID); err != nil {
|
||||
errs.CaptureException(fmt.Errorf("placement probe: organization key: %w", err))
|
||||
return EmailMessage{}, "The workspace's encryption key is unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
var tracking *models.TrackingInfo
|
||||
if openTracking || linkTracking {
|
||||
tracking = &models.TrackingInfo{OpenTracking: openTracking, LinkTracking: linkTracking, TrackingDomain: trackingDomain}
|
||||
}
|
||||
headerURL := ""
|
||||
if campaign.UnsubscribeHeader {
|
||||
headerURL = unsubscribeURL
|
||||
}
|
||||
var attachments []models.AttachmentRef
|
||||
if test.CampaignID != nil {
|
||||
attachments = s.campaignAttachmentRefs(ctx, campaign.ID, sequenceID)
|
||||
}
|
||||
return EmailMessage{
|
||||
From: account.Email,
|
||||
To: []string{recipient},
|
||||
Subject: subject,
|
||||
BodyHTML: bodyHTML,
|
||||
BodyPlain: bodyPlain,
|
||||
MessageID: generateMessageID(account.SendFrom()),
|
||||
Tracking: tracking,
|
||||
UnsubscribeURL: headerURL,
|
||||
Attachments: attachments,
|
||||
}, ""
|
||||
}
|
||||
@@ -94,6 +94,10 @@ type TasksService interface {
|
||||
// SetFormLinks wires the minter that resolves {{form_link:...}} markers
|
||||
// into per-recipient form URLs. Nil-safe: markers drop with a warning.
|
||||
SetFormLinks(m FormLinkMinter)
|
||||
// SetPlacement wires the placement test store; without it a placement
|
||||
// task fails cleanly.
|
||||
SetPlacement(repo repository.PlacementRepository)
|
||||
HandlePlacementTask(task *proto.ProcessTask) *errx.Error
|
||||
}
|
||||
|
||||
// CloudLinkReader reports whether Warmbly Cloud warms a mailbox.
|
||||
@@ -181,6 +185,9 @@ type tasksService struct {
|
||||
// unsubTickets stores the short form of those links. Nil-safe: without it
|
||||
// the address is the signed token, which is longer but works the same.
|
||||
unsubTickets repository.UnsubscribeLinkRepository
|
||||
|
||||
// placementRepo backs the placement probe handler (SetPlacement).
|
||||
placementRepo repository.PlacementRepository
|
||||
}
|
||||
|
||||
// SetUnsubscribeLinks wires the unsubscribe link signer and ticket store.
|
||||
|
||||
@@ -467,6 +467,10 @@ defmodule RealtimeWeb.OrgChannel do
|
||||
String.contains?(event_type, "DIRECT_EMAIL_") ->
|
||||
has.(:view_analytics)
|
||||
|
||||
# Inbox placement tests: results sit behind the analytics read endpoints.
|
||||
String.contains?(event_type, "PLACEMENT_TEST") ->
|
||||
has.(:view_analytics)
|
||||
|
||||
# Campaign activity: lifecycle, task progress, send/open/click/reply pulses
|
||||
String.contains?(event_type, "CAMPAIGN") or String.contains?(event_type, "TASK_PROGRESS") or
|
||||
event_type in [
|
||||
|
||||
@@ -80,6 +80,7 @@ gives the arguments and flags. Ids are positional, not flags.
|
||||
| `segment`, `template`, `automation`, `form` | audiences, reply templates, automations, lead capture |
|
||||
| `deal`, `pipeline`, `task` | the CRM |
|
||||
| `analytics`, `audit`, `advisor` | numbers, the audit trail, recommendations |
|
||||
| `placement` | inbox placement tests: overview, test, list, view, cancel, seed inboxes, a campaign's scheduled test |
|
||||
| `webhook`, `key`, `oauth-app`, `integration` | the developer surface |
|
||||
| `org`, `team`, `settings`, `warmup-routing` | the workspace surface a key can reach |
|
||||
| `tool` | the AI tool registry, listed and called |
|
||||
@@ -102,8 +103,9 @@ warmbly api /contacts/search -X POST --input filter.json
|
||||
## Sending safety, read before anything that sends
|
||||
|
||||
These put real mail on the wire and prompt before doing so:
|
||||
`campaign start`, `campaign test`, `mailbox send`, `inbox reply`,
|
||||
`inbox compose`, `inbox approve-draft`. Everything else is safe to run freely.
|
||||
`campaign start`, `campaign test`, `placement test`, `mailbox send`,
|
||||
`inbox reply`, `inbox compose`, `inbox approve-draft`. Everything else is safe
|
||||
to run freely.
|
||||
|
||||
- With no terminal they refuse rather than send. `--yes` is what proceeds, so
|
||||
**only pass `--yes` when the user asked for that specific send.** Never add
|
||||
@@ -130,6 +132,12 @@ These put real mail on the wire and prompt before doing so:
|
||||
that reason.
|
||||
- If deliverability shows rising bounces or complaints, stop the campaign and
|
||||
report. Do not push volume into a degrading mailbox.
|
||||
- To check where copy lands before a launch, `warmbly placement test --mailbox
|
||||
MAILBOX_ID --campaign CAMPAIGN_ID --step STEP_ID` sends one copy to each seed
|
||||
inbox, every one counted against that mailbox's daily limit; `placement view
|
||||
TEST_ID` reads the result. One test is a signal, not a verdict: compare a few
|
||||
before rewriting copy, and `--tracking compare` isolates open and click
|
||||
tracking.
|
||||
|
||||
## Errors
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user