Merge pull request #26 from warmbly/feature/warmup-messages

feat: add warmup deliverability controls
This commit is contained in:
Matthew Meszaros
2026-06-03 16:51:51 +02:00
committed by GitHub
117 changed files with 12197 additions and 235 deletions
+549
View File
@@ -0,0 +1,549 @@
// /placement — seed inbox-placement testing.
//
// Send a tokenized copy of a real template through a real sender to the panel
// of Warmbly-controlled SEED mailboxes, then watch where each landed (Inbox /
// Spam / Promotions / other), rolled up per provider. A backend poller fills in
// results as the probes sync into each seed's inbox, so a test starts "pending"
// and resolves over the next few minutes.
import { useMemo, useState } from "react";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Inbox, Send, Sparkles, Tag, TriangleAlert } 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 { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DataTable, type Column } from "@/components/data/DataTable";
import { useCursorPager } from "@/lib/useCursorPager";
import {
createPlacementTest,
getPlacementTest,
listPlacementTests,
listSeedCandidates,
listSeedMailboxes,
setSeedMailbox,
type PlacementFolder,
type PlacementTestRow,
type SeedAccount,
} from "@/lib/api/client/admin/placement";
import { fmtDate } from "./warmup-content/shared";
const STATUS_TONE: Record<string, string> = {
pending: "border-amber-300 bg-amber-50 text-amber-700",
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
};
const FOLDER_TONE: Record<PlacementFolder, string> = {
inbox: "border-emerald-300 bg-emerald-50 text-emerald-700",
promotions: "border-sky-300 bg-sky-50 text-sky-700",
spam: "border-red-300 bg-red-50 text-red-700",
other: "border-zinc-300 bg-zinc-50 text-zinc-600",
pending: "border-amber-300 bg-amber-50 text-amber-700",
};
const PROVIDER_LABEL: Record<string, string> = {
gmail: "Gmail / Workspace",
outlook: "Outlook / M365",
smtp_imap: "SMTP / IMAP",
unknown: "Unknown",
};
function providerLabel(p: string): string {
return PROVIDER_LABEL[p] ?? p;
}
export default function PlacementPage() {
return (
<div>
<PageHeader
title="Inbox placement"
description="Send a tokenized copy of a template through a real sender to the seed panel, then classify where it landed per provider. Results fill in as the probes sync into each seed's inbox."
/>
<OperationalNotice />
<section className="mt-6">
<h2 className="text-sm font-semibold mb-2">Run a placement test</h2>
<CreateTestForm />
</section>
<section className="mt-8">
<h2 className="text-sm font-semibold mb-2">Tests</h2>
<TestsTable />
</section>
<section className="mt-8">
<h2 className="text-sm font-semibold mb-2">Seed mailboxes</h2>
<SeedsSection />
</section>
</div>
);
}
function OperationalNotice() {
return (
<div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-[12.5px] text-amber-800">
<TriangleAlert className="size-4 shrink-0 mt-0.5" />
<div>
Placement classification only works for mailboxes Warmbly owns and
syncs. Connect real seed mailboxes across providers (Gmail/Workspace,
Outlook/M365, Yahoo, AOL, iCloud, corporate) and flag them below.
Gmail Promotions-tab detection additionally needs category-label sync
(a worker follow-up); until then a Gmail tab reads as Inbox.
</div>
</div>
);
}
// --- Create -------------------------------------------------------------
function CreateTestForm() {
const qc = useQueryClient();
const [senderId, setSenderId] = useState("");
const [subject, setSubject] = useState("");
const [bodyPlain, setBodyPlain] = useState("");
const create = useMutation({
mutationFn: () =>
createPlacementTest({
sender_account_id: senderId.trim(),
subject: subject.trim(),
body_plain: bodyPlain,
body_html: "",
}),
onSuccess: () => {
toast.success("Placement test sent to the seed panel");
setSubject("");
setBodyPlain("");
qc.invalidateQueries({ queryKey: ["admin", "placement", "tests"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to send test"),
});
const canSubmit =
senderId.trim() !== "" && subject.trim() !== "" && bodyPlain.trim() !== "";
return (
<div className="rounded-md border border-zinc-200 bg-white p-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="pl-sender">Sender account ID</Label>
<Input
id="pl-sender"
placeholder="email_account UUID to send from"
value={senderId}
onChange={(e) => setSenderId(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground">
The real mailbox whose deliverability you are testing. Copy its
ID from the Mailboxes explorer.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="pl-subject">Subject</Label>
<Input
id="pl-subject"
placeholder="Template subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground">
A hidden placement token is appended so we can find the copy in
each seed inbox.
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="pl-body">Body (plain text)</Label>
<Textarea
id="pl-body"
rows={6}
placeholder="The template body to test."
value={bodyPlain}
onChange={(e) => setBodyPlain(e.target.value)}
/>
</div>
<div className="flex justify-end">
<Button
onClick={() => create.mutate()}
disabled={!canSubmit || create.isPending}
>
<Send className="size-4" />
{create.isPending ? "Sending…" : "Send placement test"}
</Button>
</div>
</div>
);
}
// --- Tests --------------------------------------------------------------
function TestsTable() {
const pager = useCursorPager();
const [openId, setOpenId] = useState<string | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "placement", "tests", pager.cursor],
queryFn: () => listPlacementTests({ cursor: pager.cursor, limit: 25 }),
staleTime: 15_000,
refetchInterval: 30_000,
placeholderData: keepPreviousData,
});
const rows = data?.data ?? [];
const columns: Column<PlacementTestRow>[] = useMemo(
() => [
{
id: "subject",
header: "Subject",
cell: (r) => (
<span className="font-medium text-zinc-800">{r.subject}</span>
),
},
{
id: "status",
header: "Status",
cell: (r) => (
<Badge
variant="outline"
className={STATUS_TONE[r.status] ?? FOLDER_TONE.other}
>
{r.status}
</Badge>
),
},
{
id: "created_at",
header: "Created",
cell: (r) => (
<span className="text-zinc-500">{fmtDate(r.created_at)}</span>
),
},
{
id: "finished_at",
header: "Finished",
cell: (r) => (
<span className="text-zinc-500">
{r.finished_at ? fmtDate(r.finished_at) : "—"}
</span>
),
},
],
[],
);
return (
<>
<DataTable<PlacementTestRow>
columns={columns}
rows={rows}
getRowId={(r) => r.id}
loading={isLoading}
error={error}
onRetry={() => refetch()}
onRowClick={(r) => setOpenId(r.id)}
emptyTitle="No placement tests yet"
emptyHint="Send one above to see where it lands."
noun="tests"
pager={{
canPrev: pager.canPrev,
canNext: !!data?.pagination.has_more,
onPrev: pager.prev,
onNext: () => pager.next(data?.pagination.next_cursor ?? null),
page: pager.page,
shown: rows.length,
total: data?.pagination.total,
}}
/>
<TestDetailDialog
id={openId}
onClose={() => setOpenId(null)}
/>
</>
);
}
function TestDetailDialog({
id,
onClose,
}: {
id: string | null;
onClose: () => void;
}) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "placement", "test", id],
queryFn: () => getPlacementTest(id as string),
enabled: !!id,
refetchInterval: 15_000,
});
const detail = data?.data;
return (
<Dialog open={!!id} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Placement result</DialogTitle>
<DialogDescription>
{detail?.test.subject ?? "Per-provider folder rollup."}
</DialogDescription>
</DialogHeader>
{isLoading && <Skeleton className="h-40" />}
{error && (
<ErrorState
error={error}
title="Failed to load test"
onRetry={() => refetch()}
/>
)}
{detail && (
<div className="space-y-5">
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Per-provider rollup
</div>
{detail.rollup.length === 0 ? (
<p className="text-[12.5px] text-zinc-500">
No seeds resolved yet.
</p>
) : (
<div className="space-y-2">
{detail.rollup.map((r) => (
<div
key={r.provider}
className="rounded-md border border-zinc-200 p-3"
>
<div className="mb-2 flex items-center gap-1.5 text-[12.5px] font-medium text-zinc-800">
<Sparkles className="size-3.5 text-zinc-400" />
{providerLabel(r.provider)}
<span className="text-zinc-400 font-normal">
· {r.total} seed{r.total === 1 ? "" : "s"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
<Count tone={FOLDER_TONE.inbox} icon={<Inbox className="size-3" />} label="Inbox" n={r.inbox} />
<Count tone={FOLDER_TONE.promotions} icon={<Tag className="size-3" />} label="Promotions" n={r.promotions} />
<Count tone={FOLDER_TONE.spam} icon={<TriangleAlert className="size-3" />} label="Spam" n={r.spam} />
<Count tone={FOLDER_TONE.other} label="Other" n={r.other} />
<Count tone={FOLDER_TONE.pending} label="Pending" n={r.pending} />
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Per-seed detail
</div>
<div className="overflow-hidden rounded-md border border-zinc-200">
<table className="w-full text-[12.5px]">
<thead className="bg-zinc-50 text-left text-zinc-500">
<tr>
<th className="px-3 py-1.5 font-medium">Seed</th>
<th className="px-3 py-1.5 font-medium">Provider</th>
<th className="px-3 py-1.5 font-medium">Folder</th>
<th className="px-3 py-1.5 font-medium">Detected</th>
</tr>
</thead>
<tbody>
{detail.results.map((res) => (
<tr
key={res.seed_account_id}
className="border-t border-zinc-100"
>
<td className="px-3 py-1.5 font-mono text-[11px] text-zinc-600">
{res.seed_account_id.slice(0, 8)}
</td>
<td className="px-3 py-1.5 text-zinc-600">
{providerLabel(res.provider || "unknown")}
</td>
<td className="px-3 py-1.5">
<Badge
variant="outline"
className={FOLDER_TONE[res.folder]}
>
{res.folder}
</Badge>
</td>
<td className="px-3 py-1.5 text-zinc-500">
{res.detected_at
? fmtDate(res.detected_at)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
function Count({
tone,
icon,
label,
n,
}: {
tone: string;
icon?: React.ReactNode;
label: string;
n: number;
}) {
return (
<Badge variant="outline" className={tone}>
{icon}
<span className="ml-0.5">
{label} {n}
</span>
</Badge>
);
}
// --- Seeds --------------------------------------------------------------
function SeedsSection() {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const seeds = useQuery({
queryKey: ["admin", "placement", "seeds"],
queryFn: () => listSeedMailboxes(),
staleTime: 15_000,
});
const candidates = useQuery({
queryKey: ["admin", "placement", "seed-candidates", search],
queryFn: () => listSeedCandidates(search || undefined),
enabled: search.trim().length >= 2,
placeholderData: keepPreviousData,
});
const toggle = useMutation({
mutationFn: ({ id, isSeed }: { id: string; isSeed: boolean }) =>
setSeedMailbox(id, isSeed),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "placement", "seeds"] });
qc.invalidateQueries({
queryKey: ["admin", "placement", "seed-candidates"],
});
},
onError: (err: Error) => toast.error(err.message || "Failed to update seed"),
});
return (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-md border border-zinc-200 bg-white p-4">
<div className="mb-2 text-[12.5px] font-medium text-zinc-800">
Active seed panel
</div>
{seeds.isLoading && <Skeleton className="h-24" />}
{seeds.error && (
<ErrorState
error={seeds.error}
title="Failed to load seeds"
onRetry={() => seeds.refetch()}
/>
)}
{seeds.data && seeds.data.data.length === 0 && (
<p className="text-[12.5px] text-zinc-500">
No seed mailboxes yet. Search on the right to flag one.
</p>
)}
<div className="space-y-1.5">
{seeds.data?.data.map((s) => (
<SeedRow
key={s.id}
seed={s}
disabled={toggle.isPending}
onToggle={(isSeed) => toggle.mutate({ id: s.id, isSeed })}
/>
))}
</div>
</div>
<div className="rounded-md border border-zinc-200 bg-white p-4">
<div className="mb-2 text-[12.5px] font-medium text-zinc-800">
Add a seed
</div>
<Input
placeholder="Search connected mailboxes by email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<p className="mt-1 mb-2 text-[11px] text-muted-foreground">
Type at least 2 characters. Flagging a mailbox as a seed makes it a
placement recipient it should be a mailbox Warmbly owns and syncs.
</p>
{candidates.isLoading && search.trim().length >= 2 && (
<Skeleton className="h-20" />
)}
<div className="space-y-1.5">
{candidates.data?.data.map((s) => (
<SeedRow
key={s.id}
seed={s}
disabled={toggle.isPending}
onToggle={(isSeed) => toggle.mutate({ id: s.id, isSeed })}
/>
))}
</div>
</div>
</div>
);
}
function SeedRow({
seed,
disabled,
onToggle,
}: {
seed: SeedAccount;
disabled: boolean;
onToggle: (isSeed: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-100 px-2.5 py-1.5">
<div className="min-w-0">
<div className="truncate text-[12.5px] text-zinc-800">{seed.email}</div>
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
<span>{providerLabel(seed.provider)}</span>
<span>·</span>
<span>{seed.status}</span>
</div>
</div>
<Switch
checked={seed.is_seed}
disabled={disabled}
onCheckedChange={(v) => onToggle(v)}
/>
</div>
);
}
@@ -0,0 +1,581 @@
// Warmup appeals review surface. Where the Warmup page is the at-a-glance
// pool monitor, this page is the focused enforcement queue: admins triage
// warmup-ban appeals (approve = unblock the mailbox, reject = stays blocked)
// and can unblock blocked mailboxes directly.
//
// - Appeals tab: filterable by status (pending/approved/rejected), with
// approve/reject actions on pending rows that capture optional review
// notes. The pending view polls so the queue stays current.
// - Blocked mailboxes tab: every mailbox currently blocked from the pool,
// with whether it has an open appeal and a direct (confirmed) unblock.
//
// Mirrors LimitRequestsPage's review pattern so the two enforcement queues
// read the same way.
import { useEffect, useState } from "react";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
import { PageHeader } from "@/components/layout/PageHeader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Explorer,
FilterGroup,
SelectFilter,
} from "@/components/data/Explorer";
import { DataTable, type Column } from "@/components/data/DataTable";
import { useCursorPager } from "@/lib/useCursorPager";
import {
approveAppeal,
listBlockedWarmupAccounts,
listWarmupAppeals,
rejectAppeal,
unblockWarmupAccount,
} from "@/lib/api/client/admin/warmup";
import type {
AdminBlockedAccount,
WarmupAppeal,
WarmupAppealStatus,
} from "@/lib/api/models/admin";
const STATUS_TONE: Record<WarmupAppealStatus, string> = {
pending: "border-amber-300 text-amber-700 bg-amber-50",
approved: "border-emerald-300 text-emerald-700 bg-emerald-50",
rejected: "border-red-300 text-red-700 bg-red-50",
};
type AppealStatusFilter = WarmupAppealStatus | "all";
const STATUS_OPTIONS: { value: AppealStatusFilter; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "rejected", label: "Rejected" },
{ value: "all", label: "All statuses" },
];
export default function WarmupAppealsPage() {
const [tab, setTab] = useState("appeals");
return (
<div>
<PageHeader
title="Warmup appeals"
description="Review warmup-ban appeals and unblock mailboxes. Approving an appeal unblocks the mailbox and re-admits it to the pool; rejecting keeps it blocked. Shared paid-pool reputation matters more than any single mailbox."
/>
<Tabs value={tab} onValueChange={setTab}>
<TabsList variant="line">
<TabsTrigger value="appeals">Appeals</TabsTrigger>
<TabsTrigger value="blocked">Blocked mailboxes</TabsTrigger>
</TabsList>
<TabsContent value="appeals" className="mt-5">
<AppealsTab />
</TabsContent>
<TabsContent value="blocked" className="mt-5">
<BlockedTab />
</TabsContent>
</Tabs>
</div>
);
}
function AppealsTab() {
const [status, setStatus] = useState<AppealStatusFilter>("pending");
const pager = useCursorPager();
const { reset } = pager;
const [reviewing, setReviewing] = useState<{
appeal: WarmupAppeal;
mode: "approve" | "reject";
} | null>(null);
useEffect(() => {
reset();
}, [status, reset]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup", "appeals", status, pager.cursor],
queryFn: () => listWarmupAppeals(status, pager.cursor),
// Keep the pending queue current without a manual refresh.
refetchInterval: status === "pending" ? 15_000 : false,
placeholderData: keepPreviousData,
staleTime: 10_000,
});
const rows = data?.data ?? [];
const activeCount = status !== "pending" ? 1 : 0;
const columns: Column<WarmupAppeal>[] = [
{
id: "mailbox",
header: "Mailbox",
cell: (a) => (
<span className="font-mono text-xs">
{a.email_account?.email ?? a.email_account_id}
</span>
),
csv: (a) => a.email_account?.email ?? a.email_account_id,
},
{
id: "user",
header: "User",
cell: (a) => (
<span className="text-xs">{a.user?.email ?? a.user_id}</span>
),
csv: (a) => a.user?.email ?? a.user_id,
},
{
id: "reason",
header: "Reason",
cell: (a) => (
<span className="block max-w-md truncate text-xs" title={a.reason}>
{a.reason}
</span>
),
csv: (a) => a.reason,
},
{
id: "status",
header: "Status",
cell: (a) => (
<div>
<Badge
variant="outline"
className={`text-[10px] ${STATUS_TONE[a.status]}`}
>
{a.status}
</Badge>
{a.review_notes && a.status !== "pending" && (
<div
className="mt-1 max-w-xs truncate text-[10px] text-muted-foreground"
title={a.review_notes}
>
"{a.review_notes}"
</div>
)}
</div>
),
csv: (a) => a.status,
},
{
id: "created",
header: "Submitted",
cell: (a) => (
<span className="text-xs text-muted-foreground">
{new Date(a.created_at).toLocaleDateString()}
</span>
),
csv: (a) => a.created_at,
},
{
id: "reviewed",
header: "Reviewed",
defaultHidden: true,
cell: (a) => (
<span className="text-xs text-muted-foreground">
{a.reviewed_at
? new Date(a.reviewed_at).toLocaleDateString()
: "—"}
</span>
),
csv: (a) => a.reviewed_at ?? "",
},
{
id: "actions",
header: "",
align: "right",
cell: (a) => {
const canReview = a.status === "pending";
return (
<div className="space-x-1.5 whitespace-nowrap">
<Button
size="sm"
disabled={!canReview}
onClick={() => setReviewing({ appeal: a, mode: "approve" })}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs disabled:bg-zinc-200"
>
<CheckCircle2 className="size-3" /> Approve
</Button>
<Button
size="sm"
disabled={!canReview}
onClick={() => setReviewing({ appeal: a, mode: "reject" })}
className="bg-red-600 hover:bg-red-700 text-white text-xs disabled:bg-zinc-200"
>
<XCircle className="size-3" /> Reject
</Button>
</div>
);
},
},
];
return (
<>
<Explorer
activeCount={activeCount}
onReset={() => setStatus("pending")}
filters={
<FilterGroup label="Status">
<SelectFilter
value={status}
onChange={(v) => setStatus(v as AppealStatusFilter)}
options={STATUS_OPTIONS}
placeholder="Pending"
/>
</FilterGroup>
}
>
<DataTable
columns={columns}
rows={rows}
getRowId={(a) => a.id}
loading={isLoading}
error={error}
onRetry={() => refetch()}
errorTitle="Failed to load appeals"
storageKey="admin.warmup-appeals"
csvName="warmbly-warmup-appeals"
noun="appeals"
emptyTitle="No appeals"
emptyHint={
status === "pending"
? "No pending appeals to review."
: "No appeals match this status."
}
pager={{
canPrev: pager.canPrev,
canNext: !!data?.pagination?.has_more,
onPrev: pager.prev,
onNext: () => pager.next(data?.pagination?.next_cursor),
page: pager.page,
shown: rows.length,
total: data?.pagination?.total ?? null,
}}
/>
</Explorer>
{reviewing && (
<ReviewAppealDialog
appeal={reviewing.appeal}
mode={reviewing.mode}
open
onOpenChange={(v) => !v && setReviewing(null)}
onDone={() => setReviewing(null)}
/>
)}
</>
);
}
function ReviewAppealDialog({
appeal,
mode,
open,
onOpenChange,
onDone,
}: {
appeal: WarmupAppeal;
mode: "approve" | "reject";
open: boolean;
onOpenChange: (v: boolean) => void;
onDone: () => void;
}) {
const qc = useQueryClient();
const [notes, setNotes] = useState("");
const mailbox = appeal.email_account?.email ?? appeal.email_account_id;
const mutation = useMutation({
mutationFn: () =>
mode === "approve"
? approveAppeal(appeal.id, { approved: true, notes })
: rejectAppeal(appeal.id, { approved: false, notes }),
onSuccess: () => {
toast.success(
`Appeal ${mode === "approve" ? "approved" : "rejected"}`,
);
// Approving unblocks the mailbox, so refresh both queues.
qc.invalidateQueries({ queryKey: ["admin", "warmup"] });
onDone();
},
onError: (err: Error) => toast.error(err.message || "Action failed"),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "approve" ? "Approve appeal" : "Reject appeal"}
</DialogTitle>
<DialogDescription>
{mode === "approve" ? (
<>
Approving unblocks{" "}
<span className="font-mono">{mailbox}</span> and
re-admits it to the warmup pool. Notes are recorded
for audit.
</>
) : (
<>
Rejecting keeps{" "}
<span className="font-mono">{mailbox}</span>{" "}
blocked. Notes are recorded for audit and may be
shown to the user.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-border bg-muted/40 p-2.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
Appeal reason:
</span>{" "}
{appeal.reason}
</div>
<div>
<Label htmlFor="notes" className="text-xs font-medium">
Review notes (optional)
</Label>
<Textarea
id="notes"
placeholder={
mode === "approve"
? "Optional: why the mailbox is being re-admitted"
: "Optional: why the appeal is being rejected"
}
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-1"
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className={
mode === "approve"
? "bg-emerald-600 hover:bg-emerald-700 text-white"
: "bg-red-600 hover:bg-red-700 text-white"
}
>
{mutation.isPending
? "Working…"
: mode === "approve"
? "Approve & unblock"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BlockedTab() {
const pager = useCursorPager();
const [unblocking, setUnblocking] = useState<AdminBlockedAccount | null>(
null,
);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup", "blocked", pager.cursor],
queryFn: () => listBlockedWarmupAccounts(pager.cursor),
refetchInterval: 30_000,
placeholderData: keepPreviousData,
staleTime: 10_000,
});
const rows = data?.data ?? [];
const columns: Column<AdminBlockedAccount>[] = [
{
id: "mailbox",
header: "Mailbox",
cell: (a) => <span className="font-mono text-xs">{a.email}</span>,
csv: (a) => a.email,
},
{
id: "owner",
header: "Owner",
cell: (a) => (
<span className="text-xs">{a.user?.email ?? a.user_id}</span>
),
csv: (a) => a.user?.email ?? a.user_id,
},
{
id: "reason",
header: "Reason",
cell: (a) => (
<span
className="block max-w-md truncate text-xs"
title={a.block_reason}
>
{a.block_reason}
</span>
),
csv: (a) => a.block_reason,
},
{
id: "blocked",
header: "Blocked",
cell: (a) => (
<span className="text-xs text-muted-foreground">
{new Date(a.blocked_at).toLocaleDateString()}
</span>
),
csv: (a) => a.blocked_at,
},
{
id: "appeal",
header: "Appeal",
cell: (a) =>
a.has_appeal ? (
<Badge
variant="outline"
className={`text-[10px] ${
STATUS_TONE[a.appeal_status ?? "pending"]
}`}
>
{a.appeal_status ?? "pending"}
</Badge>
) : (
<span className="text-muted-foreground"></span>
),
csv: (a) => (a.has_appeal ? (a.appeal_status ?? "pending") : ""),
},
{
id: "actions",
header: "",
align: "right",
cell: (a) => (
<Button
size="sm"
variant="outline"
onClick={() => setUnblocking(a)}
className="text-xs"
>
<ShieldCheck className="size-3" /> Unblock
</Button>
),
},
];
return (
<>
<DataTable
columns={columns}
rows={rows}
getRowId={(a) => a.id}
loading={isLoading}
error={error}
onRetry={() => refetch()}
errorTitle="Failed to load blocked mailboxes"
storageKey="admin.warmup-blocked"
csvName="warmbly-warmup-blocked"
noun="mailboxes"
emptyTitle="No blocked mailboxes"
emptyHint="No mailboxes are currently blocked from warmup."
pager={{
canPrev: pager.canPrev,
canNext: !!data?.pagination?.has_more,
onPrev: pager.prev,
onNext: () => pager.next(data?.pagination?.next_cursor),
page: pager.page,
shown: rows.length,
total: data?.pagination?.total ?? null,
}}
/>
{unblocking && (
<UnblockDialog
account={unblocking}
open
onOpenChange={(v) => !v && setUnblocking(null)}
onDone={() => setUnblocking(null)}
/>
)}
</>
);
}
function UnblockDialog({
account,
open,
onOpenChange,
onDone,
}: {
account: AdminBlockedAccount;
open: boolean;
onOpenChange: (v: boolean) => void;
onDone: () => void;
}) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => unblockWarmupAccount(account.id),
onSuccess: () => {
toast.success("Mailbox unblocked");
qc.invalidateQueries({ queryKey: ["admin", "warmup"] });
onDone();
},
onError: (err: Error) => toast.error(err.message || "Failed to unblock"),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Unblock mailbox</DialogTitle>
<DialogDescription>
Unblock <span className="font-mono">{account.email}</span>{" "}
and re-admit it to the warmup pool? This bypasses any open
appeal and is recorded for audit.
</DialogDescription>
</DialogHeader>
{account.has_appeal && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-2.5 text-xs text-amber-800">
This mailbox has an open appeal
{account.appeal_status
? ` (${account.appeal_status})`
: ""}
. Unblocking here does not record an appeal decision
prefer approving the appeal if you want it tracked as a
review outcome.
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{mutation.isPending ? "Working…" : "Unblock"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+39 -3
View File
@@ -108,15 +108,26 @@ function HealthSummary() {
// Guard every field: a partial response must degrade gracefully, never
// throw and blank the whole tab.
//
// `avg_spam_placement_rate` is ALREADY a percent (the backend computes
// placements/sent*100), so we render it directly — no second *100.
const placement = data.avg_spam_placement_rate ?? 0;
const spamPct = (placement * 100).toFixed(1);
const spamPct = placement.toFixed(1);
const placementTone =
placement >= 0.2 ? "text-red-700" : placement >= 0.1 ? "text-amber-700" : "text-emerald-600";
placement >= 20 ? "text-red-700" : placement >= 10 ? "text-amber-700" : "text-emerald-600";
const atRisk = data.at_risk_count ?? 0;
const blocked = data.blocked_count ?? 0;
// `spam_placement_by_provider` is a Record of RAW COUNTS (backend does
// COUNT(*)), not rates. Sort worst-first by count so the riskiest
// mailbox-provider surface is the first thing an investigator sees.
const byProvider = Object.entries(data.spam_placement_by_provider ?? {}).sort(
(a, b) => b[1] - a[1],
);
return (
<div className="grid gap-3 md:grid-cols-4 mb-6">
<div className="mb-6">
<div className="grid gap-3 md:grid-cols-4">
<HealthCard
icon={<Activity className="size-4" />}
title="Total participants"
@@ -147,6 +158,31 @@ function HealthSummary() {
tone={blocked > 0 ? "text-red-700" : undefined}
/>
</div>
{byProvider.length > 0 && (
<div className="mt-3 border border-border rounded-lg p-3 bg-card">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Flame className="size-4" />
<span>Spam placements by provider (count)</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{byProvider.map(([provider, count]) => (
<div
key={provider}
className="flex items-center justify-between text-xs"
>
<span className="capitalize truncate text-foreground">
{provider}
</span>
<span className="tabular-nums font-medium text-foreground">
{(count ?? 0).toLocaleString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,690 @@
// /warmup-content/generate — enqueue warmup-content generation. Two modes in
// one full-width page:
//
// Sync — the existing immediate-enqueue path (one background job).
// Batch — OpenAI Batch API: cheaper, async, high volume. Exposes every
// knob (pool, segment, model, count up to 2000, max messages,
// completion window) plus a theme multi-input that fans out one
// batch job per theme. Returned job ids are surfaced in a live
// "Recent batch jobs" view that polls while anything is in flight,
// with an inline Cancel per job.
//
// Neither mode runs inline: submitting returns job id(s); progress is watched
// here and on the Jobs tab.
import { useMemo, useState, type KeyboardEvent } from "react";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AlertTriangle, Ban, Layers, Sparkles, X, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { ErrorState } from "@/components/ErrorState";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { APIError } from "@/lib/api/client";
import {
cancelWarmupBatch,
generateWarmupContent,
isJobActive,
isJobCancellable,
listWarmupGenerationJobs,
submitWarmupBatch,
type GenerateBatchRequest,
type WarmupGenerationJob,
} from "@/lib/api/client/admin/warmupContent";
import { cn } from "@/lib/utils";
import { PoolBadge } from "./components";
import { batchTone, fmtDate, jobTone } from "./shared";
const COMPLETION_WINDOWS = ["24h"];
// A 400 whose code marks generation as un-runnable. We disable submit and show
// a clear banner instead of letting the user fire a doomed request.
const NOT_CONFIGURED_CODES = new Set([
"not_configured",
"daily_cap_reached",
]);
function notConfiguredMessage(err: unknown): string | null {
if (err instanceof APIError && err.status === 400) {
if (!err.code || NOT_CONFIGURED_CODES.has(err.code)) {
return err.message || "Generation is not configured right now.";
}
}
return null;
}
type Mode = "sync" | "batch";
export default function GeneratePage() {
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>("sync");
return (
<div className="w-full space-y-6">
<div className="inline-flex rounded-lg border border-border bg-muted/40 p-1">
<button
type="button"
onClick={() => setMode("sync")}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
mode === "sync"
? "bg-background text-foreground shadow-sm"
: "text-foreground/60 hover:text-foreground",
)}
>
<Zap className="size-4" /> Instant
</button>
<button
type="button"
onClick={() => setMode("batch")}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
mode === "batch"
? "bg-background text-foreground shadow-sm"
: "text-foreground/60 hover:text-foreground",
)}
>
<Layers className="size-4" /> Batch
</button>
</div>
<p className="max-w-3xl text-[12.5px] text-muted-foreground">
Same result either way both produce warmup threads and add the passing ones
to the library. They only differ in <em>how</em> the AI runs them:{" "}
<strong className="font-medium text-foreground">Instant</strong> generates
immediately (best for a handful while you watch).{" "}
<strong className="font-medium text-foreground">Batch</strong> hands the work
to OpenAI's Batch API — about 50% cheaper and processed in the background (up
to ~24h) — best for generating a lot at once.
</p>
{mode === "sync" ? (
<SyncGenerate onQueued={() => navigate("/warmup-content/jobs")} />
) : (
<BatchGenerate />
)}
</div>
);
}
// ====================================================================
// Sync mode — the existing immediate-enqueue path.
// ====================================================================
function SyncGenerate({ onQueued }: { onQueued: () => void }) {
const qc = useQueryClient();
const [count, setCount] = useState(10);
const [segment, setSegment] = useState("");
const [theme, setTheme] = useState("");
const [model, setModel] = useState("");
const generate = useMutation({
mutationFn: () =>
generateWarmupContent({
count,
segment: segment.trim() || undefined,
theme: theme.trim() || undefined,
model: model.trim() || undefined,
}),
onSuccess: (res) => {
toast.success("Generation job queued", {
description: `Job ${res.job_id} is running offline. Watch the Jobs tab for progress.`,
});
qc.invalidateQueries({ queryKey: ["admin", "warmup-content", "jobs"] });
onQueued();
},
onError: (err: Error) => toast.error(err.message || "Failed to queue job"),
});
const blocked = notConfiguredMessage(generate.error);
return (
<div className="space-y-5">
<div className="rounded-lg border border-[var(--admin-accent)]/30 bg-[var(--admin-accent-soft)]/40 p-3 text-[12.5px] text-muted-foreground">
<div className="flex items-center gap-2 font-medium text-foreground">
<Sparkles className="size-4 text-[var(--admin-accent-strong)]" />
Runs offline
</div>
<p className="mt-1">
Generation does not run inline. Submitting enqueues a background job
that produces threads, lint-checks them, and adds the passing ones to
the library. Track progress on the Jobs tab.
</p>
</div>
{blocked && <NotConfiguredBanner message={blocked} />}
<div className="grid gap-4 rounded-lg border border-border bg-card p-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label htmlFor="gen-count" className="mb-1 text-xs">
Count
</Label>
<Input
id="gen-count"
type="number"
min={1}
max={500}
value={count}
onChange={(e) =>
setCount(Math.max(1, Math.min(500, Number(e.target.value) || 0)))
}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Number of conversation threads to generate.
</p>
</div>
<div>
<Label htmlFor="gen-segment" className="mb-1 text-xs">
Segment <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="gen-segment"
placeholder="e.g. saas, agency, ecommerce"
value={segment}
onChange={(e) => setSegment(e.target.value)}
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Label htmlFor="gen-theme" className="mb-1 text-xs">
Theme <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="gen-theme"
placeholder="Steer the topic, e.g. 'casual product follow-ups between colleagues'"
value={theme}
onChange={(e) => setTheme(e.target.value)}
/>
</div>
<div>
<Label htmlFor="gen-model" className="mb-1 text-xs">
Model override{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="gen-model"
placeholder="Defaults to the configured model"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<div className="flex justify-end sm:col-span-2 lg:col-span-3">
<Button
onClick={() => generate.mutate()}
disabled={generate.isPending || count < 1 || !!blocked}
>
<Sparkles className="size-4" />
{generate.isPending ? "Queuing" : "Queue generation job"}
</Button>
</div>
</div>
</div>
);
}
// ====================================================================
// Batch mode — OpenAI Batch API, every knob exposed + live monitor.
// ====================================================================
function BatchGenerate() {
const qc = useQueryClient();
const [segment, setSegment] = useState("");
const [model, setModel] = useState("");
const [count, setCount] = useState(100);
const [maxMessages, setMaxMessages] = useState(6);
const [completionWindow, setCompletionWindow] = useState("24h");
const [themes, setThemes] = useState<string[]>([]);
const [themeDraft, setThemeDraft] = useState("");
const [lastJobIds, setLastJobIds] = useState<string[]>([]);
function commitTheme() {
const t = themeDraft.trim();
if (!t) return;
setThemes((prev) => (prev.includes(t) ? prev : [...prev, t]));
setThemeDraft("");
}
function onThemeKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitTheme();
} else if (e.key === "Backspace" && !themeDraft && themes.length) {
setThemes((prev) => prev.slice(0, -1));
}
}
function removeTheme(t: string) {
setThemes((prev) => prev.filter((x) => x !== t));
}
const submit = useMutation({
mutationFn: () => {
const body: GenerateBatchRequest = {
segment: segment.trim() || undefined,
model: model.trim() || undefined,
count,
max_messages: maxMessages,
completion_window: completionWindow,
themes: themes.length ? themes : undefined,
};
return submitWarmupBatch(body);
},
onSuccess: (res) => {
const ids = res.job_ids ?? [];
setLastJobIds(ids);
toast.success(
ids.length === 1
? "Batch job submitted"
: `${ids.length} batch jobs submitted`,
{
description: "Watch progress below or on the Jobs tab.",
},
);
qc.invalidateQueries({ queryKey: ["admin", "warmup-content", "jobs"] });
},
onError: (err: Error) => {
if (!notConfiguredMessage(err)) {
toast.error(err.message || "Failed to submit batch");
}
},
});
const blocked = notConfiguredMessage(submit.error);
const jobCount = themes.length ? themes.length : 1;
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]">
<div className="space-y-5">
<div className="rounded-lg border border-sky-300/40 bg-sky-50/50 p-3 text-[12.5px] text-muted-foreground">
<div className="flex items-center gap-2 font-medium text-foreground">
<Layers className="size-4 text-sky-600" />
OpenAI Batch — async &amp; cheaper
</div>
<p className="mt-1">
Batch generation runs through the OpenAI Batch API: lower cost, much
higher volume, and completion within the chosen window (typically up
to {completionWindow}). Add multiple themes to fan out one batch job
per theme; leave themes empty to rotate the configured defaults.
</p>
</div>
{blocked && <NotConfiguredBanner message={blocked} />}
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1 text-xs">Completion window</Label>
<Select
value={completionWindow}
onValueChange={setCompletionWindow}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_WINDOWS.map((w) => (
<SelectItem key={w} value={w}>
{w}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="batch-count" className="mb-1 text-xs">
Count{" "}
<span className="text-muted-foreground">
{themes.length ? "(per theme)" : "(threads)"}
</span>
</Label>
<Input
id="batch-count"
type="number"
min={1}
max={2000}
value={count}
onChange={(e) =>
setCount(
Math.max(1, Math.min(2000, Number(e.target.value) || 0)),
)
}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Up to 2000; the server also clamps to the daily cap.
</p>
</div>
<div>
<Label htmlFor="batch-max-msgs" className="mb-1 text-xs">
Max messages / thread
</Label>
<Input
id="batch-max-msgs"
type="number"
min={1}
max={50}
value={maxMessages}
onChange={(e) =>
setMaxMessages(
Math.max(1, Math.min(50, Number(e.target.value) || 0)),
)
}
/>
</div>
</div>
<div>
<Label htmlFor="batch-segment" className="mb-1 text-xs">
Segment <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="batch-segment"
placeholder="e.g. saas, agency, ecommerce"
value={segment}
onChange={(e) => setSegment(e.target.value)}
/>
</div>
<div>
<Label htmlFor="batch-model" className="mb-1 text-xs">
Model override{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="batch-model"
placeholder="Defaults to the configured model"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<div>
<Label htmlFor="batch-theme" className="mb-1 text-xs">
Themes{" "}
<span className="text-muted-foreground">
(optional — one job per theme)
</span>
</Label>
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent p-1.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30">
{themes.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 rounded bg-sky-50 px-2 py-0.5 text-xs text-sky-700 ring-1 ring-inset ring-sky-200"
>
{t}
<button
type="button"
className="rounded-full p-0.5 hover:bg-sky-100"
onClick={() => removeTheme(t)}
aria-label={`Remove ${t}`}
>
<X className="size-3" />
</button>
</span>
))}
<input
id="batch-theme"
value={themeDraft}
onChange={(e) => setThemeDraft(e.target.value)}
onKeyDown={onThemeKeyDown}
onBlur={commitTheme}
placeholder={
themes.length
? "Add another"
: "Type a theme and press Enter"
}
className="min-w-[8rem] flex-1 bg-transparent px-1 py-0.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
Press Enter or comma to add. With themes set, the count above is
per theme.
</p>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-[11px] text-muted-foreground">
Will submit{" "}
<span className="font-medium text-foreground">{jobCount}</span>{" "}
batch job{jobCount === 1 ? "" : "s"} ·{" "}
<span className="font-medium text-foreground">{count}</span>{" "}
thread{count === 1 ? "" : "s"} each
</p>
<Button
onClick={() => submit.mutate()}
disabled={submit.isPending || count < 1 || !!blocked}
>
<Layers className="size-4" />
{submit.isPending ? "Submitting" : "Submit batch"}
</Button>
</div>
</div>
{lastJobIds.length > 0 && (
<div className="rounded-lg border border-emerald-300/50 bg-emerald-50/50 p-3 text-[12.5px]">
<div className="font-medium text-emerald-800">
Submitted {lastJobIds.length} job
{lastJobIds.length === 1 ? "" : "s"}
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{lastJobIds.map((id) => (
<Badge
key={id}
variant="outline"
className="font-mono text-[10px]"
>
{id}
</Badge>
))}
</div>
</div>
)}
</div>
<BatchMonitor highlightIds={lastJobIds} />
</div>
);
}
// ====================================================================
// Live batch monitor — polls jobs while any is in flight, cancel inline.
// ====================================================================
function BatchMonitor({ highlightIds }: { highlightIds: string[] }) {
const qc = useQueryClient();
const highlight = useMemo(() => new Set(highlightIds), [highlightIds]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "jobs", "batch-monitor"],
queryFn: () => listWarmupGenerationJobs({ limit: 25 }),
placeholderData: keepPreviousData,
refetchInterval: (query) => {
const rows = query.state.data?.data ?? [];
return rows.some(isJobActive) ? 10_000 : false;
},
});
const cancel = useMutation({
mutationFn: (id: string) => cancelWarmupBatch(id),
onSuccess: () => {
toast.success("Batch cancellation requested");
qc.invalidateQueries({ queryKey: ["admin", "warmup-content", "jobs"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to cancel job"),
});
const batchJobs = (data?.data ?? []).filter((j) => j.mode === "batch");
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Recent batch jobs</h2>
<span className="text-[11px] text-muted-foreground">
auto-refreshes while running
</span>
</div>
{error ? (
<ErrorState
error={error}
title="Failed to load batch jobs"
onRetry={() => refetch()}
/>
) : isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
) : batchJobs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
No batch jobs yet. Submit one to watch it here.
</div>
) : (
<div className="space-y-2">
{batchJobs.map((j) => (
<BatchJobCard
key={j.id}
job={j}
highlighted={highlight.has(j.id)}
onCancel={() => cancel.mutate(j.id)}
cancelling={cancel.isPending}
/>
))}
</div>
)}
</div>
);
}
function BatchJobCard({
job,
highlighted,
onCancel,
cancelling,
}: {
job: WarmupGenerationJob;
highlighted: boolean;
onCancel: () => void;
cancelling: boolean;
}) {
const requested = job.requested_count || 0;
const generated = job.generated_count || 0;
const pct =
requested > 0 ? Math.min(100, Math.round((generated / requested) * 100)) : 0;
return (
<div
className={cn(
"rounded-lg border bg-card p-3",
highlighted ? "border-sky-400 ring-1 ring-sky-200" : "border-border",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<PoolBadge pool={job.pool_type} />
<Badge
variant="outline"
className={`text-[10px] ${jobTone(job.status)}`}
>
{job.status}
</Badge>
{job.batch_status && (
<Badge
variant="outline"
className={`text-[10px] ${batchTone(job.batch_status)}`}
>
{job.batch_status}
</Badge>
)}
{job.completion_window && (
<span className="text-[10px] text-muted-foreground">
{job.completion_window}
</span>
)}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">
{job.id}
</div>
{job.theme && (
<div className="truncate text-[11px] text-muted-foreground">
theme: {job.theme}
</div>
)}
</div>
{isJobCancellable(job) && (
<Button
size="xs"
variant="outline"
className="shrink-0 text-red-700 hover:bg-red-50"
onClick={onCancel}
disabled={cancelling}
>
<Ban className="size-3" /> Cancel
</Button>
)}
</div>
<div className="mt-2.5">
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{generated.toLocaleString()} / {requested.toLocaleString()} generated
</span>
<span className="tabular-nums">{pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-sky-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px] text-muted-foreground">
<span>Lint rejected: {job.lint_rejected_count}</span>
<span>Failed: {job.failed_count}</span>
<span>Started: {fmtDate(job.started_at)}</span>
<span>Finished: {fmtDate(job.finished_at)}</span>
</div>
{job.error && (
<div className="mt-2 rounded border border-red-200 bg-red-50 px-2 py-1 text-[11px] text-red-700">
{job.error}
</div>
)}
</div>
);
}
// ====================================================================
// Shared "not configured" banner.
// ====================================================================
function NotConfiguredBanner({ message }: { message: string }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[12.5px] text-amber-800">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Generation unavailable</div>
<p className="mt-0.5">{message}</p>
</div>
</div>
);
}
@@ -0,0 +1,494 @@
// /warmup-content/jobs — paged table of generation jobs (sync + batch),
// polled live so running jobs update without a manual refresh. Batch jobs
// surface their OpenAI batch status and an inline Cancel action.
import { useMemo, useState } from "react";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Ban } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DataTable, type Column } from "@/components/data/DataTable";
import { useCursorPager } from "@/lib/useCursorPager";
import {
cancelWarmupBatch,
getWarmupGenerationJob,
isJobActive,
isJobCancellable,
listWarmupGenerationJobs,
type WarmupGenerationJob,
} from "@/lib/api/client/admin/warmupContent";
import { PoolBadge } from "./components";
import { batchTone, fmtDate, jobTone } from "./shared";
export default function JobsPage() {
const qc = useQueryClient();
const pager = useCursorPager();
const [openId, setOpenId] = useState<string | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "jobs", pager.cursor],
queryFn: () => listWarmupGenerationJobs({ cursor: pager.cursor, limit: 50 }),
placeholderData: keepPreviousData,
// Poll only while at least one job is still in flight; idle pages stop
// hammering the endpoint.
refetchInterval: (query) => {
const rows = query.state.data?.data ?? [];
return rows.some(isJobActive) ? 10_000 : false;
},
});
const cancel = useMutation({
mutationFn: (id: string) => cancelWarmupBatch(id),
onSuccess: () => {
toast.success("Batch cancellation requested");
qc.invalidateQueries({ queryKey: ["admin", "warmup-content", "jobs"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to cancel job"),
});
const columns: Column<WarmupGenerationJob>[] = useMemo(
() => [
{
id: "status",
header: "Status",
cell: (j) => (
<Badge variant="outline" className={`text-[10px] ${jobTone(j.status)}`}>
{j.status}
</Badge>
),
csv: (j) => j.status,
},
{
id: "mode",
header: "Mode",
cell: (j) => (
<Badge
variant="outline"
className={`text-[10px] ${
j.mode === "batch"
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-zinc-300 text-zinc-600"
}`}
>
{j.mode ?? "sync"}
</Badge>
),
csv: (j) => j.mode ?? "sync",
},
{
id: "batch_status",
header: "Batch",
cell: (j) =>
j.mode === "batch" && j.batch_status ? (
<Badge
variant="outline"
className={`text-[10px] ${batchTone(j.batch_status)}`}
>
{j.batch_status}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
),
csv: (j) => j.batch_status ?? "",
},
{
id: "pool",
header: "Pool",
cell: (j) => <PoolBadge pool={j.pool_type} />,
csv: (j) => j.pool_type,
},
{
id: "segment",
header: "Segment",
cell: (j) => <span className="text-xs">{j.segment || "—"}</span>,
csv: (j) => j.segment,
},
{
id: "trigger",
header: "Trigger",
cell: (j) => (
<span className="text-xs text-muted-foreground">
{j.trigger || "—"}
</span>
),
csv: (j) => j.trigger,
},
{
id: "model",
header: "Model",
cell: (j) => <span className="text-xs">{j.model || "—"}</span>,
csv: (j) => j.model,
},
{
id: "counts",
header: "Generated / Requested",
align: "right",
cell: (j) => (
<span className="tabular-nums">
{j.generated_count} / {j.requested_count}
</span>
),
csv: (j) => `${j.generated_count}/${j.requested_count}`,
},
{
id: "rejected",
header: "Lint rej.",
align: "right",
cell: (j) => (
<span
className={`tabular-nums ${
j.lint_rejected_count > 0
? "text-amber-700"
: "text-muted-foreground"
}`}
>
{j.lint_rejected_count}
</span>
),
csv: (j) => j.lint_rejected_count,
},
{
id: "failed",
header: "Failed",
align: "right",
cell: (j) => (
<span
className={`tabular-nums ${
j.failed_count > 0 ? "text-red-700" : "text-muted-foreground"
}`}
>
{j.failed_count}
</span>
),
csv: (j) => j.failed_count,
},
{
id: "window",
header: "Window",
cell: (j) => (
<span className="text-xs text-muted-foreground">
{j.completion_window || "—"}
</span>
),
csv: (j) => j.completion_window ?? "",
defaultHidden: true,
},
{
id: "batch_id",
header: "Batch ID",
cell: (j) => (
<span className="font-mono text-[11px] text-muted-foreground">
{j.batch_id || "—"}
</span>
),
csv: (j) => j.batch_id ?? "",
defaultHidden: true,
},
{
id: "started",
header: "Started",
cell: (j) => (
<span className="text-xs text-muted-foreground">
{fmtDate(j.started_at)}
</span>
),
csv: (j) => j.started_at ?? "",
},
{
id: "finished",
header: "Finished",
cell: (j) => (
<span className="text-xs text-muted-foreground">
{fmtDate(j.finished_at)}
</span>
),
csv: (j) => j.finished_at ?? "",
},
{
id: "error",
header: "Error",
cell: (j) =>
j.error ? (
<span className="text-xs text-red-700" title={j.error}>
{j.error.length > 60 ? `${j.error.slice(0, 60)}` : j.error}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
),
csv: (j) => j.error,
defaultHidden: true,
},
{
id: "created",
header: "Created",
cell: (j) => (
<span className="text-xs text-muted-foreground">
{new Date(j.created_at).toLocaleString()}
</span>
),
csv: (j) => j.created_at,
defaultHidden: true,
},
{
id: "actions",
header: "Actions",
align: "right",
cell: (j) =>
isJobCancellable(j) ? (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<Button
size="xs"
variant="outline"
className="text-red-700 hover:bg-red-50"
onClick={() => cancel.mutate(j.id)}
disabled={cancel.isPending}
>
<Ban className="size-3" /> Cancel
</Button>
</div>
) : (
<span className="block text-right text-xs text-muted-foreground">
</span>
),
},
],
[cancel],
);
const rows = data?.data ?? [];
return (
<>
<DataTable
columns={columns}
rows={rows}
getRowId={(j) => j.id}
loading={isLoading}
error={error}
onRetry={() => refetch()}
onRowClick={(j) => setOpenId(j.id)}
errorTitle="Failed to load jobs"
storageKey="admin.warmup-content.jobs"
csvName="warmbly-warmup-content-jobs"
noun="jobs"
emptyTitle="No generation jobs"
emptyHint="Queue a job from the Generate tab to see it here."
pager={{
canPrev: pager.canPrev,
canNext: !!data?.pagination.has_more,
onPrev: pager.prev,
onNext: () => pager.next(data?.pagination.next_cursor),
page: pager.page,
shown: rows.length,
total: data?.pagination.total ?? null,
}}
/>
{openId && (
<JobDetailDialog
id={openId}
open
onOpenChange={(v) => !v && setOpenId(null)}
/>
)}
</>
);
}
function JobDetailField({
label,
value,
mono,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className={`text-[13px] text-foreground ${mono ? "font-mono break-all" : ""}`}>
{value}
</div>
</div>
);
}
function JobDetailDialog({
id,
open,
onOpenChange,
}: {
id: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
// Poll while the job is still running so the drawer mirrors the live table.
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "job", id],
queryFn: () => getWarmupGenerationJob(id),
refetchInterval: (query) => {
const j = query.state.data?.data;
return j && isJobActive(j) ? 10_000 : false;
},
});
const j = data?.data;
const isBatch = j?.mode === "batch";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Generation job
{j ? (
<Badge
variant="outline"
className={`text-[10px] ${jobTone(j.status)}`}
>
{j.status}
</Badge>
) : null}
</DialogTitle>
<DialogDescription>
{j ? (
<span className="font-mono text-[11px]">{j.id}</span>
) : (
"Full job detail."
)}
</DialogDescription>
</DialogHeader>
{error ? (
<ErrorState
error={error}
title="Failed to load job"
onRetry={() => refetch()}
/>
) : isLoading || !j ? (
<div className="space-y-2">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<JobDetailField
label="Mode"
value={
<Badge
variant="outline"
className={`text-[10px] ${
isBatch
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-zinc-300 text-zinc-600"
}`}
>
{j.mode ?? "sync"}
</Badge>
}
/>
<JobDetailField label="Pool" value={<PoolBadge pool={j.pool_type} />} />
<JobDetailField label="Segment" value={j.segment || "—"} />
<JobDetailField label="Trigger" value={j.trigger || "—"} />
<JobDetailField label="Model" value={j.model || "—"} />
<JobDetailField label="Theme" value={j.theme || "—"} />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<JobDetailField
label="Requested"
value={j.requested_count.toLocaleString()}
/>
<JobDetailField
label="Generated"
value={j.generated_count.toLocaleString()}
/>
<JobDetailField
label="Lint rejected"
value={j.lint_rejected_count.toLocaleString()}
/>
<JobDetailField
label="Failed"
value={j.failed_count.toLocaleString()}
/>
</div>
{isBatch && (
<div className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-muted/30 p-3 sm:grid-cols-3">
<JobDetailField
label="Batch status"
value={
j.batch_status ? (
<Badge
variant="outline"
className={`text-[10px] ${batchTone(
j.batch_status,
)}`}
>
{j.batch_status}
</Badge>
) : (
"—"
)
}
/>
<JobDetailField
label="Completion window"
value={j.completion_window || "—"}
/>
<JobDetailField
label="Batch ID"
value={j.batch_id || "—"}
mono
/>
</div>
)}
{j.error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wider text-red-700">
Error
</div>
<div className="mt-1 text-[13px] whitespace-pre-wrap text-red-700">
{j.error}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-[11px] text-muted-foreground sm:grid-cols-3">
<div>Started: {fmtDate(j.started_at)}</div>
<div>Finished: {fmtDate(j.finished_at)}</div>
<div>Created: {fmtDate(j.created_at)}</div>
</div>
</div>
)}
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,455 @@
// /warmup-content/library — filterable, paged table of generated conversation
// threads, with a detail dialog + archive/unarchive/delete actions.
import { useEffect, useMemo, useState } from "react";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DataTable, type Column } from "@/components/data/DataTable";
import { useCursorPager } from "@/lib/useCursorPager";
import {
archiveWarmupConversation,
deleteWarmupConversation,
getWarmupConversation,
listWarmupConversations,
unarchiveWarmupConversation,
type WarmupConversationRow,
} from "@/lib/api/client/admin/warmupContent";
import { CONTENT_STATUS_TONE, fmtDate } from "./shared";
export default function LibraryPage() {
const qc = useQueryClient();
const [source, setSource] = useState("");
const [status, setStatus] = useState("");
const [openId, setOpenId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WarmupConversationRow | null>(
null,
);
const pager = useCursorPager();
const { reset } = pager;
const filterKey = JSON.stringify({ source, status });
useEffect(() => {
reset();
}, [filterKey, reset]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "conversations", filterKey, pager.cursor],
queryFn: () =>
listWarmupConversations({
source: source || undefined,
status: status || undefined,
cursor: pager.cursor,
limit: 50,
}),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const archive = useMutation({
mutationFn: (id: string) => archiveWarmupConversation(id),
onSuccess: () => {
toast.success("Conversation archived");
qc.invalidateQueries({ queryKey: ["admin", "warmup-content"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to archive"),
});
const unarchive = useMutation({
mutationFn: (id: string) => unarchiveWarmupConversation(id),
onSuccess: () => {
toast.success("Conversation restored");
qc.invalidateQueries({ queryKey: ["admin", "warmup-content"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to restore"),
});
const remove = useMutation({
mutationFn: (id: string) => deleteWarmupConversation(id),
onSuccess: () => {
toast.success("Conversation deleted");
setConfirmDelete(null);
qc.invalidateQueries({ queryKey: ["admin", "warmup-content"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to delete"),
});
const columns: Column<WarmupConversationRow>[] = useMemo(
() => [
{
id: "subject",
header: "Thread",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-medium">
{c.subject || "(no subject)"}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{c.theme || c.description || "—"}
</div>
</div>
),
csv: (c) => c.subject,
},
{
id: "segment",
header: "Segment",
cell: (c) => <span className="text-xs">{c.segment || "—"}</span>,
csv: (c) => c.segment,
},
{
id: "source",
header: "Source",
cell: (c) => (
<span className="text-xs text-muted-foreground">
{c.source || "—"}
</span>
),
csv: (c) => c.source,
},
{
id: "messages",
header: "Msgs",
align: "right",
cell: (c) => <span className="tabular-nums">{c.message_count}</span>,
csv: (c) => c.message_count,
},
{
id: "usage",
header: "Used",
align: "right",
cell: (c) => <span className="tabular-nums">{c.usage_count}</span>,
csv: (c) => c.usage_count,
},
{
id: "lint",
header: "Lint",
cell: (c) =>
c.lint_passed ? (
<Badge
variant="outline"
className="text-[10px] border-emerald-300 bg-emerald-50 text-emerald-700"
>
pass
</Badge>
) : (
<Badge
variant="outline"
className="text-[10px] border-red-300 bg-red-50 text-red-700"
>
fail
</Badge>
),
csv: (c) => (c.lint_passed ? "pass" : "fail"),
},
{
id: "status",
header: "Status",
cell: (c) => (
<Badge
variant="outline"
className={`text-[10px] ${
CONTENT_STATUS_TONE[c.status] ?? "border-zinc-300 text-zinc-600"
}`}
>
{c.status}
</Badge>
),
csv: (c) => c.status,
},
{
id: "created",
header: "Created",
cell: (c) => (
<span className="text-xs text-muted-foreground">
{new Date(c.created_at).toLocaleDateString()}
</span>
),
csv: (c) => c.created_at,
defaultHidden: true,
},
{
id: "actions",
header: "Actions",
align: "right",
cell: (c) => (
<div
className="flex items-center justify-end gap-1.5"
onClick={(e) => e.stopPropagation()}
>
{c.status === "archived" ? (
<Button
size="xs"
variant="outline"
onClick={() => unarchive.mutate(c.id)}
disabled={unarchive.isPending}
>
<ArchiveRestore className="size-3" /> Restore
</Button>
) : (
<Button
size="xs"
variant="outline"
onClick={() => archive.mutate(c.id)}
disabled={archive.isPending}
>
<Archive className="size-3" /> Archive
</Button>
)}
<Button
size="xs"
variant="outline"
className="text-red-700 hover:bg-red-50"
onClick={() => setConfirmDelete(c)}
>
<Trash2 className="size-3" /> Delete
</Button>
</div>
),
},
],
[archive, unarchive],
);
const rows = data?.data ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="w-44">
<Label className="mb-1 text-xs text-muted-foreground">Source</Label>
<Select
value={source || "any"}
onValueChange={(v) => setSource(v === "any" ? "" : v)}
>
<SelectTrigger size="sm">
<SelectValue placeholder="Any source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any source</SelectItem>
<SelectItem value="ai">AI generated</SelectItem>
<SelectItem value="curated">Curated</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-40">
<Label className="mb-1 text-xs text-muted-foreground">Status</Label>
<Select
value={status || "any"}
onValueChange={(v) => setStatus(v === "any" ? "" : v)}
>
<SelectTrigger size="sm">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DataTable
columns={columns}
rows={rows}
getRowId={(c) => c.id}
loading={isLoading}
error={error}
onRetry={() => refetch()}
onRowClick={(c) => setOpenId(c.id)}
errorTitle="Failed to load conversations"
storageKey="admin.warmup-content.library"
csvName="warmbly-warmup-content"
noun="conversations"
emptyTitle="No conversations"
emptyHint="No warmup content matches these filters."
pager={{
canPrev: pager.canPrev,
canNext: !!data?.pagination.has_more,
onPrev: pager.prev,
onNext: () => pager.next(data?.pagination.next_cursor),
page: pager.page,
shown: rows.length,
total: data?.pagination.total ?? null,
}}
/>
{openId && (
<ConversationDialog
id={openId}
open
onOpenChange={(v) => !v && setOpenId(null)}
/>
)}
<Dialog
open={!!confirmDelete}
onOpenChange={(v) => !v && setConfirmDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete conversation</DialogTitle>
<DialogDescription>
This permanently removes the thread{" "}
<span className="font-medium">
{confirmDelete?.subject || "(no subject)"}
</span>{" "}
from the library. This cannot be undone archive instead if you
only want it out of rotation.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={remove.isPending}
onClick={() => confirmDelete && remove.mutate(confirmDelete.id)}
>
{remove.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ConversationDialog({
id,
open,
onOpenChange,
}: {
id: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "conversation", id],
queryFn: () => getWarmupConversation(id),
});
const c = data?.data;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="truncate pr-6">
{c?.subject || (isLoading ? "Loading…" : "Conversation")}
</DialogTitle>
<DialogDescription>
{c?.description || "Full generated warmup thread."}
</DialogDescription>
</DialogHeader>
{error ? (
<ErrorState
error={error}
title="Failed to load conversation"
onRetry={() => refetch()}
/>
) : isLoading || !c ? (
<div className="space-y-2">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs">
{c.segment && (
<Badge variant="outline" className="text-[10px]">
segment: {c.segment}
</Badge>
)}
<Badge variant="outline" className="text-[10px]">
source: {c.source || "—"}
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${
CONTENT_STATUS_TONE[c.status] ??
"border-zinc-300 text-zinc-600"
}`}
>
{c.status}
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${
c.lint_passed
? "border-emerald-300 bg-emerald-50 text-emerald-700"
: "border-red-300 bg-red-50 text-red-700"
}`}
>
lint {c.lint_passed ? "pass" : "fail"}
</Badge>
<span className="text-muted-foreground">
used {c.usage_count}×
</span>
</div>
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded-lg border border-border bg-muted/30 p-3">
{(c.messages ?? []).map((m, i) => (
<div
key={i}
className="rounded-md border border-border bg-card p-2.5 text-[13px] leading-relaxed whitespace-pre-wrap"
>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Message {i + 1}
</div>
{m}
</div>
))}
{(c.messages ?? []).length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
No messages in this thread.
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-[11px] text-muted-foreground">
<div>
Generated by job:{" "}
<span className="font-mono">
{c.generated_by_job_id ?? "—"}
</span>
</div>
<div>Created: {fmtDate(c.created_at)}</div>
<div>Updated: {fmtDate(c.updated_at)}</div>
</div>
</div>
)}
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,250 @@
// /warmup-content/overview — headline counts, AI/schedule status, per-pool
// library breakdown, and content-source vs spam-placement A/B comparison.
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Archive, CalendarClock, Inbox, Play, Sparkles } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import {
getWarmupContentAb,
getWarmupContentOverview,
} from "@/lib/api/client/admin/warmupContent";
import { StatCard } from "./components";
import { fmtDate } from "./shared";
export default function OverviewPage() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "overview"],
queryFn: getWarmupContentOverview,
refetchInterval: 30_000,
});
const ab = useQuery({
queryKey: ["admin", "warmup-content", "ab", 14],
queryFn: () => getWarmupContentAb(14),
staleTime: 60_000,
});
// Content is one shared library now — pools only isolate mailbox
// reputation, not content. Aggregate the per-pool breakdown by
// segment+source so the table reflects the actual library shape rather
// than misleading per-pool rows (e.g. "free has no content").
const bySegmentSource = useMemo(() => {
const acc = new Map<
string,
{ segment: string; source: string; active: number; archived: number }
>();
for (const p of data?.by_pool ?? []) {
const key = `${p.segment}::${p.source}`;
const cur = acc.get(key);
if (cur) {
cur.active += p.active;
cur.archived += p.archived;
} else {
acc.set(key, {
segment: p.segment,
source: p.source,
active: p.active,
archived: p.archived,
});
}
}
return Array.from(acc.values());
}, [data?.by_pool]);
if (isLoading) {
return (
<div className="grid gap-3 md:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
);
}
if (error) {
return (
<ErrorState
error={error}
title="Failed to load overview"
onRetry={() => refetch()}
/>
);
}
if (!data) return null;
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-5">
<StatCard
icon={<Inbox className="size-4" />}
title="Active threads"
value={(data.total_active ?? 0).toLocaleString()}
hint="available to warmup sends"
/>
<StatCard
icon={<Archive className="size-4" />}
title="Archived"
value={(data.total_archived ?? 0).toLocaleString()}
hint="retired from rotation"
/>
<StatCard
icon={<Sparkles className="size-4" />}
title="AI generation"
value={data.ai_enabled ? "Enabled" : "Disabled"}
tone={data.ai_enabled ? "text-emerald-600" : "text-muted-foreground"}
hint="master generation toggle"
/>
<StatCard
icon={<CalendarClock className="size-4" />}
title="Schedule"
value={data.schedule_enabled ? "On" : "Off"}
tone={
data.schedule_enabled ? "text-emerald-600" : "text-muted-foreground"
}
hint="automatic top-up jobs"
/>
<StatCard
icon={<Play className="size-4" />}
title="Last generated"
value={
data.last_generated_at
? new Date(data.last_generated_at).toLocaleDateString()
: "Never"
}
hint={
data.last_generated_at
? fmtDate(data.last_generated_at)
: "no jobs yet"
}
/>
</div>
<section>
<h2 className="mb-2 text-sm font-semibold">Library by segment & source</h2>
<div className="overflow-hidden rounded-lg border border-border bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Segment</th>
<th className="px-3 py-2 text-left font-medium">Source</th>
<th className="px-3 py-2 text-right font-medium">Active</th>
<th className="px-3 py-2 text-right font-medium">Archived</th>
</tr>
</thead>
<tbody>
{bySegmentSource.map((p, i) => (
<tr
key={`${p.segment}-${p.source}-${i}`}
className="border-t border-border"
>
<td className="px-3 py-2 text-xs">{p.segment || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{p.source || "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-600">
{p.active.toLocaleString()}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{p.archived.toLocaleString()}
</td>
</tr>
))}
{bySegmentSource.length === 0 && (
<tr>
<td
colSpan={4}
className="py-6 text-center text-sm text-muted-foreground"
>
No content generated yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-semibold">
Content source vs spam placement
{ab.data ? (
<span className="ml-2 text-xs font-normal text-muted-foreground">
last {ab.data.window_days} days
</span>
) : null}
</h2>
{ab.error ? (
<ErrorState
error={ab.error}
title="Failed to load A/B comparison"
onRetry={() => ab.refetch()}
/>
) : ab.isLoading ? (
<Skeleton className="h-24" />
) : (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Source</th>
<th className="px-3 py-2 text-right font-medium">Sent</th>
<th className="px-3 py-2 text-right font-medium">
Spam placements
</th>
<th className="px-3 py-2 text-right font-medium">
Placement rate
</th>
</tr>
</thead>
<tbody>
{(ab.data?.data ?? []).map((r) => {
// Backend already returns a percent (it
// multiplies by 100), so use it directly.
const pct = r.spam_placement_rate ?? 0;
const tone =
pct >= 20
? "text-red-700"
: pct >= 10
? "text-amber-700"
: "text-emerald-600";
return (
<tr
key={r.content_source}
className="border-t border-border"
>
<td className="px-3 py-2 text-xs">
{r.content_source}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.sent.toLocaleString()}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.spam_placements.toLocaleString()}
</td>
<td
className={`px-3 py-2 text-right tabular-nums ${tone}`}
>
{pct.toFixed(2)}%
</td>
</tr>
);
})}
{(ab.data?.data ?? []).length === 0 && (
<tr>
<td
colSpan={4}
className="py-6 text-center text-sm text-muted-foreground"
>
Not enough delivery data yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,367 @@
// /warmup-content/settings — full editor for WarmupGenerationSettings:
// master toggles, generation cadence/model/caps, per-pool targets, and the
// engagement-simulation knobs.
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { FlaskConical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import {
getWarmupGenerationSettings,
updateWarmupGenerationSettings,
type WarmupGenerationPoolConfig,
type WarmupGenerationSettings,
} from "@/lib/api/client/admin/warmupContent";
function NumberField({
id,
label,
hint,
value,
onChange,
min,
max,
step,
}: {
id: string;
label: string;
hint?: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<div>
<Label htmlFor={id} className="mb-1 text-xs">
{label}
</Label>
<Input
id={id}
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
{hint && <p className="mt-1 text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
function ToggleRow({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-start justify-between gap-3 rounded-md border border-border bg-card px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{hint && (
<div className="mt-0.5 text-[11px] text-muted-foreground">{hint}</div>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["admin", "warmup-content", "settings"],
queryFn: getWarmupGenerationSettings,
});
const [form, setForm] = useState<WarmupGenerationSettings | null>(null);
useEffect(() => {
if (data?.data) setForm(data.data);
}, [data]);
const save = useMutation({
mutationFn: (body: WarmupGenerationSettings) =>
updateWarmupGenerationSettings(body),
onSuccess: () => {
toast.success("Settings saved");
qc.invalidateQueries({ queryKey: ["admin", "warmup-content"] });
},
onError: (err: Error) => toast.error(err.message || "Failed to save settings"),
});
if (isLoading || !form) {
if (error) {
return (
<ErrorState
error={error}
title="Failed to load settings"
onRetry={() => refetch()}
/>
);
}
return (
<div className="space-y-3">
<Skeleton className="h-16" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
);
}
function patch(p: Partial<WarmupGenerationSettings>) {
setForm((f) => (f ? { ...f, ...p } : f));
}
function patchEngagement(p: Partial<WarmupGenerationSettings["engagement"]>) {
setForm((f) => (f ? { ...f, engagement: { ...f.engagement, ...p } } : f));
}
// Warmup content is a single shared library — free/premium pools only
// isolate mailbox reputation, not content. We persist that library as a
// single `pools` entry (pool_type "premium") so the scheduler still tops
// up the shared library. `library` reads that entry (with sane defaults);
// `patchLibrary` writes it back, collapsing any legacy multi-pool array.
const library: WarmupGenerationPoolConfig = form.pools[0] ?? {
pool_type: "premium",
enabled: true,
target_active_threads: 0,
segments: [],
};
function patchLibrary(p: Partial<WarmupGenerationPoolConfig>) {
setForm((f) => {
if (!f) return f;
const next: WarmupGenerationPoolConfig = {
...(f.pools[0] ?? {
pool_type: "premium",
enabled: true,
target_active_threads: 0,
segments: [],
}),
...p,
pool_type: "premium",
};
return { ...f, pools: [next] };
});
}
return (
<div className="space-y-6">
<section className="space-y-2">
<h2 className="text-sm font-semibold">Master controls</h2>
<div className="grid gap-2 sm:grid-cols-2">
<ToggleRow
label="AI generation enabled"
hint="Master switch for the offline generator. When off, no new content is produced."
checked={form.enabled}
onChange={(v) => patch({ enabled: v })}
/>
<ToggleRow
label="Scheduled generation"
hint="Automatically enqueue top-up jobs on a cadence to keep the library stocked."
checked={form.schedule_enabled}
onChange={(v) => patch({ schedule_enabled: v })}
/>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">Generation</h2>
<div className="grid gap-4 rounded-lg border border-border bg-card p-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<NumberField
id="set-cadence"
label="Cadence (hours)"
hint="Interval between scheduled top-up jobs."
value={form.cadence_hours}
min={1}
onChange={(v) => patch({ cadence_hours: v })}
/>
<div>
<Label htmlFor="set-model" className="mb-1 text-xs">
Model
</Label>
<Input
id="set-model"
value={form.model}
onChange={(e) => patch({ model: e.target.value })}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Default model used for generation.
</p>
</div>
<NumberField
id="set-max-msgs"
label="Max messages / thread"
hint="Upper bound on messages in a generated conversation."
value={form.max_messages_per_thread}
min={1}
onChange={(v) => patch({ max_messages_per_thread: v })}
/>
<NumberField
id="set-daily-cap"
label="Daily generation cap"
hint="Max threads generated across all jobs per day."
value={form.daily_generation_cap}
min={0}
onChange={(v) => patch({ daily_generation_cap: v })}
/>
<NumberField
id="set-ai-share"
label="AI selection share (%)"
hint="Share of warmup sends that draw from AI-generated content (0100)."
value={form.ai_selection_share}
min={0}
max={100}
onChange={(v) =>
patch({ ai_selection_share: Math.max(0, Math.min(100, v)) })
}
/>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">Content library</h2>
<p className="text-[11px] text-muted-foreground">
Free and premium pools only isolate mailbox reputation warmup
content is shared across both, so there's one library.
</p>
<div className="space-y-3 rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Shared library</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{library.enabled ? "Enabled" : "Disabled"}</span>
<Switch
checked={library.enabled}
onCheckedChange={(v) => patchLibrary({ enabled: v })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<NumberField
id="library-target"
label="Target active threads"
hint="Library top-up target the scheduler aims to keep stocked."
value={library.target_active_threads}
min={0}
onChange={(v) => patchLibrary({ target_active_threads: v })}
/>
<div>
<Label htmlFor="library-segments" className="mb-1 text-xs">
Segments
</Label>
<Input
id="library-segments"
placeholder="comma,separated,segments"
value={library.segments.join(", ")}
onChange={(e) =>
patchLibrary({
segments: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Comma-separated segments to generate content for.
</p>
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">Engagement simulation</h2>
<p className="text-[12.5px] text-muted-foreground">
How recipient mailboxes behave toward warmup mail rescuing from spam,
marking important/read, and dwell time before actions.
</p>
<div className="grid gap-4 rounded-lg border border-border bg-card p-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<NumberField
id="eng-rescue"
label="Spam rescue rate"
hint="Fraction of spam-foldered warmup mail that gets rescued (01)."
value={form.engagement.spam_rescue_rate}
min={0}
max={1}
step={0.01}
onChange={(v) => patchEngagement({ spam_rescue_rate: v })}
/>
<NumberField
id="eng-important"
label="Mark important rate"
hint="Fraction marked as important (01)."
value={form.engagement.mark_important_rate}
min={0}
max={1}
step={0.01}
onChange={(v) => patchEngagement({ mark_important_rate: v })}
/>
<NumberField
id="eng-read"
label="Mark read rate"
hint="Fraction opened / marked read (01)."
value={form.engagement.mark_read_rate}
min={0}
max={1}
step={0.01}
onChange={(v) => patchEngagement({ mark_read_rate: v })}
/>
<NumberField
id="eng-star"
label="Star rate (%)"
hint="Share of warmup mail starred / flagged (0100)."
value={form.engagement.star_rate}
min={0}
max={100}
onChange={(v) =>
patchEngagement({ star_rate: Math.max(0, Math.min(100, v)) })
}
/>
<NumberField
id="eng-min-dwell"
label="Min dwell (seconds)"
hint="Shortest simulated read time before an action."
value={form.engagement.min_dwell_seconds}
min={0}
onChange={(v) => patchEngagement({ min_dwell_seconds: v })}
/>
<NumberField
id="eng-max-dwell"
label="Max dwell (seconds)"
hint="Longest simulated read time before an action."
value={form.engagement.max_dwell_seconds}
min={0}
onChange={(v) => patchEngagement({ max_dwell_seconds: v })}
/>
</div>
</section>
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-border bg-background/95 py-3 backdrop-blur">
<Button
variant="outline"
onClick={() => data?.data && setForm(data.data)}
disabled={save.isPending}
>
Reset
</Button>
<Button onClick={() => form && save.mutate(form)} disabled={save.isPending}>
<FlaskConical className="size-4" />
{save.isPending ? "Saving…" : "Save settings"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
// Warmup Content section shell. Each tab is now its own route — this layout
// renders the shared page header plus a sub-nav tab bar (NavLink, so the
// active route highlights) and an <Outlet/> for the active tab's full-width
// page body.
//
// /warmup-content/overview — headline counts, AI/schedule status, A/B
// /warmup-content/library — generated-thread library + actions
// /warmup-content/generate — sync + OpenAI Batch generation
// /warmup-content/jobs — generation jobs, polled live
// /warmup-content/settings — generation + engagement settings
import { NavLink, Outlet } from "react-router-dom";
import {
CalendarClock,
Inbox,
Layers,
Play,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { PageHeader } from "@/components/layout/PageHeader";
import { cn } from "@/lib/utils";
interface SubTab {
to: string;
label: string;
icon: LucideIcon;
}
const TABS: SubTab[] = [
{ to: "/warmup-content/overview", label: "Overview", icon: Layers },
{ to: "/warmup-content/library", label: "Library", icon: Inbox },
{ to: "/warmup-content/generate", label: "Generate", icon: Sparkles },
{ to: "/warmup-content/jobs", label: "Jobs", icon: Play },
{ to: "/warmup-content/settings", label: "Settings", icon: CalendarClock },
];
export default function WarmupContentLayout() {
return (
<div className="w-full">
<PageHeader
title="Warmup Content"
description="Control and observe the offline AI warmup-content generator: review the generated thread library, enqueue background generation (sync or OpenAI Batch), watch jobs, and tune generation + engagement settings."
/>
<div className="mb-6 border-b border-border">
<nav className="-mb-px flex flex-wrap items-center gap-1">
{TABS.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"relative inline-flex items-center gap-1.5 rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors",
isActive
? "border-[var(--admin-accent)] text-[var(--admin-accent-strong)]"
: "border-transparent text-foreground/60 hover:text-foreground",
)
}
>
<Icon className="size-4" />
{label}
</NavLink>
))}
</nav>
</div>
<Outlet />
</div>
);
}
@@ -0,0 +1,53 @@
// Small shared presentational components for the warmup-content section:
// the pool badge and the overview stat card. Kept JSX-only (no constant/fn
// exports) so React Fast Refresh stays happy — pure helpers live in
// `shared.ts`.
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
export function PoolBadge({ pool }: { pool: string }) {
return (
<Badge
variant="outline"
className={`text-[10px] ${
pool === "premium"
? "border-purple-300 bg-purple-50 text-purple-700"
: "border-zinc-300 text-zinc-700"
}`}
>
{pool}
</Badge>
);
}
export function StatCard({
icon,
title,
value,
hint,
tone,
}: {
icon: ReactNode;
title: string;
value: string;
hint?: string;
tone?: string;
}) {
return (
<div className="rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{title}</span>
</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${tone ?? ""}`}>
{value}
</div>
{hint && (
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{hint}
</div>
)}
</div>
);
}
@@ -0,0 +1,50 @@
// Shared constants + pure helpers for the warmup-content section. These were
// previously module-private to the single page; now that each tab is its own
// route they live here so every page renders the same tones + date formatting.
//
// Keep this file JSX-free — the badge/stat-card *components* live in
// `components.tsx` so React Fast Refresh stays happy.
const JOB_STATUS_TONE: Record<string, string> = {
pending: "border-amber-300 bg-amber-50 text-amber-700",
queued: "border-amber-300 bg-amber-50 text-amber-700",
running: "border-amber-300 bg-amber-50 text-amber-700",
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
succeeded: "border-emerald-300 bg-emerald-50 text-emerald-700",
failed: "border-red-300 bg-red-50 text-red-700",
error: "border-red-300 bg-red-50 text-red-700",
cancelled: "border-zinc-300 bg-zinc-50 text-zinc-600",
canceled: "border-zinc-300 bg-zinc-50 text-zinc-600",
};
export function jobTone(status: string): string {
return JOB_STATUS_TONE[status] ?? "border-zinc-300 bg-zinc-50 text-zinc-600";
}
// OpenAI Batch lifecycle tones. In-flight states read amber/sky, terminal-good
// reads emerald, terminal-bad reads red, and cancelled reads neutral.
const BATCH_STATUS_TONE: Record<string, string> = {
validating: "border-amber-300 bg-amber-50 text-amber-700",
in_progress: "border-sky-300 bg-sky-50 text-sky-700",
finalizing: "border-sky-300 bg-sky-50 text-sky-700",
cancelling: "border-amber-300 bg-amber-50 text-amber-700",
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
failed: "border-red-300 bg-red-50 text-red-700",
expired: "border-red-300 bg-red-50 text-red-700",
cancelled: "border-zinc-300 bg-zinc-50 text-zinc-600",
};
export function batchTone(status: string): string {
return BATCH_STATUS_TONE[status] ?? "border-zinc-300 bg-zinc-50 text-zinc-600";
}
export const CONTENT_STATUS_TONE: Record<string, string> = {
active: "border-emerald-300 bg-emerald-50 text-emerald-700",
archived: "border-zinc-300 bg-zinc-50 text-zinc-600",
draft: "border-amber-300 bg-amber-50 text-amber-700",
};
export function fmtDate(s: string | null | undefined): string {
if (!s) return "—";
return new Date(s).toLocaleString();
}
+6 -1
View File
@@ -24,6 +24,8 @@ import {
Send,
Server,
ServerCog,
ShieldCheck,
Sparkles,
Ticket,
Users,
} from "lucide-react";
@@ -55,7 +57,10 @@ const GROUPS: NavGroup[] = [
icon: Rocket,
},
{ to: "/mailboxes", label: "Mailboxes", icon: Mailbox },
{ to: "/warmup", label: "Warmup", icon: Flame },
{ to: "/warmup", label: "Warmup", icon: Flame, end: true },
{ to: "/warmup/appeals", label: "Warmup Appeals", icon: ShieldCheck },
{ to: "/warmup-content", label: "Warmup Content", icon: Sparkles },
{ to: "/placement", label: "Inbox Placement", icon: Inbox },
{ to: "/campaigns", label: "Campaigns", icon: Megaphone },
],
},
+162
View File
@@ -0,0 +1,162 @@
// /admin/placement/* — seed inbox-placement testing.
//
// A placement test sends a tokenized copy of a template through a real sender
// to a panel of Warmbly-controlled SEED mailboxes; a backend poller then looks
// the token up in each seed's synced inbox (the unibox) and classifies where it
// landed (Inbox / Spam / Promotions / other), per provider. This module shapes
// the requests and returns the backend's `{ data }` / `{ pagination }`
// envelopes. Types are co-located here since this surface is self-contained.
import { Request } from "@/lib/api/client";
import { buildSearchQuery } from "@/lib/api/client/admin/query";
// --------------------------------------------------------------------
// Types
// --------------------------------------------------------------------
export type PlacementFolder =
| "inbox"
| "promotions"
| "spam"
| "other"
| "pending";
export type PlacementStatus = "pending" | "completed";
export interface PlacementTestRow {
id: string;
organization_id: string | null;
sender_account_id: string;
subject: string;
status: PlacementStatus;
created_at: string;
finished_at: string | null;
}
export interface PlacementResultRow {
seed_account_id: string;
provider: string;
folder: PlacementFolder;
detected_at: string | null;
raw_flags: string;
}
export interface PlacementProviderRollup {
provider: string;
inbox: number;
promotions: number;
spam: number;
other: number;
pending: number;
total: number;
}
export interface PlacementTestDetail {
test: PlacementTestRow;
rollup: PlacementProviderRollup[];
results: PlacementResultRow[];
}
export interface SeedAccount {
id: string;
email: string;
name: string;
provider: string;
status: string;
worker_id: string | null;
is_seed: boolean;
}
export interface Pagination {
total: number;
has_more: boolean;
next_cursor: string | null;
}
export interface PlacementTestsResult {
data: PlacementTestRow[];
pagination: Pagination;
}
export interface CreatePlacementTestRequest {
sender_account_id: string;
subject: string;
body_plain: string;
body_html: string;
}
export interface ListTestsParams {
cursor?: string;
limit?: number;
}
// --------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------
export function listPlacementTests(
params: ListTestsParams = {},
): Promise<PlacementTestsResult> {
return Request({
method: "GET",
url: `/admin/placement/tests${buildSearchQuery(
params as Record<string, unknown>,
)}`,
authorization: true,
});
}
export function getPlacementTest(
id: string,
): Promise<{ data: PlacementTestDetail }> {
return Request({
method: "GET",
url: `/admin/placement/tests/${id}`,
authorization: true,
});
}
export function createPlacementTest(
body: CreatePlacementTestRequest,
): Promise<{ data: PlacementTestRow }> {
return Request({
method: "POST",
url: "/admin/placement/tests",
data: body,
authorization: true,
});
}
// --------------------------------------------------------------------
// Seeds
// --------------------------------------------------------------------
export function listSeedMailboxes(): Promise<{ data: SeedAccount[] }> {
return Request({
method: "GET",
url: "/admin/placement/seeds",
authorization: true,
});
}
export function listSeedCandidates(
search?: string,
): Promise<{ data: SeedAccount[] }> {
return Request({
method: "GET",
url: `/admin/placement/seeds/candidates${buildSearchQuery({ search })}`,
authorization: true,
});
}
export function setSeedMailbox(
id: string,
isSeed: boolean,
): Promise<{ ok: true }> {
return Request({
method: "POST",
url: `/admin/placement/seeds/${id}`,
data: { is_seed: isSeed },
authorization: true,
});
}
+4
View File
@@ -65,9 +65,13 @@ export function unblockWarmupAccount(accountId: string): Promise<void> {
export function listWarmupAppeals(
status: "pending" | "approved" | "rejected" | "all" = "pending",
cursor?: string,
limit = 50,
): Promise<WarmupAppealsResult> {
const usp = new URLSearchParams();
if (status !== "all") usp.set("status", status);
if (cursor) usp.set("cursor", cursor);
usp.set("limit", String(limit));
return Request({
method: "GET",
url: `/admin/warmup/appeals?${usp.toString()}`,
@@ -0,0 +1,417 @@
// /admin/warmup-content/* — admin control + visibility surface for the
// offline AI warmup-content generator.
//
// The generator runs OUT OF BAND: `generate` enqueues a background job and
// returns a job id; the Jobs view polls for progress. This module just shapes
// the requests and returns the backend's `{ data }` / `{ pagination }`
// envelopes. Types are co-located here since this surface is self-contained.
import { Request } from "@/lib/api/client";
import { buildSearchQuery } from "@/lib/api/client/admin/query";
// --------------------------------------------------------------------
// Types
// --------------------------------------------------------------------
export interface WarmupContentPoolBreakdown {
pool_type: string;
segment: string;
source: string;
active: number;
archived: number;
}
export interface WarmupContentOverview {
total_active: number;
total_archived: number;
by_pool: WarmupContentPoolBreakdown[];
last_generated_at: string | null;
ai_enabled: boolean;
schedule_enabled: boolean;
}
export interface WarmupConversationRow {
id: string;
pool_type: string;
segment: string;
source: string;
theme: string;
subject: string;
description: string;
message_count: number;
status: string;
lint_passed: boolean;
usage_count: number;
created_at: string;
}
export interface WarmupConversationDetail {
id: string;
pool_type: string;
segment: string;
source: string;
theme: string;
subject: string;
description: string;
messages: string[];
status: string;
lint_passed: boolean;
usage_count: number;
generated_by_job_id: string | null;
created_at: string;
updated_at: string;
}
/** Whether a job ran inline ("sync") or via the OpenAI Batch API ("batch"). */
export type WarmupGenerationMode = "sync" | "batch";
/**
* OpenAI Batch lifecycle status, surfaced verbatim for batch-mode jobs.
* Terminal states are `completed`, `failed`, `expired`, and `cancelled`.
*/
export type WarmupBatchStatus =
| "validating"
| "in_progress"
| "finalizing"
| "completed"
| "failed"
| "expired"
| "cancelling"
| "cancelled";
const TERMINAL_BATCH_STATUS: ReadonlySet<string> = new Set([
"completed",
"failed",
"expired",
"cancelled",
]);
const TERMINAL_JOB_STATUS: ReadonlySet<string> = new Set([
"completed",
"succeeded",
"failed",
"error",
"cancelled",
"canceled",
]);
export interface WarmupGenerationJob {
id: string;
requested_by: string | null;
trigger: string;
pool_type: string;
segment: string;
theme: string;
model: string;
requested_count: number;
generated_count: number;
lint_rejected_count: number;
failed_count: number;
status: string;
error: string;
started_at: string | null;
finished_at: string | null;
created_at: string;
/** "sync" for inline jobs, "batch" for OpenAI Batch API jobs. */
mode?: WarmupGenerationMode;
/** Batch-only fields — present when `mode === "batch"`. */
batch_id?: string | null;
batch_input_file_id?: string | null;
batch_output_file_id?: string | null;
batch_status?: WarmupBatchStatus | null;
completion_window?: string | null;
}
/**
* True while a job is still doing work and should keep being polled. Covers
* both the inline `status` lifecycle and the OpenAI `batch_status` lifecycle.
*/
export function isJobActive(job: WarmupGenerationJob): boolean {
if (job.mode === "batch") {
const bs = job.batch_status ?? "";
if (bs) return !TERMINAL_BATCH_STATUS.has(bs);
}
return !TERMINAL_JOB_STATUS.has(job.status);
}
/** Only batch jobs that are still in flight can be cancelled. */
export function isJobCancellable(job: WarmupGenerationJob): boolean {
if (job.mode !== "batch") return false;
const bs = job.batch_status ?? "";
if (!bs) return !TERMINAL_JOB_STATUS.has(job.status);
return !TERMINAL_BATCH_STATUS.has(bs);
}
export interface WarmupGenerationPoolConfig {
pool_type: string;
enabled: boolean;
target_active_threads: number;
segments: string[];
}
export interface WarmupGenerationEngagement {
spam_rescue_rate: number;
mark_important_rate: number;
mark_read_rate: number;
star_rate: number;
min_dwell_seconds: number;
max_dwell_seconds: number;
}
export interface WarmupGenerationSettings {
enabled: boolean;
schedule_enabled: boolean;
cadence_hours: number;
model: string;
max_messages_per_thread: number;
daily_generation_cap: number;
/** 0-100 — share of warmup sends that draw from AI-generated content. */
ai_selection_share: number;
pools: WarmupGenerationPoolConfig[];
engagement: WarmupGenerationEngagement;
}
export interface WarmupAbRow {
content_source: string;
sent: number;
spam_placements: number;
spam_placement_rate: number;
}
export interface Pagination {
total: number;
has_more: boolean;
next_cursor: string | null;
}
export interface WarmupConversationsResult {
data: WarmupConversationRow[];
pagination: Pagination;
}
export interface WarmupGenerationJobsResult {
data: WarmupGenerationJob[];
pagination: Pagination;
}
export interface WarmupAbResult {
data: WarmupAbRow[];
window_days: number;
}
export interface ListConversationsParams {
pool?: string;
segment?: string;
source?: string;
status?: string;
cursor?: string;
limit?: number;
}
export interface ListJobsParams {
cursor?: string;
limit?: number;
}
export interface GenerateContentRequest {
count: number;
/**
* Reputation pool the threads nominally belong to. Optional — warmup
* content is a single shared library, so the UI omits this and the
* backend defaults it to "premium".
*/
pool_type?: string;
segment?: string;
theme?: string;
model?: string;
}
/**
* Batch generation request. Every field is optional; the backend applies
* defaults. If `themes` is non-empty the backend fans out one batch job per
* theme, and `count` is interpreted as threads-per-job (default 100, clamped
* to 2000 and the daily cap). `completion_window` defaults to "24h".
*/
export interface GenerateBatchRequest {
pool_type?: string;
segment?: string;
theme?: string;
themes?: string[];
model?: string;
count?: number;
max_messages?: number;
completion_window?: string;
}
export interface GenerateBatchResult {
job_ids: string[];
}
// --------------------------------------------------------------------
// Overview
// --------------------------------------------------------------------
export function getWarmupContentOverview(): Promise<WarmupContentOverview> {
return Request({
method: "GET",
url: "/admin/warmup-content/overview",
authorization: true,
});
}
// --------------------------------------------------------------------
// Library (conversations)
// --------------------------------------------------------------------
export function listWarmupConversations(
params: ListConversationsParams = {},
): Promise<WarmupConversationsResult> {
return Request({
method: "GET",
url: `/admin/warmup-content/conversations${buildSearchQuery(
params as Record<string, unknown>,
)}`,
authorization: true,
});
}
export function getWarmupConversation(
id: string,
): Promise<{ data: WarmupConversationDetail }> {
return Request({
method: "GET",
url: `/admin/warmup-content/conversations/${id}`,
authorization: true,
});
}
export function archiveWarmupConversation(id: string): Promise<{ ok: true }> {
return Request({
method: "POST",
url: `/admin/warmup-content/conversations/${id}/archive`,
authorization: true,
});
}
export function unarchiveWarmupConversation(id: string): Promise<{ ok: true }> {
return Request({
method: "POST",
url: `/admin/warmup-content/conversations/${id}/unarchive`,
authorization: true,
});
}
export function deleteWarmupConversation(id: string): Promise<{ ok: true }> {
return Request({
method: "DELETE",
url: `/admin/warmup-content/conversations/${id}`,
authorization: true,
});
}
// --------------------------------------------------------------------
// Generate (offline job)
// --------------------------------------------------------------------
export function generateWarmupContent(
body: GenerateContentRequest,
): Promise<{ job_id: string }> {
return Request({
method: "POST",
url: "/admin/warmup-content/generate",
data: body,
authorization: true,
});
}
// --------------------------------------------------------------------
// Batch generate (OpenAI Batch API — async, cheaper, large volume)
// --------------------------------------------------------------------
/**
* Enqueue one or more OpenAI Batch jobs. Returns the created job ids; watch
* them on the Jobs view via `listWarmupGenerationJobs`. A 400 with code
* `not_configured` / `daily_cap_reached` means generation can't run yet.
*/
export function submitWarmupBatch(
body: GenerateBatchRequest,
): Promise<GenerateBatchResult> {
return Request({
method: "POST",
url: "/admin/warmup-content/batch",
data: body,
authorization: true,
});
}
/** Cancel an in-flight batch job. 400 if it isn't a cancellable batch job. */
export function cancelWarmupBatch(jobId: string): Promise<{ ok: true }> {
return Request({
method: "POST",
url: `/admin/warmup-content/jobs/${jobId}/cancel`,
authorization: true,
});
}
// --------------------------------------------------------------------
// Jobs
// --------------------------------------------------------------------
export function listWarmupGenerationJobs(
params: ListJobsParams = {},
): Promise<WarmupGenerationJobsResult> {
return Request({
method: "GET",
url: `/admin/warmup-content/jobs${buildSearchQuery(
params as Record<string, unknown>,
)}`,
authorization: true,
});
}
export function getWarmupGenerationJob(
id: string,
): Promise<{ data: WarmupGenerationJob }> {
return Request({
method: "GET",
url: `/admin/warmup-content/jobs/${id}`,
authorization: true,
});
}
// --------------------------------------------------------------------
// Settings
// --------------------------------------------------------------------
export function getWarmupGenerationSettings(): Promise<{
data: WarmupGenerationSettings;
}> {
return Request({
method: "GET",
url: "/admin/warmup-content/settings",
authorization: true,
});
}
export function updateWarmupGenerationSettings(
body: WarmupGenerationSettings,
): Promise<{ ok: true }> {
return Request({
method: "PUT",
url: "/admin/warmup-content/settings",
data: body,
authorization: true,
});
}
// --------------------------------------------------------------------
// A/B (content source vs spam placement)
// --------------------------------------------------------------------
export function getWarmupContentAb(days?: number): Promise<WarmupAbResult> {
return Request({
method: "GET",
url: `/admin/warmup-content/ab${buildSearchQuery({ days })}`,
authorization: true,
});
}
+1
View File
@@ -1022,6 +1022,7 @@ export interface WarmupPoolHealthSummary {
by_state: Record<string, number>;
avg_spam_score: number;
avg_spam_placement_rate: number;
spam_placement_by_provider: Record<string, number>;
blocked_count: number;
at_risk_count: number;
}
+27
View File
@@ -33,6 +33,13 @@ import OrganizationDetailPage from "@/app/dashboard/OrganizationDetailPage";
import UsersPage from "@/app/dashboard/UsersPage";
import UserDetailPage from "@/app/dashboard/UserDetailPage";
import WarmupPage from "@/app/dashboard/WarmupPage";
import WarmupAppealsPage from "@/app/dashboard/WarmupAppealsPage";
import WarmupContentLayout from "@/app/dashboard/warmup-content/WarmupContentLayout";
import WarmupContentOverviewPage from "@/app/dashboard/warmup-content/OverviewPage";
import WarmupContentLibraryPage from "@/app/dashboard/warmup-content/LibraryPage";
import WarmupContentGeneratePage from "@/app/dashboard/warmup-content/GeneratePage";
import WarmupContentJobsPage from "@/app/dashboard/warmup-content/JobsPage";
import WarmupContentSettingsPage from "@/app/dashboard/warmup-content/SettingsPage";
import CampaignsPage from "@/app/dashboard/CampaignsPage";
import EnterprisePage from "@/app/dashboard/EnterprisePage";
import PlansPage from "@/app/dashboard/PlansPage";
@@ -41,6 +48,7 @@ import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
import OutreachPage from "@/app/dashboard/OutreachPage";
import AnalyticsPage from "@/app/dashboard/AnalyticsPage";
import MailboxesPage from "@/app/dashboard/MailboxesPage";
import PlacementPage from "@/app/dashboard/PlacementPage";
import { NotFoundPage } from "@/app/dashboard/StubPages";
// Mirror of web/src/main.tsx's tuned defaults. The admin app sees less
@@ -87,6 +95,25 @@ const router = createBrowserRouter([
{ path: "plans", element: <PlansPage /> },
{ path: "discounts", element: <DiscountsPage /> },
{ path: "warmup", element: <WarmupPage /> },
{ path: "warmup/appeals", element: <WarmupAppealsPage /> },
{
path: "warmup-content",
element: <WarmupContentLayout />,
children: [
{
index: true,
element: (
<Navigate to="/warmup-content/overview" replace />
),
},
{ path: "overview", element: <WarmupContentOverviewPage /> },
{ path: "library", element: <WarmupContentLibraryPage /> },
{ path: "generate", element: <WarmupContentGeneratePage /> },
{ path: "jobs", element: <WarmupContentJobsPage /> },
{ path: "settings", element: <WarmupContentSettingsPage /> },
],
},
{ path: "placement", element: <PlacementPage /> },
{ path: "campaigns", element: <CampaignsPage /> },
{ path: "enterprise", element: <EnterprisePage /> },
{ path: "limit-requests", element: <LimitRequestsPage /> },
+69 -1
View File
@@ -34,6 +34,7 @@ import (
"github.com/warmbly/warmbly/internal/app/discount"
"github.com/warmbly/warmbly/internal/app/email"
"github.com/warmbly/warmbly/internal/app/emailsend"
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/fleet"
"github.com/warmbly/warmbly/internal/app/group"
@@ -41,6 +42,7 @@ import (
"github.com/warmbly/warmbly/internal/app/integration"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/passkey"
"github.com/warmbly/warmbly/internal/app/placement"
"github.com/warmbly/warmbly/internal/app/provisioning"
"github.com/warmbly/warmbly/internal/app/ratelimit"
"github.com/warmbly/warmbly/internal/app/releases"
@@ -56,6 +58,7 @@ import (
"github.com/warmbly/warmbly/internal/app/unibox"
"github.com/warmbly/warmbly/internal/app/user"
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
"github.com/warmbly/warmbly/internal/app/webhook"
"github.com/warmbly/warmbly/internal/app/worker"
"github.com/warmbly/warmbly/internal/app/worker_orchestrator"
@@ -78,6 +81,8 @@ import (
"github.com/warmbly/warmbly/internal/notify"
"github.com/warmbly/warmbly/internal/observability"
"github.com/warmbly/warmbly/internal/pkg/captcha"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
"github.com/warmbly/warmbly/internal/pkg/generation"
"github.com/warmbly/warmbly/internal/pkg/geo"
"github.com/warmbly/warmbly/internal/repository"
"github.com/warmbly/warmbly/internal/scheduler"
@@ -116,6 +121,11 @@ func main() {
var provisioningPolicyRepo repository.ProvisioningPolicyRepository
var tasksService tasks.TasksService
var advancedService advanced.Service
var warmupContentRepo repository.WarmupContentRepository
var warmupContentService warmupcontent.Service
var emailVerifyService emailverifyapp.Service
var placementRepository repository.PlacementRepository
var placementService placement.Service
var folderService group.GroupService
var tagService group.GroupService
@@ -475,6 +485,16 @@ func main() {
warmupRepository := repository.NewWarmupRepository(primaryDB.Pool)
warmupRoutingRepository := repository.NewWarmupRoutingRepository(primaryDB.Pool)
warmupRoutingRepoForHandler = warmupRoutingRepository
// Warmup content bank + offline AI generator. The generation client is
// optional: without OPENAI_API_KEY the live send path simply keeps using
// the static library and admin generation returns "not configured".
warmupContentRepo = repository.NewWarmupContentRepository(primaryDB.Pool)
var generationClient *generation.GenerationClient
if openaiKey := cfg.GetSecretOptional(ctx, "OPENAI_API_KEY", "openai_api_key", ""); openaiKey != "" {
generationClient = generation.NewClient(openaiKey)
}
warmupContentService = warmupcontent.NewService(warmupContentRepo, generationClient)
webhookRepository := repository.NewWebhookRepository(primaryDB.Pool)
webhookService := webhook.NewService(webhookRepository)
webhookServiceForHandler = webhookService
@@ -792,7 +812,7 @@ func main() {
tasksService = tasks.NewService(
tasksClient,
kafkaProducer,
nil, // AI generation client is optional for task execution
generationClient,
streamingPublisher,
eventsPublisher,
schedulerService,
@@ -803,6 +823,7 @@ func main() {
taskRepository,
warmupRepository,
warmupRoutingRepository,
warmupContentRepo,
campaignProgressRepository,
emailRepostory,
campaignRepostory,
@@ -856,6 +877,42 @@ func main() {
auditRetentionScheduler := jobs.NewAuditRetentionScheduler(auditRetentionJob, 6*time.Hour)
go auditRetentionScheduler.Start(ctx)
// Warmup content generator: tops the AI thread bank up toward the
// admin-configured per-pool/segment targets. The internal cadence gate
// honours the admin's cadence_hours; it no-ops when generation is
// disabled or unconfigured.
warmupGenerationJob := jobs.NewWarmupGenerationJob(warmupContentService, warmupContentRepo)
warmupGenerationScheduler := jobs.NewWarmupGenerationScheduler(warmupGenerationJob, 30*time.Minute)
go warmupGenerationScheduler.Start(ctx)
// Warmup batch poller: reconciles in-flight OpenAI Batch API generation
// jobs (~50% cheaper, async up to 24h), ingesting completed batches into
// the content bank and marking failed/expired/cancelled ones. No-ops when
// generation is unconfigured or there are no active batch jobs.
warmupBatchPoller := jobs.NewWarmupBatchPoller(warmupContentService, 5*time.Minute)
go warmupBatchPoller.Start(ctx)
// Pre-send email verification: verify a capped batch of not-yet-checked
// contacts each tick so hard-bouncing addresses are dropped before any
// worker sends. CONTROL-PLANE ONLY — the SMTP RCPT probe dials remote MX
// on :25 from this backend host (a non-sending IP), never a worker.
emailVerifier := emailverify.New(emailverify.Config{
HeloHost: os.Getenv("EMAIL_VERIFY_HELO_HOST"), // e.g. verify.warmbly.com
MailFrom: os.Getenv("EMAIL_VERIFY_MAIL_FROM"), // e.g. verify@warmbly.com
})
emailVerifyService = emailverifyapp.NewService(contactRepostory, emailVerifier)
emailVerificationJob := jobs.NewEmailVerificationJob(emailVerifyService, 100)
emailVerificationScheduler := jobs.NewEmailVerificationScheduler(emailVerificationJob, 15*time.Minute)
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)
addr = apiCfg.Hostname
ginMode = apiCfg.GinMode
websocketURI = apiCfg.WebsocketURI
@@ -927,6 +984,17 @@ func main() {
WarmupRoutingRepo: warmupRoutingRepoForHandler,
WebhookService: webhookServiceForHandler,
// Warmup content bank + offline AI generator
WarmupContentRepo: warmupContentRepo,
WarmupContentService: warmupContentService,
// Pre-send email verification
EmailVerifyService: emailVerifyService,
// Seed inbox-placement testing
PlacementRepo: placementRepository,
PlacementService: placementService,
// Third-party integrations
IntegrationService: integrationServiceForHandler,
ContactRepo: contactRepoForHandler,
+14
View File
@@ -198,6 +198,13 @@ func main() {
integrationRepoC := repository.NewIntegrationRepository(primaryDB.Pool)
integrationServiceC := integration.NewService(integrationRepoC, cipherService, integration.NewOAuthManager())
webhookService.WireDispatchSink(integrationServiceC.DispatchAny)
// Warmup health transitions happen in THIS process (the health sweep + all
// event-driven re-evaluations run in the consumer). Without wiring the
// webhook dispatcher here, dispatchHealthEvent saw s.webhooks == nil and
// every warmup.health_changed / quarantined / blocked event silently fired
// no webhook. Dispatch only enqueues delivery rows in Postgres (drained by
// the backend's DeliveryWorker), so no worker/PG boundary is crossed.
warmupService.WireWebhooks(webhookService, emailRepo)
advancedService := advanced.NewService(
advancedRepo,
@@ -232,6 +239,8 @@ func main() {
EmailHistoryIDRepository: emailHistoryIDRepo,
EmailAccountErrorRepository: emailAccountErrorRepo,
WarmupRepo: warmupRepo,
WarmupContentRepo: repository.NewWarmupContentRepository(primaryDB.Pool),
WarmupEngagementRepo: repository.NewWarmupEngagementRepository(primaryDB.Pool),
WarmupService: warmupService,
WorkerRepo: workerRepo,
Publisher: eventsPublisher,
@@ -260,6 +269,11 @@ func main() {
// Start warmup health evaluation sweep (every hour)
go jobsService.StartWarmupHealthSweep(ctx, 1*time.Hour)
// Drains the durable delayed-engagement schedule (read/important/star) so the
// recipient-side dwell survives worker restarts. Short interval keeps the
// effective dwell close to the requested value.
go jobsService.StartWarmupEngagementPoller(ctx, 30*time.Second)
// Start dead worker detection (every 5 minutes)
go jobsService.StartDeadWorkerDetection(ctx, 5*time.Minute)
+125
View File
@@ -0,0 +1,125 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// submitBatchRequest is the admin payload for an async Batch API generation run.
// Admins control every parameter; sensible service-side defaults apply when a
// field is omitted (pool_type=premium, count=100, completion_window=24h, model
// and max_messages from the generation settings).
type submitBatchRequest struct {
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Theme string `json:"theme"`
Themes []string `json:"themes"`
Model string `json:"model"`
Count int `json:"count"`
MaxMessages int `json:"max_messages"`
CompletionWindow string `json:"completion_window"`
}
// AdminSubmitWarmupBatch submits an async OpenAI Batch API generation run.
//
// The Batch API is ~50% cheaper than synchronous generation and processes
// asynchronously (up to the completion window, typically 24h). Results are
// ingested into the content bank by the batch poller once OpenAI finishes.
//
// When a non-empty `themes` array is supplied, one batch job is submitted per
// theme (each gets `count` threads of that theme); otherwise a single batch
// runs with the optional pinned `theme` (empty = rotate the default theme set).
func (h *Handler) AdminSubmitWarmupBatch(c *gin.Context) {
if h.WarmupContentService == nil {
errx.JSON(c, errx.New(errx.BadRequest, "warmup generation is not configured"))
return
}
var req submitBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
adminID := middleware.GetAdminUserID(c)
// Normalise the theme set: explicit `themes` wins; otherwise fall back to the
// single pinned `theme` (which may be empty → service rotates defaults).
themes := make([]string, 0, len(req.Themes))
for _, t := range req.Themes {
if t != "" {
themes = append(themes, t)
}
}
if len(themes) == 0 {
themes = []string{req.Theme}
}
jobIDs := make([]uuid.UUID, 0, len(themes))
for _, theme := range themes {
jobID, err := h.WarmupContentService.GenerateBatch(c.Request.Context(), warmupcontent.GenerateRequest{
RequestedBy: adminID,
Trigger: "manual",
PoolType: req.PoolType,
Segment: req.Segment,
Theme: theme,
Model: req.Model,
Count: req.Count,
MaxMessages: req.MaxMessages,
CompletionWindow: req.CompletionWindow,
})
if err != nil {
if errors.Is(err, warmupcontent.ErrNotConfigured) {
errx.JSON(c, errx.New(errx.BadRequest, "warmup AI generation is not configured (set OPENAI_API_KEY)"))
return
}
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
return
}
jobIDs = append(jobIDs, jobID)
}
for _, id := range jobIDs {
jid := id
h.audit(c, models.AuditActionCreate, warmupContentEntity, &jid, map[string]string{
"mode": "batch",
"pool_type": req.PoolType,
"segment": req.Segment,
"count": strconv.Itoa(req.Count),
})
}
c.JSON(http.StatusOK, gin.H{"job_ids": jobIDs})
}
// AdminCancelWarmupBatch cancels an in-flight batch generation job (OpenAI + the
// local job row).
func (h *Handler) AdminCancelWarmupBatch(c *gin.Context) {
if h.WarmupContentService == nil {
errx.JSON(c, errx.New(errx.BadRequest, "warmup generation is not configured"))
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
if err := h.WarmupContentService.CancelBatch(c.Request.Context(), id); err != nil {
if errors.Is(err, warmupcontent.ErrNotConfigured) {
errx.JSON(c, errx.New(errx.BadRequest, "warmup AI generation is not configured (set OPENAI_API_KEY)"))
return
}
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
return
}
h.audit(c, models.AuditActionUpdate, warmupContentEntity, &id, map[string]string{"action": "cancel_batch"})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -0,0 +1,343 @@
package handler
import (
"encoding/base64"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
const warmupContentEntity models.AuditEntityType = "warmup_content"
// --- cursor helpers (opaque base64 offset) ---
func decodeOffsetCursor(s string) (int, bool) {
if s == "" {
return 0, true
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return 0, false
}
n, err := strconv.Atoi(string(b))
if err != nil || n < 0 {
return 0, false
}
return n, true
}
func encodeOffsetCursor(n int) string {
return base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(n)))
}
type pageMeta struct {
Total int `json:"total"`
HasMore bool `json:"has_more"`
NextCursor *string `json:"next_cursor"`
}
func pageMetaFor(offset, limit, returned, total int) pageMeta {
hasMore := offset+returned < total
var next *string
if hasMore {
c := encodeOffsetCursor(offset + limit)
next = &c
}
return pageMeta{Total: total, HasMore: hasMore, NextCursor: next}
}
// AdminWarmupContentOverview returns content-bank counts + generator status.
func (h *Handler) AdminWarmupContentOverview(c *gin.Context) {
ctx := c.Request.Context()
stats, err := h.WarmupContentRepo.ConversationStats(ctx)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
totalActive, totalArchived := 0, 0
for _, s := range stats {
totalActive += s.Active
totalArchived += s.Archived
}
lastGen, _ := h.WarmupContentRepo.LastGeneratedAt(ctx)
settings, _ := h.WarmupContentRepo.GetGenerationSettings(ctx)
if settings == nil {
def := models.DefaultWarmupGenerationSettings()
settings = &def
}
c.JSON(http.StatusOK, gin.H{
"total_active": totalActive,
"total_archived": totalArchived,
"by_pool": stats,
"last_generated_at": lastGen,
"ai_enabled": settings.Enabled,
"schedule_enabled": settings.ScheduleEnabled,
})
}
type conversationListItem struct {
ID uuid.UUID `json:"id"`
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Source string `json:"source"`
Theme string `json:"theme"`
Subject string `json:"subject"`
Description string `json:"description"`
MessageCount int `json:"message_count"`
Status string `json:"status"`
LintPassed bool `json:"lint_passed"`
UsageCount int64 `json:"usage_count"`
CreatedAt time.Time `json:"created_at"`
}
// AdminListWarmupConversations lists cached conversations with filters.
func (h *Handler) AdminListWarmupConversations(c *gin.Context) {
offset, ok := decodeOffsetCursor(c.Query("cursor"))
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
return
}
limit := parseLimit(c.Query("limit"), 50)
f := repository.ConversationFilter{
PoolType: c.Query("pool"),
Segment: c.Query("segment"),
Source: c.Query("source"),
Status: c.Query("status"),
Limit: limit,
Offset: offset,
}
rows, total, err := h.WarmupContentRepo.ListConversations(c.Request.Context(), f)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
items := make([]conversationListItem, 0, len(rows))
for _, r := range rows {
items = append(items, conversationListItem{
ID: r.ID, PoolType: r.PoolType, Segment: r.Segment, Source: r.Source,
Theme: r.Theme, Subject: r.Subject, Description: r.Description,
MessageCount: len(r.Messages), Status: r.Status, LintPassed: r.LintPassed,
UsageCount: r.UsageCount, CreatedAt: r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"data": items, "pagination": pageMetaFor(offset, limit, len(rows), total)})
}
// AdminGetWarmupConversation returns a single conversation in full.
func (h *Handler) AdminGetWarmupConversation(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
conv, err := h.WarmupContentRepo.GetConversation(c.Request.Context(), id)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
if conv == nil {
errx.JSON(c, errx.New(errx.NotFound, "conversation not found"))
return
}
c.JSON(http.StatusOK, gin.H{"data": conv})
}
func (h *Handler) setConversationStatus(c *gin.Context, status string) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
if err := h.WarmupContentRepo.SetConversationStatus(c.Request.Context(), id, status); err != nil {
errx.JSON(c, errx.InternalError())
return
}
h.audit(c, models.AuditActionUpdate, warmupContentEntity, &id, map[string]string{"status": status})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminArchiveWarmupConversation archives a conversation (excludes it from selection).
func (h *Handler) AdminArchiveWarmupConversation(c *gin.Context) {
h.setConversationStatus(c, "archived")
}
// AdminUnarchiveWarmupConversation re-activates a conversation.
func (h *Handler) AdminUnarchiveWarmupConversation(c *gin.Context) {
h.setConversationStatus(c, "active")
}
// AdminDeleteWarmupConversation permanently removes a conversation.
func (h *Handler) AdminDeleteWarmupConversation(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
if err := h.WarmupContentRepo.DeleteConversation(c.Request.Context(), id); err != nil {
errx.JSON(c, errx.InternalError())
return
}
h.audit(c, models.AuditActionDelete, warmupContentEntity, &id, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type generateWarmupRequest struct {
Count int `json:"count"`
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Theme string `json:"theme"`
Model string `json:"model"`
}
// AdminGenerateWarmupContent kicks off an offline generation run.
func (h *Handler) AdminGenerateWarmupContent(c *gin.Context) {
if h.WarmupContentService == nil {
errx.JSON(c, errx.New(errx.BadRequest, "warmup generation is not configured"))
return
}
var req generateWarmupRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
adminID := middleware.GetAdminUserID(c)
jobID, err := h.WarmupContentService.Generate(c.Request.Context(), warmupcontent.GenerateRequest{
RequestedBy: adminID,
Trigger: "manual",
PoolType: req.PoolType,
Segment: req.Segment,
Theme: req.Theme,
Model: req.Model,
Count: req.Count,
})
if err != nil {
if errors.Is(err, warmupcontent.ErrNotConfigured) {
errx.JSON(c, errx.New(errx.BadRequest, "warmup AI generation is not configured (set OPENAI_API_KEY)"))
return
}
errx.JSON(c, errx.InternalError())
return
}
h.audit(c, models.AuditActionCreate, warmupContentEntity, &jobID, map[string]string{
"pool_type": req.PoolType,
"segment": req.Segment,
"count": strconv.Itoa(req.Count),
})
c.JSON(http.StatusOK, gin.H{"job_id": jobID})
}
// AdminListWarmupGenerationJobs lists generation runs (visibility).
func (h *Handler) AdminListWarmupGenerationJobs(c *gin.Context) {
offset, ok := decodeOffsetCursor(c.Query("cursor"))
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
return
}
limit := parseLimit(c.Query("limit"), 50)
jobs, total, err := h.WarmupContentRepo.ListGenerationJobs(c.Request.Context(), limit, offset)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
c.JSON(http.StatusOK, gin.H{"data": jobs, "pagination": pageMetaFor(offset, limit, len(jobs), total)})
}
// AdminGetWarmupGenerationJob returns one generation run.
func (h *Handler) AdminGetWarmupGenerationJob(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
job, err := h.WarmupContentRepo.GetGenerationJob(c.Request.Context(), id)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
if job == nil {
errx.JSON(c, errx.New(errx.NotFound, "job not found"))
return
}
c.JSON(http.StatusOK, gin.H{"data": job})
}
// AdminGetWarmupGenerationSettings returns the current generation settings.
func (h *Handler) AdminGetWarmupGenerationSettings(c *gin.Context) {
settings, err := h.WarmupContentRepo.GetGenerationSettings(c.Request.Context())
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
c.JSON(http.StatusOK, gin.H{"data": settings})
}
// AdminUpdateWarmupGenerationSettings replaces the generation settings.
func (h *Handler) AdminUpdateWarmupGenerationSettings(c *gin.Context) {
var settings models.WarmupGenerationSettings
if err := c.ShouldBindJSON(&settings); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
adminID := middleware.GetAdminUserID(c)
if err := h.WarmupContentRepo.SetGenerationSettings(c.Request.Context(), &settings, adminID); err != nil {
errx.JSON(c, errx.InternalError())
return
}
h.audit(c, models.AuditActionUpdate, warmupContentEntity, nil, map[string]string{
"enabled": strconv.FormatBool(settings.Enabled),
"schedule_enabled": strconv.FormatBool(settings.ScheduleEnabled),
})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type abRow struct {
ContentSource string `json:"content_source"`
Sent int `json:"sent"`
SpamPlacements int `json:"spam_placements"`
SpamPlacementRate float64 `json:"spam_placement_rate"`
}
// AdminWarmupContentAB returns spam-placement rate by content cohort.
func (h *Handler) AdminWarmupContentAB(c *gin.Context) {
days := 30
if v := c.Query("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 365 {
days = n
}
}
since := time.Now().AddDate(0, 0, -days)
stats, err := h.WarmupContentRepo.SpamPlacementByCohort(c.Request.Context(), since)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
rows := make([]abRow, 0, len(stats))
for _, s := range stats {
rate := 0.0
if s.Sent > 0 {
rate = float64(s.SpamPlacements) / float64(s.Sent) * 100
}
rows = append(rows, abRow{
ContentSource: s.ContentSource, Sent: s.Sent,
SpamPlacements: s.SpamPlacements, SpamPlacementRate: rate,
})
}
c.JSON(http.StatusOK, gin.H{"data": rows, "window_days": days})
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/dnsauth"
)
// GetEmailAuthCheck validates SPF/DKIM/DMARC for a mailbox's sending domain on
// demand. Authentication alignment is a hard bulk-sender requirement and the
// most common silent deliverability failure, so this lets the user confirm
// their domain is set up correctly without leaving the dashboard.
func (h *Handler) GetEmailAuthCheck(c *gin.Context) {
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
account, xerr := h.EmailService.Get(c.Request.Context(), userID.String(), c.Param("id"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
domain := ""
if at := strings.LastIndex(account.Email, "@"); at >= 0 {
domain = account.Email[at+1:]
}
res := dnsauth.Check(c.Request.Context(), domain, nil)
c.JSON(http.StatusOK, res)
}
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
)
// verifyEmailRequest is the optional JSON body for VerifyEmail. The address may
// also be passed as the `email` query param; the body takes precedence.
type verifyEmailRequest struct {
Email string `json:"email"`
}
// VerifyEmail verifies a single email address on demand (syntax -> MX -> SMTP
// RCPT probe -> catch-all detection) and returns the emailverify.Result. This
// is pre-send verification: it lets the user/admin confirm an address is
// deliverable *before* a worker ever sends to it, instead of learning from a
// hard bounce after the fact.
//
// Control-plane only: the SMTP RCPT probe behind this runs from the backend (a
// non-sending IP). Probing must never run from worker (sending) IPs — see
// internal/pkg/emailverify.
//
// Route registration is intentionally NOT done here; the parent workstream
// wires it in internal/api/routes.go behind the appropriate permission gates.
func (h *Handler) VerifyEmail(c *gin.Context) {
if _, err := middleware.GetUserUUID(c); err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
if h.EmailVerifyService == nil {
errx.JSON(c, errx.InternalError())
return
}
var req verifyEmailRequest
// Body is optional; ignore a bind error and fall back to the query param.
_ = c.ShouldBindJSON(&req)
email := strings.TrimSpace(req.Email)
if email == "" {
email = strings.TrimSpace(c.Query("email"))
}
if email == "" {
errx.JSON(c, errx.ErrEmail)
return
}
res := h.EmailVerifyService.VerifyAddress(c.Request.Context(), email)
c.JSON(http.StatusOK, res)
}
+15
View File
@@ -15,11 +15,13 @@ import (
"github.com/warmbly/warmbly/internal/app/discount"
"github.com/warmbly/warmbly/internal/app/email"
"github.com/warmbly/warmbly/internal/app/emailsend"
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/group"
"github.com/warmbly/warmbly/internal/app/integration"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/passkey"
"github.com/warmbly/warmbly/internal/app/placement"
"github.com/warmbly/warmbly/internal/app/ratelimit"
"github.com/warmbly/warmbly/internal/app/releases"
"github.com/warmbly/warmbly/internal/app/sequence"
@@ -33,6 +35,7 @@ import (
"github.com/warmbly/warmbly/internal/app/unibox"
"github.com/warmbly/warmbly/internal/app/user"
"github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
"github.com/warmbly/warmbly/internal/app/webhook"
"github.com/warmbly/warmbly/internal/app/worker"
"github.com/warmbly/warmbly/internal/app/worker_orchestrator"
@@ -104,6 +107,10 @@ type Handler struct {
// Advanced outreach controls
AdvancedService advanced.Service
// Pre-send email verification (control-plane SMTP RCPT probe / pluggable
// paid backend). Drops hard-bouncing addresses before a worker sends.
EmailVerifyService emailverifyapp.Service
// Warmup health
WarmupService warmup.Service
@@ -111,6 +118,14 @@ type Handler struct {
// partner selection (e.g. Gmail recipients from Google Workspace senders).
WarmupRoutingRepo repository.WarmupRoutingRepository
// Warmup content bank + offline AI generator (admin control/visibility).
WarmupContentRepo repository.WarmupContentRepository
WarmupContentService warmupcontent.Service
// Seed inbox-placement testing.
PlacementRepo repository.PlacementRepository
PlacementService placement.Service
// Customer-facing webhooks (subscribe → HMAC-signed delivery).
WebhookService webhook.Service
+302
View File
@@ -0,0 +1,302 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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,
}
}
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,
}
}
// --- Tests -------------------------------------------------------------
type createPlacementTestRequest struct {
SenderAccountID string `json:"sender_account_id"`
Subject string `json:"subject"`
BodyPlain string `json:"body_plain"`
BodyHTML string `json:"body_html"`
}
// 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"))
return
}
var req createPlacementTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
senderID, err := uuid.Parse(req.SenderAccountID)
if err != nil {
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()))
return
}
h.audit(c, models.AuditActionCreate, placementEntity, &test.ID, map[string]string{
"sender_account_id": senderID.String(),
"subject": test.Subject,
})
c.JSON(http.StatusOK, gin.H{"data": toPlacementTestRow(*test)})
}
// AdminListPlacementTests lists placement tests, newest first.
func (h *Handler) AdminListPlacementTests(c *gin.Context) {
if h.PlacementRepo == nil {
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
return
}
offset, ok := decodeOffsetCursor(c.Query("cursor"))
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
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())
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)})
}
// AdminGetPlacementTest returns a test with its per-provider rollup and the
// per-seed detail rows.
func (h *Handler) AdminGetPlacementTest(c *gin.Context) {
if h.PlacementRepo == nil {
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
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())
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,
},
})
}
// --- Seeds -------------------------------------------------------------
// AdminListSeedMailboxes lists the configured seed panel.
func (h *Handler) AdminListSeedMailboxes(c *gin.Context) {
if h.PlacementRepo == nil {
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
return
}
seeds, err := h.PlacementRepo.ListSeedAccounts(c.Request.Context(), false)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
rows := make([]seedAccountRow, 0, len(seeds))
for _, s := range seeds {
rows = append(rows, toSeedRow(s))
}
c.JSON(http.StatusOK, gin.H{"data": rows})
}
// AdminListSeedCandidates lists connected mailboxes an admin can flag as seeds,
// optionally filtered by an email substring.
func (h *Handler) AdminListSeedCandidates(c *gin.Context) {
if h.PlacementRepo == nil {
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
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())
return
}
rows := make([]seedAccountRow, 0, len(candidates))
for _, s := range candidates {
rows = append(rows, toSeedRow(s))
}
c.JSON(http.StatusOK, gin.H{"data": rows})
}
type setSeedRequest struct {
IsSeed bool `json:"is_seed"`
}
// AdminSetSeedMailbox toggles is_seed on a mailbox (register/unregister a seed).
func (h *Handler) AdminSetSeedMailbox(c *gin.Context) {
if h.PlacementRepo == nil {
errx.JSON(c, errx.New(errx.BadRequest, "placement testing is not configured"))
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
var req setSeedRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
seed, err := h.PlacementRepo.GetSeedAccount(c.Request.Context(), id)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
if seed == nil {
errx.JSON(c, errx.New(errx.NotFound, "mailbox not found"))
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,
})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+31
View File
@@ -0,0 +1,31 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/warmlint"
)
type scoreTemplateRequest struct {
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyPlain string `json:"body_plain"`
}
// ScoreTemplateContent returns an advisory deliverability content score for a
// campaign template (subject + body) before it is sent. Advisory only — it
// never blocks sending — so the user gets content feedback on the mail that
// actually reaches prospects, which warmup-only linting never covered.
func (h *Handler) ScoreTemplateContent(c *gin.Context) {
var req scoreTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
res := warmlint.Score(req.Subject, req.BodyHTML, req.BodyPlain)
c.JSON(http.StatusOK, res)
}
+58
View File
@@ -0,0 +1,58 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
)
// Unsubscribe handles the List-Unsubscribe link and the RFC 8058 one-click POST.
// It is PUBLIC + unauthenticated — mailbox providers and recipients hit it
// directly. Both GET (a recipient clicking the link) and POST (the provider's
// one-click, body "List-Unsubscribe=One-Click") suppress the recipient org-wide.
// The link shape is /unsubscribe?cid=<campaign>&rid=<contact>.
func (h *Handler) Unsubscribe(c *gin.Context) {
isPost := c.Request.Method == http.MethodPost
cid, err1 := uuid.Parse(c.Query("cid"))
rid, err2 := uuid.Parse(c.Query("rid"))
if err1 != nil || err2 != nil {
if isPost {
c.Status(http.StatusBadRequest)
return
}
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", unsubPage("This unsubscribe link is invalid."))
return
}
xerr := h.AdvancedService.Unsubscribe(c.Request.Context(), cid, rid)
if isPost {
// RFC 8058: acknowledge one-click. Return 5xx only on a genuine
// server-side failure so the provider can retry; a bad/expired link
// (BadRequest) is terminal, so 200 to stop pointless retries.
if xerr != nil && xerr.Code != errx.BadRequest {
c.Status(http.StatusBadGateway)
return
}
c.Status(http.StatusOK)
return
}
msg := "You've been unsubscribed."
if xerr != nil {
msg = "We couldn't process that unsubscribe link."
}
c.Data(http.StatusOK, "text/html; charset=utf-8", unsubPage(msg))
}
func unsubPage(msg string) []byte {
return []byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
`<meta name="viewport" content="width=device-width,initial-scale=1"><title>Unsubscribe</title></head>` +
`<body style="font-family:system-ui,-apple-system,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#0f172a">` +
`<h1 style="font-size:1.25rem;margin:0 0 .5rem">` + msg + `</h1>` +
`<p style="color:#64748b;margin:0">You will no longer receive emails from this sender.</p></body></html>`)
}
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
type submitWarmupAppealRequest struct {
Reason string `json:"reason"`
}
// GetWarmupBanStatus returns whether a mailbox is blocked from warmup, why, and
// whether the owner can appeal. Powers the dashboard ban banner.
func (h *Handler) GetWarmupBanStatus(c *gin.Context) {
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
accountID, perr := uuid.Parse(c.Param("id"))
if perr != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid email account id"))
return
}
status, xerr := h.WarmupService.GetBanStatus(c.Request.Context(), userID, accountID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, status)
}
// SubmitWarmupAppeal lets the mailbox owner appeal a warmup ban with a reason.
func (h *Handler) SubmitWarmupAppeal(c *gin.Context) {
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
accountID, perr := uuid.Parse(c.Param("id"))
if perr != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid email account id"))
return
}
var req submitWarmupAppealRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
appealID, xerr := h.WarmupService.SubmitAppeal(c.Request.Context(), userID, accountID, req.Reason)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityEmailAccount, &accountID, map[string]string{"warmup_appeal": "submitted"}, nil)
c.JSON(http.StatusOK, gin.H{"appeal_id": appealID})
}
+37
View File
@@ -55,6 +55,12 @@ func Run(
// postMessages code+state to the SPA opener, which calls oauth/finish.
r.GET("/integrations/oauth/callback", h.IntegrationOAuthCallback)
// Public List-Unsubscribe endpoint (RFC 8058). GET = recipient clicks the
// link; POST = mailbox provider's one-click (body List-Unsubscribe=One-Click).
// Both suppress the recipient org-wide. Unauthenticated by design.
r.GET("/unsubscribe", h.Unsubscribe)
r.POST("/unsubscribe", h.Unsubscribe)
// Internal backend-to-backend endpoints. Workers call these instead of
// touching Postgres directly, per the no-direct-data-services rule in
// CLAUDE.md. Auth: shared bearer token (INTERNAL_API_TOKEN).
@@ -205,6 +211,10 @@ func Run(
emails.POST("/:id/warmup/pause", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.PauseWarmup)
emails.POST("/:id/warmup/resume", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.ResumeWarmup)
emails.POST("/:id/warmup/stop", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.StopWarmup)
emails.GET("/:id/auth-check", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmailAuthCheck)
emails.POST("/verify", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.VerifyEmail)
emails.GET("/:id/warmup/ban-status", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetWarmupBanStatus)
emails.POST("/:id/warmup/appeal", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SubmitWarmupAppeal)
emails.DELETE("/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.DeleteEmail)
emails.POST("/:id/send", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), middleware.RequireAPIKeyEmailAccountParam("id"), h.SendEmailFromAccount)
}
@@ -457,6 +467,7 @@ func Run(
templates.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DeleteTemplate)
templates.POST("/:id/duplicate", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DuplicateTemplate)
templates.POST("/:id/render", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.RenderTemplate)
templates.POST("/score", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ScoreTemplateContent)
}
// CRM routes (require org)
@@ -715,6 +726,32 @@ func Run(
adminRoutes.POST("/warmup/appeals/:id/approve", middleware.RequireAdminPermission(models.AdminPermReviewAppeals), h.AdminApproveAppeal)
adminRoutes.POST("/warmup/appeals/:id/reject", middleware.RequireAdminPermission(models.AdminPermReviewAppeals), h.AdminRejectAppeal)
// Warmup content bank + offline AI generator. Reads use the warmup
// view permission; the A/B analytics uses the analytics permission;
// mutations (generate, archive/delete, settings) use ManageSettings.
adminRoutes.GET("/warmup-content/overview", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminWarmupContentOverview)
adminRoutes.GET("/warmup-content/conversations", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminListWarmupConversations)
adminRoutes.GET("/warmup-content/conversations/:id", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminGetWarmupConversation)
adminRoutes.POST("/warmup-content/conversations/:id/archive", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminArchiveWarmupConversation)
adminRoutes.POST("/warmup-content/conversations/:id/unarchive", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminUnarchiveWarmupConversation)
adminRoutes.DELETE("/warmup-content/conversations/:id", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminDeleteWarmupConversation)
adminRoutes.POST("/warmup-content/generate", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminGenerateWarmupContent)
adminRoutes.POST("/warmup-content/batch", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminSubmitWarmupBatch)
adminRoutes.POST("/warmup-content/jobs/:id/cancel", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminCancelWarmupBatch)
// Seed inbox-placement testing.
adminRoutes.GET("/placement/tests", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminListPlacementTests)
adminRoutes.GET("/placement/tests/:id", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminGetPlacementTest)
adminRoutes.POST("/placement/tests", middleware.RequireAdminPermission(models.AdminPermManageWarmupBans), h.AdminCreatePlacementTest)
adminRoutes.GET("/placement/seeds", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminListSeedMailboxes)
adminRoutes.GET("/placement/seeds/candidates", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminListSeedCandidates)
adminRoutes.POST("/placement/seeds/:id", middleware.RequireAdminPermission(models.AdminPermManageWarmupBans), h.AdminSetSeedMailbox)
adminRoutes.GET("/warmup-content/jobs", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminListWarmupGenerationJobs)
adminRoutes.GET("/warmup-content/jobs/:id", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminGetWarmupGenerationJob)
adminRoutes.GET("/warmup-content/settings", middleware.RequireAdminPermission(models.AdminPermViewWarmupPool), h.AdminGetWarmupGenerationSettings)
adminRoutes.PUT("/warmup-content/settings", middleware.RequireAdminPermission(models.AdminPermManageSettings), h.AdminUpdateWarmupGenerationSettings)
adminRoutes.GET("/warmup-content/ab", middleware.RequireAdminPermission(models.AdminPermViewAnalytics), h.AdminWarmupContentAB)
// Mailbox admin (cross-org). Reuses ViewUsers since mailboxes
// are tightly coupled to user/org context; a dedicated bit
// can be carved later if mailbox-specific actions land.
+127 -11
View File
@@ -36,6 +36,10 @@ type Service interface {
IngestDeliverabilityEvent(ctx context.Context, organizationID uuid.UUID, req *models.IngestDeliverabilityEventRequest) *errx.Error
ShouldSuppressRecipient(ctx context.Context, organizationID uuid.UUID, recipient string) (bool, string, *errx.Error)
// Unsubscribe suppresses a contact in response to a List-Unsubscribe action
// (one-click POST or the manual link). Always suppresses — it's an explicit
// recipient request, independent of the auto-suppress settings.
Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) *errx.Error
SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error)
OptimizeSendTime(ctx context.Context, organizationID uuid.UUID, contact *models.Contact, base time.Time) (time.Time, *errx.Error)
@@ -269,6 +273,38 @@ func (s *service) ShouldSuppressRecipient(ctx context.Context, organizationID uu
return true, entry.Reason, nil
}
// Unsubscribe resolves the campaign + contact behind a List-Unsubscribe link and
// suppresses the recipient org-wide. Always suppresses (an explicit recipient
// request), then fans out the campaign.unsubscribed event for Slack/CRM.
func (s *service) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) *errx.Error {
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
if err != nil || campaign == nil || campaign.OrganizationID == nil {
return errx.New(errx.BadRequest, "invalid unsubscribe link")
}
contact, cerr := s.contactRepo.GetByID(ctx, contactID)
if cerr != nil || contact == nil || contact.Email == "" {
return errx.New(errx.BadRequest, "invalid unsubscribe link")
}
if err := s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: *campaign.OrganizationID,
Email: contact.Email,
Reason: "one-click unsubscribe",
Source: models.DeliverabilityEventUnsubscribe,
CampaignID: &campaignID,
}); err != nil {
return toErrx(err)
}
s.emit(ctx, *campaign.OrganizationID, models.WebhookEventCampaignUnsubscribed, map[string]any{
"campaign_id": campaignID.String(),
"contact_id": contactID.String(),
"contact_email": contact.Email,
"source": "one_click",
})
return nil
}
func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) {
settings, xerr := s.effectiveSettings(ctx, organizationID, campaignID)
if xerr != nil {
@@ -668,24 +704,25 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
_ = s.repo.MarkVariantEvent(ctx, *req.CampaignID, *req.ContactID, string(eventType))
}
// Record bounces in campaign progress so analytics and auto-pause work correctly
// Record bounces + complaints in campaign progress so analytics and the
// breaker work correctly. Complaints were previously never recorded, which
// is why complaint-rate auto-pause could never fire.
if req.CampaignID != nil && req.ContactID != nil && req.TaskID != nil &&
eventType == models.DeliverabilityEventBounce {
(eventType == models.DeliverabilityEventBounce || eventType == models.DeliverabilityEventComplaint) {
campaignTask, cErr := s.taskRepo.GetCampaignTask(ctx, *req.TaskID)
if cErr == nil && campaignTask != nil && campaignTask.SequenceID != nil {
_ = s.campaignProgressRepo.RecordEmailBounced(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID)
switch eventType {
case models.DeliverabilityEventBounce:
_ = s.campaignProgressRepo.RecordEmailBounced(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID)
case models.DeliverabilityEventComplaint:
_ = s.campaignProgressRepo.RecordEmailComplained(ctx, *req.CampaignID, *req.ContactID, *campaignTask.SequenceID)
}
}
}
if req.CampaignID != nil && settings.BouncePipeline.AutoPauseCampaignOnSpike &&
if req.CampaignID != nil &&
(eventType == models.DeliverabilityEventBounce || eventType == models.DeliverabilityEventComplaint) {
progress, pErr := s.campaignProgressRepo.GetCampaignProgress(ctx, *req.CampaignID)
if pErr == nil && progress != nil && progress.EmailsSent > 0 {
rate := float64(progress.EmailsBounced) / float64(progress.EmailsSent) * 100
if rate >= settings.BouncePipeline.PauseBounceRateThreshold {
_ = s.campaignRepo.UpdateStatus(ctx, *req.CampaignID, "paused")
}
}
s.evaluateCampaignBreaker(ctx, organizationID, *req.CampaignID, settings)
}
// Trigger warmup health re-evaluation on bounce or complaint events.
@@ -726,6 +763,85 @@ func (s *service) IngestDeliverabilityEvent(ctx context.Context, organizationID
return nil
}
// Campaign deliverability circuit breaker tuning. Mirrors Instantly's safeguards:
// a minimum sample before acting (so a single early bounce can't pause a
// campaign) and a rolling window so the breaker reacts to recent behaviour
// rather than a campaign's lifetime average.
const (
campaignBreakerWindow = 7 * 24 * time.Hour
campaignBreakerMinSample = 50
// Early-warning band: emit a warning webhook at half the pause threshold.
campaignBreakerWarnRatio = 0.5
)
// evaluateCampaignBreaker auto-pauses a campaign when its rolling bounce or
// complaint rate breaches the configured threshold, and emits an early-warning
// webhook in the band below. Rolling-first with a cumulative fallback when the
// recent window is too small a sample.
func (s *service) evaluateCampaignBreaker(ctx context.Context, orgID, campaignID uuid.UUID, settings *models.AdvancedOutreachSettings) {
if settings == nil || !settings.BouncePipeline.AutoPauseCampaignOnSpike {
return
}
bounceThresh := settings.BouncePipeline.PauseBounceRateThreshold
complaintThresh := settings.BouncePipeline.PauseComplaintRateThreshold
if bounceThresh <= 0 && complaintThresh <= 0 {
return
}
sent, bounced, complained := 0, 0, 0
if rolling, err := s.campaignProgressRepo.GetCampaignRollingRates(ctx, campaignID, time.Now().Add(-campaignBreakerWindow)); err == nil && rolling != nil && rolling.Sent >= campaignBreakerMinSample {
sent, bounced, complained = rolling.Sent, rolling.Bounced, rolling.Complained
} else if progress, pErr := s.campaignProgressRepo.GetCampaignProgress(ctx, campaignID); pErr == nil && progress != nil {
sent, bounced, complained = progress.EmailsSent, progress.EmailsBounced, progress.EmailsComplained
}
// Not enough delivered volume yet to judge — never pause on a tiny sample.
if sent < campaignBreakerMinSample {
return
}
bounceRate := float64(bounced) / float64(sent) * 100
complaintRate := float64(complained) / float64(sent) * 100
pauseBounce := bounceThresh > 0 && bounceRate >= bounceThresh
pauseComplaint := complaintThresh > 0 && complaintRate >= complaintThresh
if pauseBounce || pauseComplaint {
if err := s.campaignRepo.UpdateStatus(ctx, campaignID, "paused"); err == nil {
s.emit(ctx, orgID, models.WebhookEventCampaignPaused, map[string]any{
"campaign_id": campaignID.String(),
"reason": "deliverability_auto_pause",
"bounce_rate": bounceRate,
"complaint_rate": complaintRate,
"sample_size": sent,
"breached": breachLabel(pauseBounce, pauseComplaint),
})
}
return
}
warnBounce := bounceThresh > 0 && bounceRate >= bounceThresh*campaignBreakerWarnRatio
warnComplaint := complaintThresh > 0 && complaintRate >= complaintThresh*campaignBreakerWarnRatio
if warnBounce || warnComplaint {
s.emit(ctx, orgID, models.WebhookEventCampaignDeliverabilityWarning, map[string]any{
"campaign_id": campaignID.String(),
"bounce_rate": bounceRate,
"complaint_rate": complaintRate,
"sample_size": sent,
})
}
}
func breachLabel(bounce, complaint bool) string {
switch {
case bounce && complaint:
return "bounce_and_complaint"
case bounce:
return "bounce"
default:
return "complaint"
}
}
func (s *service) OptimizeSendTime(ctx context.Context, organizationID uuid.UUID, contact *models.Contact, base time.Time) (time.Time, *errx.Error) {
settings, err := s.repo.GetOutreachSettings(ctx, organizationID)
if err != nil {
+21 -1
View File
@@ -14,6 +14,23 @@ import (
)
func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlags) error {
// Tampering check first: verified warmup mail is NOT in the unibox, so we
// detect it via the warmup_received record. If the recipient marked a
// warmup email as spam, that harms the pool — penalise the sender for the
// spam signal AND ban the harmer (they can appeal). Warmup mail isn't
// tracked in the unibox, so there's nothing else to do for it.
if s.WarmupRepo != nil {
if rec, _ := s.WarmupRepo.GetWarmupReceived(ctx, e.EmailID, e.ID); rec != nil {
if containsSpamFlag(e.Flags) && s.WarmupService != nil {
hSender, _ := s.WarmupService.ApplySpamReport(ctx, e.EmailID, rec.SenderAccountID, rec.MessageID, "user_complaint")
s.markRiskBandFromWarmupHealth(ctx, rec.SenderAccountID, hSender)
hHarmer, _ := s.WarmupService.RecordTampering(ctx, e.EmailID, rec.MessageID, "spam_flag")
s.markRiskBandFromWarmupHealth(ctx, e.EmailID, hHarmer)
}
return nil
}
}
email, err := s.UniboxRepository.GetByID(ctx, e.UserID, e.ID)
if err != nil {
CaptureError(e.UserID, e.EmailID, fmt.Errorf("Email (%s): %w", e.ID.String(), err))
@@ -31,8 +48,11 @@ func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlag
health, _ := s.WarmupService.ApplySpamReport(ctx, e.EmailID, token.SenderAccountID, email.MessageID, "user_complaint")
s.markRiskBandFromWarmupHealth(ctx, token.SenderAccountID, health)
} else {
// Degraded mode (no warmup service): record the raw signal
// only. Blocking is owned solely by the banded health model
// (evaluateMetrics) so all blocks carry a blocked_until +
// appeal path; the old permanent auto-block diverged from it.
_, _ = s.WarmupRepo.IncrementSpamScore(ctx, token.SenderAccountID, 10)
s.checkAndAutoBlock(ctx, token.SenderAccountID)
s.markRiskBandFromWarmupHealth(ctx, token.SenderAccountID, nil)
}
}
+95 -33
View File
@@ -2,12 +2,14 @@ package jobs
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/models"
@@ -114,13 +116,24 @@ func (s *JobsService) handleWarmupEmail(ctx context.Context, e *models.JobEventN
// Valid! Consume the token
s.WarmupRepo.ConsumeWarmupToken(ctx, tokenUUID)
// Record the receipt so a later deletion or spam-flag of THIS message can be
// attributed back to warmup and to the sender. Verified warmup mail is not
// stored in the unibox, so this is the only record that the message was a
// warmup email.
if e.Message != nil {
_ = s.WarmupRepo.RecordWarmupReceived(ctx, e.Message.EmailID, e.Message.ID, e.Message.MessageID, token.SenderAccountID)
}
// If the warmup mail arrived in a Junk/Spam state, record a
// spam_placement event against the sender. This is distinct from a
// user_complaint (which fires later via HandleFlagsAdd when a recipient
// flags an already-delivered message) because nobody actively rejected
// it — the provider classifier placed it there on arrival.
if containsSpamFlag(e.Message.Flags) && s.WarmupService != nil {
health, _ := s.WarmupService.RecordSpamPlacement(ctx, e.Message.EmailID, token.SenderAccountID, e.Message.MessageID)
// Record which recipient provider/domain filtered it into spam so the
// placement signal can be segmented per provider, not one flat rate.
provider, domain := s.recipientProviderDomain(ctx, e.Message.EmailID)
health, _ := s.WarmupService.RecordSpamPlacement(ctx, e.Message.EmailID, token.SenderAccountID, e.Message.MessageID, token.ContentSource, provider, domain)
s.markRiskBandFromWarmupHealth(ctx, token.SenderAccountID, health)
}
@@ -129,28 +142,96 @@ func (s *JobsService) handleWarmupEmail(ctx context.Context, e *models.JobEventN
return true, nil
}
// performWarmupActions publishes warmup action events to the worker
// performWarmupActions publishes warmup action events to the worker. Action
// selection is probabilistic and per-mailbox (see engagementPlan) so the pool
// doesn't behave in detectable lockstep, with a randomised recipient-side
// dwell before the actions run.
func (s *JobsService) performWarmupActions(ctx context.Context, e *models.JobEventNewEmail) {
if s.Publisher == nil {
return
}
action := &models.WarmupEmailAction{
settings := s.getGenerationSettings(ctx)
actions, delaySeconds := engagementPlan(e.Message.EmailID, settings.Engagement)
immediate, delayed := splitEngagementLegs(actions)
base := models.WarmupEmailAction{
UserID: e.UserID,
EmailID: e.Message.EmailID,
GmailID: e.Message.GmailID,
UID: e.Message.UID,
MailboxUIDValidity: e.Message.Mailbox,
Actions: []string{"move_to_warmbly", "mark_read", "remove_from_spam", "mark_important"},
}
// Look up the worker ID from the email account
// Resolve the receiving mailbox's worker once.
var workerID *uuid.UUID
if s.EmailRepository != nil {
account, xerr := s.EmailRepository.GetByID(ctx, e.Message.EmailID)
if xerr == nil && account != nil && account.WorkerID != nil {
s.Publisher.PublishWarmupAction(ctx, *account.WorkerID, action)
if account, xerr := s.EmailRepository.GetByID(ctx, e.Message.EmailID); xerr == nil && account != nil {
workerID = account.WorkerID
}
}
if workerID == nil {
// No assigned worker (mid-migration / just-unassigned / assignment lag):
// the warmup mail can't be foldered or engaged with. Log instead of
// dropping silently so the gap is observable.
log.Warn().
Str("email_id", e.Message.EmailID.String()).
Msg("Warmup actions skipped: recipient mailbox has no assigned worker")
return
}
// Immediate, durable leg (folder + spam-rescue): publish to the worker now.
if len(immediate) > 0 {
act := base
act.Actions = immediate
s.Publisher.PublishWarmupAction(ctx, *workerID, &act)
}
if len(delayed) == 0 {
return
}
act := base
act.Actions = delayed
// Delayed leg (read / important / star): with no dwell (or no durable store
// available) publish immediately; otherwise persist it to the durable
// schedule so a worker restart mid-dwell can't drop it. The poller publishes
// it when fire_at passes.
if delaySeconds <= 0 || s.WarmupEngagementRepo == nil {
s.Publisher.PublishWarmupAction(ctx, *workerID, &act)
return
}
payload, err := json.Marshal(act)
if err != nil {
log.Warn().Err(err).Str("email_id", e.Message.EmailID.String()).Msg("Failed to marshal delayed warmup engagement; publishing immediately")
s.Publisher.PublishWarmupAction(ctx, *workerID, &act)
return
}
fireAt := time.Now().Add(time.Duration(delaySeconds) * time.Second)
if err := s.WarmupEngagementRepo.EnqueuePendingEngagement(ctx, e.Message.EmailID, payload, fireAt); err != nil {
log.Warn().Err(err).Str("email_id", e.Message.EmailID.String()).Msg("Failed to enqueue delayed warmup engagement; publishing immediately")
s.Publisher.PublishWarmupAction(ctx, *workerID, &act)
}
}
// recipientProviderDomain best-effort resolves a recipient mailbox's provider
// ("google"/"smtp_imap") and email domain for the per-provider placement
// dimension. Returns empty strings when the account can't be loaded.
func (s *JobsService) recipientProviderDomain(ctx context.Context, accountID uuid.UUID) (string, string) {
if s.EmailRepository == nil {
return "", ""
}
acc, err := s.EmailRepository.GetByID(ctx, accountID)
if err != nil || acc == nil {
return "", ""
}
domain := ""
if at := strings.LastIndex(acc.Email, "@"); at >= 0 {
domain = strings.ToLower(acc.Email[at+1:])
}
return acc.Provider, domain
}
func (s *JobsService) applyInvalidWarmupAttempt(ctx context.Context, accountID uuid.UUID, attemptedToken string, scoreDelta int) {
@@ -165,38 +246,19 @@ func (s *JobsService) applyInvalidWarmupAttempt(ctx context.Context, accountID u
return
}
// Degraded mode (no warmup service): record the raw signal only. All
// blocking is owned by the banded health model (evaluateMetrics), which
// already enforces the invalid-token threshold with a blocked_until and an
// appeal path. The old checkAndAutoBlock issued permanent blocks
// (blocked_until = NULL) that UpdateParticipantHealth then refused to ever
// re-evaluate — a divergent dead-end that is now removed.
_ = s.WarmupRepo.RecordInvalidTokenAttempt(ctx, accountID, attemptedToken)
if scoreDelta > 0 {
_, _ = s.WarmupRepo.IncrementSpamScore(ctx, accountID, scoreDelta)
}
s.checkAndAutoBlock(ctx, accountID)
s.markRiskBandFromWarmupHealth(ctx, accountID, nil)
}
// checkAndAutoBlock checks if an account should be auto-blocked based on invalid token attempts or spam score
func (s *JobsService) checkAndAutoBlock(ctx context.Context, accountID uuid.UUID) {
if s.WarmupRepo == nil {
return
}
since := time.Now().Add(-24 * time.Hour)
attempts, _ := s.WarmupRepo.CountRecentInvalidAttempts(ctx, accountID, since)
if attempts >= 3 {
_ = s.WarmupRepo.BlockFromPool(ctx, accountID,
fmt.Sprintf("Auto-blocked: %d invalid warmup token attempts in 24h", attempts))
s.markRiskBandFromWarmupHealth(ctx, accountID, nil)
return
}
score, _ := s.WarmupRepo.GetSpamScore(ctx, accountID)
if score > 50 {
_ = s.WarmupRepo.BlockFromPool(ctx, accountID,
fmt.Sprintf("Auto-blocked: spam score %d exceeds threshold", score))
s.markRiskBandFromWarmupHealth(ctx, accountID, nil)
}
}
// containsSpamFlag checks if any flag is a spam flag
func containsSpamFlag(flags []string) bool {
spamFlags := []string{"\\Junk", "\\Spam", "SPAM", "Junk"}
@@ -0,0 +1,31 @@
package jobs
import (
"context"
"github.com/warmbly/warmbly/internal/models"
)
// HandleRemoveEmail processes a message removal observed during mailbox sync.
//
// Tampering protection: if the removed message was a warmup email (tracked in
// warmup_received), the recipient deleted pool warmup mail — that harms the
// pool, so we record a tampering strike against the mailbox and ban it from
// warmup once the threshold is crossed. The owner can appeal.
//
// It also drops the local unibox entry for the removed message (best-effort).
func (s *JobsService) HandleRemoveEmail(ctx context.Context, e *models.JobEventRemoveEmail) error {
if s.WarmupRepo != nil {
if rec, _ := s.WarmupRepo.GetWarmupReceived(ctx, e.EmailID, e.ID); rec != nil {
if s.WarmupService != nil {
health, _ := s.WarmupService.RecordTampering(ctx, e.EmailID, rec.MessageID, "deletion")
s.markRiskBandFromWarmupHealth(ctx, e.EmailID, health)
}
}
}
if s.UniboxRepository != nil {
_ = s.UniboxRepository.Delete(ctx, e.UserID, e.ID)
}
return nil
}
+1
View File
@@ -22,6 +22,7 @@ func (w *JobsService) InitEvents() {
w.eventHandlers = make(map[models.JobEventType]func(ctx context.Context, body any) error)
Register(w, models.JobEventTypeNewEmail, w.HandleNewEmail)
Register(w, models.JobEventTypeEmailUpdate, w.HandleUpdateEmail)
Register(w, models.JobEventTypeRemoveEmail, w.HandleRemoveEmail)
Register(w, models.JobEventTypeFlagsAdd, w.HandleFlagsAdd)
Register(w, models.JobEventTypeFlagsRemove, w.HandleFlagsRemove)
Register(w, models.JobEventTypeMailboxUpdate, w.HandleMailboxUpdate)
+2
View File
@@ -22,6 +22,8 @@ type JobsService struct {
EmailHistoryIDRepository repository.EmailHistoryIDRepository
EmailAccountErrorRepository repository.EmailAccountErrorRepository
WarmupRepo repository.WarmupRepository
WarmupContentRepo repository.WarmupContentRepository
WarmupEngagementRepo repository.WarmupEngagementRepository
WarmupService warmupapp.Service
WorkerRepo repository.WorkerRepository
+134
View File
@@ -0,0 +1,134 @@
package jobs
import (
"context"
"math/rand"
"sync"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/warmpersona"
)
// engagementSettingsCache is a process-wide TTL cache over the warmup
// generation settings so performWarmupActions doesn't read Postgres on every
// incoming warmup email.
var (
engSettingsMu sync.RWMutex
engSettingsVal models.WarmupGenerationSettings
engSettingsFetched time.Time
)
// getGenerationSettings returns the warmup generation settings (engagement
// rates live here), cached for 60s. Falls back to defaults when unconfigured.
func (s *JobsService) getGenerationSettings(ctx context.Context) models.WarmupGenerationSettings {
if s.WarmupContentRepo == nil {
return models.DefaultWarmupGenerationSettings()
}
engSettingsMu.RLock()
fresh := !engSettingsFetched.IsZero() && time.Since(engSettingsFetched) < 60*time.Second
val := engSettingsVal
engSettingsMu.RUnlock()
if fresh {
return val
}
set, err := s.WarmupContentRepo.GetGenerationSettings(ctx)
if err != nil || set == nil {
return models.DefaultWarmupGenerationSettings()
}
engSettingsMu.Lock()
engSettingsVal = *set
engSettingsFetched = time.Now()
engSettingsMu.Unlock()
return *set
}
// engagementPlan decides which recipient-side warmup actions to perform and
// how long to wait first ("dwell"). Two goals:
// - break the uniform "every account marks important, instantly" bot
// signature: rates are probabilistic and biased per-mailbox by persona, so
// the pool shows a natural distribution rather than lockstep behaviour;
// - keep the strong positive signals: foldering always happens, reads happen
// most of the time, and spam-rescue (the "not spam" signal warmup exists
// for) fires at the configured rate but only actually executes when the
// message really landed in spam (the worker guards this).
//
// The dwell delay is applied recipient-side by the worker so opens/reads don't
// all happen milliseconds after delivery.
func engagementPlan(accountID uuid.UUID, e models.WarmupEngagementSettings) (actions []string, delaySeconds int) {
p := warmpersona.For(accountID)
// Foldering is organisational, not an engagement fingerprint — always do it.
actions = append(actions, "move_to_warmbly")
if rollPct(e.MarkReadRate, p.Bias("read", 0.85, 1.15)) {
actions = append(actions, "mark_read")
}
// Spam-rescue: the worker only actually moves it if it's in spam.
if rollPct(e.SpamRescueRate, p.Bias("rescue", 0.8, 1.2)) {
actions = append(actions, "remove_from_spam")
}
if rollPct(e.MarkImportantRate, p.Bias("important", 0.7, 1.3)) {
actions = append(actions, "mark_important")
}
// Starring is a separate, lower-rate positive signal (Gmail STARRED). On
// IMAP the worker no-ops it because \Flagged is already covered by
// mark_important — so it never double-flags the same message.
if rollPct(e.StarRate, p.Bias("star", 0.6, 1.4)) {
actions = append(actions, "star")
}
delaySeconds = dwellSeconds(e.MinDwellSeconds, e.MaxDwellSeconds, p.Bias("dwell", 0.7, 1.3))
return actions, delaySeconds
}
// splitEngagementLegs separates the reputation-critical, durable actions
// (foldering + spam-rescue, published immediately) from the low-stakes
// engagement-timing signals (read / important / star) that carry the
// recipient-side dwell. The dwell is now applied durably in the control plane
// (a fire_at row drained by the poller), not by an in-process worker timer, so
// a worker restart can no longer drop the delayed leg.
func splitEngagementLegs(actions []string) (immediate, delayed []string) {
for _, a := range actions {
if a == "move_to_warmbly" || a == "remove_from_spam" {
immediate = append(immediate, a)
} else {
delayed = append(delayed, a)
}
}
return immediate, delayed
}
// rollPct rolls a biased percentage chance. The persona bias nudges a given
// mailbox consistently above/below the configured rate so mailboxes differ.
func rollPct(rate int, bias float64) bool {
if rate <= 0 {
return false
}
if rate >= 100 {
return true
}
effective := float64(rate) * bias
return rand.Float64()*100 < effective
}
// dwellSeconds returns a randomised delay within [min,max], nudged by persona.
func dwellSeconds(minS, maxS int, bias float64) int {
if maxS <= 0 || maxS < minS {
return 0
}
span := maxS - minS
base := minS
if span > 0 {
base += rand.Intn(span + 1)
}
out := int(float64(base) * bias)
if out < minS {
out = minS
}
if out > maxS {
out = maxS
}
return out
}
@@ -0,0 +1,66 @@
package jobs
import (
"context"
"encoding/json"
"time"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
)
// StartWarmupEngagementPoller drains due delayed-engagement rows and publishes
// them to the worker. This is the durable replacement for the worker's old
// in-process dwell timer: because the schedule lives in Postgres, a worker (or
// consumer) restart can no longer drop the delayed read/important/star signals.
func (s *JobsService) StartWarmupEngagementPoller(ctx context.Context, interval time.Duration) {
if s.WarmupEngagementRepo == nil || s.Publisher == nil {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drainDueEngagements(ctx)
}
}
}
func (s *JobsService) drainDueEngagements(ctx context.Context) {
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
due, err := s.WarmupEngagementRepo.ClaimDuePendingEngagements(cctx, 200)
if err != nil {
log.Warn().Err(err).Msg("warmup engagement poller: claim failed")
return
}
for _, p := range due {
var action models.WarmupEmailAction
if err := json.Unmarshal(p.Payload, &action); err != nil {
log.Warn().Err(err).Str("id", p.ID.String()).Msg("warmup engagement poller: bad payload, dropping")
continue
}
// Re-resolve the worker at fire time so a mid-dwell reassignment routes
// to the current worker (the payload deliberately doesn't bake one in).
if s.EmailRepository == nil {
continue
}
account, xerr := s.EmailRepository.GetByID(cctx, action.EmailID)
if xerr != nil || account == nil || account.WorkerID == nil {
// Mailbox now unassigned — drop (best-effort low-stakes engagement).
continue
}
action.DelaySeconds = 0 // dwell already elapsed; run immediately
s.Publisher.PublishWarmupAction(cctx, *account.WorkerID, &action)
}
}
+82
View File
@@ -0,0 +1,82 @@
// Package emailverify (app layer) orchestrates pre-send email verification:
// it loads contacts, runs them through a pkg/emailverify.Verifier, and persists
// the result back onto the contact. This is the control-plane home for
// verification — the SMTP RCPT probe inside the Verifier dials remote MX hosts
// on :25 and must never run from a worker (a sending IP). See
// internal/pkg/emailverify for the probing details and the in-repo-vs-paid
// backend split.
package emailverify
import (
"context"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
"github.com/warmbly/warmbly/internal/repository"
)
// Service verifies contact email addresses before they are ever sent to.
type Service interface {
// VerifyContact verifies a single stored contact by id and persists the
// result. Returns the Result so callers (admin/on-demand) can surface it.
VerifyContact(ctx context.Context, contactID uuid.UUID) (emailverify.Result, *errx.Error)
// VerifyAddress verifies an arbitrary address without touching the DB. Used
// by the on-demand handler for addresses that aren't stored contacts yet.
VerifyAddress(ctx context.Context, email string) emailverify.Result
// VerifyPending verifies up to `limit` not-yet-checked contacts, persisting
// each result. Returns the number processed. Driven by the ticker scheduler.
VerifyPending(ctx context.Context, limit int) (int, *errx.Error)
}
type service struct {
repo repository.ContactRepository
verifier emailverify.Verifier
}
// NewService wires the verification service. verifier is the pluggable backend:
// the in-house emailverify.SMTPVerifier in dev/self-host, or a paid provider
// (ZeroBounce/NeverBounce/Bouncer) implementing the same interface in prod.
func NewService(repo repository.ContactRepository, verifier emailverify.Verifier) Service {
return &service{repo: repo, verifier: verifier}
}
func (s *service) VerifyAddress(ctx context.Context, email string) emailverify.Result {
return s.verifier.Verify(ctx, email)
}
func (s *service) VerifyContact(ctx context.Context, contactID uuid.UUID) (emailverify.Result, *errx.Error) {
contact, xerr := s.repo.GetByID(ctx, contactID)
if xerr != nil {
return emailverify.Result{}, xerr
}
res := s.verifier.Verify(ctx, contact.Email)
if xerr := s.repo.UpdateContactVerification(ctx, contactID, res); xerr != nil {
return res, xerr
}
return res, nil
}
func (s *service) VerifyPending(ctx context.Context, limit int) (int, *errx.Error) {
contacts, xerr := s.repo.ListUnverifiedContacts(ctx, limit)
if xerr != nil {
return 0, xerr
}
processed := 0
for i := range contacts {
// Honour cancellation between addresses; each probe can take seconds.
if err := ctx.Err(); err != nil {
break
}
res := s.verifier.Verify(ctx, contacts[i].Email)
if xerr := s.repo.UpdateContactVerification(ctx, contacts[i].ID, res); xerr != nil {
// Skip this one; a transient DB error shouldn't abort the whole tick.
continue
}
processed++
}
return processed, nil
}
+277
View File
@@ -0,0 +1,277 @@
// 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.
//
// 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.
package placement
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/repository"
"github.com/warmbly/warmbly/internal/tasks"
)
// 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
// 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:"
// Service is the seed inbox-placement testing service.
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
}
type service struct {
repo repository.PlacementRepository
emailRepo repository.EmailRepository
sender tasks.EmailSender
}
// 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}
}
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")
}
if bodyPlain == "" && bodyHTML == "" {
return nil, errors.New("a plaintext or HTML body is required")
}
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)
}
seeds, err := s.repo.ListSeedAccounts(ctx, true)
if err != nil {
return nil, fmt.Errorf("list seed accounts: %w", err)
}
if len(seeds) == 0 {
return nil, errors.New("no active seed mailboxes are configured")
}
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)
}
// 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
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
}
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)),
UserID: mustUserID(sender.UserID),
// 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")
}
return test, nil
}
func (s *service) ClassifyPending(ctx context.Context) error {
jobs, err := s.repo.ListPendingResults(ctx, 200)
if err != nil {
return fmt.Errorf("list pending results: %w", err)
}
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)
if err != nil {
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: unibox lookup failed")
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")
}
}
}
// 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
}
if pending == 0 {
now := time.Now()
_ = s.repo.SetTestStatus(ctx, testID, repository.PlacementStatusCompleted, &now)
}
}
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
}
if hasPromotionsLabel(flags) {
return repository.PlacementFolderPromotions
}
return repository.PlacementFolderInbox
}
// containsSpamFlag mirrors internal/app/consumer/event_new_email.go's
// containsSpamFlag so placement classification and warmup spam-placement
// detection agree on what "landed in spam" means.
func containsSpamFlag(flags []string) bool {
spamFlags := []string{"\\Junk", "\\Spam", "SPAM", "Junk"}
for _, f := range flags {
if slices.Contains(spamFlags, f) {
return true
}
}
return false
}
// 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
}
}
return false
}
func domainOf(email string) string {
if at := strings.LastIndex(email, "@"); at >= 0 && at < len(email)-1 {
return email[at+1:]
}
return "localhost"
}
// mustUserID parses the email account's string UserID, returning uuid.Nil on a
// malformed value (the send still goes out; the worker keys off the account).
func mustUserID(s string) uuid.UUID {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil
}
return id
}
+47
View File
@@ -0,0 +1,47 @@
package provisioning
import (
"context"
"net"
"strings"
"time"
)
const rdnsLookupTimeout = 5 * time.Second
// VerifyReverseDNS confirms a sending IP has a PTR record AND that the PTR name
// forward-resolves back to the same IP (forward-confirmed reverse DNS / FCrDNS).
// A missing or mismatched PTR is a classic mailbox-provider rejection cause, so
// this complements the set-side SetReverseDNS by verifying the record actually
// took and is self-consistent.
//
// Returns the PTR hostname (if any), whether FCrDNS holds, and any lookup error.
func VerifyReverseDNS(ctx context.Context, ip string) (ptr string, fcrdnsOK bool, err error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return "", false, nil
}
resolver := &net.Resolver{}
c1, cancel1 := context.WithTimeout(ctx, rdnsLookupTimeout)
defer cancel1()
names, err := resolver.LookupAddr(c1, ip)
if err != nil || len(names) == 0 {
return "", false, err
}
ptr = strings.TrimSuffix(names[0], ".")
c2, cancel2 := context.WithTimeout(ctx, rdnsLookupTimeout)
defer cancel2()
addrs, ferr := resolver.LookupHost(c2, ptr)
if ferr != nil {
return ptr, false, ferr
}
for _, a := range addrs {
if a == ip {
return ptr, true, nil
}
}
return ptr, false, nil
}
+18 -1
View File
@@ -23,6 +23,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/infrastructure/cloudprovider"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
@@ -200,7 +201,23 @@ func (s *Service) Run(ctx context.Context, jobID uuid.UUID) error {
hostname := strings.ReplaceAll(cfg.RDNSPattern, "{{ip}}", strings.ReplaceAll(ip, ".", "-"))
if err := provider.SetReverseDNS(ctx, ipID, hostname); err != nil {
// rDNS failure is non-fatal — log and continue.
_ = err
log.Warn().Err(err).Str("ip", ip).Str("hostname", hostname).Msg("provisioning: SetReverseDNS failed")
continue
}
// Verify the PTR actually took and forward-confirms (FCrDNS).
// A missing/mismatched PTR is a classic mailbox-provider
// rejection cause, so surface it; non-fatal (DNS may still be
// propagating). Best-effort — only verify when we know the IP.
if ip != "" {
ptr, ok, vErr := VerifyReverseDNS(ctx, ip)
switch {
case vErr != nil:
log.Warn().Err(vErr).Str("ip", ip).Msg("provisioning: rDNS verification lookup failed")
case !ok:
log.Warn().Str("ip", ip).Str("ptr", ptr).Str("expected", hostname).Msg("provisioning: rDNS not yet forward-confirmed (FCrDNS)")
default:
log.Info().Str("ip", ip).Str("ptr", ptr).Msg("provisioning: rDNS forward-confirmed")
}
}
}
}
+164 -7
View File
@@ -3,6 +3,7 @@ package warmup
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -53,6 +54,13 @@ const (
invalidTokenBlockThreshold = 3
// Tampering: harming pool warmup mail (deleting it or marking it as spam)
// bans the mailbox once this many harm events occur within the window.
// Default 1 = ban on first harm (Instantly-style zero tolerance); the
// owner can appeal. Bump to forgive accidental actions.
warmupTamperingBlockThreshold = 1
warmupTamperingWindow = 7 * 24 * time.Hour
warmupThrottleDuration = 3 * 24 * time.Hour
warmupQuarantineDuration = 7 * 24 * time.Hour
warmupBlockDuration = 30 * 24 * time.Hour
@@ -68,10 +76,20 @@ type Service interface {
// RecordSpamPlacement records that a warmup message landed in the
// recipient's Junk/Spam folder on arrival. Counted separately from
// user complaints so the two signals can drive distinct thresholds.
RecordSpamPlacement(ctx context.Context, reporterAccountID, reportedAccountID uuid.UUID, messageID string) (*models.WarmupParticipantHealth, *errx.Error)
RecordSpamPlacement(ctx context.Context, reporterAccountID, reportedAccountID uuid.UUID, messageID, contentSource, recipientProvider, recipientDomain string) (*models.WarmupParticipantHealth, *errx.Error)
ApplyInvalidTokenAttempt(ctx context.Context, accountID uuid.UUID, attemptedToken string, scoreDelta int) (*models.WarmupParticipantHealth, *errx.Error)
ApplyRateLimitExceeded(ctx context.Context, accountID uuid.UUID, reason string) (*models.WarmupParticipantHealth, *errx.Error)
// RecordTampering records that a participant harmed a warmup email (deleted
// it or marked it as spam) and bans the mailbox from warmup once the harm
// count crosses the threshold. The owner can then appeal.
RecordTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (*models.WarmupParticipantHealth, *errx.Error)
// SubmitAppeal lets the mailbox owner appeal a warmup ban with a reason.
SubmitAppeal(ctx context.Context, userID, accountID uuid.UUID, reason string) (uuid.UUID, *errx.Error)
// GetBanStatus returns the user-facing warmup standing for a mailbox.
GetBanStatus(ctx context.Context, userID, accountID uuid.UUID) (*models.WarmupBanStatus, *errx.Error)
// Scheduled health evaluation
EvaluateAllParticipants(ctx context.Context) (evaluated int, stateChanges int, err *errx.Error)
GetPoolHealthSummary(ctx context.Context) (*models.WarmupPoolHealthSummary, *errx.Error)
@@ -265,13 +283,16 @@ func (s *service) CanParticipate(ctx context.Context, accountID uuid.UUID, poolT
// 'spam_placement' type and a smaller spam-score delta (placement is a
// weaker individual signal than a user complaint — it is more likely to
// reflect content rather than malice).
func (s *service) RecordSpamPlacement(ctx context.Context, reporterAccountID, reportedAccountID uuid.UUID, messageID string) (*models.WarmupParticipantHealth, *errx.Error) {
func (s *service) RecordSpamPlacement(ctx context.Context, reporterAccountID, reportedAccountID uuid.UUID, messageID, contentSource, recipientProvider, recipientDomain string) (*models.WarmupParticipantHealth, *errx.Error) {
inserted, err := s.repo.RecordSpamReport(ctx, &repository.SpamReport{
ID: uuid.New(),
ReporterAccountID: reporterAccountID,
ReportedAccountID: reportedAccountID,
MessageID: messageID,
ReportType: "spam_placement",
ContentSource: contentSource,
RecipientProvider: recipientProvider,
RecipientDomain: recipientDomain,
})
if err != nil {
return nil, errx.InternalError()
@@ -285,6 +306,128 @@ func (s *service) RecordSpamPlacement(ctx context.Context, reporterAccountID, re
return s.evaluateAndPersistAnyPool(ctx, reportedAccountID)
}
// RecordTampering records that a participant harmed a warmup email (deleted it
// or marked it as spam) and bans the mailbox from warmup once the harm count
// crosses the threshold within the window. The block carries a clear,
// user-facing reason and fires the health transition so the dashboard updates.
func (s *service) RecordTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (*models.WarmupParticipantHealth, *errx.Error) {
inserted, err := s.repo.RecordWarmupTampering(ctx, accountID, messageID, kind)
if err != nil {
return nil, errx.InternalError()
}
if !inserted {
// Already counted this exact harm — don't double-penalise.
return s.getParticipantForAnyPool(ctx, accountID)
}
since := s.now().Add(-warmupTamperingWindow)
count, err := s.repo.CountWarmupTamperingSince(ctx, accountID, since)
if err != nil {
return nil, errx.InternalError()
}
if count >= warmupTamperingBlockThreshold {
prev, _ := s.getParticipantForAnyPool(ctx, accountID)
reason := fmt.Sprintf("Auto-blocked from warmup: %s a warmup email. Warmup mailboxes must let warmup mail be delivered and engaged with. You can appeal this from your dashboard.", tamperingVerb(kind))
if count > 1 {
reason = fmt.Sprintf("Auto-blocked from warmup: harmed %d warmup emails (deleted or marked as spam) in the last %d days. You can appeal this from your dashboard.", count, int(warmupTamperingWindow.Hours()/24))
}
if err := s.repo.BlockFromPool(ctx, accountID, reason); err != nil {
return nil, errx.InternalError()
}
if prev != nil && prev.HealthState != models.WarmupHealthBlocked {
s.dispatchHealthEvent(ctx, accountID, prev.HealthState, models.WarmupHealthBlocked, reason)
}
}
return s.getParticipantForAnyPool(ctx, accountID)
}
func tamperingVerb(kind string) string {
switch kind {
case "deletion":
return "deleted"
case "spam_flag":
return "marked as spam"
default:
return "tampered with"
}
}
// SubmitAppeal records a user's appeal against a warmup ban. Verifies the
// mailbox belongs to the user, is actually blocked, and has no open appeal.
func (s *service) SubmitAppeal(ctx context.Context, userID, accountID uuid.UUID, reason string) (uuid.UUID, *errx.Error) {
reason = strings.TrimSpace(reason)
if reason == "" {
return uuid.Nil, errx.New(errx.BadRequest, "an appeal reason is required")
}
if len(reason) > 2000 {
reason = reason[:2000]
}
if s.emailRepo != nil {
acc, _ := s.emailRepo.GetByID(ctx, accountID)
if acc == nil || acc.UserID != userID.String() {
return uuid.Nil, errx.New(errx.Forbidden, "this mailbox does not belong to you")
}
}
health, _ := s.getParticipantForAnyPool(ctx, accountID)
if health == nil || (health.HealthState != models.WarmupHealthBlocked && health.HealthState != models.WarmupHealthQuarantined) {
return uuid.Nil, errx.New(errx.BadRequest, "this mailbox is not blocked from warmup")
}
pending, err := s.repo.HasPendingWarmupAppeal(ctx, accountID)
if err != nil {
return uuid.Nil, errx.InternalError()
}
if pending {
return uuid.Nil, errx.New(errx.BadRequest, "an appeal is already pending for this mailbox")
}
id, err := s.repo.CreateWarmupAppeal(ctx, accountID, userID, reason)
if err != nil {
return uuid.Nil, errx.InternalError()
}
return id, nil
}
// GetBanStatus returns the user-facing warmup standing for a mailbox.
func (s *service) GetBanStatus(ctx context.Context, userID, accountID uuid.UUID) (*models.WarmupBanStatus, *errx.Error) {
if s.emailRepo != nil {
acc, _ := s.emailRepo.GetByID(ctx, accountID)
if acc == nil || acc.UserID != userID.String() {
return nil, errx.New(errx.Forbidden, "this mailbox does not belong to you")
}
}
status := &models.WarmupBanStatus{
EmailAccountID: accountID,
HealthState: string(models.WarmupHealthHealthy),
}
health, _ := s.getParticipantForAnyPool(ctx, accountID)
if health != nil {
status.HealthState = string(health.HealthState)
status.BlockedAt = health.BlockedAt
status.BlockedUntil = health.BlockedUntil
if health.BlockedReason != nil {
status.Reason = *health.BlockedReason
}
if health.HealthState == models.WarmupHealthBlocked || health.HealthState == models.WarmupHealthQuarantined {
status.Blocked = true
}
}
if status.Blocked {
pending, _ := s.repo.HasPendingWarmupAppeal(ctx, accountID)
status.PendingAppeal = pending
status.CanAppeal = !pending
}
return status, nil
}
func (s *service) ApplySpamReport(ctx context.Context, reporterAccountID, reportedAccountID uuid.UUID, messageID, reportType string) (*models.WarmupParticipantHealth, *errx.Error) {
inserted, err := s.repo.RecordSpamReport(ctx, &repository.SpamReport{
ID: uuid.New(),
@@ -679,6 +822,18 @@ func (s *service) GetPoolHealthSummary(ctx context.Context) (*models.WarmupPoolH
return nil, errx.InternalError()
}
// Pool-wide spam-placement rate over the last 7 days. Previously this
// summary field was always serialised as 0 because nothing populated it.
since := s.now().UTC().Add(-7 * 24 * time.Hour)
placementRate, prErr := s.repo.PoolSpamPlacementRate(ctx, since)
if prErr != nil {
return nil, errx.InternalError()
}
byProvider, bpErr := s.repo.PoolSpamPlacementsByProvider(ctx, since)
if bpErr != nil {
return nil, errx.InternalError()
}
total := 0
blockedCount := 0
atRiskCount := 0
@@ -693,10 +848,12 @@ func (s *service) GetPoolHealthSummary(ctx context.Context) (*models.WarmupPoolH
}
return &models.WarmupPoolHealthSummary{
TotalParticipants: total,
ByState: counts,
AvgSpamScore: avgScore,
BlockedCount: blockedCount,
AtRiskCount: atRiskCount,
TotalParticipants: total,
ByState: counts,
AvgSpamScore: avgScore,
AvgSpamPlacement: placementRate,
SpamPlacementByProvider: byProvider,
BlockedCount: blockedCount,
AtRiskCount: atRiskCount,
}, nil
}
+350
View File
@@ -0,0 +1,350 @@
package warmupcontent
import (
"context"
"fmt"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/generation"
"github.com/warmbly/warmbly/internal/pkg/humanlint"
"github.com/warmbly/warmbly/internal/pkg/warmlint"
)
// themeForIndex mirrors runJob's theme selection: a pinned theme wins, otherwise
// rotate through the default theme set so a batch spans varied topics.
func themeForIndex(pinned string, i int) string {
if pinned != "" {
return pinned
}
return defaultThemes[i%len(defaultThemes)]
}
// GenerateBatch submits an async OpenAI Batch API run. It builds N
// chat-completion requests (theme rotation identical to the sync runJob),
// uploads them as a batch, and persists a job row in mode='batch' with the
// OpenAI batch/file identifiers. It returns immediately — the batch is ingested
// later by PollBatches when OpenAI finishes processing (up to the completion
// window, typically 24h).
func (s *service) GenerateBatch(ctx context.Context, req GenerateRequest) (uuid.UUID, error) {
if s.gen == nil {
return uuid.Nil, ErrNotConfigured
}
if req.PoolType == "" {
req.PoolType = "premium"
}
if req.Trigger == "" {
req.Trigger = "manual"
}
if req.Count <= 0 {
req.Count = 100
}
if req.Count > maxPerBatch {
req.Count = maxPerBatch
}
settings, _ := s.repo.GetGenerationSettings(ctx)
if req.Model == "" && settings != nil {
req.Model = settings.Model
}
maxMessages := 4
if settings != nil {
maxMessages = settings.MaxMessagesPerThread
}
if req.MaxMessages > 0 {
maxMessages = req.MaxMessages
}
// Respect the daily generation cap (shared with the sync/scheduled paths) so
// a huge batch can't blow past the admin's budget for the day.
if settings != nil && settings.DailyGenerationCap > 0 {
remaining := dailyRemaining(ctx, s.repo, settings.DailyGenerationCap)
if remaining <= 0 {
return uuid.Nil, fmt.Errorf("daily generation cap reached")
}
if req.Count > remaining {
req.Count = remaining
}
}
window := req.CompletionWindow
if window == "" {
window = "24h"
}
job := &models.WarmupGenerationJob{
ID: uuid.New(),
RequestedBy: req.RequestedBy,
Trigger: req.Trigger,
Mode: models.WarmupGenerationModeBatch,
PoolType: req.PoolType,
Segment: req.Segment,
Theme: req.Theme,
Model: req.Model,
RequestedCount: req.Count,
Status: "pending",
CompletionWindow: window,
}
// custom_id → theme so results map back after the (unordered) batch returns.
requests := make([]generation.BatchRequest, 0, req.Count)
for i := 0; i < req.Count; i++ {
theme := themeForIndex(req.Theme, i)
requests = append(requests, generation.BatchRequest{
CustomID: fmt.Sprintf("%s-%d", job.ID.String(), i),
Theme: theme,
Model: req.Model,
MaxMessages: maxMessages,
})
}
batchID, inputFileID, err := s.gen.SubmitBatch(ctx, requests, window)
if err != nil {
// Persist a failed job row for visibility rather than dropping silently.
now := time.Now()
job.Status = "failed"
job.Error = err.Error()
job.StartedAt = &now
job.FinishedAt = &now
if cerr := s.repo.CreateGenerationJob(ctx, job); cerr != nil {
return uuid.Nil, cerr
}
return uuid.Nil, err
}
now := time.Now()
job.Status = "running"
job.BatchStatus = "submitted"
job.BatchID = batchID
job.BatchInputFileID = inputFileID
job.StartedAt = &now
if err := s.repo.CreateGenerationJob(ctx, job); err != nil {
return uuid.Nil, err
}
log.Info().
Str("job_id", job.ID.String()).
Str("batch_id", batchID).
Int("requested", req.Count).
Str("completion_window", window).
Msg("warmup batch generation submitted")
return job.ID, nil
}
// PollBatches reconciles every in-flight batch job against OpenAI. Completed
// batches are downloaded and ingested (clean + lint + cache, mirroring runJob);
// failed/expired/cancelled batches mark the job failed; otherwise the latest
// batch status is persisted so the admin UI reflects progress.
func (s *service) PollBatches(ctx context.Context) error {
if s.gen == nil {
return nil
}
jobs, err := s.repo.ListActiveBatchJobs(ctx)
if err != nil {
return err
}
for i := range jobs {
job := &jobs[i]
if err := s.pollBatchJob(ctx, job); err != nil {
sentry.CaptureException(err)
log.Warn().Err(err).Str("job_id", job.ID.String()).Str("batch_id", job.BatchID).
Msg("warmup batch generation: poll failed")
}
}
return nil
}
// pollBatchJob reconciles a single batch job.
func (s *service) pollBatchJob(ctx context.Context, job *models.WarmupGenerationJob) error {
status, outputFileID, counts, err := s.gen.GetBatch(ctx, job.BatchID)
if err != nil {
return err
}
job.BatchStatus = status
switch status {
case "completed":
job.BatchOutputFileID = outputFileID
return s.ingestBatch(ctx, job, outputFileID, counts)
case "failed", "expired", "cancelled":
now := time.Now()
job.Status = "failed"
job.FinishedAt = &now
if job.Error == "" {
job.Error = fmt.Sprintf("batch %s", status)
}
return s.repo.UpdateGenerationJob(ctx, job)
default:
// validating | in_progress | finalizing | cancelling | submitted —
// still running; persist the latest status for visibility.
return s.repo.UpdateGenerationJob(ctx, job)
}
}
// ingestBatch downloads a completed batch's output, cleans + lints + caches each
// conversation (mirroring runJob), and finalises the job row.
func (s *service) ingestBatch(ctx context.Context, job *models.WarmupGenerationJob, outputFileID string, counts generation.BatchCounts) error {
results, err := s.gen.FetchBatchResults(ctx, outputFileID)
if err != nil {
now := time.Now()
job.Status = "failed"
job.FinishedAt = &now
job.Error = err.Error()
_ = s.repo.UpdateGenerationJob(ctx, job)
return err
}
// Reset the per-ingest counters; the output file is the source of truth.
job.GeneratedCount = 0
job.LintRejectedCount = 0
job.FailedCount = 0
for i := range results {
r := &results[i]
if r.Err != "" || r.Conversation == nil {
job.FailedCount++
if r.Err != "" {
log.Debug().Str("job_id", job.ID.String()).Str("custom_id", r.CustomID).Str("err", r.Err).
Msg("warmup batch generation: result line failed")
}
continue
}
theme := themeForCustomID(r.CustomID, job.Theme)
subject := strings.TrimSpace(r.Conversation.Subject)
description := strings.TrimSpace(r.Conversation.Description)
messages := cleanMessages(r.Conversation.Messages)
if description == "" || subject == "" {
job.FailedCount++
continue
}
// Humanize before the gates (identical to the sync runJob path).
subject, description, messages = humanizeThread(subject, description, messages)
lintBody := description
if len(messages) > 0 {
lintBody += "\n\n" + strings.Join(messages, "\n")
}
if humanlint.LooksRobotic(lintBody) {
job.LintRejectedCount++
continue
}
if err := warmlint.Check(subject, lintBody, false); err != nil {
job.LintRejectedCount++
continue
}
record := &models.WarmupConversation{
ID: uuid.New(),
PoolType: job.PoolType,
Segment: job.Segment,
Source: models.WarmupContentSourceAI,
Theme: theme,
Subject: subject,
Description: description,
Messages: messages,
Status: "active",
LintPassed: true,
GeneratedByJob: &job.ID,
}
if err := s.repo.InsertConversation(ctx, record); err != nil {
job.FailedCount++
sentry.CaptureException(err)
continue
}
job.GeneratedCount++
}
// The output file already contains one line per request (success or error),
// so iterating results above accounts for every line — including provider
// failures, which surface as error lines. counts.Failed is therefore not
// added on top (that would double-count); it's reconciled only if the output
// reported fewer lines than the batch's total, which shouldn't normally
// happen but guards against a truncated/partial download.
if missing := counts.Total - len(results); missing > 0 {
job.FailedCount += missing
}
now := time.Now()
job.FinishedAt = &now
job.Status = "completed"
if job.GeneratedCount == 0 && (job.FailedCount > 0 || job.Error != "") {
job.Status = "failed"
if job.Error == "" {
job.Error = fmt.Sprintf("all %d batch results failed", job.FailedCount)
}
}
if err := s.repo.UpdateGenerationJob(ctx, job); err != nil {
return err
}
log.Info().
Str("job_id", job.ID.String()).
Str("batch_id", job.BatchID).
Int("generated", job.GeneratedCount).
Int("lint_rejected", job.LintRejectedCount).
Int("failed", job.FailedCount).
Msg("warmup batch generation ingested")
return nil
}
// themeForCustomID recovers the theme for a result. The custom_id is
// "<jobID>-<index>"; the index re-derives the rotated theme so cached rows carry
// the right topic even though the batch output is unordered.
func themeForCustomID(customID, pinnedTheme string) string {
if pinnedTheme != "" {
return pinnedTheme
}
idx := strings.LastIndexByte(customID, '-')
if idx < 0 || idx+1 >= len(customID) {
return defaultThemes[0]
}
n := 0
for _, ch := range customID[idx+1:] {
if ch < '0' || ch > '9' {
return defaultThemes[0]
}
n = n*10 + int(ch-'0')
}
return defaultThemes[n%len(defaultThemes)]
}
// CancelBatch cancels an in-flight batch job both on OpenAI and locally.
func (s *service) CancelBatch(ctx context.Context, jobID uuid.UUID) error {
if s.gen == nil {
return ErrNotConfigured
}
job, err := s.repo.GetGenerationJob(ctx, jobID)
if err != nil {
return err
}
if job == nil {
return fmt.Errorf("generation job not found")
}
if job.Mode != models.WarmupGenerationModeBatch || job.BatchID == "" {
return fmt.Errorf("job is not a batch job")
}
if job.Status == "completed" || job.Status == "failed" {
return fmt.Errorf("job already finished")
}
if err := s.gen.CancelBatch(ctx, job.BatchID); err != nil {
return err
}
now := time.Now()
job.Status = "failed"
job.BatchStatus = "cancelling"
job.FinishedAt = &now
if job.Error == "" {
job.Error = "cancelled by admin"
}
return s.repo.UpdateGenerationJob(ctx, job)
}
+353
View File
@@ -0,0 +1,353 @@
// Package warmupcontent runs the offline warmup-content generator: it calls
// the AI client, lints each result, and caches accepted threads in the
// warmup_conversations bank for the live send path to draw from. Generation is
// always offline (admin-triggered or scheduled) and never on the send hot path.
package warmupcontent
import (
"context"
"errors"
"fmt"
"hash/fnv"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/generation"
"github.com/warmbly/warmbly/internal/pkg/humanlint"
"github.com/warmbly/warmbly/internal/pkg/warmlint"
"github.com/warmbly/warmbly/internal/repository"
)
// ErrNotConfigured is returned when generation is requested but no AI client is
// wired (OPENAI_API_KEY unset).
var ErrNotConfigured = errors.New("warmup AI generation is not configured")
// defaultThemes seed topic variety when a generation run doesn't pin a theme.
var defaultThemes = []string{
"productivity", "learning", "collaboration", "industry trends", "tools",
"networking", "feedback", "planning", "reading", "wellness", "events",
"career growth", "remote work", "meetings", "automation", "team culture",
"customer success", "writing", "travel", "community",
}
// maxPerRun bounds a single sync generation run regardless of request size.
const maxPerRun = 200
// maxPerBatch bounds a single Batch API submission. Batch is async and cheap so
// it tolerates a much larger fan-out than the sync path; OpenAI itself allows up
// to 50,000 requests per batch input file.
const maxPerBatch = 2000
// GenerateRequest describes one generation run.
type GenerateRequest struct {
RequestedBy *uuid.UUID
Trigger string // "manual" | "schedule"
PoolType string
Segment string
Theme string
Model string
Count int
// MaxMessages overrides the per-thread follow-up count for this run; 0 falls
// back to the admin generation settings. Used by the batch path so callers
// have full control over the thread shape.
MaxMessages int
// CompletionWindow is the OpenAI Batch API processing window (batch path
// only); empty defaults to "24h".
CompletionWindow string
}
// Service drives offline warmup content generation.
type Service interface {
// Generate starts an offline generation run in the background and returns
// the job ID immediately so callers can track progress.
Generate(ctx context.Context, req GenerateRequest) (uuid.UUID, error)
// GenerateBatch submits an async OpenAI Batch API run (~50% cheaper) and
// returns the job ID immediately. Results are ingested later by PollBatches.
GenerateBatch(ctx context.Context, req GenerateRequest) (uuid.UUID, error)
// PollBatches reconciles in-flight batch jobs against OpenAI: it ingests
// completed batches and marks failed/expired/cancelled ones.
PollBatches(ctx context.Context) error
// CancelBatch cancels an in-flight batch job (OpenAI + local job row).
CancelBatch(ctx context.Context, jobID uuid.UUID) error
// RunScheduled tops every enabled pool/segment up toward its target.
RunScheduled(ctx context.Context) error
// Enabled reports whether an AI client is configured.
Enabled() bool
}
type service struct {
repo repository.WarmupContentRepository
gen *generation.GenerationClient
}
// NewService creates the generation service. gen may be nil (no OPENAI_API_KEY),
// in which case generation requests return ErrNotConfigured.
func NewService(repo repository.WarmupContentRepository, gen *generation.GenerationClient) Service {
return &service{repo: repo, gen: gen}
}
func (s *service) Enabled() bool { return s.gen != nil }
func (s *service) Generate(ctx context.Context, req GenerateRequest) (uuid.UUID, error) {
if s.gen == nil {
return uuid.Nil, ErrNotConfigured
}
if req.PoolType == "" {
req.PoolType = "premium"
}
if req.Trigger == "" {
req.Trigger = "manual"
}
if req.Count <= 0 {
req.Count = 10
}
if req.Count > maxPerRun {
req.Count = maxPerRun
}
settings, _ := s.repo.GetGenerationSettings(ctx)
if req.Model == "" && settings != nil {
req.Model = settings.Model
}
job := &models.WarmupGenerationJob{
ID: uuid.New(),
RequestedBy: req.RequestedBy,
Trigger: req.Trigger,
PoolType: req.PoolType,
Segment: req.Segment,
Theme: req.Theme,
Model: req.Model,
RequestedCount: req.Count,
Status: "pending",
}
if err := s.repo.CreateGenerationJob(ctx, job); err != nil {
return uuid.Nil, err
}
// Run in the background so the admin request returns immediately; the job
// row is the progress/visibility surface.
go func() {
bg, cancel := context.WithTimeout(context.Background(), time.Duration(req.Count)*45*time.Second+time.Minute)
defer cancel()
s.runJob(bg, job)
}()
return job.ID, nil
}
func (s *service) RunScheduled(ctx context.Context) error {
if s.gen == nil {
return nil
}
settings, err := s.repo.GetGenerationSettings(ctx)
if err != nil || settings == nil {
return err
}
if !settings.ScheduleEnabled {
return nil
}
remaining := dailyRemaining(ctx, s.repo, settings.DailyGenerationCap)
if settings.DailyGenerationCap > 0 && remaining <= 0 {
log.Info().Msg("warmup generation: daily cap reached; scheduled run skipped")
return nil
}
for _, pool := range settings.Pools {
if !pool.Enabled {
continue
}
segments := pool.Segments
if len(segments) == 0 {
segments = []string{""}
}
for _, segment := range segments {
active, _ := s.repo.CountActiveConversations(ctx, pool.PoolType, segment)
deficit := pool.TargetActiveThreads - active
if deficit <= 0 {
continue
}
count := deficit
if count > 25 {
count = 25 // per-segment per-run cap so one run doesn't monopolise
}
if settings.DailyGenerationCap > 0 && count > remaining {
count = remaining
}
if count <= 0 {
continue
}
job := &models.WarmupGenerationJob{
ID: uuid.New(),
Trigger: "schedule",
PoolType: pool.PoolType,
Segment: segment,
Model: settings.Model,
RequestedCount: count,
Status: "pending",
}
if err := s.repo.CreateGenerationJob(ctx, job); err != nil {
log.Warn().Err(err).Msg("warmup generation: failed to create scheduled job")
continue
}
generated := s.runJob(ctx, job)
if settings.DailyGenerationCap > 0 {
remaining -= generated
if remaining <= 0 {
return nil
}
}
}
}
return nil
}
// runJob executes a generation job, updating its row as it goes. Returns the
// number of conversations successfully cached.
func (s *service) runJob(ctx context.Context, job *models.WarmupGenerationJob) int {
now := time.Now()
job.StartedAt = &now
job.Status = "running"
if err := s.repo.UpdateGenerationJob(ctx, job); err != nil {
log.Warn().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: failed to mark job running")
}
settings, _ := s.repo.GetGenerationSettings(ctx)
maxMessages := 4
if settings != nil {
maxMessages = settings.MaxMessagesPerThread
}
for i := 0; i < job.RequestedCount; i++ {
if ctx.Err() != nil {
job.Error = ctx.Err().Error()
break
}
theme := job.Theme
if theme == "" {
theme = defaultThemes[i%len(defaultThemes)]
}
conv, err := s.gen.GenerateConversation(ctx, theme, job.Model, maxMessages)
if err != nil {
job.FailedCount++
log.Warn().Err(err).Str("job_id", job.ID.String()).Str("theme", theme).Msg("warmup generation: model call failed")
continue
}
subject := strings.TrimSpace(conv.Subject)
description := strings.TrimSpace(conv.Description)
messages := cleanMessages(conv.Messages)
if description == "" || subject == "" {
job.FailedCount++
continue
}
// Humanize BEFORE the gates: strip AI tells (em-dashes, stock openers,
// AI-accent vocabulary, "not only X but also Y") and apply casual
// contractions, so the cached copy reads like a person wrote it.
subject, description, messages = humanizeThread(subject, description, messages)
lintBody := description
if len(messages) > 0 {
lintBody += "\n\n" + strings.Join(messages, "\n")
}
// Reject threads that still read machine-generated after cleanup.
if humanlint.LooksRobotic(lintBody) {
job.LintRejectedCount++
continue
}
if err := warmlint.Check(subject, lintBody, false); err != nil {
job.LintRejectedCount++
log.Debug().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: lint rejected a thread")
continue
}
record := &models.WarmupConversation{
ID: uuid.New(),
PoolType: job.PoolType,
Segment: job.Segment,
Source: models.WarmupContentSourceAI,
Theme: theme,
Subject: subject,
Description: description,
Messages: messages,
Status: "active",
LintPassed: true,
GeneratedByJob: &job.ID,
}
if err := s.repo.InsertConversation(ctx, record); err != nil {
job.FailedCount++
sentry.CaptureException(err)
continue
}
job.GeneratedCount++
}
finished := time.Now()
job.FinishedAt = &finished
job.Status = "completed"
if job.GeneratedCount == 0 && (job.FailedCount > 0 || job.Error != "") {
job.Status = "failed"
if job.Error == "" {
job.Error = fmt.Sprintf("all %d generations failed", job.FailedCount)
}
}
if err := s.repo.UpdateGenerationJob(ctx, job); err != nil {
log.Warn().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: failed to finalise job")
}
log.Info().
Str("job_id", job.ID.String()).
Int("generated", job.GeneratedCount).
Int("lint_rejected", job.LintRejectedCount).
Int("failed", job.FailedCount).
Msg("warmup generation run finished")
return job.GeneratedCount
}
func cleanMessages(in []string) []string {
out := make([]string, 0, len(in))
for _, m := range in {
m = strings.TrimSpace(m)
if m != "" {
out = append(out, m)
}
}
return out
}
// humanizeThread runs the deterministic humanizer over a generated thread's
// subject, opening line, and follow-ups, seeded by content so the same thread
// humanizes identically (reproducible) while differing across threads. Shared
// by the sync (runJob) and batch (ingestBatch) ingest paths.
func humanizeThread(subject, description string, messages []string) (string, string, []string) {
h := fnv.New64a()
_, _ = h.Write([]byte(subject + "|" + description))
seed := int64(h.Sum64())
subject = humanlint.HumanizeSubject(subject, seed)
description = humanlint.Humanize(description, seed+1)
for i := range messages {
messages[i] = humanlint.Humanize(messages[i], seed+int64(i)+2)
}
return subject, description, messages
}
func dailyRemaining(ctx context.Context, repo repository.WarmupContentRepository, dailyCap int) int {
if dailyCap <= 0 {
return 1 << 30 // effectively unlimited
}
since := time.Now().Truncate(24 * time.Hour)
used, err := repo.GeneratedCountSince(ctx, since)
if err != nil {
return dailyCap
}
return dailyCap - used
}
+13 -12
View File
@@ -64,18 +64,19 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, body any) error {
w.recordSendAttempt()
sendStart := time.Now()
result := mail.Send(ctx, &wmail.SendRequest{
TaskID: sendEmail.TaskID,
To: sendEmail.To,
Cc: sendEmail.Cc,
Bcc: sendEmail.Bcc,
MessageID: sendEmail.MessageID,
Subject: subject,
BodyPlain: bodyPlain,
BodyHTML: bodyHTML,
InReplyTo: sendEmail.InReplyTo,
Parent: sendEmail.Parent,
IsWarmup: sendEmail.IsWarmup,
WarmupToken: sendEmail.WarmupToken,
TaskID: sendEmail.TaskID,
To: sendEmail.To,
Cc: sendEmail.Cc,
Bcc: sendEmail.Bcc,
MessageID: sendEmail.MessageID,
Subject: subject,
BodyPlain: bodyPlain,
BodyHTML: bodyHTML,
InReplyTo: sendEmail.InReplyTo,
Parent: sendEmail.Parent,
IsWarmup: sendEmail.IsWarmup,
WarmupToken: sendEmail.WarmupToken,
UnsubscribeURL: sendEmail.UnsubscribeURL,
})
w.recordSendLatency(time.Since(sendStart))
w.recordSendOutcome(result)
+23 -7
View File
@@ -10,6 +10,15 @@ import (
"github.com/warmbly/warmbly/internal/models"
)
// HandleWarmupAction executes recipient-side warmup actions on the mailbox.
//
// The worker just runs whatever actions it receives, immediately. The
// recipient-side "dwell" and the immediate-vs-delayed split are now owned by the
// CONSUMER's durable schedule (internal/app/consumer/warmup_engagement_poller):
// the consumer publishes the immediate leg (folder + spam-rescue) right away and
// the delayed leg (read / important / star) when its fire_at passes, each with
// DelaySeconds=0. That makes the dwell survive a worker restart, which the old
// in-process time.AfterFunc here could not.
func (w *WorkerService) HandleWarmupAction(ctx context.Context, body any) error {
action, ok := body.(models.WarmupEmailAction)
if !ok {
@@ -25,13 +34,16 @@ func (w *WorkerService) HandleWarmupAction(ctx context.Context, body any) error
Strs("actions", action.Actions).
Msg("Processing warmup email action")
if len(action.Actions) == 0 {
return nil
}
w.mailManager.RLock()
mail, exists := w.mailManager.Emails[action.EmailID]
w.mailManager.RUnlock()
if !exists {
log.Warn().Str("email_id", action.EmailID.String()).Msg("Email account not found for warmup action")
return fmt.Errorf("email account %s not found", action.EmailID.String())
return nil
}
switch {
@@ -44,11 +56,6 @@ func (w *WorkerService) HandleWarmupAction(ctx context.Context, body any) error
Str("email_id", action.EmailID.String()).
Msg("No mail client available for warmup actions; skipping")
}
log.Info().
Str("email_id", action.EmailID.String()).
Msg("Warmup email actions completed")
return nil
}
@@ -71,6 +78,10 @@ func (w *WorkerService) runGoogleWarmupActions(ctx context.Context, mail *wmail.
if err := mail.GoogleData.Client.MarkImportant(ctx, action.GmailID); err != nil {
log.Error().Err(err).Str("gmail_id", action.GmailID).Msg("Failed to mark important")
}
case "star":
if err := mail.GoogleData.Client.AddStar(ctx, action.GmailID); err != nil {
log.Error().Err(err).Str("gmail_id", action.GmailID).Msg("Failed to star warmup message")
}
default:
log.Warn().Str("action", act).Msg("Unknown warmup action")
}
@@ -117,6 +128,11 @@ func (w *WorkerService) runImapWarmupActions(ctx context.Context, mail *wmail.WM
if err := imapClient.MarkImportant(ctx, sourceBox.Name, uid); err != nil {
log.Error().Err(err).Uint32("uid", uid).Msg("Failed to mark important (IMAP)")
}
case "star":
// No-op on IMAP: \Flagged is already set by mark_important, so
// starring here would just re-flag the same message. Star is a
// Gmail-only distinct signal.
continue
default:
log.Warn().Str("action", act).Msg("Unknown warmup action")
}
+27 -14
View File
@@ -26,6 +26,29 @@ type SendRequest struct {
Parent *models.EmailParent
IsWarmup bool
WarmupToken string
// UnsubscribeURL, when set (campaign sends with the unsubscribe header
// enabled), produces RFC 8058 one-click unsubscribe headers.
UnsubscribeURL string
}
// buildSendHeaders assembles the outbound custom headers: the warmup
// verification token (warmup sends) and RFC 8058 one-click unsubscribe headers
// (campaign sends). Returns nil when there are none so callers can branch.
func buildSendHeaders(req *SendRequest) map[string]string {
h := map[string]string{}
if req.WarmupToken != "" {
h[config.WarmupVerifyHeader] = req.WarmupToken
}
if req.UnsubscribeURL != "" {
// RFC 8058: the HTTPS URI in List-Unsubscribe plus the one-click marker
// tells Gmail/Yahoo/Microsoft to POST List-Unsubscribe=One-Click here.
h["List-Unsubscribe"] = "<" + req.UnsubscribeURL + ">"
h["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
}
if len(h) == 0 {
return nil
}
return h
}
// SendResult contains the result of a send operation
@@ -119,13 +142,8 @@ func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML str
}
}
// Build custom headers for warmup token
var customHeaders map[string]string
if req.WarmupToken != "" {
customHeaders = map[string]string{
config.WarmupVerifyHeader: req.WarmupToken,
}
}
// Build custom headers (warmup token + RFC 8058 one-click unsubscribe).
customHeaders := buildSendHeaders(req)
// Send via Gmail API
gmailMsg, err := w.GoogleData.Client.SendMessage(
@@ -179,13 +197,8 @@ func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML stri
return result
}
// Build custom headers for warmup token
var smtpCustomHeaders map[string]string
if req.WarmupToken != "" {
smtpCustomHeaders = map[string]string{
config.WarmupVerifyHeader: req.WarmupToken,
}
}
// Build custom headers (warmup token + RFC 8058 one-click unsubscribe).
smtpCustomHeaders := buildSendHeaders(req)
// Send via SMTP
var merr *errx.MailError
+5 -2
View File
@@ -41,9 +41,12 @@ func (c *Client) SendMessage(
}
if parent != nil && parent.MessageID != "" {
// Trim any existing <...> before re-wrapping so we don't emit <<id>>,
// which won't match the original Message-ID header and breaks threading.
mid := "<" + strings.Trim(parent.MessageID, "<>") + ">"
headers = append(headers,
&gmail.MessagePartHeader{Name: "In-Reply-To", Value: fmt.Sprintf("<%s>", parent.MessageID)},
&gmail.MessagePartHeader{Name: "References", Value: fmt.Sprintf("<%s>", parent.MessageID)},
&gmail.MessagePartHeader{Name: "In-Reply-To", Value: mid},
&gmail.MessagePartHeader{Name: "References", Value: mid},
)
}
+18
View File
@@ -62,6 +62,24 @@ func (c *Client) RemoveFromSpam(ctx context.Context, messageID string) error {
return nil
}
// AddStar stars a message by adding the STARRED system label. Distinct from
// MarkImportant (the IMPORTANT label): a star is a deliberate, visible positive
// signal, so providers weight it as genuine engagement.
func (c *Client) AddStar(ctx context.Context, messageID string) error {
if c.srv == nil {
return fmt.Errorf("gmail service not initialized")
}
_, err := c.srv.Users.Messages.Modify("me", messageID, &gmail.ModifyMessageRequest{
AddLabelIds: []string{Starred},
}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to add star: %w", err)
}
return nil
}
// MarkImportant marks a message as important
func (c *Client) MarkImportant(ctx context.Context, messageID string) error {
if c.srv == nil {
+6 -2
View File
@@ -56,8 +56,12 @@ func (c *Client) Send(
headers["Cc"] = strings.Join(cc, ", ")
}
if inReplyTo != "" {
headers["In-Reply-To"] = fmt.Sprintf("<%s>", inReplyTo)
headers["References"] = fmt.Sprintf("<%s>", inReplyTo)
// The parent Message-ID may arrive already wrapped in <...>; trim before
// re-wrapping so we don't emit <<id>>, which won't match the original
// Message-ID header and breaks Gmail/Outlook threading.
mid := "<" + strings.Trim(inReplyTo, "<>") + ">"
headers["In-Reply-To"] = mid
headers["References"] = mid
}
// Add custom headers (e.g., X-Warmbly-Token for warmup)
@@ -0,0 +1,10 @@
DROP INDEX IF EXISTS public.idx_warmup_spam_reports_cohort;
DROP INDEX IF EXISTS public.idx_warmup_tokens_cohort;
ALTER TABLE IF EXISTS public.warmup_spam_reports DROP COLUMN IF EXISTS content_source;
ALTER TABLE IF EXISTS public.warmup_tokens DROP COLUMN IF EXISTS conversation_id;
ALTER TABLE IF EXISTS public.warmup_tokens DROP COLUMN IF EXISTS content_source;
DROP TABLE IF EXISTS public.admin_settings;
DROP TABLE IF EXISTS public.warmup_generation_jobs;
DROP TABLE IF EXISTS public.warmup_conversations;
@@ -0,0 +1,95 @@
-- Warmup content system.
--
-- The live warmup send flow previously drew message bodies from a small,
-- fixed in-code library (~30 conversations). Reusing the same handful of
-- bodies across every mailbox in the pool is the documented content-
-- fingerprinting risk: collaborative bulk-checksum systems (DCC/Razor/Pyzor)
-- and provider ML learn to recognise the "Warmbly warmup dialect". This
-- migration adds the control-plane plumbing to fix that:
--
-- 1. warmup_conversations — a DB-backed bank of conversation threads that
-- an offline generator (OpenAI, run as a job)
-- continuously refills per pool + segment. The
-- static in-code library remains the fallback,
-- so an outage or empty bank never stops warmup.
-- 2. warmup_generation_jobs — observability for every generation run
-- (manual or scheduled): how many were asked
-- for, generated, lint-rejected, failed.
-- 3. admin_settings — a generic key/value JSON settings store; the
-- warmup generation + engagement config lives
-- under the 'warmup_generation' key so admins
-- have full control over volume, cadence, model,
-- per-pool segments and engagement rates.
-- 4. content-cohort columns — content_source / conversation_id on
-- warmup_tokens and content_source on
-- warmup_spam_reports, so the A/B harness can
-- compare spam-placement rate by content cohort
-- (static vs AI) without fragile time-window joins.
CREATE TABLE public.warmup_conversations (
id uuid DEFAULT gen_random_uuid() NOT NULL,
pool_type text NOT NULL,
segment text DEFAULT ''::text NOT NULL,
source text NOT NULL,
theme text DEFAULT ''::text NOT NULL,
subject text DEFAULT ''::text NOT NULL,
description text DEFAULT ''::text NOT NULL,
messages jsonb DEFAULT '[]'::jsonb NOT NULL,
status text DEFAULT 'active'::text NOT NULL,
lint_passed boolean DEFAULT true NOT NULL,
usage_count bigint DEFAULT 0 NOT NULL,
generated_by_job_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT warmup_conversations_pkey PRIMARY KEY (id),
CONSTRAINT warmup_conversations_pool_type_check CHECK ((pool_type = ANY (ARRAY['free'::text, 'premium'::text]))),
CONSTRAINT warmup_conversations_source_check CHECK ((source = ANY (ARRAY['ai'::text, 'static'::text]))),
CONSTRAINT warmup_conversations_status_check CHECK ((status = ANY (ARRAY['active'::text, 'archived'::text])))
);
CREATE INDEX idx_warmup_conversations_pick ON public.warmup_conversations USING btree (pool_type, segment, status);
CREATE INDEX idx_warmup_conversations_source ON public.warmup_conversations USING btree (source);
CREATE INDEX idx_warmup_conversations_created ON public.warmup_conversations USING btree (created_at DESC);
CREATE TABLE public.warmup_generation_jobs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
requested_by uuid,
trigger text DEFAULT 'manual'::text NOT NULL,
pool_type text DEFAULT ''::text NOT NULL,
segment text DEFAULT ''::text NOT NULL,
theme text DEFAULT ''::text NOT NULL,
model text DEFAULT ''::text NOT NULL,
requested_count integer DEFAULT 0 NOT NULL,
generated_count integer DEFAULT 0 NOT NULL,
lint_rejected_count integer DEFAULT 0 NOT NULL,
failed_count integer DEFAULT 0 NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
error text DEFAULT ''::text NOT NULL,
started_at timestamp with time zone,
finished_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT warmup_generation_jobs_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_warmup_generation_jobs_created ON public.warmup_generation_jobs USING btree (created_at DESC);
CREATE INDEX idx_warmup_generation_jobs_status ON public.warmup_generation_jobs USING btree (status);
CREATE TABLE public.admin_settings (
key text NOT NULL,
value jsonb DEFAULT '{}'::jsonb NOT NULL,
updated_by uuid,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT admin_settings_pkey PRIMARY KEY (key)
);
ALTER TABLE ONLY public.warmup_tokens
ADD COLUMN content_source text DEFAULT ''::text NOT NULL;
ALTER TABLE ONLY public.warmup_tokens
ADD COLUMN conversation_id uuid;
ALTER TABLE ONLY public.warmup_spam_reports
ADD COLUMN content_source text DEFAULT ''::text NOT NULL;
CREATE INDEX idx_warmup_tokens_cohort ON public.warmup_tokens USING btree (content_source, created_at DESC);
CREATE INDEX idx_warmup_spam_reports_cohort ON public.warmup_spam_reports USING btree (content_source, report_type, created_at DESC);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.warmup_tampering_events;
DROP TABLE IF EXISTS public.warmup_received;
@@ -0,0 +1,41 @@
-- Warmup tampering protection.
--
-- Verified warmup mail is NOT stored in the unibox (handleWarmupEmail returns
-- "handled" before CreateEntry), so there is otherwise no record that a given
-- message in a recipient's mailbox was a warmup email. Without that, a later
-- flag-change (mark-as-spam) or deletion event — which only carries the
-- internal message id — can't be attributed to warmup.
--
-- 1. warmup_received — records every verified warmup email delivered to a
-- participant, keyed by (recipient mailbox, internal
-- message id) so a later delete/flag event can be
-- matched back to warmup and to the sender.
-- 2. warmup_tampering_events — one row per "harm" a participant does to a
-- warmup email (deleted it, or marked it as spam).
-- Crossing the threshold bans the mailbox from warmup
-- (BlockFromPool); the user can then appeal
-- (warmup_appeals, already present).
CREATE TABLE public.warmup_received (
email_account_id uuid NOT NULL,
internal_id uuid NOT NULL,
message_id text DEFAULT ''::text NOT NULL,
sender_account_id uuid NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT warmup_received_pkey PRIMARY KEY (email_account_id, internal_id)
);
CREATE INDEX idx_warmup_received_created ON public.warmup_received USING btree (created_at);
CREATE TABLE public.warmup_tampering_events (
id uuid DEFAULT gen_random_uuid() NOT NULL,
email_account_id uuid NOT NULL,
message_id text DEFAULT ''::text NOT NULL,
kind text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT warmup_tampering_events_pkey PRIMARY KEY (id),
CONSTRAINT warmup_tampering_events_uniq UNIQUE (email_account_id, message_id, kind),
CONSTRAINT warmup_tampering_events_kind_check CHECK ((kind = ANY (ARRAY['deletion'::text, 'spam_flag'::text, 'other'::text])))
);
CREATE INDEX idx_warmup_tampering_account_created ON public.warmup_tampering_events USING btree (email_account_id, created_at DESC);
@@ -0,0 +1,6 @@
ALTER TABLE public.email_accounts ALTER COLUMN warmup_tag DROP DEFAULT;
DROP INDEX IF EXISTS public.idx_warmup_spam_reports_provider;
ALTER TABLE public.warmup_spam_reports DROP COLUMN IF EXISTS recipient_domain;
ALTER TABLE public.warmup_spam_reports DROP COLUMN IF EXISTS recipient_provider;
@@ -0,0 +1,24 @@
-- Warmup analytics correctness + content-segment fix.
--
-- 1. per-provider placement (B3): warmup_spam_reports records which recipient
-- provider/domain a warmup message was filtered into spam at, so placement
-- can be segmented (Gmail-spam vs Outlook-inbox) instead of a single flat
-- rate. Both columns default '' so existing rows and callers that don't yet
-- supply the dimension keep working.
--
-- 2. content-segment fix (B11): warmup_tag was bound to a random RID(8) at
-- account creation and then used as the warmup content segment, so the
-- segment-aware AI bank never matched and every mailbox silently drew only
-- generic content. Default it to '' going forward; it is now a real,
-- user/admin-settable content segment (slug). Existing random tags simply
-- fall back to generic content, which is the safe behaviour, so no backfill
-- is required.
ALTER TABLE public.warmup_spam_reports
ADD COLUMN recipient_provider text DEFAULT ''::text NOT NULL,
ADD COLUMN recipient_domain text DEFAULT ''::text NOT NULL;
CREATE INDEX idx_warmup_spam_reports_provider
ON public.warmup_spam_reports USING btree (report_type, recipient_provider, created_at DESC);
ALTER TABLE public.email_accounts ALTER COLUMN warmup_tag SET DEFAULT ''::text;
@@ -0,0 +1 @@
ALTER TABLE public.campaign_contact_progress DROP COLUMN IF EXISTS complained_at;
@@ -0,0 +1,9 @@
-- Campaign deliverability circuit breaker.
--
-- The auto-pause logic computed only a cumulative bounce rate and never read
-- PauseComplaintRateThreshold, so complaint-rate protection was dead. Record
-- per-contact complaint events alongside bounces so the breaker can enforce a
-- complaint-rate threshold and compute rolling rates from the same table.
ALTER TABLE public.campaign_contact_progress
ADD COLUMN complained_at timestamp with time zone;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.warmup_pending_engagements;
@@ -0,0 +1,23 @@
-- Durable dwell for delayed warmup engagement actions.
--
-- The recipient-side "dwell" delay for the low-stakes engagement signals
-- (mark_read / mark_important / star) previously lived only in a worker-process
-- time.AfterFunc timer, so a worker restart mid-dwell dropped those signals with
-- no trace. This table is the durable schedule: the consumer enqueues the
-- delayed leg here with a fire_at, and a consumer-side poller publishes it to
-- the worker when due. The reputation-critical leg (folder + spam-rescue) is
-- still published immediately and is unaffected.
--
-- Control-plane only: written and drained by the consumer (Postgres-backed),
-- never by the worker.
CREATE TABLE public.warmup_pending_engagements (
id uuid DEFAULT gen_random_uuid() NOT NULL,
email_account_id uuid NOT NULL,
payload jsonb NOT NULL,
fire_at timestamp with time zone NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT warmup_pending_engagements_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_warmup_pending_engagements_due ON public.warmup_pending_engagements USING btree (fire_at);
@@ -0,0 +1,9 @@
DROP INDEX IF EXISTS idx_warmup_generation_jobs_batch;
ALTER TABLE ONLY public.warmup_generation_jobs
DROP COLUMN IF EXISTS mode,
DROP COLUMN IF EXISTS batch_id,
DROP COLUMN IF EXISTS batch_input_file_id,
DROP COLUMN IF EXISTS batch_output_file_id,
DROP COLUMN IF EXISTS batch_status,
DROP COLUMN IF EXISTS completion_window;
@@ -0,0 +1,25 @@
-- Warmup batch generation.
--
-- The offline warmup-content generator originally ran every model call
-- synchronously (one chat-completion per thread). The OpenAI Batch API is ~50%
-- cheaper and processes asynchronously (up to a 24h window), which is a much
-- better fit for bulk thread-bank refills where latency does not matter.
--
-- This migration extends warmup_generation_jobs so one job row can represent
-- either a synchronous run (mode='sync', the existing behaviour) or an async
-- batch run (mode='batch'). For batch runs we track the OpenAI batch ID, the
-- uploaded input file ID, the output file ID (populated when the batch
-- completes), the last-observed OpenAI batch status, and the requested
-- completion window. A background poller reconciles in-flight batch jobs against
-- the OpenAI batch status and ingests results when they finish.
ALTER TABLE ONLY public.warmup_generation_jobs
ADD COLUMN mode text NOT NULL DEFAULT 'sync',
ADD COLUMN batch_id text NOT NULL DEFAULT '',
ADD COLUMN batch_input_file_id text NOT NULL DEFAULT '',
ADD COLUMN batch_output_file_id text NOT NULL DEFAULT '',
ADD COLUMN batch_status text NOT NULL DEFAULT '',
ADD COLUMN completion_window text NOT NULL DEFAULT '24h';
-- The poller scans for in-flight batch jobs by mode + status, so index that.
CREATE INDEX idx_warmup_generation_jobs_batch ON public.warmup_generation_jobs USING btree (mode, status, batch_status);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS public.idx_warmup_conversations_pick;
CREATE INDEX idx_warmup_conversations_pick ON public.warmup_conversations
USING btree (pool_type, segment, status);
@@ -0,0 +1,13 @@
-- Realign the content-pick index with the shared-library selection predicate.
--
-- PickConversation no longer filters by pool_type (content is one shared library;
-- pools only isolate mailbox reputation). It now selects:
-- WHERE status = 'active' AND (segment = $1 OR segment = '')
-- so the old idx_warmup_conversations_pick (pool_type, segment, status) — which
-- leads on the unused pool_type column — no longer matches. Replace it with a
-- partial index on the columns actually filtered.
DROP INDEX IF EXISTS public.idx_warmup_conversations_pick;
CREATE INDEX idx_warmup_conversations_pick ON public.warmup_conversations
USING btree (segment) WHERE (status = 'active'::text);
@@ -0,0 +1,8 @@
DROP INDEX IF EXISTS public.idx_contacts_verification_status;
DROP INDEX IF EXISTS public.idx_contacts_verification_pending;
ALTER TABLE public.contacts
DROP COLUMN IF EXISTS verification_checked_at,
DROP COLUMN IF EXISTS is_catch_all,
DROP COLUMN IF EXISTS verification_reason,
DROP COLUMN IF EXISTS verification_status;
@@ -0,0 +1,28 @@
-- Pre-send email verification columns on contacts.
--
-- Suppression today is reactive: we only stop sending to an address *after* it
-- hard-bounces or complains, so every bad address costs a real bounce against
-- our sending reputation. These columns let the control plane verify an address
-- (syntax -> MX -> SMTP RCPT probe -> catch-all detection) before any worker
-- sends to it, turning a future hard bounce into a zero-cost pre-send drop.
--
-- status values mirror emailverify.Status: 'valid' | 'risky' | 'invalid' |
-- 'unknown'. New contacts default to 'unknown' so the verification scheduler
-- picks them up; the pre-send gate only ever drops 'invalid'.
ALTER TABLE public.contacts
ADD COLUMN IF NOT EXISTS verification_status text NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS verification_reason text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS is_catch_all boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS verification_checked_at timestamptz;
-- Partial index over the addresses the batch scheduler scans for: anything not
-- yet conclusively checked (status = 'unknown' AND never verified). Keeps
-- ListUnverifiedContacts a cheap index scan instead of a full table sweep.
CREATE INDEX IF NOT EXISTS idx_contacts_verification_pending
ON public.contacts (verification_checked_at)
WHERE verification_status = 'unknown' AND verification_checked_at IS NULL;
-- Supports the pre-send gate's "is this contact invalid?" lookups.
CREATE INDEX IF NOT EXISTS idx_contacts_verification_status
ON public.contacts (verification_status);
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS placement_results;
DROP TABLE IF EXISTS placement_tests;
DROP INDEX IF EXISTS idx_email_accounts_is_seed;
ALTER TABLE email_accounts DROP COLUMN IF EXISTS is_seed;
@@ -0,0 +1,54 @@
-- Seed inbox-placement testing.
--
-- A "seed" mailbox is an ordinary connected + synced email_account that we
-- flag with is_seed. Because it syncs like any other mailbox, mail it receives
-- lands in unibox_emails — which is exactly where the placement poller looks
-- up the per-test token and reads the folder/flags to classify where the test
-- message landed (Inbox / Spam / Promotions / other), per provider.
ALTER TABLE email_accounts
ADD COLUMN is_seed boolean NOT NULL DEFAULT false;
-- Cheap lookup of the active seed panel.
CREATE INDEX idx_email_accounts_is_seed
ON email_accounts (is_seed)
WHERE is_seed = true;
-- A placement test: one tokenized copy of a template is sent from a chosen
-- sender mailbox to every active seed. Status is "pending" while results are
-- still being classified, "completed" once all resolve or the timeout passes.
CREATE TABLE placement_tests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
sender_account_id uuid NOT NULL REFERENCES email_accounts (id) ON DELETE CASCADE,
subject text NOT NULL,
body_plain text NOT NULL DEFAULT '',
body_html text NOT NULL DEFAULT '',
token text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT NOW(),
finished_at timestamptz
);
CREATE INDEX idx_placement_tests_status ON placement_tests (status);
CREATE INDEX idx_placement_tests_org ON placement_tests (organization_id, created_at DESC);
-- One row per (test, seed). folder starts "pending" and is set when the
-- token is found in that seed's unibox entries. provider records the seed
-- mailbox's provider so results roll up per provider, not one flat rate.
CREATE TABLE placement_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
test_id uuid NOT NULL REFERENCES placement_tests (id) ON DELETE CASCADE,
seed_account_id uuid NOT NULL REFERENCES email_accounts (id) ON DELETE CASCADE,
provider text NOT NULL DEFAULT '',
folder text NOT NULL DEFAULT 'pending'
CHECK (folder IN ('inbox', 'promotions', 'spam', 'other', 'pending')),
detected_at timestamptz,
raw_flags text NOT NULL DEFAULT '',
UNIQUE (test_id, seed_account_id)
);
CREATE INDEX idx_placement_results_test ON placement_results (test_id);
CREATE INDEX idx_placement_results_pending
ON placement_results (folder)
WHERE folder = 'pending';
+84
View File
@@ -0,0 +1,84 @@
package jobs
import (
"context"
"time"
"github.com/getsentry/sentry-go"
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
)
// EmailVerificationJob verifies a capped batch of not-yet-checked contacts each
// run, so the platform can drop hard-bouncing addresses before any worker sends
// to them. It is a thin wrapper around emailverify.Service.VerifyPending; the
// per-tick cap bounds how much outbound SMTP-probe work one pass does.
//
// Control-plane only: the underlying verifier dials remote MX hosts on :25 and
// must run from the backend/consumer, never a worker (sending) IP.
type EmailVerificationJob struct {
svc emailverifyapp.Service
batchSize int
}
// NewEmailVerificationJob creates the job. batchSize caps how many contacts are
// verified per tick (defaults to 100 when non-positive).
func NewEmailVerificationJob(svc emailverifyapp.Service, batchSize int) *EmailVerificationJob {
if batchSize <= 0 {
batchSize = 100
}
return &EmailVerificationJob{svc: svc, batchSize: batchSize}
}
// Run performs one capped verification pass. Safe to call frequently — it
// no-ops when there are no unverified contacts.
func (j *EmailVerificationJob) Run(ctx context.Context) error {
if j.svc == nil {
return nil
}
if _, err := j.svc.VerifyPending(ctx, j.batchSize); err != nil {
sentry.CaptureException(err)
return err
}
return nil
}
// EmailVerificationScheduler runs the job on a fixed interval.
type EmailVerificationScheduler struct {
job *EmailVerificationJob
interval time.Duration
stopCh chan struct{}
}
// NewEmailVerificationScheduler creates the scheduler.
func NewEmailVerificationScheduler(job *EmailVerificationJob, interval time.Duration) *EmailVerificationScheduler {
return &EmailVerificationScheduler{
job: job,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Start begins scheduled execution.
func (s *EmailVerificationScheduler) Start(ctx context.Context) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.job.Run(ctx); err != nil {
sentry.CaptureException(err)
}
case <-s.stopCh:
return
case <-ctx.Done():
return
}
}
}
// Stop halts the scheduled execution.
func (s *EmailVerificationScheduler) Stop() {
close(s.stopCh)
}
+72
View File
@@ -0,0 +1,72 @@
package jobs
import (
"context"
"time"
"github.com/getsentry/sentry-go"
"github.com/warmbly/warmbly/internal/app/placement"
)
// 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.
type PlacementPoller struct {
svc placement.Service
interval time.Duration
stopCh chan struct{}
}
// NewPlacementPoller creates the poller.
func NewPlacementPoller(svc placement.Service, interval time.Duration) *PlacementPoller {
if interval <= 0 {
interval = 2 * time.Minute
}
return &PlacementPoller{
svc: svc,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Run performs one classification pass. Safe to call frequently — ClassifyPending
// no-ops when there are no pending results.
func (p *PlacementPoller) Run(ctx context.Context) error {
if p.svc == nil {
return nil
}
if err := p.svc.ClassifyPending(ctx); err != nil {
sentry.CaptureException(err)
return err
}
return nil
}
// Start begins scheduled execution on the configured interval.
func (p *PlacementPoller) Start(ctx context.Context) {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := p.Run(ctx); err != nil {
sentry.CaptureException(err)
}
case <-p.stopCh:
return
case <-ctx.Done():
return
}
}
}
// Stop halts scheduled execution.
func (p *PlacementPoller) Stop() {
close(p.stopCh)
}
+70
View File
@@ -0,0 +1,70 @@
package jobs
import (
"context"
"time"
"github.com/getsentry/sentry-go"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
)
// WarmupBatchPoller reconciles in-flight OpenAI Batch API warmup-generation jobs:
// it polls each active batch, ingests completed ones into the content bank, and
// marks failed/expired/cancelled ones. It is a thin scheduler around
// warmupcontent.Service.PollBatches; all policy lives in the service. Batches run
// async (up to a 24h window) so a coarse 5-minute tick is plenty.
type WarmupBatchPoller struct {
svc warmupcontent.Service
interval time.Duration
stopCh chan struct{}
}
// NewWarmupBatchPoller creates the poller.
func NewWarmupBatchPoller(svc warmupcontent.Service, interval time.Duration) *WarmupBatchPoller {
if interval <= 0 {
interval = 5 * time.Minute
}
return &WarmupBatchPoller{
svc: svc,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Run performs one reconciliation pass. Safe to call frequently — PollBatches
// no-ops when generation is unconfigured or there are no active batch jobs.
func (p *WarmupBatchPoller) Run(ctx context.Context) error {
if p.svc == nil || !p.svc.Enabled() {
return nil
}
if err := p.svc.PollBatches(ctx); err != nil {
sentry.CaptureException(err)
return err
}
return nil
}
// Start begins scheduled execution on the configured interval.
func (p *WarmupBatchPoller) Start(ctx context.Context) {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := p.Run(ctx); err != nil {
sentry.CaptureException(err)
}
case <-p.stopCh:
return
case <-ctx.Done():
return
}
}
}
// Stop halts scheduled execution.
func (p *WarmupBatchPoller) Stop() {
close(p.stopCh)
}
+102
View File
@@ -0,0 +1,102 @@
package jobs
import (
"context"
"sync"
"time"
"github.com/getsentry/sentry-go"
"github.com/warmbly/warmbly/internal/app/warmupcontent"
"github.com/warmbly/warmbly/internal/repository"
)
// WarmupGenerationJob periodically tops up the warmup content bank toward the
// per-pool/segment targets in the admin settings. It is a thin scheduler around
// warmupcontent.Service.RunScheduled; all policy (enabled, cadence, caps,
// targets) lives in the settings so admins keep full control.
type WarmupGenerationJob struct {
svc warmupcontent.Service
repo repository.WarmupContentRepository
mu sync.Mutex
lastRun time.Time
}
// NewWarmupGenerationJob creates the job.
func NewWarmupGenerationJob(svc warmupcontent.Service, repo repository.WarmupContentRepository) *WarmupGenerationJob {
return &WarmupGenerationJob{svc: svc, repo: repo}
}
// Run performs one top-up pass when scheduling is enabled and the configured
// cadence has elapsed. Safe to call frequently — it no-ops when there's nothing
// to do.
func (j *WarmupGenerationJob) Run(ctx context.Context) error {
if j.svc == nil || j.repo == nil || !j.svc.Enabled() {
return nil
}
settings, err := j.repo.GetGenerationSettings(ctx)
if err != nil {
sentry.CaptureException(err)
return err
}
if settings == nil || !settings.ScheduleEnabled {
return nil
}
cadence := time.Duration(settings.CadenceHours) * time.Hour
j.mu.Lock()
if !j.lastRun.IsZero() && cadence > 0 && time.Since(j.lastRun) < cadence {
j.mu.Unlock()
return nil
}
j.lastRun = time.Now()
j.mu.Unlock()
if err := j.svc.RunScheduled(ctx); err != nil {
sentry.CaptureException(err)
return err
}
return nil
}
// WarmupGenerationScheduler runs the job on a fixed interval (the per-run
// cadence gate inside Run honours the admin's configured cadence_hours).
type WarmupGenerationScheduler struct {
job *WarmupGenerationJob
interval time.Duration
stopCh chan struct{}
}
// NewWarmupGenerationScheduler creates the scheduler.
func NewWarmupGenerationScheduler(job *WarmupGenerationJob, interval time.Duration) *WarmupGenerationScheduler {
return &WarmupGenerationScheduler{
job: job,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Start begins scheduled execution.
func (s *WarmupGenerationScheduler) Start(ctx context.Context) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.job.Run(ctx); err != nil {
sentry.CaptureException(err)
}
case <-s.stopCh:
return
case <-ctx.Done():
return
}
}
}
// Stop halts the scheduled execution.
func (s *WarmupGenerationScheduler) Stop() {
close(s.stopCh)
}
+9
View File
@@ -30,6 +30,15 @@ type Contact struct {
Campaigns []MiniCampaign `json:"campaigns"`
Categories []MiniCategory `json:"categories"`
// Pre-send verification state (see internal/pkg/emailverify). Populated by
// the verification scheduler / on-demand verify; the campaign send path uses
// VerificationStatus == "invalid" to drop addresses before a worker sends.
// VerificationStatus is one of: valid | risky | invalid | unknown.
VerificationStatus string `json:"verification_status"`
VerificationReason string `json:"verification_reason"`
IsCatchAll bool `json:"is_catch_all"`
VerificationCheckedAt *time.Time `json:"verification_checked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
+1
View File
@@ -165,6 +165,7 @@ type UpdateEmail struct {
WarmupMax *int `json:"warmup_max"`
WarmupIncrease *int `json:"warmup_increase"`
WarmupReplyRate *int `json:"warmup_reply_rate"`
WarmupTag *string `json:"warmup_tag"`
WarmupStartTime *string `json:"warmup_start_time"`
WarmupEndTime *string `json:"warmup_end_time"`
WarmupDays *int `json:"warmup_days"`
+43 -10
View File
@@ -7,14 +7,20 @@ import (
)
type WarmupToken struct {
Token uuid.UUID `json:"token"`
TaskID uuid.UUID `json:"task_id"`
SenderAccountID uuid.UUID `json:"sender_account_id"`
RecipientAccountID uuid.UUID `json:"recipient_account_id"`
ConversationTheme string `json:"conversation_theme"`
CreatedAt time.Time `json:"created_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
Token uuid.UUID `json:"token"`
TaskID uuid.UUID `json:"task_id"`
SenderAccountID uuid.UUID `json:"sender_account_id"`
RecipientAccountID uuid.UUID `json:"recipient_account_id"`
ConversationTheme string `json:"conversation_theme"`
// ContentSource records which content cohort produced this send
// ("static" or "ai") so the A/B harness can compare spam-placement
// rate by cohort. ConversationID points at the cached warmup_conversations
// row when the body came from the AI bank (nil for static content).
ContentSource string `json:"content_source"`
ConversationID *uuid.UUID `json:"conversation_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
// WarmupEmailAction represents actions to perform on a detected warmup email.
@@ -30,6 +36,15 @@ type WarmupEmailAction struct {
UID uint32 `json:"uid"`
MailboxUIDValidity uint32 `json:"mailbox_uid_validity"`
Actions []string `json:"actions"` // "move_to_warmbly", "mark_read", "remove_from_spam", "mark_important"
// DelaySeconds is retained for wire compatibility but is now always 0: the
// recipient-side "dwell" is owned by the consumer's durable schedule
// (warmup_pending_engagements + the engagement poller), which publishes the
// immediate leg (folder + spam-rescue) now and the delayed leg (read /
// important / star) when due. The worker runs whatever it receives
// immediately. This survives a worker restart, which the old in-process
// timer did not.
DelaySeconds int `json:"delay_seconds,omitempty"`
}
type WarmupHealthState string
@@ -57,13 +72,31 @@ type WarmupParticipantHealth struct {
LastHealthEvaluatedAt *time.Time `json:"last_health_evaluated_at,omitempty"`
}
// WarmupBanStatus is the user-facing view of a mailbox's warmup standing,
// returned by GetBanStatus so the dashboard can show why warmup is blocked and
// whether the user can appeal.
type WarmupBanStatus struct {
EmailAccountID uuid.UUID `json:"email_account_id"`
Blocked bool `json:"blocked"`
HealthState string `json:"health_state"`
Reason string `json:"reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
BlockedUntil *time.Time `json:"blocked_until,omitempty"`
CanAppeal bool `json:"can_appeal"`
PendingAppeal bool `json:"pending_appeal"`
}
type WarmupPoolHealthSummary struct {
TotalParticipants int `json:"total_participants"`
ByState map[string]int `json:"by_state"`
AvgSpamScore float64 `json:"avg_spam_score"`
AvgSpamPlacement float64 `json:"avg_spam_placement_rate"`
BlockedCount int `json:"blocked_count"`
AtRiskCount int `json:"at_risk_count"`
// SpamPlacementByProvider breaks recent spam-placement counts down by the
// recipient provider so the admin can see where warmup mail is being
// filtered (e.g. mostly at Outlook vs Gmail) rather than one flat rate.
SpamPlacementByProvider map[string]int `json:"spam_placement_by_provider"`
BlockedCount int `json:"blocked_count"`
AtRiskCount int `json:"at_risk_count"`
}
type WarmupHealthMetrics struct {
+226
View File
@@ -0,0 +1,226 @@
package models
import (
"time"
"github.com/google/uuid"
)
// Content sources for warmup message bodies. "static" is the in-code library;
// "ai" is a thread drawn from the offline-generated warmup_conversations bank.
const (
WarmupContentSourceStatic = "static"
WarmupContentSourceAI = "ai"
)
// AdminSettingsKeyWarmupGeneration is the admin_settings key under which the
// warmup generation + engagement config document is stored.
const AdminSettingsKeyWarmupGeneration = "warmup_generation"
// WarmupConversation is a cached conversation thread used as warmup content.
// Messages are the follow-up question lines the sender can pick from; the
// Description is the opening body line. Both may contain {a|b|c} spintax,
// which is expanded at render time.
type WarmupConversation struct {
ID uuid.UUID `json:"id"`
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Source string `json:"source"`
Theme string `json:"theme"`
Subject string `json:"subject"`
Description string `json:"description"`
Messages []string `json:"messages"`
Status string `json:"status"`
LintPassed bool `json:"lint_passed"`
UsageCount int64 `json:"usage_count"`
GeneratedByJob *uuid.UUID `json:"generated_by_job_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Warmup generation job modes. "sync" runs every model call inline (the
// original behaviour); "batch" submits one OpenAI Batch API job and ingests its
// results asynchronously when the batch completes.
const (
WarmupGenerationModeSync = "sync"
WarmupGenerationModeBatch = "batch"
)
// WarmupGenerationJob records one offline generation run for observability.
// Batch runs additionally carry the OpenAI batch/file identifiers and the
// last-observed batch status so the poller can reconcile them.
type WarmupGenerationJob struct {
ID uuid.UUID `json:"id"`
RequestedBy *uuid.UUID `json:"requested_by,omitempty"`
Trigger string `json:"trigger"` // "manual" | "schedule"
Mode string `json:"mode"` // "sync" | "batch"
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Theme string `json:"theme"`
Model string `json:"model"`
RequestedCount int `json:"requested_count"`
GeneratedCount int `json:"generated_count"`
LintRejectedCount int `json:"lint_rejected_count"`
FailedCount int `json:"failed_count"`
Status string `json:"status"` // pending | running | completed | failed
Error string `json:"error"`
// Batch-only fields. BatchStatus is the last status reported by OpenAI
// (validating | in_progress | finalizing | completed | failed | expired |
// cancelling | cancelled); empty for sync jobs.
BatchID string `json:"batch_id,omitempty"`
BatchInputFileID string `json:"batch_input_file_id,omitempty"`
BatchOutputFileID string `json:"batch_output_file_id,omitempty"`
BatchStatus string `json:"batch_status,omitempty"`
CompletionWindow string `json:"completion_window,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WarmupGenerationPoolConfig is per-pool generation policy.
type WarmupGenerationPoolConfig struct {
PoolType string `json:"pool_type"`
Enabled bool `json:"enabled"`
TargetActiveThreads int `json:"target_active_threads"`
Segments []string `json:"segments"`
}
// WarmupEngagementSettings controls how recipient-side warmup engagement
// actions are selected. Rates are percentages (0-100). Defaults preserve the
// previous always-on rescue behaviour but break the uniform "every account
// marks important, instantly" bot signature with per-mailbox probability and
// a randomised dwell delay.
type WarmupEngagementSettings struct {
SpamRescueRate int `json:"spam_rescue_rate"`
MarkImportantRate int `json:"mark_important_rate"`
MarkReadRate int `json:"mark_read_rate"`
// StarRate is the chance a warmup message is starred (Gmail STARRED). A
// star is a deliberate positive signal distinct from "important"; kept
// lower than read/important so the pool doesn't star in lockstep.
StarRate int `json:"star_rate"`
MinDwellSeconds int `json:"min_dwell_seconds"`
MaxDwellSeconds int `json:"max_dwell_seconds"`
}
// WarmupGenerationSettings is the admin-controlled config document for the
// offline AI thread-bank and recipient engagement. Stored as JSON in
// admin_settings under AdminSettingsKeyWarmupGeneration.
type WarmupGenerationSettings struct {
// Enabled is the master switch for using AI-generated content in the
// live send selection. When false the static library is used exclusively.
Enabled bool `json:"enabled"`
// ScheduleEnabled runs the background generation job on CadenceHours.
ScheduleEnabled bool `json:"schedule_enabled"`
CadenceHours int `json:"cadence_hours"`
Model string `json:"model"`
MaxMessagesPerThread int `json:"max_messages_per_thread"`
DailyGenerationCap int `json:"daily_generation_cap"`
AISelectionShare int `json:"ai_selection_share"` // 0-100
Pools []WarmupGenerationPoolConfig `json:"pools"`
Engagement WarmupEngagementSettings `json:"engagement"`
}
// DefaultWarmupGenerationSettings returns conservative defaults: AI off (static
// library only) and engagement rates that keep the strong "not spam" rescue
// signal while adding variation and dwell. Content is ONE shared library
// (free/premium pools only isolate mailbox reputation, not content), so there's
// a single library config; "premium" is just its canonical bucket label.
func DefaultWarmupGenerationSettings() WarmupGenerationSettings {
return WarmupGenerationSettings{
Enabled: false,
ScheduleEnabled: false,
CadenceHours: 24,
Model: "gpt-4o-mini",
MaxMessagesPerThread: 6,
DailyGenerationCap: 200,
AISelectionShare: 50,
Pools: []WarmupGenerationPoolConfig{
{PoolType: "premium", Enabled: true, TargetActiveThreads: 60, Segments: []string{""}},
},
Engagement: WarmupEngagementSettings{
SpamRescueRate: 85,
MarkImportantRate: 30,
MarkReadRate: 95,
StarRate: 20,
MinDwellSeconds: 20,
MaxDwellSeconds: 240,
},
}
}
// Normalize clamps settings into safe ranges so a bad admin payload can't
// produce nonsense (negative counts, percentages over 100, inverted dwell).
func (s *WarmupGenerationSettings) Normalize() {
if s.CadenceHours < 1 {
s.CadenceHours = 1
}
if s.Model == "" {
s.Model = "gpt-4o-mini"
}
if s.MaxMessagesPerThread < 1 {
s.MaxMessagesPerThread = 1
}
if s.MaxMessagesPerThread > 20 {
s.MaxMessagesPerThread = 20
}
if s.DailyGenerationCap < 0 {
s.DailyGenerationCap = 0
}
s.AISelectionShare = clampPct(s.AISelectionShare)
s.Engagement.SpamRescueRate = clampPct(s.Engagement.SpamRescueRate)
s.Engagement.MarkImportantRate = clampPct(s.Engagement.MarkImportantRate)
s.Engagement.MarkReadRate = clampPct(s.Engagement.MarkReadRate)
s.Engagement.StarRate = clampPct(s.Engagement.StarRate)
if s.Engagement.MinDwellSeconds < 0 {
s.Engagement.MinDwellSeconds = 0
}
if s.Engagement.MaxDwellSeconds < s.Engagement.MinDwellSeconds {
s.Engagement.MaxDwellSeconds = s.Engagement.MinDwellSeconds
}
if s.Engagement.MaxDwellSeconds > 3600 {
s.Engagement.MaxDwellSeconds = 3600
}
// Collapse to a single shared content library. Content isn't split by tier
// (PickConversation ignores pool_type), so a multi-pool config would leave a
// dead, never-topped-up branch and contradict the single-library admin UI.
// Keep one entry under the canonical "premium" bucket, preferring an enabled
// one from any legacy multi-pool doc.
s.collapsePools()
}
func (s *WarmupGenerationSettings) collapsePools() {
chosen := WarmupGenerationPoolConfig{PoolType: "premium", Enabled: true, TargetActiveThreads: 60, Segments: []string{""}}
for _, p := range s.Pools {
chosen = p
if p.Enabled {
break // prefer an enabled entry
}
}
chosen.PoolType = "premium"
if len(chosen.Segments) == 0 {
chosen.Segments = []string{""}
}
s.Pools = []WarmupGenerationPoolConfig{chosen}
}
func clampPct(v int) int {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
// PoolConfig returns the config for a pool type, or false when absent/disabled.
func (s *WarmupGenerationSettings) PoolConfig(poolType string) (WarmupGenerationPoolConfig, bool) {
for _, p := range s.Pools {
if p.PoolType == poolType {
return p, true
}
}
return WarmupGenerationPoolConfig{}, false
}
+5
View File
@@ -28,6 +28,10 @@ const (
WebhookEventCampaignStarted WebhookEventType = "campaign.started"
WebhookEventCampaignPaused WebhookEventType = "campaign.paused"
WebhookEventCampaignCompleted WebhookEventType = "campaign.completed"
// campaign.deliverability_warning fires when a campaign's rolling
// bounce/complaint rate enters the early-warning band (half the pause
// threshold) — a graduated signal short of an auto-pause.
WebhookEventCampaignDeliverabilityWarning WebhookEventType = "campaign.deliverability_warning"
// Warmup
WebhookEventWarmupEmailSent WebhookEventType = "warmup.email_sent"
@@ -56,6 +60,7 @@ var AllWebhookEventTypes = []WebhookEventType{
WebhookEventCampaignStarted,
WebhookEventCampaignPaused,
WebhookEventCampaignCompleted,
WebhookEventCampaignDeliverabilityWarning,
WebhookEventWarmupEmailSent,
WebhookEventWarmupHealthChanged,
WebhookEventWarmupPlacementInSpam,
+125
View File
@@ -0,0 +1,125 @@
// Package dnsauth validates a sending domain's email authentication records
// (SPF, DKIM, DMARC) via DNS TXT lookups. Authentication alignment is a hard
// Google/Yahoo bulk-sender requirement and the most common silent deliverability
// failure, so this lets the platform surface missing/misconfigured records.
//
// Control-plane only: this performs outbound DNS lookups and is meant to run in
// the backend (on demand or on a schedule), never in the worker.
package dnsauth
import (
"context"
"net"
"strings"
"time"
)
// Result is the outcome of an authentication check for one domain.
type Result struct {
Domain string `json:"domain"`
SPFFound bool `json:"spf_found"`
SPFRecord string `json:"spf_record,omitempty"`
DKIMFound bool `json:"dkim_found"`
DKIMSelectors []string `json:"dkim_selectors,omitempty"`
DMARCFound bool `json:"dmarc_found"`
DMARCPolicy string `json:"dmarc_policy,omitempty"`
AllAligned bool `json:"all_aligned"`
Summary string `json:"summary"`
}
// defaultSelectors are common DKIM selectors to probe when the caller doesn't
// know the domain's selector. DKIM selectors aren't discoverable from DNS, so a
// "not found" only means none of these matched, not that DKIM is absent.
var defaultSelectors = []string{"google", "default", "selector1", "selector2", "k1", "mail", "dkim", "s1", "s2"}
const lookupTimeout = 5 * time.Second
// Check validates SPF, DKIM and DMARC for the domain. dkimSelectors may be nil
// to probe a default selector set.
func Check(ctx context.Context, domain string, dkimSelectors []string) Result {
domain = strings.ToLower(strings.TrimSpace(domain))
res := Result{Domain: domain}
if domain == "" {
res.Summary = "no domain to check"
return res
}
resolver := &net.Resolver{}
lookup := func(name string) []string {
c, cancel := context.WithTimeout(ctx, lookupTimeout)
defer cancel()
txts, err := resolver.LookupTXT(c, name)
if err != nil {
return nil
}
return txts
}
// SPF: a TXT record on the root domain beginning v=spf1.
for _, t := range lookup(domain) {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=spf1") {
res.SPFFound = true
res.SPFRecord = strings.TrimSpace(t)
break
}
}
// DMARC: a TXT record at _dmarc.<domain> containing v=DMARC1; capture p=.
for _, t := range lookup("_dmarc." + domain) {
if strings.Contains(strings.ToLower(t), "v=dmarc1") {
res.DMARCFound = true
res.DMARCPolicy = parseDMARCPolicy(t)
break
}
}
// DKIM: a TXT record at <selector>._domainkey.<domain>.
if len(dkimSelectors) == 0 {
dkimSelectors = defaultSelectors
}
for _, sel := range dkimSelectors {
for _, t := range lookup(sel + "._domainkey." + domain) {
lt := strings.ToLower(t)
if strings.Contains(lt, "v=dkim1") || strings.Contains(lt, "k=rsa") || strings.Contains(lt, "p=") {
res.DKIMFound = true
res.DKIMSelectors = append(res.DKIMSelectors, sel)
break
}
}
}
res.AllAligned = res.SPFFound && res.DKIMFound && res.DMARCFound
res.Summary = summarize(res)
return res
}
func parseDMARCPolicy(record string) string {
for _, part := range strings.Split(record, ";") {
part = strings.TrimSpace(strings.ToLower(part))
if strings.HasPrefix(part, "p=") {
return strings.TrimSpace(strings.TrimPrefix(part, "p="))
}
}
return ""
}
func summarize(r Result) string {
var missing []string
if !r.SPFFound {
missing = append(missing, "SPF")
}
if !r.DKIMFound {
missing = append(missing, "DKIM")
}
if !r.DMARCFound {
missing = append(missing, "DMARC")
}
if len(missing) == 0 {
policy := r.DMARCPolicy
if policy == "" {
policy = "none"
}
return "SPF, DKIM and DMARC all present (DMARC policy: " + policy + ")"
}
return "missing or unverifiable: " + strings.Join(missing, ", ")
}
+350
View File
@@ -0,0 +1,350 @@
// Package emailverify performs pre-send email verification so the platform can
// drop addresses that would hard-bounce *before* a worker ever sends to them.
// Today suppression is purely reactive (we only suppress after a bounce/complaint
// lands), which means every bad address costs us one real bounce against our
// sending reputation. Verifying up front turns that into a zero-bounce drop.
//
// CONTROL-PLANE ONLY. This package dials remote MX hosts on :25 to run an SMTP
// RCPT probe. That probe MUST run from the backend/consumer (a dedicated,
// non-sending IP), never from a worker. Workers are our sending IPs, and running
// RCPT probes from them pollutes the exact reputation this feature exists to
// protect — recipient servers treat probe traffic from a sending IP as
// suspicious, and a probe that gets greylisted/tarpitted ties up a sending
// connection. Keep all probing on the control plane.
//
// Operational caveats baked into the design:
// - Many cloud providers (AWS, GCP, most consumer ISPs) block *outbound* :25.
// On such a host the SMTP probe will always time out and every address
// degrades to Status "unknown". Run the prober from a host with open
// outbound :25, or plug in a paid backend (see below).
// - Greylisting is normal and correct server behaviour: a first-contact RCPT
// often gets a 4xx "try again later". We deliberately map 4xx -> "unknown"
// (never "invalid") so greylisting can't cause us to drop a real contact.
// - Catch-all domains accept *every* localpart, so a 250 on the real address
// proves nothing. We detect catch-all by probing a random localpart; if that
// is also accepted the real address is downgraded to "risky", not "valid".
//
// For production-grade accuracy the Verifier interface is intentionally the only
// contract the rest of the platform depends on. A paid provider (ZeroBounce,
// NeverBounce, Bouncer, etc.) can be dropped in as an alternate Verifier
// implementation without touching the service, repo, scheduler, or handler.
package emailverify
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"sort"
"strconv"
"strings"
"time"
)
// Status is the verification outcome for a single address. It is a small closed
// set so callers (and the DB column) can reason about it without parsing free
// text.
type Status string
const (
// StatusValid means syntax + MX + an accepted RCPT on a non-catch-all domain.
StatusValid Status = "valid"
// StatusRisky means deliverable-looking but unsafe to trust — primarily a
// catch-all domain where acceptance proves nothing.
StatusRisky Status = "risky"
// StatusInvalid means a hard failure: bad syntax, no MX, or a 550-class RCPT
// rejection. These are the addresses the pre-send gate should drop.
StatusInvalid Status = "invalid"
// StatusUnknown means we couldn't determine deliverability — timeout, 4xx
// greylisting, blocked outbound :25, or any transient/ambiguous condition.
// Unknown is never dropped; it is retried or sent cautiously.
StatusUnknown Status = "unknown"
)
// Result is the outcome of verifying one address. It round-trips into the
// contacts table (verification_status / verification_reason / is_catch_all /
// verification_checked_at).
type Result struct {
Email string `json:"email"`
Status Status `json:"status"`
Reason string `json:"reason"`
IsCatchAll bool `json:"is_catch_all"`
HasMX bool `json:"has_mx"`
CheckedAt time.Time `json:"checked_at"`
}
// Verifier is the single contract the rest of the platform depends on. The
// in-house SMTPVerifier implements it; a paid provider can implement the same
// interface and be swapped in at wiring time with no other code changes.
type Verifier interface {
Verify(ctx context.Context, email string) Result
}
// Config tunes the in-house SMTP prober. HeloHost is the hostname sent in
// EHLO/HELO and MailFrom is the envelope sender used in MAIL FROM. Both should
// be real, attributable values for the verifying (non-sending) host so remote
// servers see consistent, non-spoofed probe identity. Timeouts default to sane
// values when zero.
type Config struct {
// HeloHost is the hostname announced in EHLO/HELO, e.g. "verify.warmbly.com".
HeloHost string
// MailFrom is the envelope sender for the probe, e.g. "verify@warmbly.com".
// An empty MAIL FROM ("<>") is technically valid but more often filtered, so
// a real address on the verifying host is preferred.
MailFrom string
// DialTimeout bounds the TCP connect to a single MX host. Default 5s.
DialTimeout time.Duration
// CommandTimeout bounds each SMTP command exchange. Default 5s.
CommandTimeout time.Duration
// MXTimeout bounds the MX DNS lookup. Default 5s.
MXTimeout time.Duration
}
func (c Config) withDefaults() Config {
if c.HeloHost == "" {
c.HeloHost = "localhost"
}
if c.MailFrom == "" {
c.MailFrom = "verify@" + c.HeloHost
}
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CommandTimeout <= 0 {
c.CommandTimeout = 5 * time.Second
}
if c.MXTimeout <= 0 {
c.MXTimeout = 5 * time.Second
}
return c
}
// SMTPVerifier is the in-house Verifier: syntax -> MX -> SMTP RCPT probe ->
// catch-all detection. It opens exactly one connection to the lowest-preference
// MX and probes both the real address and a random localpart on the same
// session, so catch-all detection costs no extra connection.
type SMTPVerifier struct {
cfg Config
resolver *net.Resolver
}
// New constructs the in-house SMTP verifier. The resolver mirrors dnsauth's
// net.Resolver usage (context-bounded lookups, no custom dialer needed).
func New(cfg Config) *SMTPVerifier {
return &SMTPVerifier{
cfg: cfg.withDefaults(),
resolver: &net.Resolver{},
}
}
// Verify runs the full pipeline for one address. It never returns an error; an
// undeterminable result is encoded as StatusUnknown so callers have a single
// code path.
func (v *SMTPVerifier) Verify(ctx context.Context, email string) Result {
now := time.Now().UTC()
res := Result{Email: email, CheckedAt: now, Status: StatusUnknown}
// 1. Syntax (RFC 5322-ish via net/mail). A parse failure is a hard invalid.
addr, err := mail.ParseAddress(email)
if err != nil {
res.Status = StatusInvalid
res.Reason = "invalid syntax"
return res
}
normalized := strings.ToLower(strings.TrimSpace(addr.Address))
res.Email = normalized
at := strings.LastIndex(normalized, "@")
if at <= 0 || at == len(normalized)-1 {
res.Status = StatusInvalid
res.Reason = "invalid syntax"
return res
}
localpart := normalized[:at]
domain := normalized[at+1:]
// 2. MX lookup. No MX (and no usable fallback) is a hard invalid: nowhere to
// deliver. A lookup *error* (timeout/SERVFAIL) is unknown, not invalid.
hosts, mxErr := v.lookupMXHosts(ctx, domain)
if mxErr != nil {
res.Status = StatusUnknown
res.Reason = "mx lookup failed: " + mxErr.Error()
return res
}
if len(hosts) == 0 {
res.Status = StatusInvalid
res.Reason = "no MX records"
return res
}
res.HasMX = true
// 3. SMTP RCPT probe against the lowest-preference (highest priority) MX.
probe := v.probe(ctx, hosts[0], localpart, domain)
switch probe.outcome {
case probeAccepted:
// 4. Catch-all check already folded into probe(): if the random control
// localpart was also accepted, the 250 on the real address is meaningless.
if probe.catchAll {
res.IsCatchAll = true
res.Status = StatusRisky
res.Reason = "catch-all domain; acceptance is not conclusive"
return res
}
res.Status = StatusValid
res.Reason = "recipient accepted"
return res
case probeRejected:
res.Status = StatusInvalid
res.Reason = probe.reason
return res
default: // probeUnknown
res.Status = StatusUnknown
res.Reason = probe.reason
return res
}
}
// lookupMXHosts returns MX hosts ordered by ascending preference (most-preferred
// first). When a domain publishes no MX, RFC 5321 permits implicit-MX fallback
// to the A/AAAA record of the domain itself; we honour that so apex-only mail
// domains aren't misjudged as invalid.
func (v *SMTPVerifier) lookupMXHosts(ctx context.Context, domain string) ([]string, error) {
c, cancel := context.WithTimeout(ctx, v.cfg.MXTimeout)
defer cancel()
mxs, err := v.resolver.LookupMX(c, domain)
if err != nil {
// A "no such host" / "no MX" style miss is not a transport error; treat
// it as "no MX" and let the implicit-MX fallback below decide.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || dnsErr.Err == "no such host") {
mxs = nil
} else {
return nil, err
}
}
if len(mxs) > 0 {
sort.SliceStable(mxs, func(i, j int) bool { return mxs[i].Pref < mxs[j].Pref })
hosts := make([]string, 0, len(mxs))
for _, mx := range mxs {
h := strings.TrimSuffix(strings.TrimSpace(mx.Host), ".")
if h != "" {
hosts = append(hosts, h)
}
}
if len(hosts) > 0 {
return hosts, nil
}
}
// Implicit MX: fall back to the domain's own A/AAAA if it resolves.
ac, acancel := context.WithTimeout(ctx, v.cfg.MXTimeout)
defer acancel()
if addrs, aerr := v.resolver.LookupHost(ac, domain); aerr == nil && len(addrs) > 0 {
return []string{domain}, nil
}
return nil, nil
}
type probeOutcome int
const (
probeUnknown probeOutcome = iota
probeAccepted
probeRejected
)
type probeResult struct {
outcome probeOutcome
catchAll bool
reason string
}
// probe opens one SMTP session to host:25, greets it, sets the envelope sender,
// and issues RCPT TO for the real address. If the real address is accepted it
// also issues RCPT TO for a random control localpart on the same domain to
// detect catch-all behaviour. Interpretation:
//
// 250 -> accepted
// 550 (5xx) -> rejected (hard invalid)
// 4xx / timeout/ dial error -> unknown (greylist, blocked :25, transient)
func (v *SMTPVerifier) probe(ctx context.Context, host, localpart, domain string) probeResult {
dialer := net.Dialer{Timeout: v.cfg.DialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, "25"))
if err != nil {
// Most commonly: outbound :25 blocked by the cloud provider, or the MX is
// firewalled/tarpitting. Either way we cannot conclude invalid.
return probeResult{outcome: probeUnknown, reason: "smtp dial failed (port 25 may be blocked): " + err.Error()}
}
// Bound the whole session.
deadline := time.Now().Add(v.cfg.CommandTimeout * 4)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = conn.SetDeadline(deadline)
client, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return probeResult{outcome: probeUnknown, reason: "smtp handshake failed: " + err.Error()}
}
defer func() { _ = client.Close() }()
if err := client.Hello(v.cfg.HeloHost); err != nil {
return probeResult{outcome: probeUnknown, reason: "EHLO/HELO rejected: " + err.Error()}
}
if err := client.Mail(v.cfg.MailFrom); err != nil {
return probeResult{outcome: probeUnknown, reason: "MAIL FROM rejected: " + err.Error()}
}
realOutcome, realReason := classifyRcpt(client.Rcpt(localpart + "@" + domain))
switch realOutcome {
case probeRejected:
return probeResult{outcome: probeRejected, reason: realReason}
case probeUnknown:
return probeResult{outcome: probeUnknown, reason: realReason}
}
// Real address accepted — probe a random control localpart to detect a
// catch-all. If that is also accepted, the domain accepts everything and the
// real 250 proves nothing.
control := randomLocalpart()
controlOutcome, _ := classifyRcpt(client.Rcpt(control + "@" + domain))
return probeResult{outcome: probeAccepted, catchAll: controlOutcome == probeAccepted}
}
// classifyRcpt maps the error from smtp.Client.Rcpt to a probe outcome. The
// stdlib surfaces the SMTP reply code on *textproto.Error; 5xx is a hard
// rejection, 4xx is transient (unknown), and a nil error is acceptance.
func classifyRcpt(err error) (probeOutcome, string) {
if err == nil {
return probeAccepted, "recipient accepted"
}
var protoErr *textproto.Error
if errors.As(err, &protoErr) {
code := protoErr.Code
switch {
case code >= 500 && code < 600:
return probeRejected, "recipient rejected (" + strconv.Itoa(code) + "): " + protoErr.Msg
case code >= 400 && code < 500:
return probeUnknown, "transient/greylisted (" + strconv.Itoa(code) + "): " + protoErr.Msg
}
}
// Connection reset, timeout mid-command, or an unparseable reply.
return probeUnknown, "rcpt indeterminate: " + err.Error()
}
func randomLocalpart() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
// Fall back to a fixed-but-unlikely localpart; the worst case is a
// false-negative catch-all check, never a wrong invalid verdict.
return "no-such-mailbox-warmbly-probe"
}
return "wb-verify-" + hex.EncodeToString(b)
}
+206
View File
@@ -0,0 +1,206 @@
package generation
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/openai/openai-go/v2"
)
// BatchRequest is one warmup thread to generate via the OpenAI Batch API. The
// CustomID round-trips through the batch so results can be mapped back to their
// theme (the batch output is unordered and may drop failed lines).
type BatchRequest struct {
CustomID string
Theme string
Model string
MaxMessages int
}
// BatchResult is one parsed line from a completed batch's output file. Exactly
// one of Conversation or Err is set per line; Err carries a per-line failure so
// a single bad response never fails the whole ingest.
type BatchResult struct {
CustomID string
Theme string
Conversation *Conversation
Err string
}
// BatchCounts mirrors the OpenAI batch request_counts object.
type BatchCounts struct {
Completed int
Failed int
Total int
}
// batchInputLine is one line of the Batch API JSONL input file. The body is the
// same chat-completion request the sync path sends, so sync and batch produce
// identical threads.
type batchInputLine struct {
CustomID string `json:"custom_id"`
Method string `json:"method"`
URL string `json:"url"`
Body openai.ChatCompletionNewParams `json:"body"`
}
// batchOutputLine is one line of the Batch API JSONL output file.
type batchOutputLine struct {
CustomID string `json:"custom_id"`
Response *struct {
StatusCode int `json:"status_code"`
Body openai.ChatCompletion `json:"body"`
} `json:"response"`
Error *struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
// SubmitBatch uploads a JSONL of chat-completion requests as a batch input file
// and creates a batch over the /v1/chat/completions endpoint. It returns the
// batch ID and the uploaded input file ID. completionWindow is the Batch API
// processing window (only "24h" is currently accepted by OpenAI; empty defaults
// to "24h").
func (c *GenerationClient) SubmitBatch(ctx context.Context, requests []BatchRequest, completionWindow string) (batchID, inputFileID string, err error) {
if len(requests) == 0 {
return "", "", fmt.Errorf("submit batch: no requests")
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for _, r := range requests {
body := buildConversationParams(r.Theme, r.Model, normalizeMaxMessages(r.MaxMessages))
line := batchInputLine{
CustomID: r.CustomID,
Method: "POST",
URL: "/v1/chat/completions",
Body: body,
}
if err := enc.Encode(line); err != nil {
return "", "", fmt.Errorf("submit batch: encode line %s: %w", r.CustomID, err)
}
}
file, err := c.client.Files.New(ctx, openai.FileNewParams{
File: namedReader{Reader: bytes.NewReader(buf.Bytes()), name: "warmup_batch.jsonl"},
Purpose: openai.FilePurposeBatch,
})
if err != nil {
return "", "", fmt.Errorf("submit batch: upload input file: %w", err)
}
window := openai.BatchNewParamsCompletionWindow24h
if completionWindow != "" {
window = openai.BatchNewParamsCompletionWindow(completionWindow)
}
batch, err := c.client.Batches.New(ctx, openai.BatchNewParams{
CompletionWindow: window,
Endpoint: openai.BatchNewParamsEndpointV1ChatCompletions,
InputFileID: file.ID,
})
if err != nil {
return "", "", fmt.Errorf("submit batch: create batch: %w", err)
}
return batch.ID, file.ID, nil
}
// GetBatch returns the current status, output file ID (when completed), and
// request counts for a batch.
func (c *GenerationClient) GetBatch(ctx context.Context, batchID string) (status, outputFileID string, counts BatchCounts, err error) {
batch, err := c.client.Batches.Get(ctx, batchID)
if err != nil {
return "", "", BatchCounts{}, err
}
counts = BatchCounts{
Completed: int(batch.RequestCounts.Completed),
Failed: int(batch.RequestCounts.Failed),
Total: int(batch.RequestCounts.Total),
}
return string(batch.Status), batch.OutputFileID, counts, nil
}
// CancelBatch requests cancellation of an in-flight batch.
func (c *GenerationClient) CancelBatch(ctx context.Context, batchID string) error {
_, err := c.client.Batches.Cancel(ctx, batchID)
return err
}
// FetchBatchResults downloads a completed batch's output file and parses each
// JSONL line back into a Conversation. Per-line failures (HTTP error, malformed
// body, JSON parse error) are reported on the individual BatchResult rather than
// failing the whole fetch, so a few bad lines don't discard the good ones.
func (c *GenerationClient) FetchBatchResults(ctx context.Context, outputFileID string) ([]BatchResult, error) {
if outputFileID == "" {
return nil, fmt.Errorf("fetch batch results: empty output file id")
}
resp, err := c.client.Files.Content(ctx, outputFileID)
if err != nil {
return nil, fmt.Errorf("fetch batch results: download output file: %w", err)
}
defer resp.Body.Close()
var out []BatchResult
scanner := bufio.NewScanner(resp.Body)
// Output bodies can be large; raise the line buffer well above the default 64KiB.
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
for scanner.Scan() {
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
continue
}
out = append(out, parseBatchOutputLine(raw))
}
if err := scanner.Err(); err != nil {
return out, fmt.Errorf("fetch batch results: read output file: %w", err)
}
return out, nil
}
// parseBatchOutputLine decodes one output JSONL line, tolerating per-line errors.
func parseBatchOutputLine(raw string) BatchResult {
var line batchOutputLine
if err := json.Unmarshal([]byte(raw), &line); err != nil {
return BatchResult{Err: fmt.Sprintf("parse output line: %v", err)}
}
res := BatchResult{CustomID: line.CustomID}
if line.Error != nil && line.Error.Message != "" {
res.Err = line.Error.Message
return res
}
if line.Response == nil {
res.Err = "missing response in output line"
return res
}
if line.Response.StatusCode < 200 || line.Response.StatusCode >= 300 {
res.Err = fmt.Sprintf("response status %d", line.Response.StatusCode)
return res
}
if len(line.Response.Body.Choices) == 0 {
res.Err = "response returned no choices"
return res
}
var conv Conversation
if err := json.Unmarshal([]byte(line.Response.Body.Choices[0].Message.Content), &conv); err != nil {
res.Err = fmt.Sprintf("parse conversation: %v", err)
return res
}
res.Conversation = &conv
return res
}
// namedReader adapts a reader so the multipart upload carries a filename, which
// the Files endpoint requires for the JSONL input.
type namedReader struct {
io.Reader
name string
}
func (n namedReader) Name() string { return n.name }
+94 -17
View File
@@ -10,42 +10,119 @@ import (
var ConversationSchema = GenerateSchema[Conversation]()
func (c *GenerationClient) GenerateConversation(ctx context.Context, theme string) (*Conversation, error) {
systemPrompt := fmt.Sprintf(`
Output valid JSON only.
// normalizeMaxMessages clamps the admin-controlled follow-up count into the
// range the warmup prompt is tuned for. Shared so the sync and batch paths
// produce identical thread shapes.
func normalizeMaxMessages(maxMessages int) int {
if maxMessages <= 0 {
return 4
}
if maxMessages > 5 {
return 5
}
return maxMessages
}
Positive realistic warmup email thread for theme: %s
// warmupSystemPrompt builds the deliverability-tuned system prompt for one
// theme. Extracted so the sync path (GenerateConversation) and the Batch API
// path (SubmitBatch) send the exact same instruction, keeping the two
// generation modes byte-for-byte identical.
func warmupSystemPrompt(theme string, maxMessages int) string {
// The prompt is grounded in the ACTUAL signals AI-text detectors key on
// (burstiness, contractions, AI-accent vocabulary, stock openers, symmetric
// templates), not a vague "sound human". A deterministic post-processing
// pass (internal/pkg/humanlint) then strips residual tells before the
// content is cached, so this is the first of two coordinated layers.
return fmt.Sprintf(`Output valid JSON only.
- Friendly colleague tone, occasional 😊👍, always ask questions to continue
- Use {{.FirstName}} {{.LastName}} {{.Email}} {{.Company}} {{.Signature}} naturally
- Every body = only text sender writes + {{.Signature}} at end (no headers, no quotes, no "On ...")
- Aim for 1014 messages total across branches
- Include at least 4 branch points with 24 variants each (short/long/different questions)
- Keep replies natural & positive
`, theme)
Write a short, real message a busy colleague would actually send to someone they
already know, about: %s. You are that person, NOT an assistant.
Return:
- subject: 2-6 words, lowercase-natural, never prefixed with "Re:" or "Fwd:".
- description: the OPENING body only (no greeting, sign-off, signature, or the
subject) — 1 to 3 sentences.
- messages: %d short follow-up lines (one sentence each) that could continue it.
Write like a person, by following how people actually write — not by trying to
"sound human":
- Use contractions naturally (I'm, don't, you're, it's, can't, I'll, that's,
we've, didn't). Don't contract every single one; leave a couple expanded.
- Vary sentence length hard. Put at least one very short line or fragment
(2-5 words, e.g. "Makes sense." / "No rush.") next to a longer one. Never let
every sentence land at the same length.
- Include exactly one concrete, specific detail a stranger couldn't guess — a
day ("Tuesday"), a time ("after lunch"), a named thing ("the second draft",
"the deck"), or a small number.
- Start with the actual point or a bare "hey" — never a stock opener ("I hope
this email finds you well", "I wanted to reach out", "I just wanted to").
- No intro-body-conclusion shape, no restating, no "in conclusion".
Hard bans (these are the strongest AI tells):
- NO AI-accent words: delve, leverage, utilize, harness, robust, seamless,
underscore, showcase, foster, streamline, elevate, pivotal, comprehensive,
testament, tapestry, realm, synergy, paradigm, furthermore, moreover,
additionally. Use plain words (use, show, solid, smooth, key, full, also).
- NO "not only X but also Y", NO "it's not X, it's Y", NO rule-of-three lists
("fast, reliable, and affordable"). Use at most one dash; prefer none.
- NO hedging/corporate filler: "it's worth noting", "in order to", "circle
back", "touch base", "at the end of the day". It's fine to start with "And"
or "But".
- Plain text only. No links, URLs, phone numbers, attachments, emoji, ALL-CAPS,
or marketing/sales language. At most one "!", prefer zero.
Vary the shape across messages — sometimes a fragment plus a question, sometimes
one longer line — so a batch isn't uniform.`, theme, maxMessages)
}
// conversationResponseFormat returns the strict JSON-schema response format
// shared by sync and batch generation.
func conversationResponseFormat() openai.ChatCompletionNewParamsResponseFormatUnion {
schemaParam := openai.ResponseFormatJSONSchemaJSONSchemaParam{
Name: "conversation",
Description: openai.String("Realistic branched email warmup thread"),
Schema: ConversationSchema,
Strict: openai.Bool(true),
}
return openai.ChatCompletionNewParamsResponseFormatUnion{
OfJSONSchema: &openai.ResponseFormatJSONSchemaParam{JSONSchema: schemaParam},
}
}
req := openai.ChatCompletionNewParams{
Model: openai.ChatModelGPT4oMini,
// buildConversationParams assembles the chat-completion request for one warmup
// 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
if model != "" {
chatModel = openai.ChatModel(model)
}
return openai.ChatCompletionNewParams{
Model: chatModel,
Messages: []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.SystemMessage(warmupSystemPrompt(theme, maxMessages)),
openai.UserMessage("Generate one complete thread now."),
},
ResponseFormat: openai.ChatCompletionNewParamsResponseFormatUnion{
OfJSONSchema: &openai.ResponseFormatJSONSchemaParam{JSONSchema: schemaParam},
},
ResponseFormat: conversationResponseFormat(),
}
}
// GenerateConversation produces one realistic warmup email thread for a theme.
//
// 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).
// model and maxMessages are admin-controlled (empty model → gpt-4o-mini).
func (c *GenerationClient) GenerateConversation(ctx context.Context, theme, model string, maxMessages int) (*Conversation, error) {
req := buildConversationParams(theme, model, normalizeMaxMessages(maxMessages))
resp, err := c.client.Chat.Completions.New(ctx, req)
if err != nil {
return nil, err
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("generation returned no choices")
}
var parsed Conversation
if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &parsed); err != nil {
+7 -9
View File
@@ -1,13 +1,11 @@
package generation
// Conversation is the structured output of warmup content generation. The
// shape matches how the warmup renderer consumes content: it composes the
// greeting, sign-off and signature itself, so Description is the opening body
// text only (no greeting/signature) and Messages are follow-up question lines.
type Conversation struct {
Title string `json:"title" jsonschema:"description=Short descriptive title"`
Description string `json:"description" jsonschema:"description=1-2 sentence summary"`
Subject string `json:"subject" jsonschema:"description=Initial subject line (no Re:)"`
Messages []ConversationMessage `json:"messages" jsonschema:"minItems=1"`
}
type ConversationMessage struct {
Body string `json:"body" jsonschema:"description=Plaintext content the sender actually types. Greeting + message + sign-off + {{.Signature}} at the very bottom. No email headers, no quoting, no 'On ... wrote:'. Keep positive and natural."`
Messages []ConversationMessage `json:"messages" jsonschema:"description=Alternative replies (0-4). Use to create branches with different tones/lengths/questions. Aim for deep threads."`
Subject string `json:"subject" jsonschema:"description=Short lowercase-natural subject line, 2-6 words, never prefixed with Re:"`
Description string `json:"description" jsonschema:"description=The opening message body ONLY: a couple of short natural sentences. No greeting, no sign-off, no signature, no subject line."`
Messages []string `json:"messages" jsonschema:"description=Short natural follow-up question lines, one sentence each, that could continue the thread. No greeting or sign-off."`
}
+465
View File
@@ -0,0 +1,465 @@
// Package humanlint makes generated warmup copy read like a real person wrote
// it, using the actual signals AI-text detectors and linguists key on rather
// than just telling the model to "sound human".
//
// It does two things, both with NO ML model:
//
// - Humanize(text, seed): conservative, meaning-preserving transforms that
// remove the strongest AI tells — em-dashes, stock openers ("I hope this
// email finds you well"), AI-accent vocabulary (delve/leverage/robust...),
// the "not only X but also Y" template, curly quotes, exclamation spam — and
// applies contractions PROBABILISTICALLY (~0.7, seeded) so the result isn't
// itself a uniform fingerprint.
// - Score(text): an advisory AI-likeness score (0 clean .. 100 robotic) from
// tell density + burstiness (sentence-length variation, the most-cited
// non-ML human/AI separator). LooksRobotic gates content offline.
//
// Hard safety constraints for warmup mail (see the research): every transform
// preserves meaning and NEVER adds a spam signal — no fake typos/leetspeak, no
// urgency/marketing, no links, exclamations capped. Humanized output must still
// pass the existing warmlint spam gate; the score is advisory ("remove obvious
// tells + read naturally"), never a "guaranteed undetectable" claim, and never
// overrides warmlint.
package humanlint
import (
"math/rand"
"regexp"
"strings"
)
// contractionProbability is how often an eligible formal phrase is contracted.
// NOT 1.0 on purpose: contracting 100% is its own uniform fingerprint; humans
// leave some expanded.
const contractionProbability = 0.72
// aiTellWords are single-word "AI accent" terms. Their presence raises the
// robotic score; a subset with a safe plain equivalent is also swapped by
// Humanize (see wordSwaps). The rest are penalized/regenerated, not mangled.
var aiTellWords = map[string]struct{}{
"delve": {}, "leverage": {}, "utilize": {}, "utilise": {}, "harness": {},
"streamline": {}, "underscore": {}, "foster": {}, "fostering": {},
"navigating": {}, "showcase": {}, "showcasing": {}, "emphasize": {},
"enhance": {}, "unlock": {}, "unveil": {}, "embark": {}, "elevate": {},
"amplify": {}, "galvanize": {}, "illuminate": {}, "resonate": {},
"boasts": {}, "garner": {}, "facilitate": {}, "robust": {}, "seamless": {},
"pivotal": {}, "multifaceted": {}, "comprehensive": {}, "intricate": {},
"intricacies": {}, "meticulous": {}, "nuanced": {}, "bespoke": {},
"cutting-edge": {}, "transformative": {}, "groundbreaking": {},
"compelling": {}, "holistic": {}, "proactive": {}, "iterative": {},
"palpable": {}, "bolstered": {}, "tapestry": {}, "realm": {},
"testament": {}, "synergy": {}, "paradigm": {}, "ecosystem": {},
"beacon": {}, "bastion": {}, "camaraderie": {}, "interplay": {},
"furthermore": {}, "moreover": {}, "additionally": {}, "consequently": {},
"notably": {}, "amidst": {},
}
// wordSwaps maps an AI-accent word to a safe, plainer, meaning-preserving
// equivalent for casual email. Only high-confidence 1:1 swaps live here; words
// without a clean swap stay in aiTellWords (scored, not transformed).
var wordSwaps = map[string]string{
"leverage": "use",
"utilize": "use",
"utilise": "use",
"harness": "use",
"streamline": "simplify",
"underscore": "stress",
"showcase": "show",
"showcasing": "showing",
"foster": "build",
"facilitate": "help",
"garner": "get",
"enhance": "improve",
"robust": "solid",
"seamless": "smooth",
"pivotal": "key",
"comprehensive": "full",
"delve": "dig",
"furthermore": "also",
"moreover": "also",
"additionally": "also",
"consequently": "so",
}
// aiTellPhrases are multi-word stock LLM/corporate constructions. Scored; the
// fillerStrips subset is also removed/rewritten by Humanize.
var aiTellPhrases = []string{
"i hope this email finds you well", "i hope this message finds you well",
"i hope this note finds you well", "i wanted to reach out",
"i just wanted to reach out", "i am reaching out to", "i just wanted to",
"in today's fast-paced world", "in today's digital age",
"in today's digital landscape", "in the realm of", "when it comes to",
"it is worth noting that", "it's worth noting", "it is important to note",
"it's important to note", "it bears mentioning", "that being said",
"a wide range of", "a wide array of", "a testament to",
"due to the fact that", "embark on a journey", "at the end of the day",
"rest assured", "please be advised", "at your earliest convenience",
"as previously mentioned", "circle back", "touch base", "in order to",
"in conclusion", "to sum up", "in summary", "in essence",
"serves as a", "stands as a", "plays a pivotal role", "not only", "but also",
}
// clicheOpeners are formulaic email first lines. If the text starts with one,
// Humanize drops that leading clause.
var clicheOpeners = []string{
"i hope this email finds you well", "i hope this message finds you well",
"i hope this note finds you well", "i hope you're doing well",
"i hope you are doing well", "i hope all is well", "i wanted to reach out",
"i just wanted to reach out", "i am reaching out", "i'm reaching out",
"i just wanted to touch base", "i wanted to touch base",
"dear sir or madam", "to whom it may concern", "i hope this finds you well",
}
// fillerStrips are phrases Humanize rewrites to plainer equivalents (empty = drop).
var fillerStrips = []struct{ from, to string }{
{"due to the fact that", "because"},
{"in order to", "to"},
{"it is worth noting that ", ""},
{"it's worth noting that ", ""},
{"it is worth mentioning that ", ""},
{"it's worth mentioning that ", ""},
{"that being said, ", ""},
{"at the end of the day, ", ""},
{"to be honest, ", ""},
{"as previously mentioned, ", ""},
}
// contractions are ordered so longer/negation forms apply before shorter ones.
var contractions = []struct{ from, to string }{
{"do not", "don't"}, {"does not", "doesn't"}, {"did not", "didn't"},
{"is not", "isn't"}, {"are not", "aren't"}, {"was not", "wasn't"},
{"were not", "weren't"}, {"has not", "hasn't"}, {"have not", "haven't"},
{"had not", "hadn't"}, {"will not", "won't"}, {"would not", "wouldn't"},
{"should not", "shouldn't"}, {"could not", "couldn't"}, {"cannot", "can't"},
{"can not", "can't"}, {"I will", "I'll"}, {"I am", "I'm"}, {"I have", "I've"},
{"I would", "I'd"}, {"we are", "we're"}, {"we will", "we'll"},
{"we have", "we've"}, {"you are", "you're"}, {"you will", "you'll"},
{"you have", "you've"}, {"they are", "they're"}, {"they will", "they'll"},
{"it is", "it's"}, {"that is", "that's"}, {"there is", "there's"},
{"here is", "here's"}, {"let us", "let's"}, {"what is", "what's"},
{"who is", "who's"},
}
var (
emDash = regexp.MustCompile(`\s*[\x{2014}\x{2013}]\s*`)
spacedDash = regexp.MustCompile(`\s+-{1,2}\s+`)
notOnlyButAlso = regexp.MustCompile(`(?i)\bnot only\b(.+?)\bbut also\b`)
multiSpace = regexp.MustCompile(`[ \t]{2,}`)
sentenceSplit = regexp.MustCompile(`[.!?]+`)
wordRe = regexp.MustCompile(`[A-Za-z0-9'%-]+`)
tricolon = regexp.MustCompile(`(?i)\b([a-z]+), ([a-z]+),? and ([a-z]+)\b`)
)
// Humanize applies conservative, meaning-preserving transforms that strip the
// strongest AI tells and contract probabilistically. seed makes it
// deterministic per call (so the same content humanizes identically) while
// still varying across different content.
func Humanize(text string, seed int64) string {
if strings.TrimSpace(text) == "" {
return text
}
rng := rand.New(rand.NewSource(seed))
out := normalizePunctuation(text)
out = stripLeadingOpener(out)
out = applyFillerStrips(out)
out = applyWordSwaps(out)
out = flattenNotOnlyButAlso(out)
out = applyContractions(out, rng)
out = capExclamations(out)
out = multiSpace.ReplaceAllString(out, " ")
return strings.TrimSpace(out)
}
// HumanizeSubject is Humanize tuned for short subject lines. It applies only the
// safe normalizations (punctuation, AI-word swaps, contractions, exclamation
// cap) and skips the clause-level transforms meant for multi-sentence bodies
// (opener stripping, filler strips, "not only X but also Y" flattening) — those
// can mangle a 2-6 word subject, e.g. turning "I'm reaching out re Tuesday" into
// a stray "Re Tuesday".
func HumanizeSubject(text string, seed int64) string {
if strings.TrimSpace(text) == "" {
return text
}
rng := rand.New(rand.NewSource(seed))
out := normalizePunctuation(text)
out = applyWordSwaps(out)
out = applyContractions(out, rng)
out = capExclamations(out)
out = multiSpace.ReplaceAllString(out, " ")
return strings.TrimSpace(out)
}
// normalizePunctuation straightens curly quotes/ellipsis and turns em/spaced
// dashes into commas (a clause join) — the single most cited punctuation tell.
func normalizePunctuation(s string) string {
r := strings.NewReplacer(
"", "'", "", "'", "“", "\"", "”", "\"",
"…", "...",
)
s = r.Replace(s)
s = emDash.ReplaceAllString(s, ", ")
s = spacedDash.ReplaceAllString(s, ", ")
return s
}
func stripLeadingOpener(s string) string {
trimmed := strings.TrimLeft(s, " \t")
// Defensive: don't strip clause-level openers off subject-shaped text (a
// short line with no sentence terminator). Stripping "I'm reaching out" off
// the subject "I'm reaching out re Tuesday" would leave a stray "Re
// Tuesday". HumanizeSubject already avoids this; this guard covers any
// other caller.
if !strings.ContainsAny(trimmed, ".!?") && len(wordRe.FindAllString(trimmed, -1)) <= 6 {
return s
}
lower := strings.ToLower(trimmed)
for _, op := range clicheOpeners {
if strings.HasPrefix(lower, op) {
rest := strings.TrimLeft(trimmed[len(op):], " ,.!:;-")
if rest == "" {
return trimmed // opener was the whole thing — keep something
}
// Capitalize the new first letter.
return upperFirst(rest)
}
}
return s
}
func applyFillerStrips(s string) string {
for _, f := range fillerStrips {
s = replaceFold(s, f.from, f.to)
}
return s
}
func applyWordSwaps(s string) string {
return wordRe.ReplaceAllStringFunc(s, func(w string) string {
repl, ok := wordSwaps[strings.ToLower(w)]
if !ok {
return w
}
if isUpperFirst(w) {
return upperFirst(repl)
}
return repl
})
}
func flattenNotOnlyButAlso(s string) string {
return notOnlyButAlso.ReplaceAllString(s, "$1 and ")
}
func applyContractions(s string, rng *rand.Rand) string {
for _, c := range contractions {
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(c.from) + `\b`)
s = re.ReplaceAllStringFunc(s, func(m string) string {
if rng.Float64() > contractionProbability {
return m // leave a fraction expanded
}
if isUpperFirst(m) {
return upperFirst(c.to)
}
return c.to
})
}
return s
}
// capExclamations keeps at most one '!' and never stacked punctuation.
func capExclamations(s string) string {
s = strings.ReplaceAll(s, "!!", "!")
s = strings.ReplaceAll(s, "?!", "?")
if strings.Count(s, "!") <= 1 {
return s
}
seen := false
var b strings.Builder
for _, r := range s {
if r == '!' {
if seen {
b.WriteRune('.')
continue
}
seen = true
}
b.WriteRune(r)
}
return b.String()
}
// Issue is one robotic signal found by Score.
type Issue struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Score returns an advisory AI-likeness score (0 = reads human, 100 = robotic)
// plus the signals found. It is advisory only — never a pass/fail "undetectable"
// gate, and it must never override the warmlint spam gate.
func Score(text string) (int, []Issue) {
lower := strings.ToLower(text)
score := 0
var issues []Issue
add := func(n int, code, msg string) {
score += n
issues = append(issues, Issue{Code: code, Message: msg})
}
for _, op := range clicheOpeners {
if strings.HasPrefix(strings.TrimSpace(lower), op) {
add(30, "stock_opener", "opens with a stock line: "+op)
break
}
}
tellWords := 0
for _, w := range wordRe.FindAllString(lower, -1) {
if _, ok := aiTellWords[w]; ok {
tellWords++
}
}
if tellWords > 0 {
add(min(40, tellWords*12), "ai_vocabulary", "uses AI-accent vocabulary")
}
phraseHits := 0
for _, p := range aiTellPhrases {
if strings.Contains(lower, p) {
phraseHits++
}
}
if phraseHits > 0 {
add(min(35, phraseHits*12), "stock_phrases", "uses stock LLM phrasing")
}
if strings.ContainsAny(text, "—–") {
add(15, "em_dash", "contains an em-dash")
}
if notOnlyButAlso.MatchString(text) {
add(20, "not_only_but_also", "uses the 'not only X but also Y' template")
}
if tricolon.MatchString(text) {
add(8, "tricolon", "uses a rule-of-three list")
}
// Formal (uncontracted) forms present where a human would contract.
formal := 0
for _, c := range contractions {
if regexpFoldContains(lower, c.from) {
formal++
}
}
if formal >= 2 {
add(min(20, formal*5), "no_contractions", "formal/uncontracted phrasing")
}
// Burstiness: low sentence-length variation reads robotic. Only meaningful
// for multi-sentence text; single-line messages skip it (CV undefined).
if cv, ok := sentenceLengthCV(text); ok && cv < 0.5 {
add(18, "low_burstiness", "uniform sentence length (low burstiness)")
}
if score > 100 {
score = 100
}
return score, issues
}
// LooksRobotic reports whether text still reads machine-generated after
// humanization (used as an offline content-quality gate, not a hard spam gate).
func LooksRobotic(text string) bool {
s, _ := Score(text)
return s >= 45
}
// sentenceLengthCV returns the coefficient of variation of per-sentence word
// counts and whether it's defined (needs >=2 sentences with words).
func sentenceLengthCV(text string) (float64, bool) {
parts := sentenceSplit.Split(text, -1)
var lens []float64
for _, p := range parts {
n := len(wordRe.FindAllString(p, -1))
if n > 0 {
lens = append(lens, float64(n))
}
}
if len(lens) < 2 {
return 0, false
}
var sum float64
for _, l := range lens {
sum += l
}
mean := sum / float64(len(lens))
if mean == 0 {
return 0, false
}
var variance float64
for _, l := range lens {
d := l - mean
variance += d * d
}
variance /= float64(len(lens))
return sqrt(variance) / mean, true
}
// ---- small helpers (kept dependency-free) ----
func replaceFold(s, from, to string) string {
if from == "" {
return s
}
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(from))
return re.ReplaceAllStringFunc(s, func(m string) string {
if to != "" && isUpperFirst(m) {
return upperFirst(to)
}
return to
})
}
func regexpFoldContains(lowerText, phrase string) bool {
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(strings.ToLower(phrase)) + `\b`)
return re.MatchString(lowerText)
}
func isUpperFirst(s string) bool {
if s == "" {
return false
}
r := rune(s[0])
return r >= 'A' && r <= 'Z'
}
func upperFirst(s string) string {
if s == "" {
return s
}
r := rune(s[0])
if r >= 'a' && r <= 'z' {
return string(r-32) + s[1:]
}
return s
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func sqrt(x float64) float64 {
if x <= 0 {
return 0
}
// Newton's method — avoids importing math for one call.
z := x
for i := 0; i < 24; i++ {
z -= (z*z - x) / (2 * z)
}
return z
}
+115
View File
@@ -0,0 +1,115 @@
package humanlint
import (
"strings"
"testing"
"github.com/warmbly/warmbly/internal/pkg/warmlint"
)
func TestHumanizeStripsEmDash(t *testing.T) {
out := Humanize("I tried the new setup — it worked great.", 1)
if strings.ContainsAny(out, "—–") {
t.Fatalf("em-dash not removed: %q", out)
}
}
func TestHumanizeStripsClicheOpener(t *testing.T) {
out := Humanize("I hope this email finds you well. How did the demo go?", 1)
if strings.Contains(strings.ToLower(out), "hope this email finds you well") {
t.Fatalf("cliche opener not stripped: %q", out)
}
if !strings.Contains(out, "demo") {
t.Fatalf("content after opener was lost: %q", out)
}
}
func TestHumanizeSwapsAIVocabulary(t *testing.T) {
out := strings.ToLower(Humanize("Can we leverage the robust new framework?", 1))
if strings.Contains(out, "leverage") || strings.Contains(out, "robust") {
t.Fatalf("AI vocabulary not swapped: %q", out)
}
}
func TestHumanizeFlattensNotOnlyButAlso(t *testing.T) {
out := strings.ToLower(Humanize("It was not only fast but also cheap.", 1))
if strings.Contains(out, "not only") || strings.Contains(out, "but also") {
t.Fatalf("not-only-but-also not flattened: %q", out)
}
}
func TestHumanizeNormalizesQuotes(t *testing.T) {
out := Humanize("Its the “best” one…", 1)
if strings.ContainsAny(out, "‘’“”…") {
t.Fatalf("smart punctuation not normalized: %q", out)
}
}
func TestHumanizeAppliesSomeContractions(t *testing.T) {
// Across several seeds at least one should contract (probabilistic ~0.72).
in := "I am sure you do not mind. It is fine."
contracted := false
for seed := int64(0); seed < 20; seed++ {
if strings.Contains(Humanize(in, seed), "'") {
contracted = true
break
}
}
if !contracted {
t.Fatal("no contractions applied across 20 seeds")
}
}
func TestHumanizeSubjectDoesNotFabricateReplyPrefix(t *testing.T) {
// A short subject starting with a stock-opener fragment must NOT have the
// opener stripped into a stray "Re ..." prefix.
for seed := int64(0); seed < 5; seed++ {
out := HumanizeSubject("I'm reaching out re Tuesday", seed)
if strings.HasPrefix(out, "Re ") || strings.HasPrefix(strings.ToLower(out), "re:") {
t.Fatalf("subject mangled into reply-prefix: %q", out)
}
}
// The defensive guard also protects plain Humanize on subject-shaped text
// (short, no sentence terminator): the opener must not be clause-stripped.
if got := Humanize("I'm reaching out re Tuesday", 1); strings.HasPrefix(got, "Re ") {
t.Fatalf("short opener-stripping not guarded: %q", got)
}
}
func TestHumanizeIsDeterministic(t *testing.T) {
in := "I am reaching out. It is not only fast but also smooth — really."
if Humanize(in, 42) != Humanize(in, 42) {
t.Fatal("Humanize is not deterministic for a fixed seed")
}
}
func TestHumanizeDoesNotIntroduceSpam(t *testing.T) {
// Humanizing clean warmup-ish copy must never trip the spam gate.
inputs := []string{
"I hope this email finds you well. I wanted to reach out about the deck.",
"We should leverage the robust framework — it is seamless.",
"How did Tuesday go? No rush, just curious.",
}
for _, in := range inputs {
out := Humanize(in, 7)
if err := warmlint.Check("quick note", out, false); err != nil {
t.Fatalf("humanized output tripped warmlint for %q -> %q: %v", in, out, err)
}
}
}
func TestScoreFlagsRoboticHigherThanHuman(t *testing.T) {
robotic := "I hope this email finds you well. I wanted to reach out to underscore that our robust, seamless, and comprehensive framework will leverage synergy. It is worth noting that this is not only pivotal but also transformative."
human := "hey, how did Tuesday go? no rush. I'll send the second draft over after lunch."
rs, _ := Score(robotic)
hs, _ := Score(human)
if rs <= hs {
t.Fatalf("expected robotic (%d) > human (%d)", rs, hs)
}
if !LooksRobotic(robotic) {
t.Fatalf("robotic text not flagged (score %d)", rs)
}
if LooksRobotic(human) {
t.Fatalf("human text wrongly flagged (score %d)", hs)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Package warmlint is a small content-safety check shared by the live warmup
// send path and the offline AI generator. Warmup mail must look unremarkable;
// this rejects content that would raise the sending mailbox's own spam score.
package warmlint
import (
"fmt"
"regexp"
"strings"
"unicode"
)
var (
stackedPunct = regexp.MustCompile(`[!?]{2,}`)
wordToken = regexp.MustCompile(`[a-z0-9%]+`)
linkPattern = regexp.MustCompile(`https?://`)
htmlTag = regexp.MustCompile(`(?i)<[a-z!/][^>]*>`)
)
// triggerWords are single-token terms that raise SpamAssassin-style content
// scores. Warmup content should read like a normal personal email, so any
// accumulation of these is a red flag (usually an LLM drifting into ad tone).
var triggerWords = map[string]struct{}{
"free": {}, "guarantee": {}, "guaranteed": {}, "winner": {}, "congratulations": {},
"cash": {}, "prize": {}, "cheap": {}, "discount": {}, "viagra": {}, "casino": {},
"loan": {}, "credit": {}, "bitcoin": {}, "crypto": {}, "urgent": {}, "bonus": {},
"promo": {}, "refinance": {}, "mortgage": {}, "investment": {}, "deal": {},
"100%": {}, "sale": {}, "income": {}, "earnings": {}, "clearance": {},
}
var triggerPhrases = []string{
"act now", "click here", "risk free", "risk-free", "limited time", "buy now",
"earn money", "make money", "dear friend", "order now", "100% free",
"double your", "extra income", "work from home", "this is not spam",
"cash bonus", "no cost", "for free", "money back", "satisfaction guaranteed",
}
// Check rejects warmup content that would look spammy:
// - a fabricated Re:/Fwd: prefix on a NEW (non-reply) message;
// - an ALL-CAPS subject;
// - stacked punctuation (!!!, ?!);
// - three or more distinct spam-trigger terms.
func Check(subject, body string, isReply bool) error {
subj := strings.TrimSpace(subject)
lowerSubj := strings.ToLower(subj)
if !isReply && (strings.HasPrefix(lowerSubj, "re:") ||
strings.HasPrefix(lowerSubj, "fwd:") ||
strings.HasPrefix(lowerSubj, "fw:")) {
return fmt.Errorf("fabricated reply/forward prefix on a new send")
}
if isAllCaps(subj) {
return fmt.Errorf("subject is all caps")
}
combined := subject + "\n" + body
if stackedPunct.MatchString(combined) {
return fmt.Errorf("stacked punctuation")
}
if n := countTriggerTerms(combined); n >= 3 {
return fmt.Errorf("content has %d spam-trigger terms", n)
}
return nil
}
// Issue is a single advisory content problem found by Score.
type Issue struct {
Severity string `json:"severity"` // "warn" | "high"
Code string `json:"code"`
Message string `json:"message"`
}
// ScoreResult is an advisory content assessment for a campaign template.
type ScoreResult struct {
Score int `json:"score"` // 0-100, higher = safer
Issues []Issue `json:"issues"`
}
// Score gives an ADVISORY 0-100 content-safety score (higher = safer) for a
// campaign template, plus the issues found. Unlike Check — a hard gate for
// warmup mail — Score never blocks: it surfaces guidance before the user sends
// the mail that actually reaches prospects and drives complaints. It reuses the
// same trigger-term and ALL-CAPS heuristics as the warmup lint.
func Score(subject, bodyHTML, bodyPlain string) ScoreResult {
res := ScoreResult{Score: 100, Issues: []Issue{}}
deduct := func(n int, severity, code, msg string) {
res.Score -= n
res.Issues = append(res.Issues, Issue{Severity: severity, Code: code, Message: msg})
}
subj := strings.TrimSpace(subject)
body := bodyPlain
if strings.TrimSpace(body) == "" {
body = stripTags(bodyHTML)
}
combined := subj + "\n" + body
if subj == "" {
deduct(20, "high", "empty_subject", "Subject is empty.")
} else if isAllCaps(subj) {
deduct(15, "high", "all_caps_subject", "Subject is all caps — a strong spam signal.")
}
if stackedPunct.MatchString(combined) {
deduct(10, "warn", "stacked_punctuation", "Stacked punctuation (e.g. !!! or ?!) reads as promotional.")
}
if n := countTriggerTerms(combined); n > 0 {
d := n * 8
if d > 40 {
d = 40
}
severity := "warn"
if n >= 3 {
severity = "high"
}
deduct(d, severity, "spam_trigger_terms", fmt.Sprintf("%d spam-trigger term(s) found in subject/body.", n))
}
if links := len(linkPattern.FindAllString(combined, -1)); links > 3 {
d := (links - 3) * 5
if d > 20 {
d = 20
}
deduct(d, "warn", "too_many_links", fmt.Sprintf("%d links — keep cold-email link count low.", links))
}
if strings.TrimSpace(body) == "" {
deduct(25, "high", "empty_body", "Body has no text content (image-only or empty body hurts deliverability).")
} else if len(body) > 15000 {
deduct(10, "warn", "oversized_body", "Body is very large; trim it for deliverability.")
}
if res.Score < 0 {
res.Score = 0
}
return res
}
func stripTags(s string) string {
return strings.TrimSpace(htmlTag.ReplaceAllString(s, " "))
}
func isAllCaps(s string) bool {
letters := 0
for _, r := range s {
if unicode.IsLower(r) {
return false
}
if unicode.IsLetter(r) {
letters++
}
}
return letters >= 4
}
func countTriggerTerms(text string) int {
lower := strings.ToLower(text)
found := map[string]struct{}{}
for _, w := range wordToken.FindAllString(lower, -1) {
if _, ok := triggerWords[w]; ok {
found[w] = struct{}{}
}
}
for _, p := range triggerPhrases {
if strings.Contains(lower, p) {
found[p] = struct{}{}
}
}
return len(found)
}
+91
View File
@@ -0,0 +1,91 @@
// Package warmpersona derives a stable, per-mailbox "personality" from the
// mailbox account ID. The goal is to make warmup traffic from different
// mailboxes look behaviourally distinct (different voice, dwell, engagement
// propensity) instead of every mailbox in the pool behaving identically — a
// uniform pool is a fingerprint. The persona is deterministic (same mailbox →
// same persona) and requires no stored state.
package warmpersona
import (
"hash/fnv"
"github.com/google/uuid"
)
// Persona is a deterministic source of per-mailbox biases.
type Persona struct {
seed uint64
}
// For derives the persona for a mailbox account ID.
func For(id uuid.UUID) Persona {
h := fnv.New64a()
_, _ = h.Write(id[:])
return Persona{seed: h.Sum64()}
}
// axisValue returns a stable uint64 derived from the persona seed and a named
// axis, so independent axes (greeting, dwell, importance...) don't correlate.
func (p Persona) axisValue(axis string) uint64 {
h := fnv.New64a()
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(p.seed >> (8 * i))
}
_, _ = h.Write(b[:])
_, _ = h.Write([]byte(axis))
return h.Sum64()
}
// unit returns a deterministic float in [0,1) for the given axis.
func (p Persona) unit(axis string) float64 {
// Top 53 bits → float64 in [0,1), matching math/rand's Float64 precision.
return float64(p.axisValue(axis)>>11) / float64(uint64(1)<<53)
}
// Bias returns a deterministic multiplier in [lo, hi] for the given axis.
func (p Persona) Bias(axis string, lo, hi float64) float64 {
if hi < lo {
lo, hi = hi, lo
}
return lo + p.unit(axis)*(hi-lo)
}
// Index returns a deterministic index in [0, n) for the given axis.
func (p Persona) Index(axis string, n int) int {
if n <= 0 {
return 0
}
return int(p.axisValue(axis) % uint64(n))
}
// Subset returns k distinct deterministic indices in [0, n) for the axis — the
// mailbox's preferred subset (e.g. the greetings it tends to use). Callers then
// pick randomly within this subset so each mailbox has a consistent "voice"
// while individual messages still vary. Order is deterministic.
func (p Persona) Subset(axis string, n, k int) []int {
if n <= 0 || k <= 0 {
return nil
}
if k >= n {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
}
seen := make(map[int]struct{}, k)
out := make([]int, 0, k)
v := p.axisValue(axis)
// Linear congruential walk seeded by the axis value; deterministic and
// well-spread for the small n/k we use here.
for len(out) < k {
idx := int(v % uint64(n))
if _, ok := seen[idx]; !ok {
seen[idx] = struct{}{}
out = append(out, idx)
}
v = v*6364136223846793005 + 1442695040888963407
}
return out
}
+71 -19
View File
@@ -11,26 +11,37 @@ import (
// CampaignContactProgress represents the progress of a contact in a campaign
type CampaignContactProgress struct {
CampaignID uuid.UUID
ContactID uuid.UUID
SequenceID uuid.UUID
SentAt *time.Time
OpenedAt *time.Time
ClickedAt *time.Time
RepliedAt *time.Time
BouncedAt *time.Time
CampaignID uuid.UUID
ContactID uuid.UUID
SequenceID uuid.UUID
SentAt *time.Time
OpenedAt *time.Time
ClickedAt *time.Time
RepliedAt *time.Time
BouncedAt *time.Time
ComplainedAt *time.Time
}
// CampaignProgress represents overall campaign progress
type CampaignProgress struct {
TotalContacts int
TotalSequences int
EmailsSent int
EmailsPending int
EmailsOpened int
EmailsClicked int
EmailsReplied int
EmailsBounced int
TotalContacts int
TotalSequences int
EmailsSent int
EmailsPending int
EmailsOpened int
EmailsClicked int
EmailsReplied int
EmailsBounced int
EmailsComplained int
}
// CampaignRollingRates holds windowed send/bounce/complaint counts for a
// campaign, used by the deliverability circuit breaker so it reacts to recent
// behaviour rather than a campaign's lifetime average.
type CampaignRollingRates struct {
Sent int
Bounced int
Complained int
}
// ContactSequencePair represents a contact and sequence combination
@@ -52,9 +63,11 @@ type CampaignProgressRepository interface {
RecordEmailClicked(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
RecordEmailReplied(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
RecordEmailBounced(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
RecordEmailComplained(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
// Query methods
GetCampaignProgress(ctx context.Context, campaignID uuid.UUID) (*CampaignProgress, error)
GetCampaignRollingRates(ctx context.Context, campaignID uuid.UUID, since time.Time) (*CampaignRollingRates, error)
GetContactProgress(ctx context.Context, campaignID, contactID uuid.UUID) ([]CampaignContactProgress, error)
GetContactLastSequenceTime(ctx context.Context, contactID, campaignID uuid.UUID) (*time.Time, error)
CheckContactHasReplied(ctx context.Context, contactID, campaignID uuid.UUID) (bool, error)
@@ -147,6 +160,21 @@ func (r *campaignProgressRepository) RecordEmailBounced(ctx context.Context, cam
return err
}
// RecordEmailComplained records that a contact filed a spam complaint
func (r *campaignProgressRepository) RecordEmailComplained(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
query := `
UPDATE campaign_contact_progress
SET complained_at = NOW()
WHERE campaign_id = $1
AND contact_id = $2
AND sequence_id = $3
AND complained_at IS NULL
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
// GetCampaignProgress retrieves overall campaign progress statistics
func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, campaignID uuid.UUID) (*CampaignProgress, error) {
query := `
@@ -158,7 +186,8 @@ func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, ca
COUNT(CASE WHEN ccp.opened_at IS NOT NULL THEN 1 END) as emails_opened,
COUNT(CASE WHEN ccp.clicked_at IS NOT NULL THEN 1 END) as emails_clicked,
COUNT(CASE WHEN ccp.replied_at IS NOT NULL THEN 1 END) as emails_replied,
COUNT(CASE WHEN ccp.bounced_at IS NOT NULL THEN 1 END) as emails_bounced
COUNT(CASE WHEN ccp.bounced_at IS NOT NULL THEN 1 END) as emails_bounced,
COUNT(CASE WHEN ccp.complained_at IS NOT NULL THEN 1 END) as emails_complained
FROM campaigns c
LEFT JOIN campaign_leads cl ON c.id = cl.campaign_id
LEFT JOIN sequences s ON c.id = s.campaign_id
@@ -174,7 +203,8 @@ func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, ca
emails_opened,
emails_clicked,
emails_replied,
emails_bounced
emails_bounced,
emails_complained
FROM campaign_stats
`
@@ -188,6 +218,7 @@ func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, ca
&progress.EmailsClicked,
&progress.EmailsReplied,
&progress.EmailsBounced,
&progress.EmailsComplained,
)
if err == sql.ErrNoRows {
@@ -197,10 +228,30 @@ func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, ca
return progress, err
}
// GetCampaignRollingRates returns send/bounce/complaint counts for a campaign
// within the window [since, now], computed from the per-contact progress
// timestamps so the breaker can react to recent behaviour.
func (r *campaignProgressRepository) GetCampaignRollingRates(ctx context.Context, campaignID uuid.UUID, since time.Time) (*CampaignRollingRates, error) {
query := `
SELECT
COUNT(*) FILTER (WHERE sent_at IS NOT NULL AND sent_at >= $2) AS sent,
COUNT(*) FILTER (WHERE bounced_at IS NOT NULL AND bounced_at >= $2) AS bounced,
COUNT(*) FILTER (WHERE complained_at IS NOT NULL AND complained_at >= $2) AS complained
FROM campaign_contact_progress
WHERE campaign_id = $1
`
out := &CampaignRollingRates{}
err := r.db.QueryRow(ctx, query, campaignID, since).Scan(&out.Sent, &out.Bounced, &out.Complained)
if err == sql.ErrNoRows {
return &CampaignRollingRates{}, nil
}
return out, err
}
// GetContactProgress retrieves progress for a specific contact in a campaign
func (r *campaignProgressRepository) GetContactProgress(ctx context.Context, campaignID, contactID uuid.UUID) ([]CampaignContactProgress, error) {
query := `
SELECT campaign_id, contact_id, sequence_id, sent_at, opened_at, clicked_at, replied_at, bounced_at
SELECT campaign_id, contact_id, sequence_id, sent_at, opened_at, clicked_at, replied_at, bounced_at, complained_at
FROM campaign_contact_progress
WHERE campaign_id = $1 AND contact_id = $2
ORDER BY sent_at ASC
@@ -224,6 +275,7 @@ func (r *campaignProgressRepository) GetContactProgress(ctx context.Context, cam
&progress.ClickedAt,
&progress.RepliedAt,
&progress.BouncedAt,
&progress.ComplainedAt,
)
if err != nil {
return nil, err
+94 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
"github.com/warmbly/warmbly/internal/pkg/encrypt"
"github.com/warmbly/warmbly/internal/utils"
)
@@ -24,6 +25,13 @@ type ContactRepository interface {
Add(ctx context.Context, userID string, contacts []models.AddContact) ([]models.Contact, *errx.Error)
GetByID(ctx context.Context, contactID uuid.UUID) (*models.Contact, *errx.Error)
GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error)
// Pre-send email verification round-trip. UpdateContactVerification stores
// the outcome of a verify pass; ListUnverifiedContacts returns contacts that
// have never been conclusively checked (status 'unknown', never verified) so
// the batch scheduler can work them off a cap per tick.
UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error
ListUnverifiedContacts(ctx context.Context, limit int) ([]models.Contact, *errx.Error)
GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error)
Search(ctx context.Context, userID string, category, cursor *string, filters models.SearchContacts, limit int32) (*models.ContactsResult, *errx.Error)
ExportAll(ctx context.Context, userID string, filters *models.SearchContacts, contactIDs []string, max int) ([]models.Contact, *errx.Error)
@@ -331,7 +339,8 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
query := `
SELECT
c.id, c.first_name, c.last_name, c.email, c.company, c.phone,
c.custom_fields, c.subscribed, c.updated_at, c.created_at
c.custom_fields, c.subscribed, c.updated_at, c.created_at,
c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at
FROM contacts c
WHERE c.id = $1
`
@@ -341,6 +350,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email,
&contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed,
&contact.UpdatedAt, &contact.CreatedAt,
&contact.VerificationStatus, &contact.VerificationReason, &contact.IsCatchAll, &contact.VerificationCheckedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
@@ -355,6 +365,89 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
return &contact, nil
}
// UpdateContactVerification stores the outcome of a verification pass on the
// contact. It is keyed only by contact id (the verifier runs in the control
// plane, not in a user request) and is a no-op-safe single UPDATE.
func (r *contactRepository) UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error {
status := string(res.Status)
if status == "" {
status = string(emailverify.StatusUnknown)
}
checkedAt := res.CheckedAt
if checkedAt.IsZero() {
checkedAt = time.Now().UTC()
}
query := `
UPDATE contacts
SET verification_status = $2,
verification_reason = $3,
is_catch_all = $4,
verification_checked_at = $5,
updated_at = NOW()
WHERE id = $1
`
params := []any{contactID, status, res.Reason, res.IsCatchAll, checkedAt}
cmd, err := r.DB.Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
}
if cmd.RowsAffected() == 0 {
return errx.ErrNotFound
}
return nil
}
// ListUnverifiedContacts returns up to `limit` contacts that have never been
// conclusively verified (status 'unknown' and no recorded check). Oldest
// contacts first so a backlog drains in creation order. The pre-send gate only
// drops 'invalid', so 'risky'/'valid'/already-checked rows are intentionally
// excluded here — they don't need re-verification on every tick.
func (r *contactRepository) ListUnverifiedContacts(ctx context.Context, limit int) ([]models.Contact, *errx.Error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT
c.id, c.first_name, c.last_name, c.email, c.company, c.phone,
c.custom_fields, c.subscribed, c.updated_at, c.created_at,
c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at
FROM contacts c
WHERE c.verification_status = 'unknown' AND c.verification_checked_at IS NULL
ORDER BY c.created_at ASC
LIMIT $1
`
rows, err := r.DB.Query(ctx, query, limit)
if err != nil {
db.CaptureError(err, query, []any{limit}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
out := make([]models.Contact, 0, limit)
for rows.Next() {
var c models.Contact
if err := rows.Scan(
&c.ID, &c.FirstName, &c.LastName, &c.Email,
&c.Company, &c.Phone, &c.CustomFields, &c.Subscribed,
&c.UpdatedAt, &c.CreatedAt,
&c.VerificationStatus, &c.VerificationReason, &c.IsCatchAll, &c.VerificationCheckedAt,
); err != nil {
db.CaptureError(err, "", nil, "ListUnverifiedContacts scan")
return nil, errx.InternalError()
}
c.Campaigns = []models.MiniCampaign{}
c.Categories = []models.MiniCategory{}
out = append(out, c)
}
if err := rows.Err(); err != nil {
db.CaptureError(err, "", nil, "ListUnverifiedContacts rows")
return nil, errx.InternalError()
}
return out, nil
}
func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error) {
query := `
SELECT
+23 -13
View File
@@ -14,7 +14,6 @@ import (
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/crypt"
"github.com/warmbly/warmbly/internal/pkg/encrypt"
"github.com/warmbly/warmbly/internal/utils"
"github.com/warmbly/warmbly/internal/utils/validate"
@@ -166,12 +165,10 @@ func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, da
t := time.Now()
id := uuid.New()
rid, err := crypt.RID(8)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
// warmup_tag is the content segment (defaults to '' = generic). It used to
// be seeded with a random RID, which silently broke segment-aware content
// selection because a random tag never matches a real segment.
query := `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, provider, signature_plain, signature_html, tracking_domain, last_synced_at, created_at, updated_at, warmup_tag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10, $11)
@@ -188,7 +185,7 @@ func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, da
sightml,
"",
t,
rid,
"",
}
_, err = tx.Exec(
@@ -274,11 +271,6 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string,
id := uuid.New()
t := time.Now()
rid, err := crypt.RID(8)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
query := `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, provider, signature_plain, signature_html, tracking_domain, last_synced_at, updated_at, created_at, warmup_tag)
@@ -295,7 +287,7 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string,
sightml,
"",
t,
rid,
"",
}
_, err = tx.Exec(
@@ -701,6 +693,24 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str
args = append(args, *udata.WarmupReplyRate)
argPos++
}
if udata.WarmupTag != nil {
// warmup_tag is the content segment: a lowercase slug (e.g. "saas",
// "agency") that the segment-aware AI content bank is keyed on. Empty
// = generic content. Reject anything that isn't a simple slug so it
// can't smuggle arbitrary text into the content-selection path.
seg := strings.ToLower(strings.TrimSpace(*udata.WarmupTag))
if len(seg) > 32 {
return nil, errx.ErrInvalid
}
for _, r := range seg {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-') {
return nil, errx.ErrInvalid
}
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_tag", argPos))
args = append(args, seg)
argPos++
}
if udata.WarmupStartTime != nil {
if err := validate.CampaignTime(*udata.WarmupStartTime); err != nil {
return nil, err
+424
View File
@@ -0,0 +1,424 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/infrastructure/db"
)
// Placement folder classifications. These mirror the CHECK constraint on
// placement_results.folder.
const (
PlacementFolderPending = "pending"
PlacementFolderInbox = "inbox"
PlacementFolderPromotions = "promotions"
PlacementFolderSpam = "spam"
PlacementFolderOther = "other"
)
// Placement test statuses.
const (
PlacementStatusPending = "pending"
PlacementStatusCompleted = "completed"
)
// PlacementTest is one inbox-placement run: a tokenized copy of a template
// sent from one sender mailbox to the active seed panel.
type PlacementTest struct {
ID uuid.UUID
OrganizationID *uuid.UUID
SenderAccountID uuid.UUID
Subject string
BodyPlain string
BodyHTML string
Token string
Status string
CreatedAt time.Time
FinishedAt *time.Time
}
// PlacementResult is the classification for one (test, seed) pair.
type PlacementResult struct {
ID uuid.UUID
TestID uuid.UUID
SeedAccountID uuid.UUID
Provider string
Folder string
DetectedAt *time.Time
RawFlags string
}
// SeedAccount is a connected mailbox flagged is_seed, used as a placement
// recipient. UserID is needed to query that seed's unibox entries (unibox is
// keyed by the owning user, not the org).
type SeedAccount struct {
ID uuid.UUID
UserID uuid.UUID
Email string
Name string
Provider string
Status string
WorkerID *uuid.UUID
IsSeed bool
}
// PendingResultJob is the minimal view the poller needs to classify one
// pending result: which seed, which user owns its unibox, and the test token.
type PendingResultJob struct {
ResultID uuid.UUID
TestID uuid.UUID
SeedAccountID uuid.UUID
SeedUserID uuid.UUID
Provider string
Token string
TestCreatedAt time.Time
}
// UniboxTokenMatch is a unibox entry whose subject (or stored token) matched a
// placement token, with the flags/labels needed to classify the folder.
type UniboxTokenMatch struct {
InternalID uuid.UUID
Subject string
Flags []string
}
// PlacementRepository is the data access surface for seed inbox-placement
// testing. It deliberately does not depend on the worker (control plane only).
type PlacementRepository interface {
CreateTest(ctx context.Context, t *PlacementTest) error
GetTest(ctx context.Context, id uuid.UUID) (*PlacementTest, error)
ListTests(ctx context.Context, orgID *uuid.UUID, limit, offset int) ([]PlacementTest, int, error)
SetTestStatus(ctx context.Context, id uuid.UUID, status string, finishedAt *time.Time) error
CreatePendingResult(ctx context.Context, testID, seedAccountID uuid.UUID, provider string) error
GetTestWithResults(ctx context.Context, id uuid.UUID) (*PlacementTest, []PlacementResult, error)
RecordResult(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, detectedAt time.Time) error
// ListPendingResults returns unresolved results joined to their seed's
// owning user + the test token, so the poller can look each one up.
ListPendingResults(ctx context.Context, limit int) ([]PendingResultJob, error)
// CountPendingForTest reports how many results are still pending, used to
// decide when a test is fully resolved.
CountPendingForTest(ctx context.Context, testID uuid.UUID) (int, error)
// ListSeedAccounts returns seed mailboxes. activeOnly restricts to
// status='active' (the send/classify panel); pass false for management UI.
ListSeedAccounts(ctx context.Context, activeOnly bool) ([]SeedAccount, error)
GetSeedAccount(ctx context.Context, id uuid.UUID) (*SeedAccount, error)
SetIsSeed(ctx context.Context, accountID uuid.UUID, isSeed bool) error
// ListSeedCandidates lists connected mailboxes (optionally filtered by
// substring) so an admin can pick which to flag as seeds.
ListSeedCandidates(ctx context.Context, search string, limit int) ([]SeedAccount, error)
// FindTokenInUnibox looks for a placement token inside a seed's received
// mail. Reuses the unibox store (unibox_emails) the worker syncs into. We
// match the token embedded in the subject (the only token carrier that
// survives sync without a worker change — see service.go) and return the
// matching entry's flags/labels for classification.
FindTokenInUnibox(ctx context.Context, userID, seedAccountID uuid.UUID, token string, since time.Time) (*UniboxTokenMatch, error)
}
type placementRepository struct {
db *db.DB
}
func NewPlacementRepository(db *db.DB) PlacementRepository {
return &placementRepository{db: db}
}
func (r *placementRepository) CreateTest(ctx context.Context, t *PlacementTest) error {
query := `
INSERT INTO placement_tests (id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
`
_, err := r.db.Exec(ctx, query,
t.ID, t.OrganizationID, t.SenderAccountID, t.Subject, t.BodyPlain, t.BodyHTML, t.Token, t.Status,
)
return err
}
func (r *placementRepository) GetTest(ctx context.Context, id uuid.UUID) (*PlacementTest, error) {
query := `
SELECT id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at, finished_at
FROM placement_tests
WHERE id = $1
`
var t PlacementTest
err := r.db.QueryRow(ctx, query, id).Scan(
&t.ID, &t.OrganizationID, &t.SenderAccountID, &t.Subject, &t.BodyPlain, &t.BodyHTML, &t.Token, &t.Status, &t.CreatedAt, &t.FinishedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
func (r *placementRepository) ListTests(ctx context.Context, orgID *uuid.UUID, limit, offset int) ([]PlacementTest, int, error) {
if limit <= 0 {
limit = 25
}
var total int
countQuery := `SELECT COUNT(*) FROM placement_tests WHERE ($1::uuid IS NULL OR organization_id = $1)`
if err := r.db.QueryRow(ctx, countQuery, orgID).Scan(&total); err != nil {
return nil, 0, err
}
query := `
SELECT id, organization_id, sender_account_id, subject, body_plain, body_html, token, status, created_at, finished_at
FROM placement_tests
WHERE ($1::uuid IS NULL OR organization_id = $1)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.Query(ctx, query, orgID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
tests := make([]PlacementTest, 0, limit)
for rows.Next() {
var t PlacementTest
if err := rows.Scan(
&t.ID, &t.OrganizationID, &t.SenderAccountID, &t.Subject, &t.BodyPlain, &t.BodyHTML, &t.Token, &t.Status, &t.CreatedAt, &t.FinishedAt,
); err != nil {
return nil, 0, err
}
tests = append(tests, t)
}
return tests, total, rows.Err()
}
func (r *placementRepository) SetTestStatus(ctx context.Context, id uuid.UUID, status string, finishedAt *time.Time) error {
_, err := r.db.Exec(ctx,
`UPDATE placement_tests SET status = $2, finished_at = $3 WHERE id = $1`,
id, status, finishedAt,
)
return err
}
func (r *placementRepository) CreatePendingResult(ctx context.Context, testID, seedAccountID uuid.UUID, provider string) error {
_, err := r.db.Exec(ctx, `
INSERT INTO placement_results (test_id, seed_account_id, provider, folder)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (test_id, seed_account_id) DO NOTHING
`, testID, seedAccountID, provider)
return err
}
func (r *placementRepository) GetTestWithResults(ctx context.Context, id uuid.UUID) (*PlacementTest, []PlacementResult, error) {
test, err := r.GetTest(ctx, id)
if err != nil || test == nil {
return test, nil, err
}
rows, err := r.db.Query(ctx, `
SELECT id, test_id, seed_account_id, provider, folder, detected_at, raw_flags
FROM placement_results
WHERE test_id = $1
ORDER BY provider ASC, seed_account_id ASC
`, id)
if err != nil {
return nil, nil, err
}
defer rows.Close()
results := make([]PlacementResult, 0)
for rows.Next() {
var res PlacementResult
if err := rows.Scan(
&res.ID, &res.TestID, &res.SeedAccountID, &res.Provider, &res.Folder, &res.DetectedAt, &res.RawFlags,
); err != nil {
return nil, nil, err
}
results = append(results, res)
}
return test, results, rows.Err()
}
func (r *placementRepository) RecordResult(ctx context.Context, resultID uuid.UUID, folder, rawFlags string, detectedAt time.Time) error {
_, err := r.db.Exec(ctx, `
UPDATE placement_results
SET folder = $2, raw_flags = $3, detected_at = $4
WHERE id = $1 AND folder = 'pending'
`, resultID, folder, rawFlags, detectedAt)
return err
}
func (r *placementRepository) ListPendingResults(ctx context.Context, limit int) ([]PendingResultJob, error) {
if limit <= 0 {
limit = 200
}
rows, err := r.db.Query(ctx, `
SELECT pr.id, pr.test_id, pr.seed_account_id, ea.user_id, pr.provider, pt.token, pt.created_at
FROM placement_results pr
JOIN placement_tests pt ON pt.id = pr.test_id
JOIN email_accounts ea ON ea.id = pr.seed_account_id
WHERE pr.folder = 'pending'
ORDER BY pt.created_at ASC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := make([]PendingResultJob, 0, limit)
for rows.Next() {
var j PendingResultJob
if err := rows.Scan(
&j.ResultID, &j.TestID, &j.SeedAccountID, &j.SeedUserID, &j.Provider, &j.Token, &j.TestCreatedAt,
); err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, rows.Err()
}
func (r *placementRepository) CountPendingForTest(ctx context.Context, testID uuid.UUID) (int, error) {
var n int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM placement_results WHERE test_id = $1 AND folder = 'pending'`,
testID,
).Scan(&n)
return n, err
}
const seedSelectCols = `ea.id, ea.user_id, ea.email, ea.name, ea.provider, ea.status, ea.worker_id, ea.is_seed`
func scanSeed(rows pgx.Rows) (SeedAccount, error) {
var s SeedAccount
err := rows.Scan(&s.ID, &s.UserID, &s.Email, &s.Name, &s.Provider, &s.Status, &s.WorkerID, &s.IsSeed)
return s, err
}
func (r *placementRepository) ListSeedAccounts(ctx context.Context, activeOnly bool) ([]SeedAccount, error) {
query := `
SELECT ` + seedSelectCols + `
FROM email_accounts ea
WHERE ea.is_seed = true
`
if activeOnly {
query += ` AND ea.status = 'active'`
}
query += ` ORDER BY ea.email ASC`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]SeedAccount, 0)
for rows.Next() {
s, err := scanSeed(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *placementRepository) GetSeedAccount(ctx context.Context, id uuid.UUID) (*SeedAccount, error) {
rows, err := r.db.Query(ctx, `
SELECT `+seedSelectCols+`
FROM email_accounts ea
WHERE ea.id = $1
`, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, nil
}
s, err := scanSeed(rows)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *placementRepository) SetIsSeed(ctx context.Context, accountID uuid.UUID, isSeed bool) error {
_, err := r.db.Exec(ctx,
`UPDATE email_accounts SET is_seed = $2, updated_at = NOW() WHERE id = $1`,
accountID, isSeed,
)
return err
}
func (r *placementRepository) ListSeedCandidates(ctx context.Context, search string, limit int) ([]SeedAccount, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
query := `
SELECT ` + seedSelectCols + `
FROM email_accounts ea
WHERE ea.status = 'active'
`
args := []any{}
if search != "" {
query += ` AND ea.email ILIKE $1`
args = append(args, "%"+search+"%")
query += ` ORDER BY ea.is_seed DESC, ea.email ASC LIMIT $2`
args = append(args, limit)
} else {
query += ` ORDER BY ea.is_seed DESC, ea.email ASC LIMIT $1`
args = append(args, limit)
}
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]SeedAccount, 0)
for rows.Next() {
s, err := scanSeed(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *placementRepository) FindTokenInUnibox(ctx context.Context, userID, seedAccountID uuid.UUID, token string, since time.Time) (*UniboxTokenMatch, error) {
// The placement token is embedded in the message subject (see
// placement.Service — the worker injects only the warmup verify header,
// which we don't control here, so the subject is the carrier that survives
// sync into the unibox without a worker change). We match the most recent
// entry for this seed whose subject contains the token. `since` bounds the
// scan to mail that arrived after the test was created.
query := `
SELECT id, subject, flags
FROM unibox_emails
WHERE user_id = $1
AND email_id = $2
AND subject LIKE '%' || $3 || '%'
AND internal_date >= $4
ORDER BY internal_date DESC
LIMIT 1
`
var m UniboxTokenMatch
err := r.db.QueryRow(ctx, query, userID, seedAccountID, token, since).Scan(&m.InternalID, &m.Subject, &m.Flags)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
+12
View File
@@ -124,6 +124,7 @@ type TaskRepository interface {
CreateTaskWithLock(ctx context.Context, task *Task, campaignTask *CampaignTask) (bool, error)
CreateWarmupTaskWithLock(ctx context.Context, task *Task, warmupTask *WarmupTask) (bool, error)
UpdateTaskStatusWithLock(ctx context.Context, taskID uuid.UUID, status string) error
UpdateTaskMessageID(ctx context.Context, taskID uuid.UUID, messageID string) error
// Update campaign task with contact/sequence IDs (for tracking)
UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error
@@ -730,6 +731,17 @@ func (r *taskRepository) UpdateTaskStatusWithLock(ctx context.Context, taskID uu
return tx.Commit(ctx)
}
// UpdateTaskMessageID persists the outbound Message-ID on the task row. Warmup
// reply threading depends on this: GetLatestReplyCandidate filters on
// message_id <> ”, so without persisting it here the reply path never finds a
// prior message to reply to and warmup conversations never thread.
func (r *taskRepository) UpdateTaskMessageID(ctx context.Context, taskID uuid.UUID, messageID string) error {
_, err := r.db.Exec(ctx,
`UPDATE tasks SET message_id = $1, updated_at = NOW() WHERE id = $2`,
messageID, taskID)
return err
}
// UpdateCampaignTaskTracking updates the campaign task with contact_id and sequence_id
// This is called when the task is processed and we know which contact/sequence to send to
// These IDs are needed for tracking pixel/click events to record progress
+255 -6
View File
@@ -43,6 +43,16 @@ type SpamReport struct {
ReportedAccountID uuid.UUID
MessageID string
ReportType string
// ContentSource is the content cohort ("static"/"ai") of the warmup send
// that landed in spam, denormalised here from the warmup token so the A/B
// harness can aggregate spam-placement rate by cohort.
ContentSource string
// RecipientProvider / RecipientDomain record where the message was filtered
// into spam (the recipient mailbox's provider, e.g. "google"/"smtp_imap",
// and domain, e.g. "outlook.com") so placement can be segmented per provider
// instead of one flat rate. Empty when the dimension isn't known.
RecipientProvider string
RecipientDomain string
CreatedAt time.Time
}
@@ -63,6 +73,17 @@ type WarmupReplyCandidate struct {
ConversationTheme string
}
// WarmupReceived records a verified warmup email delivered to a participant
// mailbox, so a later deletion/flag event (which carries only the internal
// message id) can be matched back to warmup and to the sender.
type WarmupReceived struct {
EmailAccountID uuid.UUID
InternalID uuid.UUID
MessageID string
SenderAccountID uuid.UUID
CreatedAt time.Time
}
// WarmupRepository defines methods for warmup data access
type WarmupRepository interface {
// Pool management
@@ -73,6 +94,11 @@ type WarmupRepository interface {
SetParticipantRole(ctx context.Context, poolID, accountID uuid.UUID, role string) error
LeavePool(ctx context.Context, poolID, accountID uuid.UUID) error
BlockFromPool(ctx context.Context, accountID uuid.UUID, reason string) error
// GetHealthState returns the WORST current warmup health state across the
// account's pool memberships plus its blocked_until, so non-warmup callers
// (e.g. the campaign scheduler) can gate cold sends on warmup health without
// needing a pool type. Returns ("healthy", nil) when the account is in no pool.
GetHealthState(ctx context.Context, accountID uuid.UUID) (models.WarmupHealthState, *time.Time, error)
UnblockFromPool(ctx context.Context, accountID uuid.UUID) error
IsInPool(ctx context.Context, accountID uuid.UUID, poolType string) (bool, error)
GetParticipantHealth(ctx context.Context, accountID uuid.UUID, poolType string) (*models.WarmupParticipantHealth, error)
@@ -96,9 +122,14 @@ type WarmupRepository interface {
// Statistics
IncrementDailyCount(ctx context.Context, accountID uuid.UUID, date time.Time) error
IncrementReplyCount(ctx context.Context, accountID uuid.UUID, date time.Time) error
GetWarmupStatistics(ctx context.Context, accountID uuid.UUID, from, to time.Time) ([]WarmupStatistic, error)
GetOrCreateDailyStats(ctx context.Context, accountID uuid.UUID, date time.Time, targetVolume int) (*WarmupStatistic, error)
// Pool-wide placement analytics (admin overview)
PoolSpamPlacementRate(ctx context.Context, since time.Time) (float64, error)
PoolSpamPlacementsByProvider(ctx context.Context, since time.Time) (map[string]int, error)
// Warmup token management
CreateWarmupToken(ctx context.Context, token *models.WarmupToken) error
GetWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error)
@@ -109,6 +140,7 @@ type WarmupRepository interface {
// Warmup conversation support
GetRecentlyUsedPartners(ctx context.Context, accountID uuid.UUID, since time.Time) ([]uuid.UUID, error)
GetRecentPartnerCounts(ctx context.Context, accountID uuid.UUID, since time.Time) (map[uuid.UUID]int, error)
GetLatestReplyCandidate(ctx context.Context, senderAccountID, recipientAccountID uuid.UUID) (*WarmupReplyCandidate, error)
// Partner diversity support
@@ -116,6 +148,17 @@ type WarmupRepository interface {
GetPoolParticipantEmails(ctx context.Context, poolType string, excludeBlocked bool) (map[uuid.UUID]string, error)
CountEligibleRecipients(ctx context.Context, poolType string, excludeAccountID uuid.UUID) (int, error)
GetRecentPartnerDomainCounts(ctx context.Context, accountID uuid.UUID, since time.Time) (map[string]int, error)
// Tampering protection: track delivered warmup mail so a later deletion or
// spam-flag can be attributed, and count "harm" events per mailbox.
RecordWarmupReceived(ctx context.Context, accountID, internalID uuid.UUID, messageID string, senderAccountID uuid.UUID) error
GetWarmupReceived(ctx context.Context, accountID, internalID uuid.UUID) (*WarmupReceived, error)
RecordWarmupTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (bool, error)
CountWarmupTamperingSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
// Appeals (user-facing submission; admin review lives in the admin repo).
CreateWarmupAppeal(ctx context.Context, accountID, userID uuid.UUID, reason string) (uuid.UUID, error)
HasPendingWarmupAppeal(ctx context.Context, accountID uuid.UUID) (bool, error)
}
type warmupRepository struct {
@@ -301,6 +344,36 @@ func (r *warmupRepository) BlockFromPool(ctx context.Context, accountID uuid.UUI
return err
}
// GetHealthState returns the worst current health state across the account's
// pool memberships and that row's blocked_until. Worst-wins so a mailbox that's
// blocked in any pool is treated as blocked for cold-send gating.
func (r *warmupRepository) GetHealthState(ctx context.Context, accountID uuid.UUID) (models.WarmupHealthState, *time.Time, error) {
query := `
SELECT health_state, blocked_until
FROM warmup_pool_participants
WHERE email_account_id = $1
ORDER BY CASE health_state
WHEN 'blocked' THEN 5
WHEN 'quarantined' THEN 4
WHEN 'throttled' THEN 3
WHEN 'watch' THEN 2
WHEN 'healthy' THEN 1
ELSE 0
END DESC
LIMIT 1
`
var state string
var blockedUntil *time.Time
err := r.db.QueryRow(ctx, query, accountID).Scan(&state, &blockedUntil)
if err == sql.ErrNoRows {
return models.WarmupHealthHealthy, nil, nil
}
if err != nil {
return models.WarmupHealthHealthy, nil, err
}
return models.WarmupHealthState(state), blockedUntil, nil
}
// UnblockFromPool unblocks an account from all warmup pools
func (r *warmupRepository) UnblockFromPool(ctx context.Context, accountID uuid.UUID) error {
query := `
@@ -351,8 +424,8 @@ func (r *warmupRepository) IsInPool(ctx context.Context, accountID uuid.UUID, po
// RecordSpamReport records a spam report
func (r *warmupRepository) RecordSpamReport(ctx context.Context, report *SpamReport) (bool, error) {
query := `
INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id, report_type, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id, report_type, content_source, recipient_provider, recipient_domain, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (reporter_account_id, message_id) DO NOTHING
`
@@ -362,6 +435,9 @@ func (r *warmupRepository) RecordSpamReport(ctx context.Context, report *SpamRep
report.ReportedAccountID,
report.MessageID,
report.ReportType,
report.ContentSource,
report.RecipientProvider,
report.RecipientDomain,
)
if err != nil {
@@ -588,6 +664,66 @@ func (r *warmupRepository) IncrementDailyCount(ctx context.Context, accountID uu
return err
}
// IncrementReplyCount increments the daily warmup reply count. Upserts the row
// so it is order-independent with IncrementDailyCount (either may run first).
func (r *warmupRepository) IncrementReplyCount(ctx context.Context, accountID uuid.UUID, date time.Time) error {
query := `
INSERT INTO warmup_statistics (email_account_id, date, emails_sent, emails_replied, target_volume)
VALUES ($1, DATE($2), 0, 1, 0)
ON CONFLICT (email_account_id, date)
DO UPDATE SET emails_replied = warmup_statistics.emails_replied + 1
`
_, err := r.db.Exec(ctx, query, accountID, date)
return err
}
// PoolSpamPlacementRate returns the pool-wide warmup spam-placement rate (%)
// over the window: spam_placement events divided by total warmup sends. This is
// the number surfaced as avg_spam_placement_rate in the admin health summary.
func (r *warmupRepository) PoolSpamPlacementRate(ctx context.Context, since time.Time) (float64, error) {
query := `
SELECT
(SELECT COUNT(*) FROM warmup_spam_reports WHERE report_type = 'spam_placement' AND created_at >= $1) AS placements,
(SELECT COALESCE(SUM(emails_sent), 0) FROM warmup_statistics WHERE date >= DATE($1)) AS sent
`
var placements, sent int
if err := r.db.QueryRow(ctx, query, since).Scan(&placements, &sent); err != nil {
return 0, err
}
if sent == 0 {
return 0, nil
}
return float64(placements) / float64(sent) * 100, nil
}
// PoolSpamPlacementsByProvider returns spam-placement counts grouped by the
// recipient provider over the window, so the admin overview can show where
// warmup mail is being filtered (e.g. mostly at Outlook vs Gmail).
func (r *warmupRepository) PoolSpamPlacementsByProvider(ctx context.Context, since time.Time) (map[string]int, error) {
query := `
SELECT COALESCE(NULLIF(recipient_provider, ''), 'unknown'), COUNT(*)
FROM warmup_spam_reports
WHERE report_type = 'spam_placement' AND created_at >= $1
GROUP BY 1
`
rows, err := r.db.Query(ctx, query, since)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var provider string
var n int
if err := rows.Scan(&provider, &n); err != nil {
return nil, err
}
out[provider] = n
}
return out, rows.Err()
}
// GetWarmupStatistics retrieves warmup statistics for a date range
func (r *warmupRepository) GetWarmupStatistics(ctx context.Context, accountID uuid.UUID, from, to time.Time) ([]WarmupStatistic, error) {
query := `
@@ -649,8 +785,8 @@ func (r *warmupRepository) GetOrCreateDailyStats(ctx context.Context, accountID
// CreateWarmupToken creates a warmup verification token
func (r *warmupRepository) CreateWarmupToken(ctx context.Context, token *models.WarmupToken) error {
query := `
INSERT INTO warmup_tokens (token, task_id, sender_account_id, recipient_account_id, conversation_theme, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO warmup_tokens (token, task_id, sender_account_id, recipient_account_id, conversation_theme, content_source, conversation_id, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err := r.db.Exec(ctx, query,
token.Token,
@@ -658,6 +794,8 @@ func (r *warmupRepository) CreateWarmupToken(ctx context.Context, token *models.
token.SenderAccountID,
token.RecipientAccountID,
token.ConversationTheme,
token.ContentSource,
token.ConversationID,
token.ExpiresAt,
)
return err
@@ -666,7 +804,7 @@ func (r *warmupRepository) CreateWarmupToken(ctx context.Context, token *models.
// GetWarmupToken retrieves a valid (unconsumed, unexpired) warmup token
func (r *warmupRepository) GetWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error) {
query := `
SELECT token, task_id, sender_account_id, recipient_account_id, COALESCE(conversation_theme, ''), created_at, consumed_at, expires_at
SELECT token, task_id, sender_account_id, recipient_account_id, COALESCE(conversation_theme, ''), COALESCE(content_source, ''), conversation_id, created_at, consumed_at, expires_at
FROM warmup_tokens
WHERE token = $1 AND consumed_at IS NULL AND expires_at > NOW()
`
@@ -678,6 +816,8 @@ func (r *warmupRepository) GetWarmupToken(ctx context.Context, tokenID uuid.UUID
&t.SenderAccountID,
&t.RecipientAccountID,
&t.ConversationTheme,
&t.ContentSource,
&t.ConversationID,
&t.CreatedAt,
&t.ConsumedAt,
&t.ExpiresAt,
@@ -692,7 +832,7 @@ func (r *warmupRepository) GetWarmupToken(ctx context.Context, tokenID uuid.UUID
func (r *warmupRepository) FindWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error) {
query := `
SELECT token, task_id, sender_account_id, recipient_account_id, COALESCE(conversation_theme, ''), created_at, consumed_at, expires_at
SELECT token, task_id, sender_account_id, recipient_account_id, COALESCE(conversation_theme, ''), COALESCE(content_source, ''), conversation_id, created_at, consumed_at, expires_at
FROM warmup_tokens
WHERE token = $1
`
@@ -704,6 +844,8 @@ func (r *warmupRepository) FindWarmupToken(ctx context.Context, tokenID uuid.UUI
&t.SenderAccountID,
&t.RecipientAccountID,
&t.ConversationTheme,
&t.ContentSource,
&t.ConversationID,
&t.CreatedAt,
&t.ConsumedAt,
&t.ExpiresAt,
@@ -773,6 +915,113 @@ func (r *warmupRepository) GetRecentlyUsedPartners(ctx context.Context, accountI
return partnerIDs, rows.Err()
}
// GetRecentPartnerCounts returns how many times the sender has targeted each
// partner since the provided timestamp. Used to enforce an explicit
// partner-diversity target — no single partner should absorb a large share of
// one mailbox's warmup traffic (a reciprocal-graph detection signal).
func (r *warmupRepository) GetRecentPartnerCounts(ctx context.Context, accountID uuid.UUID, since time.Time) (map[uuid.UUID]int, error) {
query := `
SELECT recipient_account_id, COUNT(*)
FROM warmup_tokens
WHERE sender_account_id = $1
AND created_at >= $2
GROUP BY recipient_account_id
`
rows, err := r.db.Query(ctx, query, accountID, since)
if err != nil {
return nil, err
}
defer rows.Close()
counts := make(map[uuid.UUID]int)
for rows.Next() {
var partnerID uuid.UUID
var n int
if err := rows.Scan(&partnerID, &n); err != nil {
return nil, err
}
counts[partnerID] = n
}
return counts, rows.Err()
}
// RecordWarmupReceived stores a delivered warmup email keyed by recipient +
// internal message id. Idempotent on re-delivery of the same message.
func (r *warmupRepository) RecordWarmupReceived(ctx context.Context, accountID, internalID uuid.UUID, messageID string, senderAccountID uuid.UUID) error {
query := `
INSERT INTO warmup_received (email_account_id, internal_id, message_id, sender_account_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email_account_id, internal_id) DO NOTHING
`
_, err := r.db.Exec(ctx, query, accountID, internalID, messageID, senderAccountID)
return err
}
// GetWarmupReceived looks up a delivered warmup email by recipient + internal
// message id. Returns nil when the message was not a warmup email.
func (r *warmupRepository) GetWarmupReceived(ctx context.Context, accountID, internalID uuid.UUID) (*WarmupReceived, error) {
query := `
SELECT email_account_id, internal_id, message_id, sender_account_id, created_at
FROM warmup_received
WHERE email_account_id = $1 AND internal_id = $2
`
var w WarmupReceived
err := r.db.QueryRow(ctx, query, accountID, internalID).Scan(
&w.EmailAccountID, &w.InternalID, &w.MessageID, &w.SenderAccountID, &w.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &w, nil
}
// RecordWarmupTampering records one "harm" a participant did to a warmup email.
// Returns whether a new row was inserted (deduped per account+message+kind).
func (r *warmupRepository) RecordWarmupTampering(ctx context.Context, accountID uuid.UUID, messageID, kind string) (bool, error) {
query := `
INSERT INTO warmup_tampering_events (email_account_id, message_id, kind)
VALUES ($1, $2, $3)
ON CONFLICT (email_account_id, message_id, kind) DO NOTHING
`
cmd, err := r.db.Exec(ctx, query, accountID, messageID, kind)
if err != nil {
return false, err
}
return cmd.RowsAffected() > 0, nil
}
// CountWarmupTamperingSince counts distinct tampering events for a mailbox.
func (r *warmupRepository) CountWarmupTamperingSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `SELECT COUNT(*) FROM warmup_tampering_events WHERE email_account_id = $1 AND created_at >= $2`
var n int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&n)
return n, err
}
// CreateWarmupAppeal inserts a pending appeal for a blocked mailbox.
func (r *warmupRepository) CreateWarmupAppeal(ctx context.Context, accountID, userID uuid.UUID, reason string) (uuid.UUID, error) {
id := uuid.New()
query := `
INSERT INTO warmup_appeals (id, email_account_id, user_id, reason, status)
VALUES ($1, $2, $3, $4, 'pending')
`
_, err := r.db.Exec(ctx, query, id, accountID, userID, reason)
return id, err
}
// HasPendingWarmupAppeal reports whether the mailbox already has an open appeal.
func (r *warmupRepository) HasPendingWarmupAppeal(ctx context.Context, accountID uuid.UUID) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM warmup_appeals WHERE email_account_id = $1 AND status = 'pending')`
var exists bool
err := r.db.QueryRow(ctx, query, accountID).Scan(&exists)
return exists, err
}
// GetPoolParticipantDomains returns a map from email_account_id to lowercased
// domain (the part after '@') for every active participant in the given pool.
// Used by the partner selector to weight selection toward under-represented
+491
View File
@@ -0,0 +1,491 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/models"
)
// ConversationFilter narrows a warmup_conversations listing.
type ConversationFilter struct {
PoolType string
Segment string
Source string
Status string
Limit int
Offset int
}
// WarmupConversationStat is a grouped count for the admin overview.
type WarmupConversationStat struct {
PoolType string `json:"pool_type"`
Segment string `json:"segment"`
Source string `json:"source"`
Active int `json:"active"`
Archived int `json:"archived"`
}
// WarmupCohortStat is the per-content-source spam-placement aggregate.
type WarmupCohortStat struct {
ContentSource string `json:"content_source"`
Sent int `json:"sent"`
SpamPlacements int `json:"spam_placements"`
}
// WarmupContentRepository is the data access for the warmup content bank,
// offline generation jobs, generation settings, and content-cohort analytics.
type WarmupContentRepository interface {
// Conversation bank
InsertConversation(ctx context.Context, c *models.WarmupConversation) error
// PickConversation draws an active thread for the segment from the SHARED
// content library, regardless of tier: free/premium separate which mailboxes
// warm together (reputation isolation), not what they say, so content is not
// split by pool. Prefers an exact segment match, falls back to generic.
PickConversation(ctx context.Context, segment string) (*models.WarmupConversation, error)
GetConversation(ctx context.Context, id uuid.UUID) (*models.WarmupConversation, error)
ListConversations(ctx context.Context, f ConversationFilter) ([]models.WarmupConversation, int, error)
SetConversationStatus(ctx context.Context, id uuid.UUID, status string) error
DeleteConversation(ctx context.Context, id uuid.UUID) error
IncrementConversationUsage(ctx context.Context, id uuid.UUID) error
CountActiveConversations(ctx context.Context, poolType, segment string) (int, error)
ConversationStats(ctx context.Context) ([]WarmupConversationStat, error)
LastGeneratedAt(ctx context.Context) (*time.Time, error)
// Generation jobs
CreateGenerationJob(ctx context.Context, j *models.WarmupGenerationJob) error
UpdateGenerationJob(ctx context.Context, j *models.WarmupGenerationJob) error
GetGenerationJob(ctx context.Context, id uuid.UUID) (*models.WarmupGenerationJob, error)
ListGenerationJobs(ctx context.Context, limit, offset int) ([]models.WarmupGenerationJob, int, error)
// ListActiveBatchJobs returns batch-mode jobs still in flight (running with a
// non-terminal OpenAI batch status), for the poller to reconcile.
ListActiveBatchJobs(ctx context.Context) ([]models.WarmupGenerationJob, error)
GeneratedCountSince(ctx context.Context, since time.Time) (int, error)
// Settings (admin_settings key/value)
GetGenerationSettings(ctx context.Context) (*models.WarmupGenerationSettings, error)
SetGenerationSettings(ctx context.Context, s *models.WarmupGenerationSettings, updatedBy *uuid.UUID) error
// Content-cohort A/B analytics
SpamPlacementByCohort(ctx context.Context, since time.Time) ([]WarmupCohortStat, error)
}
type warmupContentRepository struct {
db *pgxpool.Pool
}
// NewWarmupContentRepository creates a new warmup content repository.
func NewWarmupContentRepository(db *pgxpool.Pool) WarmupContentRepository {
return &warmupContentRepository{db: db}
}
func (r *warmupContentRepository) InsertConversation(ctx context.Context, c *models.WarmupConversation) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
msgs, err := json.Marshal(c.Messages)
if err != nil {
return err
}
query := `
INSERT INTO warmup_conversations
(id, pool_type, segment, source, theme, subject, description, messages, status, lint_passed, usage_count, generated_by_job_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
_, err = r.db.Exec(ctx, query,
c.ID, c.PoolType, c.Segment, c.Source, c.Theme, c.Subject, c.Description,
msgs, c.Status, c.LintPassed, c.UsageCount, c.GeneratedByJob,
)
return err
}
func scanConversation(row pgx.Row) (*models.WarmupConversation, error) {
var c models.WarmupConversation
var msgs []byte
err := row.Scan(
&c.ID, &c.PoolType, &c.Segment, &c.Source, &c.Theme, &c.Subject, &c.Description,
&msgs, &c.Status, &c.LintPassed, &c.UsageCount, &c.GeneratedByJob, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
if len(msgs) > 0 {
_ = json.Unmarshal(msgs, &c.Messages)
}
return &c, nil
}
const conversationCols = `id, pool_type, segment, source, theme, subject, description, messages, status, lint_passed, usage_count, generated_by_job_id, created_at, updated_at`
// PickConversation returns a random active conversation from the shared library,
// preferring an exact segment match and falling back to generic (segment=”)
// content. Tier (free/premium) is intentionally NOT a filter — content is shared
// across pools; only mailbox reputation is isolated by pool.
func (r *warmupContentRepository) PickConversation(ctx context.Context, segment string) (*models.WarmupConversation, error) {
query := `
SELECT ` + conversationCols + `
FROM warmup_conversations
WHERE status = 'active' AND (segment = $1 OR segment = '')
ORDER BY (segment = $1) DESC, random()
LIMIT 1
`
c, err := scanConversation(r.db.QueryRow(ctx, query, segment))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return c, err
}
func (r *warmupContentRepository) GetConversation(ctx context.Context, id uuid.UUID) (*models.WarmupConversation, error) {
query := `SELECT ` + conversationCols + ` FROM warmup_conversations WHERE id = $1`
c, err := scanConversation(r.db.QueryRow(ctx, query, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return c, err
}
func (r *warmupContentRepository) ListConversations(ctx context.Context, f ConversationFilter) ([]models.WarmupConversation, int, error) {
where := `WHERE 1=1`
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where += clause
}
if f.PoolType != "" {
add(" AND pool_type = $"+itoa(len(args)+1), f.PoolType)
}
if f.Segment != "" {
add(" AND segment = $"+itoa(len(args)+1), f.Segment)
}
if f.Source != "" {
add(" AND source = $"+itoa(len(args)+1), f.Source)
}
if f.Status != "" {
add(" AND status = $"+itoa(len(args)+1), f.Status)
}
var total int
if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM warmup_conversations `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
args = append(args, limit)
limitIdx := itoa(len(args))
args = append(args, f.Offset)
offsetIdx := itoa(len(args))
query := `SELECT ` + conversationCols + ` FROM warmup_conversations ` + where +
` ORDER BY created_at DESC LIMIT $` + limitIdx + ` OFFSET $` + offsetIdx
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []models.WarmupConversation{}
for rows.Next() {
c, err := scanConversation(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *c)
}
return out, total, rows.Err()
}
func (r *warmupContentRepository) SetConversationStatus(ctx context.Context, id uuid.UUID, status string) error {
_, err := r.db.Exec(ctx, `UPDATE warmup_conversations SET status = $2, updated_at = NOW() WHERE id = $1`, id, status)
return err
}
func (r *warmupContentRepository) DeleteConversation(ctx context.Context, id uuid.UUID) error {
_, err := r.db.Exec(ctx, `DELETE FROM warmup_conversations WHERE id = $1`, id)
return err
}
func (r *warmupContentRepository) IncrementConversationUsage(ctx context.Context, id uuid.UUID) error {
_, err := r.db.Exec(ctx, `UPDATE warmup_conversations SET usage_count = usage_count + 1 WHERE id = $1`, id)
return err
}
func (r *warmupContentRepository) CountActiveConversations(ctx context.Context, poolType, segment string) (int, error) {
var n int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM warmup_conversations WHERE pool_type = $1 AND segment = $2 AND status = 'active'`,
poolType, segment).Scan(&n)
return n, err
}
func (r *warmupContentRepository) ConversationStats(ctx context.Context) ([]WarmupConversationStat, error) {
query := `
SELECT pool_type, segment, source,
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status = 'archived') AS archived
FROM warmup_conversations
GROUP BY pool_type, segment, source
ORDER BY pool_type, segment, source
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
out := []WarmupConversationStat{}
for rows.Next() {
var s WarmupConversationStat
if err := rows.Scan(&s.PoolType, &s.Segment, &s.Source, &s.Active, &s.Archived); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *warmupContentRepository) LastGeneratedAt(ctx context.Context) (*time.Time, error) {
var t *time.Time
err := r.db.QueryRow(ctx, `SELECT MAX(created_at) FROM warmup_conversations WHERE source = 'ai'`).Scan(&t)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return t, err
}
func (r *warmupContentRepository) CreateGenerationJob(ctx context.Context, j *models.WarmupGenerationJob) error {
if j.ID == uuid.Nil {
j.ID = uuid.New()
}
if j.Mode == "" {
j.Mode = models.WarmupGenerationModeSync
}
if j.CompletionWindow == "" {
j.CompletionWindow = "24h"
}
query := `
INSERT INTO warmup_generation_jobs
(id, requested_by, trigger, mode, pool_type, segment, theme, model, requested_count, status,
batch_id, batch_input_file_id, batch_output_file_id, batch_status, completion_window)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`
_, err := r.db.Exec(ctx, query,
j.ID, j.RequestedBy, j.Trigger, j.Mode, j.PoolType, j.Segment, j.Theme, j.Model, j.RequestedCount, j.Status,
j.BatchID, j.BatchInputFileID, j.BatchOutputFileID, j.BatchStatus, j.CompletionWindow,
)
return err
}
func (r *warmupContentRepository) UpdateGenerationJob(ctx context.Context, j *models.WarmupGenerationJob) error {
query := `
UPDATE warmup_generation_jobs SET
generated_count = $2,
lint_rejected_count = $3,
failed_count = $4,
status = $5,
error = $6,
started_at = $7,
finished_at = $8,
batch_id = $9,
batch_input_file_id = $10,
batch_output_file_id = $11,
batch_status = $12,
completion_window = $13,
updated_at = NOW()
WHERE id = $1
`
_, err := r.db.Exec(ctx, query,
j.ID, j.GeneratedCount, j.LintRejectedCount, j.FailedCount, j.Status, j.Error, j.StartedAt, j.FinishedAt,
j.BatchID, j.BatchInputFileID, j.BatchOutputFileID, j.BatchStatus, j.CompletionWindow,
)
return err
}
const generationJobCols = `id, requested_by, trigger, mode, pool_type, segment, theme, model, requested_count, generated_count, lint_rejected_count, failed_count, status, error, batch_id, batch_input_file_id, batch_output_file_id, batch_status, completion_window, started_at, finished_at, created_at, updated_at`
func scanGenerationJob(row pgx.Row) (*models.WarmupGenerationJob, error) {
var j models.WarmupGenerationJob
err := row.Scan(
&j.ID, &j.RequestedBy, &j.Trigger, &j.Mode, &j.PoolType, &j.Segment, &j.Theme, &j.Model,
&j.RequestedCount, &j.GeneratedCount, &j.LintRejectedCount, &j.FailedCount,
&j.Status, &j.Error, &j.BatchID, &j.BatchInputFileID, &j.BatchOutputFileID, &j.BatchStatus, &j.CompletionWindow,
&j.StartedAt, &j.FinishedAt, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
return &j, nil
}
func (r *warmupContentRepository) GetGenerationJob(ctx context.Context, id uuid.UUID) (*models.WarmupGenerationJob, error) {
j, err := scanGenerationJob(r.db.QueryRow(ctx, `SELECT `+generationJobCols+` FROM warmup_generation_jobs WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return j, err
}
func (r *warmupContentRepository) ListGenerationJobs(ctx context.Context, limit, offset int) ([]models.WarmupGenerationJob, int, error) {
var total int
if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM warmup_generation_jobs`).Scan(&total); err != nil {
return nil, 0, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.db.Query(ctx,
`SELECT `+generationJobCols+` FROM warmup_generation_jobs ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []models.WarmupGenerationJob{}
for rows.Next() {
j, err := scanGenerationJob(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *j)
}
return out, total, rows.Err()
}
// ListActiveBatchJobs returns batch-mode jobs that are still running with a
// non-terminal OpenAI batch status. Terminal statuses (completed/failed/expired/
// cancelled) and a terminal job status are excluded so the poller only touches
// in-flight work. An empty batch_status (just submitted, not yet polled) is
// treated as active.
func (r *warmupContentRepository) ListActiveBatchJobs(ctx context.Context) ([]models.WarmupGenerationJob, error) {
query := `SELECT ` + generationJobCols + ` FROM warmup_generation_jobs
WHERE mode = 'batch'
AND status = 'running'
AND batch_status NOT IN ('completed', 'failed', 'expired', 'cancelled')
AND batch_id <> ''
ORDER BY created_at ASC`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.WarmupGenerationJob{}
for rows.Next() {
j, err := scanGenerationJob(rows)
if err != nil {
return nil, err
}
out = append(out, *j)
}
return out, rows.Err()
}
func (r *warmupContentRepository) GeneratedCountSince(ctx context.Context, since time.Time) (int, error) {
var n int
err := r.db.QueryRow(ctx,
`SELECT COALESCE(SUM(generated_count), 0) FROM warmup_generation_jobs WHERE created_at >= $1`, since).Scan(&n)
return n, err
}
func (r *warmupContentRepository) GetGenerationSettings(ctx context.Context) (*models.WarmupGenerationSettings, error) {
var raw []byte
err := r.db.QueryRow(ctx, `SELECT value FROM admin_settings WHERE key = $1`, models.AdminSettingsKeyWarmupGeneration).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
def := models.DefaultWarmupGenerationSettings()
return &def, nil
}
if err != nil {
return nil, err
}
s := models.DefaultWarmupGenerationSettings()
if len(raw) > 0 {
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
}
s.Normalize()
return &s, nil
}
func (r *warmupContentRepository) SetGenerationSettings(ctx context.Context, s *models.WarmupGenerationSettings, updatedBy *uuid.UUID) error {
s.Normalize()
raw, err := json.Marshal(s)
if err != nil {
return err
}
query := `
INSERT INTO admin_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = NOW()
`
_, err = r.db.Exec(ctx, query, models.AdminSettingsKeyWarmupGeneration, raw, updatedBy)
return err
}
func (r *warmupContentRepository) SpamPlacementByCohort(ctx context.Context, since time.Time) ([]WarmupCohortStat, error) {
stats := map[string]*WarmupCohortStat{}
get := func(src string) *WarmupCohortStat {
if src == "" {
src = models.WarmupContentSourceStatic
}
if s, ok := stats[src]; ok {
return s
}
s := &WarmupCohortStat{ContentSource: src}
stats[src] = s
return s
}
sentRows, err := r.db.Query(ctx,
`SELECT content_source, COUNT(*) FROM warmup_tokens WHERE created_at >= $1 GROUP BY content_source`, since)
if err != nil {
return nil, err
}
for sentRows.Next() {
var src string
var n int
if err := sentRows.Scan(&src, &n); err != nil {
sentRows.Close()
return nil, err
}
get(src).Sent += n
}
sentRows.Close()
if err := sentRows.Err(); err != nil {
return nil, err
}
placeRows, err := r.db.Query(ctx,
`SELECT content_source, COUNT(*) FROM warmup_spam_reports WHERE report_type = 'spam_placement' AND created_at >= $1 GROUP BY content_source`, since)
if err != nil {
return nil, err
}
for placeRows.Next() {
var src string
var n int
if err := placeRows.Scan(&src, &n); err != nil {
placeRows.Close()
return nil, err
}
get(src).SpamPlacements += n
}
placeRows.Close()
if err := placeRows.Err(); err != nil {
return nil, err
}
out := make([]WarmupCohortStat, 0, len(stats))
for _, s := range stats {
out = append(out, *s)
}
return out, nil
}
@@ -0,0 +1,81 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// PendingEngagement is a delayed warmup engagement action awaiting its dwell.
// Payload is a JSON-encoded models.WarmupEmailAction (the delayed leg, with
// DelaySeconds already consumed — the worker runs it immediately on receipt).
type PendingEngagement struct {
ID uuid.UUID
EmailAccountID uuid.UUID
Payload []byte
FireAt time.Time
}
// WarmupEngagementRepository is the durable schedule for dwell-delayed warmup
// engagement actions, so a worker restart can't drop them (the old in-process
// timer did). Control-plane only; drained by the consumer-side poller.
type WarmupEngagementRepository interface {
// EnqueuePendingEngagement stores a delayed engagement leg to fire at fireAt.
EnqueuePendingEngagement(ctx context.Context, accountID uuid.UUID, payload []byte, fireAt time.Time) error
// ClaimDuePendingEngagements atomically removes and returns up to limit rows
// whose fire_at has passed, so each is delivered exactly once across pollers.
ClaimDuePendingEngagements(ctx context.Context, limit int) ([]PendingEngagement, error)
}
type warmupEngagementRepository struct {
db *pgxpool.Pool
}
// NewWarmupEngagementRepository creates a new warmup engagement repository.
func NewWarmupEngagementRepository(db *pgxpool.Pool) WarmupEngagementRepository {
return &warmupEngagementRepository{db: db}
}
func (r *warmupEngagementRepository) EnqueuePendingEngagement(ctx context.Context, accountID uuid.UUID, payload []byte, fireAt time.Time) error {
_, err := r.db.Exec(ctx,
`INSERT INTO warmup_pending_engagements (email_account_id, payload, fire_at) VALUES ($1, $2, $3)`,
accountID, payload, fireAt)
return err
}
// ClaimDuePendingEngagements uses DELETE ... RETURNING over a FOR UPDATE SKIP
// LOCKED subselect so concurrent pollers never claim the same row and a claimed
// row is removed in the same statement (delivered at most once).
func (r *warmupEngagementRepository) ClaimDuePendingEngagements(ctx context.Context, limit int) ([]PendingEngagement, error) {
if limit <= 0 {
limit = 100
}
query := `
DELETE FROM warmup_pending_engagements
WHERE id IN (
SELECT id FROM warmup_pending_engagements
WHERE fire_at <= NOW()
ORDER BY fire_at
LIMIT $1
FOR UPDATE SKIP LOCKED
)
RETURNING id, email_account_id, payload, fire_at
`
rows, err := r.db.Query(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PendingEngagement
for rows.Next() {
var p PendingEngagement
if err := rows.Scan(&p.ID, &p.EmailAccountID, &p.Payload, &p.FireAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
+20
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -110,6 +111,25 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai
continue
}
// Health-gate cold sends on the SAME warmup health state used for pool
// selection, so a mailbox in deliverability trouble doesn't keep blasting
// cold volume (the concentration risk the safety policy warns about):
// - quarantined/blocked (still within blocked_until) → don't send at all
// - throttled → halve today's budget (and the wider min-gap still applies)
if state, blockedUntil, herr := s.warmupRepo.GetHealthState(ctx, acct.ID); herr == nil {
switch state {
case models.WarmupHealthQuarantined, models.WarmupHealthBlocked:
if blockedUntil == nil || blockedUntil.After(time.Now()) {
continue
}
case models.WarmupHealthThrottled:
remaining /= 2
if remaining <= 0 {
continue
}
}
}
// If the account has its own timezone, check it is within business hours
if acct.Timezone != "" && acct.Timezone != campaign.Timezone {
acctTZ := loadLocation(acct.Timezone)
+18
View File
@@ -219,6 +219,24 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error {
}
}
// Pre-send verification gate: drop addresses already known to be invalid
// (bad syntax / no MX / 550 RCPT) before a worker sends and earns a hard
// bounce. Only 'invalid' is dropped — 'risky'/'unknown'/'valid' still send.
if contact.VerificationStatus == "invalid" {
_ = s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "skipped_suppressed")
if s.campaignLogRepo != nil {
_ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{
CampaignID: campaign.ID,
EventType: "suppressed",
Message: fmt.Sprintf("Unverifiable recipient skipped: %s", contact.Email),
Metadata: map[string]interface{}{"reason": contact.VerificationReason},
})
}
_ = s.createCampaignTask(ctx, campaign.ID, accountID, nextTime)
executionStatus = "completed"
return nil
}
sequence, err := s.campaignRepo.GetSequenceByID(ctx, nextPair.SequenceID)
if err != nil {
sentry.CaptureException(err)
+11
View File
@@ -0,0 +1,11 @@
package tasks
import "github.com/warmbly/warmbly/internal/pkg/warmlint"
// lintWarmupContent rejects content that would raise the sending mailbox's own
// spam score (ALL-CAPS subjects, stacked punctuation, stacked spam triggers, a
// fabricated Re:/Fwd: on a non-reply). Shared with the offline AI generator via
// the warmlint package so static and AI content are held to the same bar.
func lintWarmupContent(subject, body string, isReply bool) error {
return warmlint.Check(subject, body, isReply)
}
+159 -42
View File
@@ -173,7 +173,8 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
// the original send so the thread stays topically coherent.
replyRate := account.WarmupReplyRate
shouldReply := rand.Float64()*100 < float64(replyRate)
var subject, emailBody, conversationTheme string
var subject, emailBody, conversationTheme, contentSource string
var conversationID *uuid.UUID
var inReplyTo string
if shouldReply {
@@ -184,23 +185,55 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
if subject == "" {
subject = generateWarmupSubject()
}
// "Re:" is legitimate here — this is a genuine reply with a real
// In-Reply-To header (synthesizeWarmupSubject no longer fabricates
// "Re:" on first-touch sends).
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
subject = "Re: " + subject
}
conv := conversationForTheme(candidate.ConversationTheme)
conversationTheme = conv.Theme
contentSource = models.WarmupContentSourceStatic
emailBody = GenerateConversationEmail(conv, *account, true)
} else {
shouldReply = false
}
}
// STEP 7: Build a new warmup message when not replying
// STEP 7: Build a new warmup message when not replying. Content comes from
// the AI bank (segment-aware) when enabled, else the static library.
if !shouldReply {
conversation := randomWarmupConversation()
conversationTheme = conversation.Theme
content := s.pickNewWarmupContent(ctx, *account)
subject = content.subject
emailBody = content.body
conversationTheme = content.theme
contentSource = content.contentSource
conversationID = content.conversationID
}
// STEP 7.5: Content-safety lint. Warmup mail must look unremarkable; if the
// chosen content trips the lint (most likely AI drift) fall back to clean
// static content so we never send spammy-looking warmup.
if err := lintWarmupContent(subject, emailBody, shouldReply); err != nil {
log.Warn().Err(err).
Str("email_account_id", account.ID.String()).
Str("content_source", contentSource).
Msg("warmup content failed lint; falling back to static")
conv := randomWarmupConversation()
conversationTheme = conv.Theme
fallbackID := conv.ID
conversationID = &fallbackID
contentSource = models.WarmupContentSourceStatic
subject = generateWarmupSubject()
emailBody = GenerateConversationEmail(conversation, *account, false)
emailBody = GenerateConversationEmail(conv, *account, false)
if err2 := lintWarmupContent(subject, emailBody, false); err2 != nil {
subject = "Quick note"
emailBody = GenerateConversationEmail(Conversation{
Theme: "checkin",
Description: "Just checking in — hope all is well.",
Messages: []string{"How have things been lately?"},
}, *account, false)
}
}
// STEP 8: Parse sender user ID for the outbound message.
@@ -215,6 +248,12 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
// STEP 9: Generate Message-ID
messageID := generateMessageID(account.Email)
// Persist it now so the reply path (GetLatestReplyCandidate, which filters
// message_id <> '') can find this send as a thread parent on a later turn.
// Without this the warmup reply/threading path never fires.
if err := s.taskRepo.UpdateTaskMessageID(ctx, taskID, messageID); err != nil {
log.Warn().Err(err).Str("task_id", taskID.String()).Msg("Failed to persist warmup task message_id")
}
// STEP 9.5: Generate warmup verification token
var warmupTokenStr string
@@ -225,6 +264,8 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
SenderAccountID: account.ID,
RecipientAccountID: partner.ID,
ConversationTheme: conversationTheme,
ContentSource: contentSource,
ConversationID: conversationID,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
}
if err := s.warmupRepo.CreateWarmupToken(ctx, tokenRecord); err != nil {
@@ -267,6 +308,13 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error {
if err := s.warmupRepo.IncrementDailyCount(ctx, account.ID, time.Now()); err != nil {
log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to increment warmup daily count")
}
// Track replies separately so warmup reply analytics (emails_replied) is no
// longer always zero. Conversational replies are a healthy-traffic signal.
if shouldReply {
if err := s.warmupRepo.IncrementReplyCount(ctx, account.ID, time.Now()); err != nil {
log.Warn().Err(err).Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()).Msg("Failed to increment warmup reply count")
}
}
// STEP 13: Mark task completed (with advisory lock)
if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil {
@@ -322,6 +370,18 @@ const recentDomainWindow = 7 * 24 * time.Hour
// that mailbox providers can cluster on.
const smallPoolWarnThreshold = 8
// partnerDiversityWindow / partnerMaxSharedWindow set the explicit
// partner-diversity target: within partnerDiversityWindow, a sender should not
// send to the same partner more than partnerMaxSharedWindow times. Over-used
// partners are demoted to the fallback tier so warmup traffic spreads across
// many partners rather than forming a tight reciprocal pair (a closed-loop
// graph signal). This is a soft target — it only applies while enough other
// partners remain available.
const (
partnerDiversityWindow = 7 * 24 * time.Hour
partnerMaxSharedWindow = 3
)
func warmupPartnerRecheckTime() time.Time {
return time.Now().Add(time.Duration(240+rand.Intn(240)) * time.Minute)
}
@@ -390,6 +450,14 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
domainCounts = nil
}
// Explicit partner-diversity target: how many times each partner has been
// used in the diversity window, so partners the sender already leans on
// heavily get demoted out of the preferred tier.
partnerCounts, err := s.warmupRepo.GetRecentPartnerCounts(ctx, account.ID, time.Now().Add(-partnerDiversityWindow))
if err != nil {
partnerCounts = nil
}
var availablePartners []uuid.UUID
var fallbackPartners []uuid.UUID
for _, id := range participantIDs {
@@ -400,7 +468,9 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
continue
}
fallbackPartners = append(fallbackPartners, id)
if _, recentlyUsed := recentPartnerSet[id]; !recentlyUsed {
_, recentlyUsed := recentPartnerSet[id]
overUsed := partnerCounts[id] >= partnerMaxSharedWindow
if !recentlyUsed && !overUsed {
availablePartners = append(availablePartners, id)
}
}
@@ -413,14 +483,43 @@ func (s *tasksService) selectWarmupPartner(ctx context.Context, account Email) (
return nil, fmt.Errorf("no available warmup partners")
}
partnerID := pickWeightedPartner(availablePartners, domainsByID, domainCounts, routingRules, account.Email, emailsByID)
// Pick a partner, then gate it through the SAME health re-evaluation the
// sender passes (email_task STEP 5). The recipient-selection SQL re-admits a
// row the instant blocked_until elapses — before the hourly sweep
// reclassifies it — so without this gate a just-expired quarantined/blocked
// mailbox could be chosen as a recipient with no re-qualification.
// CanParticipate re-evaluates and forces just-unblocked mailboxes into
// probation, matching the CLAUDE.md re-entry policy on the recipient surface.
for attempts := 0; attempts < 5 && len(availablePartners) > 0; attempts++ {
partnerID := pickWeightedPartner(availablePartners, domainsByID, domainCounts, routingRules, account.Email, emailsByID)
partner, err := s.emailRepo.GetByID(ctx, partnerID)
if err != nil {
return nil, err
if s.warmupHealth != nil {
if ok, _, _ := s.warmupHealth.CanParticipate(ctx, partnerID, poolType); !ok {
availablePartners = removePartnerID(availablePartners, partnerID)
continue
}
}
partner, err := s.emailRepo.GetByID(ctx, partnerID)
if err != nil {
return nil, err
}
return partner, nil
}
return partner, nil
return nil, fmt.Errorf("no eligible warmup partners after health gate")
}
// removePartnerID returns ids without the first occurrence of target. Used to
// drop a partner that failed the health gate before re-picking.
func removePartnerID(ids []uuid.UUID, target uuid.UUID) []uuid.UUID {
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
if id != target {
out = append(out, id)
}
}
return out
}
// pickWeightedPartner picks a partner ID using a composite weight:
@@ -620,7 +719,10 @@ func synthesizeWarmupSubject() string {
"{question} about {noun}",
"{timeRef} {noun}",
"{noun} {timeRef}",
"Re: {noun}",
// NB: no "Re:"/"Fwd:" template here — a fabricated reply prefix on a
// first-touch send is a deception signal and CAN-SPAM exposure. The
// genuine reply path adds "Re:" itself when there is a real
// In-Reply-To header.
}
adj := []string{"quick", "small", "short", "tiny", "casual", "friendly", "useful", "interesting", "brief", "minor"}
noun := []string{"check-in", "follow up", "note", "ping", "thought", "update", "idea", "heads up", "favor", "question", "nudge", "share"}
@@ -732,65 +834,80 @@ func randomWarmupConversation() Conversation {
return conversations[rand.Intn(len(conversations))]
}
// staticConvID derives a STABLE id for a static-library conversation from its
// content. Previously each call to warmupConversations() minted fresh uuid.New()
// ids, so the conversation_id recorded on a warmup token never matched across
// sends — defeating cohort correlation and dedupe. A deterministic id makes the
// static library traceable the same way the AI bank rows are.
func staticConvID(theme, description string) uuid.UUID {
return uuid.NewSHA1(uuid.NameSpaceOID, []byte("warmup-static:"+theme+"|"+description))
}
func warmupConversations() []Conversation {
conversations := []Conversation{
// Productivity & workflow
{ID: uuid.New(), Theme: "productivity", Description: "I have been trying a few workflow changes and wondered what worked best for your week.", Messages: []string{"How do you structure focused work blocks?", "Do you batch similar tasks or tackle them as they come?"}},
{ID: uuid.New(), Theme: "productivity", Description: "I started time-blocking my calendar this month and the results have been interesting so far.", Messages: []string{"Have you tried any time management methods that actually stuck?", "What does your typical morning routine look like?"}},
{ID: uuid.New(), Theme: "automation", Description: "I automated a couple of repetitive tasks recently and it freed up more time than I expected.", Messages: []string{"Are there any repetitive tasks in your day that you have managed to streamline?"}},
{Theme: "productivity", Description: "I have been trying a few workflow changes and wondered what worked best for your week.", Messages: []string{"How do you structure focused work blocks?", "Do you batch similar tasks or tackle them as they come?"}},
{Theme: "productivity", Description: "I started time-blocking my calendar this month and the results have been interesting so far.", Messages: []string{"Have you tried any time management methods that actually stuck?", "What does your typical morning routine look like?"}},
{Theme: "automation", Description: "I automated a couple of repetitive tasks recently and it freed up more time than I expected.", Messages: []string{"Are there any repetitive tasks in your day that you have managed to streamline?"}},
// Learning & growth
{ID: uuid.New(), Theme: "learning", Description: "I came across a useful article and it got me curious about what resources you rely on lately.", Messages: []string{"Any newsletter or podcast you consistently recommend?", "What is the best thing you have learned recently?"}},
{ID: uuid.New(), Theme: "learning", Description: "I have been dedicating an hour each week to learning something new and it has been surprisingly rewarding.", Messages: []string{"How do you make time for professional development?"}},
{ID: uuid.New(), Theme: "courses", Description: "I just wrapped up an online course that was really practical and well-structured.", Messages: []string{"Have you taken any courses lately that were worth the investment?"}},
{Theme: "learning", Description: "I came across a useful article and it got me curious about what resources you rely on lately.", Messages: []string{"Any newsletter or podcast you consistently recommend?", "What is the best thing you have learned recently?"}},
{Theme: "learning", Description: "I have been dedicating an hour each week to learning something new and it has been surprisingly rewarding.", Messages: []string{"How do you make time for professional development?"}},
{Theme: "courses", Description: "I just wrapped up an online course that was really practical and well-structured.", Messages: []string{"Have you taken any courses lately that were worth the investment?"}},
// Collaboration & teams
{ID: uuid.New(), Theme: "collaboration", Description: "I was thinking about how teams keep communication clear when work gets busy.", Messages: []string{"What has helped your team keep projects moving smoothly?", "How do you handle async communication across time zones?"}},
{ID: uuid.New(), Theme: "meetings", Description: "We cut our meeting load in half last month and the team seems more productive overall.", Messages: []string{"How do you decide which meetings are actually necessary?", "Have you found a good balance between sync and async?"}},
{Theme: "collaboration", Description: "I was thinking about how teams keep communication clear when work gets busy.", Messages: []string{"What has helped your team keep projects moving smoothly?", "How do you handle async communication across time zones?"}},
{Theme: "meetings", Description: "We cut our meeting load in half last month and the team seems more productive overall.", Messages: []string{"How do you decide which meetings are actually necessary?", "Have you found a good balance between sync and async?"}},
// Industry & trends
{ID: uuid.New(), Theme: "industry", Description: "I noticed a shift in how people are approaching this topic and wanted to get your take.", Messages: []string{"Have you seen any changes in how your industry handles this?", "What trends are you paying attention to right now?"}},
{ID: uuid.New(), Theme: "market", Description: "The market has been moving fast lately and I have been trying to figure out what matters most.", Messages: []string{"How are you adapting your approach given recent changes?"}},
{Theme: "industry", Description: "I noticed a shift in how people are approaching this topic and wanted to get your take.", Messages: []string{"Have you seen any changes in how your industry handles this?", "What trends are you paying attention to right now?"}},
{Theme: "market", Description: "The market has been moving fast lately and I have been trying to figure out what matters most.", Messages: []string{"How are you adapting your approach given recent changes?"}},
// Tools & technology
{ID: uuid.New(), Theme: "tools", Description: "I recently switched up a few tools in my daily workflow and the difference has been noticeable.", Messages: []string{"What tools have made the biggest impact for you this year?", "Have you found a good alternative for that?"}},
{ID: uuid.New(), Theme: "software", Description: "I have been testing a new project management setup and wondering if I am overcomplicating things.", Messages: []string{"What is your go-to for keeping projects organized?", "Do you prefer simple tools or full-featured platforms?"}},
{Theme: "tools", Description: "I recently switched up a few tools in my daily workflow and the difference has been noticeable.", Messages: []string{"What tools have made the biggest impact for you this year?", "Have you found a good alternative for that?"}},
{Theme: "software", Description: "I have been testing a new project management setup and wondering if I am overcomplicating things.", Messages: []string{"What is your go-to for keeping projects organized?", "Do you prefer simple tools or full-featured platforms?"}},
// Networking & catch-ups
{ID: uuid.New(), Theme: "networking", Description: "It has been a while since we last connected and I wanted to see how things are going on your end.", Messages: []string{"Any new projects or goals you are excited about?", "What has been keeping you busy lately?"}},
{ID: uuid.New(), Theme: "catchup", Description: "I was cleaning up my contacts list and realized we have not caught up in ages.", Messages: []string{"How has your year been going so far?", "Anything interesting happening on your side?"}},
{ID: uuid.New(), Theme: "introduction", Description: "I met someone recently who reminded me of the work you do and thought you two should connect.", Messages: []string{"Would you be open to a quick intro?"}},
{Theme: "networking", Description: "It has been a while since we last connected and I wanted to see how things are going on your end.", Messages: []string{"Any new projects or goals you are excited about?", "What has been keeping you busy lately?"}},
{Theme: "catchup", Description: "I was cleaning up my contacts list and realized we have not caught up in ages.", Messages: []string{"How has your year been going so far?", "Anything interesting happening on your side?"}},
{Theme: "introduction", Description: "I met someone recently who reminded me of the work you do and thought you two should connect.", Messages: []string{"Would you be open to a quick intro?"}},
// Feedback & advice
{ID: uuid.New(), Theme: "feedback", Description: "I have been working on something and would really value a second opinion before moving forward.", Messages: []string{"Would you mind taking a quick look when you have a moment?", "I would appreciate your honest feedback on this."}},
{ID: uuid.New(), Theme: "advice", Description: "I am facing a decision and I think your perspective could really help me think it through.", Messages: []string{"Have you dealt with anything similar before?", "What would you do in this situation?"}},
{Theme: "feedback", Description: "I have been working on something and would really value a second opinion before moving forward.", Messages: []string{"Would you mind taking a quick look when you have a moment?", "I would appreciate your honest feedback on this."}},
{Theme: "advice", Description: "I am facing a decision and I think your perspective could really help me think it through.", Messages: []string{"Have you dealt with anything similar before?", "What would you do in this situation?"}},
// Planning & strategy
{ID: uuid.New(), Theme: "planning", Description: "I am mapping out priorities for the next quarter and trying to stay realistic about what is achievable.", Messages: []string{"How do you decide what to focus on when everything feels urgent?", "What is your process for setting quarterly goals?"}},
{ID: uuid.New(), Theme: "strategy", Description: "I have been rethinking how we allocate resources across projects and it is harder than it sounds.", Messages: []string{"How do you balance long-term bets with short-term wins?"}},
{Theme: "planning", Description: "I am mapping out priorities for the next quarter and trying to stay realistic about what is achievable.", Messages: []string{"How do you decide what to focus on when everything feels urgent?", "What is your process for setting quarterly goals?"}},
{Theme: "strategy", Description: "I have been rethinking how we allocate resources across projects and it is harder than it sounds.", Messages: []string{"How do you balance long-term bets with short-term wins?"}},
// Reading & content
{ID: uuid.New(), Theme: "reading", Description: "I just finished a great book that changed how I think about a few things at work.", Messages: []string{"Read anything good lately that stuck with you?", "Any books you keep recommending to people?"}},
{ID: uuid.New(), Theme: "content", Description: "I have been curating a reading list and looking for suggestions outside my usual topics.", Messages: []string{"What is the most surprising thing you have read recently?"}},
{Theme: "reading", Description: "I just finished a great book that changed how I think about a few things at work.", Messages: []string{"Read anything good lately that stuck with you?", "Any books you keep recommending to people?"}},
{Theme: "content", Description: "I have been curating a reading list and looking for suggestions outside my usual topics.", Messages: []string{"What is the most surprising thing you have read recently?"}},
// Travel & experiences
{ID: uuid.New(), Theme: "travel", Description: "I am starting to plan a trip and looking for recommendations from people who have been there.", Messages: []string{"Any travel tips or favorite destinations you would suggest?", "Where was the last place you traveled that exceeded expectations?"}},
{ID: uuid.New(), Theme: "food", Description: "I tried a new restaurant last week that was genuinely impressive and thought you might enjoy it too.", Messages: []string{"Have you discovered any great spots lately?"}},
{Theme: "travel", Description: "I am starting to plan a trip and looking for recommendations from people who have been there.", Messages: []string{"Any travel tips or favorite destinations you would suggest?", "Where was the last place you traveled that exceeded expectations?"}},
{Theme: "food", Description: "I tried a new restaurant last week that was genuinely impressive and thought you might enjoy it too.", Messages: []string{"Have you discovered any great spots lately?"}},
// Wellness & balance
{ID: uuid.New(), Theme: "wellness", Description: "I have been trying to be more intentional about work-life balance and curious how others handle it.", Messages: []string{"What do you do to recharge after a busy stretch?", "Have you found any habits that help you stay consistent?"}},
{ID: uuid.New(), Theme: "fitness", Description: "I recently picked up a new workout routine and it has been making a real difference in my energy levels.", Messages: []string{"Do you have a go-to way to stay active during busy weeks?"}},
{Theme: "wellness", Description: "I have been trying to be more intentional about work-life balance and curious how others handle it.", Messages: []string{"What do you do to recharge after a busy stretch?", "Have you found any habits that help you stay consistent?"}},
{Theme: "fitness", Description: "I recently picked up a new workout routine and it has been making a real difference in my energy levels.", Messages: []string{"Do you have a go-to way to stay active during busy weeks?"}},
// Events & community
{ID: uuid.New(), Theme: "events", Description: "I saw a conference coming up that might be relevant and wanted to flag it for you.", Messages: []string{"Are you attending any events or meetups soon?", "What was the last event you went to that was actually worthwhile?"}},
{ID: uuid.New(), Theme: "community", Description: "I have been getting more involved in a professional community and it has been a great source of ideas.", Messages: []string{"Are you part of any groups or communities you find valuable?"}},
{Theme: "events", Description: "I saw a conference coming up that might be relevant and wanted to flag it for you.", Messages: []string{"Are you attending any events or meetups soon?", "What was the last event you went to that was actually worthwhile?"}},
{Theme: "community", Description: "I have been getting more involved in a professional community and it has been a great source of ideas.", Messages: []string{"Are you part of any groups or communities you find valuable?"}},
// Hiring & careers
{ID: uuid.New(), Theme: "hiring", Description: "We have been expanding the team and I have been learning a lot about what makes a strong hire.", Messages: []string{"What do you look for when bringing someone new on board?"}},
{ID: uuid.New(), Theme: "career", Description: "I have been reflecting on where I want to be in the next few years and it is a useful exercise.", Messages: []string{"How do you think about career growth without burning out?"}},
{Theme: "hiring", Description: "We have been expanding the team and I have been learning a lot about what makes a strong hire.", Messages: []string{"What do you look for when bringing someone new on board?"}},
{Theme: "career", Description: "I have been reflecting on where I want to be in the next few years and it is a useful exercise.", Messages: []string{"How do you think about career growth without burning out?"}},
// Gratitude & appreciation
{ID: uuid.New(), Theme: "gratitude", Description: "I was thinking about the people who have been helpful to me this year and you came to mind.", Messages: []string{"Just wanted to say thanks for being a great connection.", "Appreciate you always being willing to share your perspective."}},
{Theme: "gratitude", Description: "I was thinking about the people who have been helpful to me this year and you came to mind.", Messages: []string{"Just wanted to say thanks for being a great connection.", "Appreciate you always being willing to share your perspective."}},
}
// Assign stable, content-derived ids so a given static conversation has the
// same id on every send (see staticConvID).
for i := range conversations {
conversations[i].ID = staticConvID(conversations[i].Theme, conversations[i].Description)
}
return conversations
+16
View File
@@ -2,6 +2,7 @@ package tasks
import (
"context"
"sync"
"time"
"github.com/google/uuid"
@@ -71,11 +72,23 @@ type tasksService struct {
taskRepo repository.TaskRepository
warmupRepo repository.WarmupRepository
warmupRoutingRepo repository.WarmupRoutingRepository
warmupContentRepo repository.WarmupContentRepository
campaignProgressRepo repository.CampaignProgressRepository
emailRepo repository.EmailRepository
campaignRepo repository.CampaignRepository
contactRepo repository.ContactRepository
campaignLogRepo repository.CampaignLogRepository
// warmupSettings caches the warmup generation settings in-process so the
// per-send AI-vs-static decision doesn't hit Postgres on every warmup.
warmupSettings *warmupSettingsCache
}
// warmupSettingsCache is a tiny TTL cache over the generation settings.
type warmupSettingsCache struct {
mu sync.RWMutex
val models.WarmupGenerationSettings
fetched time.Time
}
func NewService(
@@ -92,6 +105,7 @@ func NewService(
taskRepo repository.TaskRepository,
warmupRepo repository.WarmupRepository,
warmupRoutingRepo repository.WarmupRoutingRepository,
warmupContentRepo repository.WarmupContentRepository,
campaignProgressRepo repository.CampaignProgressRepository,
emailRepo repository.EmailRepository,
campaignRepo repository.CampaignRepository,
@@ -114,10 +128,12 @@ func NewService(
taskRepo: taskRepo,
warmupRepo: warmupRepo,
warmupRoutingRepo: warmupRoutingRepo,
warmupContentRepo: warmupContentRepo,
campaignProgressRepo: campaignProgressRepo,
emailRepo: emailRepo,
campaignRepo: campaignRepo,
contactRepo: contactRepo,
campaignLogRepo: campaignLogRepo,
warmupSettings: &warmupSettingsCache{},
}
}
+38
View File
@@ -0,0 +1,38 @@
package tasks
import (
"math/rand"
"regexp"
"strings"
)
// spintaxGroup matches an innermost {a|b|c} alternation group (no nested braces
// inside). Nested groups are resolved by repeated passes, innermost first.
var spintaxGroup = regexp.MustCompile(`\{([^{}]*)\}`)
// spin expands {a|b|c} spintax: each alternation group is replaced by one of
// its options chosen at random. This is the body-level analogue of the subject
// synthesiser — it multiplies the number of distinct rendered bodies so the
// warmup corpus isn't a small fixed set that filters can fingerprint. Text
// containing no braces is returned unchanged. A group with no '|' is treated as
// literal text with the braces stripped.
func spin(s string) string {
// Bounded loop: each pass resolves the current innermost groups; the cap
// guards against pathological/malformed input that never fully resolves.
for i := 0; i < 20 && strings.Contains(s, "{"); i++ {
s = spintaxGroup.ReplaceAllStringFunc(s, func(m string) string {
inner := m[1 : len(m)-1]
opts := strings.Split(inner, "|")
return opts[rand.Intn(len(opts))]
})
}
return s
}
// spinClean expands spintax and tidies whitespace introduced by optional
// fragments (e.g. an empty option leaving a double space).
func spinClean(s string) string {
out := spin(s)
out = strings.ReplaceAll(out, " ", " ")
return strings.TrimSpace(out)
}

Some files were not shown because too many files have changed in this diff Show More