From 15ef9d4994d607a0544fcc6f79f939c328220325 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 3 Jun 2026 05:05:53 +0200 Subject: [PATCH 01/15] feat: add warmup content controls Add warmup content generation and admin review surfaces, plus mailbox warmup appeal/status APIs. Track warmup engagement and tampering signals so unsafe mailboxes can be handled by the warmup flow. --- admin/src/app/dashboard/WarmupAppealsPage.tsx | 581 +++++++ admin/src/app/dashboard/WarmupContentPage.tsx | 1515 +++++++++++++++++ admin/src/components/layout/Sidebar.tsx | 6 +- admin/src/lib/api/client/admin/warmup.ts | 4 + .../src/lib/api/client/admin/warmupContent.ts | 300 ++++ admin/src/main.tsx | 4 + cmd/backend/main.go | 29 +- cmd/consumer/main.go | 1 + internal/api/handler/admin_warmup_content.go | 343 ++++ internal/api/handler/handler.go | 5 + internal/api/handler/warmup_appeal.go | 67 + internal/api/routes.go | 18 + internal/app/consumer/event_flags_update.go | 17 + internal/app/consumer/event_new_email.go | 21 +- internal/app/consumer/event_remove_email.go | 31 + internal/app/consumer/events.go | 1 + internal/app/consumer/service.go | 1 + internal/app/consumer/warmup_engagement.go | 111 ++ internal/app/warmup/service.go | 145 +- internal/app/warmupcontent/service.go | 304 ++++ internal/app/worker/event_warmup_action.go | 96 +- .../migrations/000004_warmup_content.down.sql | 10 + .../migrations/000004_warmup_content.up.sql | 95 ++ .../000005_warmup_tampering.down.sql | 2 + .../migrations/000005_warmup_tampering.up.sql | 41 + internal/jobs/warmup_generation.go | 102 ++ internal/models/warmup.go | 43 +- internal/models/warmup_content.go | 179 ++ internal/pkg/generation/conversation.go | 48 +- internal/pkg/generation/model.go | 16 +- internal/pkg/warmlint/lint.go | 91 + internal/pkg/warmpersona/persona.go | 91 + internal/repository/pg_warmup.go | 155 +- internal/repository/pg_warmup_content.go | 438 +++++ internal/tasks/content_lint.go | 11 + internal/tasks/email_task.go | 73 +- internal/tasks/service.go | 16 + internal/tasks/spintax.go | 38 + internal/tasks/template.go | 102 +- internal/tasks/warmup_content.go | 90 + .../components/app/emails/InboxDetails.tsx | 124 ++ .../api/client/app/emails/appealWarmupBan.ts | 14 + .../client/app/emails/getWarmupBanStatus.ts | 13 + .../hooks/app/emails/useAppealWarmupBan.ts | 15 + .../hooks/app/emails/useWarmupBanStatus.ts | 14 + .../api/models/app/emails/WarmupBanStatus.ts | 19 + 46 files changed, 5348 insertions(+), 92 deletions(-) create mode 100644 admin/src/app/dashboard/WarmupAppealsPage.tsx create mode 100644 admin/src/app/dashboard/WarmupContentPage.tsx create mode 100644 admin/src/lib/api/client/admin/warmupContent.ts create mode 100644 internal/api/handler/admin_warmup_content.go create mode 100644 internal/api/handler/warmup_appeal.go create mode 100644 internal/app/consumer/event_remove_email.go create mode 100644 internal/app/consumer/warmup_engagement.go create mode 100644 internal/app/warmupcontent/service.go create mode 100644 internal/infrastructure/db/migrations/000004_warmup_content.down.sql create mode 100644 internal/infrastructure/db/migrations/000004_warmup_content.up.sql create mode 100644 internal/infrastructure/db/migrations/000005_warmup_tampering.down.sql create mode 100644 internal/infrastructure/db/migrations/000005_warmup_tampering.up.sql create mode 100644 internal/jobs/warmup_generation.go create mode 100644 internal/models/warmup_content.go create mode 100644 internal/pkg/warmlint/lint.go create mode 100644 internal/pkg/warmpersona/persona.go create mode 100644 internal/repository/pg_warmup_content.go create mode 100644 internal/tasks/content_lint.go create mode 100644 internal/tasks/spintax.go create mode 100644 internal/tasks/warmup_content.go create mode 100644 web/src/lib/api/client/app/emails/appealWarmupBan.ts create mode 100644 web/src/lib/api/client/app/emails/getWarmupBanStatus.ts create mode 100644 web/src/lib/api/hooks/app/emails/useAppealWarmupBan.ts create mode 100644 web/src/lib/api/hooks/app/emails/useWarmupBanStatus.ts create mode 100644 web/src/lib/api/models/app/emails/WarmupBanStatus.ts diff --git a/admin/src/app/dashboard/WarmupAppealsPage.tsx b/admin/src/app/dashboard/WarmupAppealsPage.tsx new file mode 100644 index 00000000..d3489f92 --- /dev/null +++ b/admin/src/app/dashboard/WarmupAppealsPage.tsx @@ -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 = { + 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 ( +
+ + + + + Appeals + Blocked mailboxes + + + + + + + + + +
+ ); +} + +function AppealsTab() { + const [status, setStatus] = useState("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[] = [ + { + id: "mailbox", + header: "Mailbox", + cell: (a) => ( + + {a.email_account?.email ?? a.email_account_id} + + ), + csv: (a) => a.email_account?.email ?? a.email_account_id, + }, + { + id: "user", + header: "User", + cell: (a) => ( + {a.user?.email ?? a.user_id} + ), + csv: (a) => a.user?.email ?? a.user_id, + }, + { + id: "reason", + header: "Reason", + cell: (a) => ( + + {a.reason} + + ), + csv: (a) => a.reason, + }, + { + id: "status", + header: "Status", + cell: (a) => ( +
+ + {a.status} + + {a.review_notes && a.status !== "pending" && ( +
+ "{a.review_notes}" +
+ )} +
+ ), + csv: (a) => a.status, + }, + { + id: "created", + header: "Submitted", + cell: (a) => ( + + {new Date(a.created_at).toLocaleDateString()} + + ), + csv: (a) => a.created_at, + }, + { + id: "reviewed", + header: "Reviewed", + defaultHidden: true, + cell: (a) => ( + + {a.reviewed_at + ? new Date(a.reviewed_at).toLocaleDateString() + : "—"} + + ), + csv: (a) => a.reviewed_at ?? "", + }, + { + id: "actions", + header: "", + align: "right", + cell: (a) => { + const canReview = a.status === "pending"; + return ( +
+ + +
+ ); + }, + }, + ]; + + return ( + <> + setStatus("pending")} + filters={ + + setStatus(v as AppealStatusFilter)} + options={STATUS_OPTIONS} + placeholder="Pending" + /> + + } + > + 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, + }} + /> + + + {reviewing && ( + !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 ( + + + + + {mode === "approve" ? "Approve appeal" : "Reject appeal"} + + + {mode === "approve" ? ( + <> + Approving unblocks{" "} + {mailbox} and + re-admits it to the warmup pool. Notes are recorded + for audit. + + ) : ( + <> + Rejecting keeps{" "} + {mailbox}{" "} + blocked. Notes are recorded for audit and may be + shown to the user. + + )} + + +
+ + Appeal reason: + {" "} + {appeal.reason} +
+
+ +