feat: add bulk mailbox warmup controls

This commit is contained in:
Matthew Meszaros
2026-06-04 08:54:42 +02:00
parent ca310748fe
commit 981ab01c8c
4 changed files with 332 additions and 33 deletions
+58 -26
View File
@@ -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<string>("");
const [tag, setTag] = React.useState<string>("");
@@ -76,6 +79,7 @@ export default function AddressesPage() {
const [view, setView] = React.useState<string>("");
const [viewTab, setViewTab] = React.useState<string>("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<string, AccountStatus>();
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<Map<string, string>>(new Map());
useEffect(() => {
if (!statuses.data) return;
if (accountStatuses.length === 0) return;
const prev = prevHealth.current;
const next = new Map<string, string>();
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 && (
<button
type="button"
onClick={() => bulkWarmup("start")}
onClick={() => setBulkStart(true)}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded text-[12px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
>
<PlayIcon className="w-3.5 h-3.5" />
@@ -357,6 +371,13 @@ export default function AddressesPage() {
</PageBody>
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} canWarmup={canWarmup} />
<BulkWarmupDialog
open={bulkStart}
ids={selected}
onClose={() => setBulkStart(false)}
onComplete={() => setSelected([])}
/>
</Page>
);
}
@@ -379,6 +400,7 @@ function MailboxRow({
onOpen: (id: string, tab?: string) => void;
}) {
const life = useWarmupLifecycle(box.id);
const confirm = useConfirm();
const off = !box.warmup;
const paused = !!box.warmup && !!box.warmup_paused_at;
@@ -412,27 +434,37 @@ function MailboxRow({
};
const stopReset = () => {
if (!window.confirm(`Stop warmup for ${box.email}? This resets ramp progress — restarting begins from the base volume. Use Pause to keep progress.`)) return;
life.mutate("stop", {
onSuccess: () => toast.success(`Warmup stopped for ${box.email}`),
onError: () => toast.error("Couldn't update warmup"),
});
confirm.show(
`Stop warmup for ${box.email}? This resets ramp progress — restarting begins from the base volume. Use Pause to keep progress.`,
async () => {
try {
await life.mutateAsync("stop");
toast.success(`Warmup stopped for ${box.email}`);
} catch {
toast.error("Couldn't update warmup");
}
},
);
};
const upsell = () => toast("Warmup is available on paid plans", { icon: "✨" });
return (
<tr className="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11">
<tr
onClick={() => onOpen(box.id)}
className="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11 cursor-pointer"
>
<td className="pl-5 pr-2">
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-sky-600"
checked={checked}
onChange={onToggleSelect}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-3">
<button type="button" onClick={() => onOpen(box.id)} className="flex items-center gap-2.5 text-left">
<button type="button" onClick={(e) => { e.stopPropagation(); onOpen(box.id); }} className="flex items-center gap-2.5 text-left">
<div className="w-6 h-6 rounded-full bg-sky-100 flex items-center justify-center shrink-0">
<span className="text-[9.5px] font-semibold text-sky-700">
{box.email.slice(0, 2).toUpperCase()}
@@ -450,7 +482,7 @@ function MailboxRow({
<td className="px-3">
<button
type="button"
onClick={() => onOpen(box.id, "overview")}
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "overview"); }}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${tone.text}`}
title="View mailbox health"
>
@@ -505,8 +537,8 @@ function MailboxRow({
<button
type="button"
className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors cursor-pointer"
onClick={() => onOpen(box.id)}
aria-label="Account details"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "settings"); }}
aria-label="Mailbox settings"
>
<RiMoreLine className="w-3.5 h-3.5" />
</button>
@@ -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<Inbox> = {
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 (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 bg-slate-900/40"
onClick={busy ? undefined : onClose}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2 }}
className="pointer-events-auto w-full max-w-[440px] max-h-[88vh] overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-[0_24px_60px_-12px_rgba(15,23,42,0.35)]"
>
{/* Header */}
<div className="px-5 h-14 flex items-center gap-3 border-b border-slate-200">
<div className="w-8 h-8 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center shrink-0">
<FlameIcon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-slate-900">Start warmup</div>
<div className="text-[11px] text-slate-400">
{n} mailbox{n > 1 ? "es" : ""} selected
</div>
</div>
<button
onClick={onClose}
disabled={busy}
aria-label="Close"
className="w-7 h-7 rounded-md flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
<XIcon className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<Toggle on={customize} onChange={setCustomize} />
<span className="text-[12.5px] text-slate-700">
Apply these settings to all selected mailboxes
</span>
</label>
<div className={customize ? "space-y-4" : "space-y-4 opacity-40 pointer-events-none"}>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Start</Label>
<NumberInput value={base} min={1} max={500} onChange={setBase} suffix="/day" />
</div>
<div>
<Label>+ / day</Label>
<NumberInput value={increase} min={0} max={100} onChange={setIncrease} />
</div>
<div>
<Label>Max</Label>
<NumberInput value={max} min={1} max={500} onChange={setMax} suffix="/day" />
</div>
</div>
{baseOverMax && (
<p className="text-[11px] text-rose-500 -mt-1.5">
Starting volume can't exceed the maximum.
</p>
)}
<div>
<Label>Reply rate</Label>
<NumberInput value={replyRate} min={0} max={100} onChange={setReplyRate} suffix="%" className="w-32" />
</div>
<div>
<Label>Sending window</Label>
<div className="grid grid-cols-2 gap-3 max-w-[320px]">
<TimeSelect value={startTime} onChange={setStartTime} />
<TimeSelect value={endTime} onChange={setEndTime} />
</div>
</div>
<div>
<Label>Sending days</Label>
<WeekdayBitmask weekdays={WEEKDAYS} value={days} setValue={setDays} />
<p className="text-[11px] text-slate-400 mt-1.5">
Leave all unselected to send every day.
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="px-5 h-14 flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50/60">
<button
onClick={onClose}
disabled={busy}
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={start}
disabled={busy || n === 0}
className="h-8 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy && <Loading className="!w-3.5 h-3.5 text-white" />}
Start warmup
</button>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={on}
onClick={() => onChange(!on)}
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? "bg-sky-600" : "bg-slate-200"}`}
>
<span className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${on ? "translate-x-4" : ""}`} />
</button>
);
}
+18 -7
View File
@@ -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:
<Eyebrow>Sending window</Eyebrow>
<div className="grid grid-cols-2 gap-3">
<FieldShell label="Start time">
<TimeSelector value={form.warmup_start_time || "08:00"} onChange={(v) => update({ warmup_start_time: v })} />
<TimeSelect value={form.warmup_start_time || "08:00"} onChange={(v) => update({ warmup_start_time: v })} />
</FieldShell>
<FieldShell label="End time">
<TimeSelector value={form.warmup_end_time || "20:00"} onChange={(v) => update({ warmup_end_time: v })} />
<TimeSelect value={form.warmup_end_time || "20:00"} onChange={(v) => update({ warmup_end_time: v })} />
</FieldShell>
</div>
<FieldShell label="Sending days" hint="Days warmup mail goes out. Leave all unchecked to send every day.">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 mt-1">
<FieldShell label="Sending days" hint="Days warmup mail goes out. Leave all unselected to send every day.">
<div className="mt-1">
<WeekdayBitmask
weekdays={WEEKDAYS}
value={form.warmup_days ?? 0}
+45
View File
@@ -0,0 +1,45 @@
import { ClockIcon } from "lucide-react";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import { timeOptions, to12Hour } from "@/lib/core/time";
import { cn } from "@/lib/utils";
// Themed 30-minute time picker. Portal-based (PopoverMenu) so it renders above
// drawers/modals. Shared by the campaign schedule and the warmup settings.
export default function TimeSelect({
value,
onChange,
className,
}: {
value: string;
onChange: (v: string) => void;
className?: string;
}) {
return (
<PopoverMenu>
<PopoverMenuTrigger asChild>
<SelectButton
icon={<ClockIcon className="w-3.5 h-3.5" />}
label={to12Hour(value)}
className={cn("w-full justify-between", className)}
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={160} className="max-h-64 overflow-y-auto">
{timeOptions.map((t) => (
<PopoverMenuItem
key={t.value}
selected={t.value === value}
onSelect={() => onChange(t.value)}
>
{t.name}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}