From 981ab01c8c3fbe98cf15603d0cd2d24733dc84d5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:42 +0200 Subject: [PATCH] feat: add bulk mailbox warmup controls --- web/src/app/app/emails/page.tsx | 84 ++++--- .../app/emails/BulkWarmupDialog.tsx | 211 ++++++++++++++++++ .../components/app/emails/InboxDetails.tsx | 25 ++- web/src/components/ui/TimeSelect.tsx | 45 ++++ 4 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 web/src/components/app/emails/BulkWarmupDialog.tsx create mode 100644 web/src/components/ui/TimeSelect.tsx diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 449a2cb92..385d3244b 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -9,7 +9,9 @@ import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus" import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle"; import removeEmail from "@/lib/api/client/app/emails/removeEmail"; import { useUserProfile } from "@/hooks/context/user"; +import { useConfirm } from "@/hooks/context/confirm"; import InboxDetails from "@/components/app/emails/InboxDetails"; +import BulkWarmupDialog from "@/components/app/emails/BulkWarmupDialog"; import type Tag from "@/lib/api/models/app/Tag"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; @@ -68,6 +70,7 @@ function healthTone(status?: AccountStatus): { dot: string; text: string; label: export default function AddressesPage() { const p = useUserProfile(); + const confirm = useConfirm(); const [query, setQuery] = React.useState(""); const [tag, setTag] = React.useState(""); @@ -76,6 +79,7 @@ export default function AddressesPage() { const [view, setView] = React.useState(""); const [viewTab, setViewTab] = React.useState("overview"); const [removing, setRemoving] = React.useState(false); + const [bulkStart, setBulkStart] = React.useState(false); const queryClient = useQueryClient(); // Warmup is a paid/trial feature; gate the start controls when the org @@ -87,19 +91,25 @@ export default function AddressesPage() { // One query feeds live health for every row; the realtime layer already // invalidates ["analytics","accounts",…] on warmup/account events. const statuses = useAccountStatuses(); + // Coerce to an array defensively: a wrong-shape (non-array) response must + // never reach a `for…of`, which would throw "{} is not iterable". + const accountStatuses = useMemo( + () => (Array.isArray(statuses.data) ? statuses.data : []), + [statuses.data], + ); const statusById = useMemo(() => { const m = new Map(); - for (const s of statuses.data ?? []) m.set(s.id, s); + for (const s of accountStatuses) m.set(s.id, s); return m; - }, [statuses.data]); + }, [accountStatuses]); // Proactively notify the user when a mailbox's health drops. const prevHealth = useRef>(new Map()); useEffect(() => { - if (!statuses.data) return; + if (accountStatuses.length === 0) return; const prev = prevHealth.current; const next = new Map(); - for (const s of statuses.data) { + for (const s of accountStatuses) { const cur = s.health?.status ?? "healthy"; next.set(s.id, cur); const before = prev.get(s.id); @@ -109,20 +119,24 @@ export default function AddressesPage() { } } prevHealth.current = next; - }, [statuses.data]); + }, [accountStatuses]); - const removeSelected = async () => { + const removeSelected = () => { if (selected.length === 0 || removing) return; const n = selected.length; - if (!window.confirm(`Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`)) return; - setRemoving(true); - const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); - const failed = results.filter((r) => r.status === "rejected").length; - await queryClient.invalidateQueries({ queryKey: ["emails"] }); - setSelected([]); - setRemoving(false); - if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); - else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + confirm.show( + `Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`, + async () => { + setRemoving(true); + const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails"] }); + setSelected([]); + setRemoving(false); + if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); + else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + }, + ); }; const bulkWarmup = async (action: "start" | "pause") => { @@ -319,7 +333,7 @@ export default function AddressesPage() { {canWarmup && ( diff --git a/web/src/components/app/emails/BulkWarmupDialog.tsx b/web/src/components/app/emails/BulkWarmupDialog.tsx new file mode 100644 index 000000000..a7b153f1b --- /dev/null +++ b/web/src/components/app/emails/BulkWarmupDialog.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { FlameIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { Label, NumberInput } from "@/components/ui/field"; +import TimeSelect from "@/components/ui/TimeSelect"; +import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask"; +import { Loading } from "@/components/loader"; +import updateEmail from "@/lib/api/client/app/emails/updateEmail"; +import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +// Bulk warmup-start dialog. Lets the user optionally apply one set of warmup +// settings to every selected mailbox before starting — useful when onboarding a +// batch of new inboxes that should all ramp the same way. +export default function BulkWarmupDialog({ + open, + ids, + onClose, + onComplete, +}: { + open: boolean; + ids: string[]; + onClose: () => void; + onComplete: () => void; +}) { + const queryClient = useQueryClient(); + const [customize, setCustomize] = useState(true); + const [busy, setBusy] = useState(false); + + const [base, setBase] = useState(10); + const [increase, setIncrease] = useState(1); + const [max, setMax] = useState(40); + const [replyRate, setReplyRate] = useState(30); + const [startTime, setStartTime] = useState("08:00"); + const [endTime, setEndTime] = useState("20:00"); + const [days, setDays] = useState(0); + + const n = ids.length; + const baseOverMax = base > max; + + const start = async () => { + if (busy || n === 0) return; + if (customize && baseOverMax) { + toast.error("Starting volume can't exceed the maximum."); + return; + } + setBusy(true); + const patch: Partial = { + warmup_base: base, + warmup_increase: increase, + warmup_max: max, + warmup_reply_rate: replyRate, + warmup_start_time: startTime, + warmup_end_time: endTime, + warmup_days: days, + }; + // Apply settings (if customizing) then start, per mailbox. allSettled so + // one failure doesn't abort the rest. + const results = await Promise.allSettled( + ids.map(async (id) => { + if (customize) await updateEmail(id, patch); + await warmupLifecycle(id, "start"); + }), + ); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails"] }); + await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] }); + setBusy(false); + if (failed > 0) { + toast.error(`${failed} of ${n} mailbox${n > 1 ? "es" : ""} couldn't start`); + } else { + toast.success(`Warmup started for ${n} mailbox${n > 1 ? "es" : ""}`); + } + onComplete(); + onClose(); + }; + + return ( + + {open && ( + <> + +
+ + {/* Header */} +
+
+ +
+
+
Start warmup
+
+ {n} mailbox{n > 1 ? "es" : ""} selected +
+
+ +
+ + {/* Body */} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ {baseOverMax && ( +

+ Starting volume can't exceed the maximum. +

+ )} +
+ + +
+
+ +
+ + +
+
+
+ + +

+ Leave all unselected to send every day. +

+
+
+
+ + {/* Footer */} +
+ + +
+
+
+ + )} +
+ ); +} + +function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 1d2f4f4ff..6e5c169f8 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -51,10 +51,11 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import EmailEditor from "../EmailEditor"; import TagSelector from "../popup/select/TagSelector"; -import TimeSelector from "../popup/select/TimeSelector"; +import TimeSelect from "@/components/ui/TimeSelect"; import WeekdayBitmask from "../campaigns/schedule/WeekdayBitmask"; import { Loading } from "@/components/loader"; import { NumberInput, TextInput } from "@/components/ui/field"; +import { useConfirm } from "@/hooks/context/confirm"; import { cn } from "@/lib/utils"; /* ── small themed primitives ─────────────────────── */ @@ -660,6 +661,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: // read live state off the mailbox prop, which the lifecycle mutation patches // back into cache on success. const life = useWarmupLifecycle(mailbox.id); + const confirm = useConfirm(); const off = !mailbox.warmup; const paused = !!mailbox.warmup && !!mailbox.warmup_paused_at; const active = !!mailbox.warmup && !mailbox.warmup_paused_at; @@ -671,8 +673,17 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: }); const stopReset = () => { - if (!window.confirm("Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.")) return; - run("stop", "stopped"); + confirm.show( + "Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.", + async () => { + try { + await life.mutateAsync("stop"); + toast.success("Warmup stopped"); + } catch (e) { + toast.error(buildError(e as unknown as AppError)); + } + }, + ); }; const baseOverMax = form.warmup_base > form.warmup_max; @@ -825,14 +836,14 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: Sending window
- update({ warmup_start_time: v })} /> + update({ warmup_start_time: v })} /> - update({ warmup_end_time: v })} /> + update({ warmup_end_time: v })} />
- -
+ +
void; + className?: string; +}) { + return ( + + + } + label={to12Hour(value)} + className={cn("w-full justify-between", className)} + /> + + + {timeOptions.map((t) => ( + onChange(t.value)} + > + {t.name} + + ))} + + + ); +}