mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 16:04:41 +00:00
Merge pull request #26 from warmbly/feature/warmup-messages
feat: add warmup deliverability controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 (0–100)."
|
||||
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 (0–1)."
|
||||
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 (0–1)."
|
||||
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 (0–1)."
|
||||
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 (0–100)."
|
||||
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();
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>`)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 10–14 messages total across branches
|
||||
- Include at least 4 branch points with 2–4 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 {
|
||||
|
||||
@@ -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."`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("It’s 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user