diff --git a/docs/content/docs/api/mcp.mdx b/docs/content/docs/api/mcp.mdx index f7579cad7..14a78b974 100644 --- a/docs/content/docs/api/mcp.mdx +++ b/docs/content/docs/api/mcp.mdx @@ -150,7 +150,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y | `mark_thread_seen` / `set_thread_labels` | Mark seen, set thread labels | `WRITE_UNIBOX` | | `snooze_thread` / `unsnooze_thread` / `cancel_scheduled_send` | Snooze a thread, cancel a queued send | `WRITE_UNIBOX` | | `get_mailbox` / `get_warmup_ban_status` | Read a mailbox's config and warmup standing | `READ_EMAILS` | -| `update_mailbox` / `set_mailbox_warmup` / `set_mailbox_tracking_domain` | Edit mailbox settings, warmup, tracking domain | `WRITE_EMAILS` | +| `update_mailbox` / `set_mailbox_warmup` / `set_mailbox_send_hold` / `set_mailbox_tracking_domain` | Edit mailbox settings, warmup, the campaign hold, tracking domain | `WRITE_EMAILS` | | `submit_warmup_appeal` / `disconnect_mailbox` | Appeal a warmup ban, disconnect a mailbox | `WRITE_EMAILS` | | `create_automation_draft` / `list_automations` / `get_automation` | Read automations, create a disabled draft | `INTEGRATIONS` | | `update_automation` / `set_automation_enabled` / `delete_automation` | Edit, enable, or delete an automation | `INTEGRATIONS` | diff --git a/docs/content/docs/guides/advisor.mdx b/docs/content/docs/guides/advisor.mdx index 8af47c7bc..775e24ef5 100644 --- a/docs/content/docs/guides/advisor.mdx +++ b/docs/content/docs/guides/advisor.mdx @@ -67,6 +67,8 @@ Two more copy findings run only when the operator has configured TypeSafe (`TYPE Opening a recommendation walks three screens: **Why** (with the measured numbers), **What changes** (the exact before and after of every field), and **Done** (with **Undo** where revertible). +A finding that asks you to stop cold sending from a mailbox, for spam placement or a lost warmup pool standing, applies [Hold from campaigns](/guides/mailboxes/#holding-a-mailbox-yourself) rather than switching the mailbox off, so its warmup keeps running while it recovers. **Undo** releases the hold. + Nothing is applied until you have seen the second screen. The change runs as you, with your permissions, and is written to the audit log. If you can see a finding but lack permission for its fix, applying it is refused rather than silently escalated. Findings needing a judgement call have no settings preview. Their second screen is the ordered set of manual steps plus a link to the right screen. A missing DMARC record tells you where the `_dmarc` host goes and why the first record should start at `p=none`. Where the remedy is genuinely one sentence, it stays one sentence. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 0c6e28f8e..144eb6070 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -353,7 +353,11 @@ A mailbox marked as a [placement seed inbox](/guides/placement-tests/#your-own-s ## Pausing and disconnecting -A mailbox can be set inactive (`PATCH /emails/{id}` with `status`) when you want it to stop without losing its settings, its history, or its place in the fleet. +A mailbox can be switched off when you want it to stop without losing its settings, its history, or its place in the fleet. Use **Switch off** on its row's **More** menu, the **Mailbox on** switch on its **Settings** tab, or the selection bar for several at once. Over the API it is `PATCH /emails/{id}` with `status` set to `inactive`, and `active` turns it back on. + +Off means the whole mailbox: it sends no campaign mail, sends and receives no warmup, and syncs nothing, whatever its warmup setting says. The list shows it as **Off**, and its warmup column reads **Stopped** when warmup is still set to run. To stop cold sending while the mailbox keeps warming, which is what a mailbox recovering from spam placement needs, use [Hold from campaigns](#holding-a-mailbox-yourself) instead. + +A switched-off mailbox is turned back on the same ways: **Switch back on** on the row's **More** menu or its warmup menu, the **Switch back on** button at the top of its Overview tab, the **Mailbox on** switch, or **Switch on** in the selection bar. Warmup that was set to run starts again straight away. A mailbox whose provider access was revoked shows **Reconnect** rather than **Off**, and is turned back on by reconnecting it. Switching it off takes effect immediately: the machine syncing it is told to drop it, so it stops importing mail and stops being picked for campaign sends and warmup within seconds rather than at that machine's next restart. Warmup pool membership is dropped, and any warmup chain it had winds down on its next step. A campaign email already handed over is answered as a failure and the step is retried later on a mailbox that is still active, so nobody receives it twice and no lead is stranded. diff --git a/internal/api/handler/email.go b/internal/api/handler/email.go index 9ee26dbe1..cbd362259 100644 --- a/internal/api/handler/email.go +++ b/internal/api/handler/email.go @@ -76,6 +76,11 @@ func (h *Handler) UpdateEmail(c *gin.Context) { return } + // A mailbox switched back on resumes its warmup now, not on the next reconciler pass. + if data.Status != nil && *data.Status == "active" && resp != nil && resp.Warmup != nil && resp.WarmupPausedAt == nil { + _ = h.TasksService.EnsureWarmupScheduled(c.Request.Context(), resp.ID) + } + // Audit log if accountID, err := uuid.Parse(emailAccountID); err == nil { h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityEmailAccount, &accountID, nil, nil) diff --git a/internal/app/advisor/advisor.go b/internal/app/advisor/advisor.go index a37635a96..435252269 100644 --- a/internal/app/advisor/advisor.go +++ b/internal/app/advisor/advisor.go @@ -246,6 +246,16 @@ func mailboxAction(id uuid.UUID, label string, args map[string]any, preview ...m return toolAction("update_mailbox", label, args, preview...) } +// mailboxHoldAction holds a mailbox out of campaigns and leaves warmup running, +// which is what every "stop cold sending, keep warming" remedy asks for. +func mailboxHoldAction(id uuid.UUID, label string) *models.AdvisorAction { + return withUndo(toolAction("set_mailbox_send_hold", label, + map[string]any{"email_account_id": id.String(), "hold": true}, + change("Cold campaigns", "sending", "held out of campaigns"), + change("Warmup", "running", "running (unchanged)"), + ), map[string]any{"email_account_id": id.String(), "hold": false}) +} + // campaignAction builds an update_campaign one-click fix with its preview. func campaignAction(id uuid.UUID, label string, args map[string]any, preview ...models.AdvisorPreviewChange) *models.AdvisorAction { args["campaign_id"] = id.String() diff --git a/internal/app/advisor/detect_deliverability.go b/internal/app/advisor/detect_deliverability.go index 1abd258b9..9fd5b1f0c 100644 --- a/internal/app/advisor/detect_deliverability.go +++ b/internal/app/advisor/detect_deliverability.go @@ -213,12 +213,7 @@ func detectSpamPlacement(s *repository.AdvisorSnapshot) []Finding { } if m.InActiveCampaign && r >= spamPlacementQuarantine { - f.Action = withUndo(mailboxAction(m.ID, - "Pause cold sending from this mailbox", - map[string]any{"status": "inactive"}, - change("Mailbox status", "active", "inactive"), - change("Warmup", "running", "running (unchanged)"), - ), map[string]any{"email_account_id": m.ID.String(), "status": "active"}) + f.Action = mailboxHoldAction(m.ID, "Hold this mailbox out of campaigns") } out = append(out, f) } diff --git a/internal/app/advisor/detect_test.go b/internal/app/advisor/detect_test.go index 34423dd66..28096a556 100644 --- a/internal/app/advisor/detect_test.go +++ b/internal/app/advisor/detect_test.go @@ -254,7 +254,14 @@ func TestSpamPlacementRespectsItsSampleFloorAndBands(t *testing.T) { t.Errorf("55%% spam placement should be critical, got %q", f.Severity) } if f.Action == nil { - t.Error("a mailbox this deep in spam while sending cold should offer to stop") + t.Fatal("a mailbox this deep in spam while sending cold should offer to stop") + } + // Stopping cold sending must leave warmup running, so the fix is the hold, never status. + if f.Action.Tool != "set_mailbox_send_hold" || f.Action.Undo == nil || f.Action.Undo.Tool != "set_mailbox_send_hold" { + t.Errorf("spam placement fix should hold the mailbox and undo by releasing it, got %q", f.Action.Tool) + } + if strings.Contains(string(f.Action.Args), "status") { + t.Errorf("spam placement fix must not switch the mailbox off: %s", f.Action.Args) } } diff --git a/internal/app/advisor/detect_warmup.go b/internal/app/advisor/detect_warmup.go index 2dbb57706..216463acb 100644 --- a/internal/app/advisor/detect_warmup.go +++ b/internal/app/advisor/detect_warmup.go @@ -269,12 +269,7 @@ func detectWarmupPoolBlocked(s *repository.AdvisorSnapshot) []Finding { }, } if m.InActiveCampaign { - f.Action = withUndo(mailboxAction(m.ID, - "Stop cold sending from this mailbox", - map[string]any{"status": "inactive"}, - change("Mailbox status", "active", "inactive"), - change("Cold campaigns", "sending", "paused for this mailbox"), - ), map[string]any{"email_account_id": m.ID.String(), "status": "active"}) + f.Action = mailboxHoldAction(m.ID, "Hold this mailbox out of campaigns") } out = append(out, f) } diff --git a/internal/app/advisor/fixer.go b/internal/app/advisor/fixer.go index cea313606..b2f73b17e 100644 --- a/internal/app/advisor/fixer.go +++ b/internal/app/advisor/fixer.go @@ -124,13 +124,13 @@ var fixTools = map[models.AdvisorCategory][]string{ "update_contact_fields", "bulk_edit_contacts", "update_campaign_step", }, models.AdvisorCategoryMailbox: { - "get_mailbox", "list_mailboxes", "update_mailbox", + "get_mailbox", "list_mailboxes", "update_mailbox", "set_mailbox_send_hold", }, models.AdvisorCategoryWarmup: { - "get_mailbox", "get_warmup_ban_status", "set_mailbox_warmup", "update_mailbox", + "get_mailbox", "get_warmup_ban_status", "set_mailbox_warmup", "update_mailbox", "set_mailbox_send_hold", }, models.AdvisorCategoryDeliverability: { - "get_mailbox", "update_mailbox", "set_mailbox_tracking_domain", + "get_mailbox", "update_mailbox", "set_mailbox_send_hold", "set_mailbox_tracking_domain", "verify_campaign_tracking_domain", }, } diff --git a/internal/app/aitools/tools_mailboxes.go b/internal/app/aitools/tools_mailboxes.go index 491316279..ceaea2068 100644 --- a/internal/app/aitools/tools_mailboxes.go +++ b/internal/app/aitools/tools_mailboxes.go @@ -36,7 +36,7 @@ func (d Deps) registerMailboxTools(r *Registry) { "email_account_id": strProp("The mailbox UUID."), "name": strProp("Display name."), "reply_to": strProp("Reply-to address."), - "status": enumProp("Mailbox status.", "active", "inactive"), + "status": enumProp("Mailbox status. inactive switches the mailbox off entirely: no sending, warmup or sync. To stop only cold sending, use set_mailbox_send_hold.", "active", "inactive"), "campaign_limit": intProp("Max cold-campaign emails per day for this mailbox, 0 to 5000. Default 50; 30-50/day is the safe cold-outreach band."), "min_wait_time": intProp("Minimum seconds between sends."), "warmup": boolProp("Enable or disable warmup."), @@ -68,6 +68,19 @@ func (d Deps) registerMailboxTools(r *Registry) { Handler: d.setMailboxWarmup, }) + r.Register(Tool{ + Name: "set_mailbox_send_hold", + Description: "Hold a mailbox out of campaign sending, or release the hold. Warmup keeps running either way; use this, not status, to stop cold sending while a mailbox recovers.", + InputSchema: objectSchema(map[string]any{ + "email_account_id": strProp("The mailbox UUID."), + "hold": boolProp("true holds the mailbox out of campaigns, false puts it back."), + }, "email_account_id", "hold"), + Risk: generation.RiskWrite, + RequiredOrgPerm: models.PermManageEmails, + RequiredAPIPerm: models.APIPermWriteEmails, + Handler: d.setMailboxSendHold, + }) + r.Register(Tool{ Name: "set_mailbox_tracking_domain", Description: "Set (and verify) a mailbox's custom tracking domain via its CNAME.", @@ -218,6 +231,33 @@ func (d Deps) setMailboxWarmup(ctx context.Context, inv Invocation, args json.Ra return jsonResult(mb) } +func (d Deps) setMailboxSendHold(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { + in, err := decodeArgs[struct { + EmailAccountID string `json:"email_account_id"` + Hold *bool `json:"hold"` + }](args) + if err != nil { + return "", err + } + aid, err := parseUUIDArg(in.EmailAccountID) + if err != nil { + return "", err + } + if in.Hold == nil { + return "", ErrInvalidArgs + } + state, xerr := d.Emails.SetSendHold(ctx, inv.OrgID.String(), in.EmailAccountID, *in.Hold) + if xerr != nil { + return "", fromErrx(xerr) + } + action := "released" + if *in.Hold { + action = "held" + } + d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityEmailAccount, &aid, map[string]string{"send_hold": action}) + return jsonResult(state) +} + func (d Deps) setMailboxTrackingDomain(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) { in, err := decodeArgs[struct { EmailAccountID string `json:"email_account_id"` diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index fcf05cc99..db71d7e95 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -11,6 +11,8 @@ import useAccountStatuses from "@/lib/api/hooks/app/analytics/useAccountStatuses 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 updateEmail from "@/lib/api/client/app/emails/updateEmail"; +import useMailboxSwitch, { switchOffPrompt } from "@/components/app/emails/useMailboxSwitch"; import invalidateAfterMailboxRemoval from "@/lib/api/hooks/app/emails/invalidateAfterMailboxRemoval"; import useRemoveEmail from "@/lib/api/hooks/app/emails/useRemoveEmail"; import { useUserProfile } from "@/hooks/context/user"; @@ -59,6 +61,8 @@ import { PauseIcon, PlayIcon, PlusIcon, + PowerIcon, + PowerOffIcon, RotateCcwIcon, SendIcon, Settings2Icon, @@ -245,6 +249,36 @@ export default function AddressesPage() { else toast.success(`Warmup ${verb} for ${n} mailbox${n > 1 ? "es" : ""}`); }; + // The whole mailbox on or off; warmup and the campaign hold are separate switches. + const bulkSwitch = (on: boolean) => { + const ids = selected.filter((id) => { + const st = emailsData.emails?.find((e) => e.id === id)?.status; + return on ? st === "inactive" : st === "active"; + }); + if (ids.length === 0) return; + const n = ids.length; + const apply = async () => { + const results = await Promise.allSettled(ids.map((id) => updateEmail(id, { status: on ? "active" : "inactive" }))); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails", "list"] }); + await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] }); + setSelected([]); + if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be switched ${on ? "on" : "off"}`); + else toast.success(`${n} mailbox${n > 1 ? "es" : ""} switched ${on ? "back on" : "off"}`); + }; + if (on) void apply(); + else confirm.show(switchOffPrompt(n > 1 ? `${n} mailboxes` : "this mailbox"), apply); + }; + const selectedStatuses = useMemo(() => { + const out = { on: 0, off: 0 }; + for (const id of selected) { + const st = emailsData.emails?.find((e) => e.id === id)?.status; + if (st === "active") out.on++; + else if (st === "inactive") out.off++; + } + return out; + }, [selected, emailsData.emails]); + const openDetail = (id: string, tab: string = "overview") => { setViewTab(tab); setView(id); @@ -543,8 +577,28 @@ export default function AddressesPage() { className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded text-[12px] font-medium text-slate-600 hover:bg-slate-100 transition-colors" > - Pause + Pause warmup + {selectedStatuses.off > 0 && ( + + )} + {selectedStatuses.on > 0 && ( + + )}
- Warmup · {inCloud ? (cloudPaused ? "Paused in cloud" : "Warmbly Cloud") : active ? "Active" : paused ? "Paused" : "Off"} + Warmup · {inCloud ? (cloudPaused ? "Paused in cloud" : "Warmbly Cloud") : switchedOff && !off ? "Mailbox off" : active ? "Active" : paused ? "Paused" : "Off"} + {switchedOff && ( + <> + }> + Switch the mailbox back on + + + + )} {inCloud && ( <> onOpen(box.id, "settings")} icon={}> Mailbox settings + {switchedOff && ( + }> + Switch back on + + )} + {box.status === "active" && ( + }> + Switch off + + )} {/* The one obvious way to remove a single mailbox. It used to exist only behind the row checkboxes and the @@ -1136,7 +1222,7 @@ function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: Acco let problem: { label: string; text: string; Icon: LucideIcon; title: string } | null = null; if (box.status === "revoked") problem = { label: "Reconnect", text: "text-rose-600", Icon: UnplugIcon, title: "Access was revoked at the provider. Reconnect the mailbox to send and warm again." }; - else if (box.status !== "active") problem = { label: "Off", text: "text-slate-500", Icon: CircleSlashIcon, title: "Switched off: it neither sends, warms nor syncs." }; + else if (box.status !== "active") problem = { label: "Off", text: "text-slate-500", Icon: CircleSlashIcon, title: "Switched off: it neither sends, warms nor syncs. Switch it back on from the row's menu or its Settings tab." }; else if (error) problem = { label: "Error", text: "text-rose-600", Icon: AlertTriangleIcon, title: error.action_required ? `${error.title}. ${error.action_required}` : error.title }; if (problem) { const { Icon } = problem; diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 2df8eb5d2..e1c18058f 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -40,8 +40,11 @@ import { HelpCircleIcon, RefreshCwIcon, TrashIcon, + PowerIcon, + PowerOffIcon, type LucideIcon, } from "lucide-react"; +import useMailboxSwitch from "@/components/app/emails/useMailboxSwitch"; import toast from "react-hot-toast"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; @@ -473,7 +476,7 @@ function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }:
- {mailbox.status} + {mailbox.status === "inactive" ? "Off" : mailbox.status} + + + + ); +} + +// The mailbox's own on/off switch; a revoked mailbox is turned back on by reconnecting. +function MailboxPowerCard({ mailbox }: { mailbox: Inbox }) { + const power = useMailboxSwitch(mailbox.id, mailbox.email); + if (mailbox.status !== "active" && mailbox.status !== "inactive") return null; + const on = mailbox.status === "active"; + return ( +
+ Mailbox +
+
+
Mailbox on
+
+ Switched off, it neither sends, warms nor syncs, and keeps its settings and history. To stop + only campaign sending and keep warming, use Hold from campaigns on the Overview tab. +
+
+ (v ? power.switchOn() : power.switchOff())} + disabled={power.pending} + ariaLabel="Mailbox on" + /> +
+
+ ); +} + function DisconnectCard({ mailbox, onDisconnected }: { mailbox: Inbox; onDisconnected: () => void }) { const confirm = useConfirm(); const remove = useRemoveEmail(mailbox.id); @@ -1988,7 +2048,7 @@ function DisconnectCard({ mailbox, onDisconnected }: { mailbox: Inbox; onDisconn const ask = () => confirm.show( - `Disconnect ${mailbox.email}? This deletes its imported mail, warmup history and credentials, and cannot be undone. Set the mailbox inactive instead if you only want it to stop sending.`, + `Disconnect ${mailbox.email}? This deletes its imported mail, warmup history and credentials, and cannot be undone. Switch the mailbox off instead if you only want it to stop.`, async () => { try { await remove.mutateAsync(); @@ -2213,6 +2273,8 @@ function SettingsTab({ form, update, mailbox, onDisconnected }: { form: Inbox; u + +
diff --git a/web/src/components/app/emails/useMailboxSwitch.ts b/web/src/components/app/emails/useMailboxSwitch.ts new file mode 100644 index 000000000..2b24e1ba8 --- /dev/null +++ b/web/src/components/app/emails/useMailboxSwitch.ts @@ -0,0 +1,34 @@ +import toast from "react-hot-toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { useConfirm } from "@/hooks/context/confirm"; +import useUpdateEmail from "@/lib/api/hooks/app/emails/useUpdateEmail"; +import buildError from "@/lib/helper/buildError"; +import type { AppError } from "@/lib/api/client/normalizeError"; + +// Off is the whole mailbox, not campaigns alone, so the prompt points at the hold. +export function switchOffPrompt(subject: string): string { + return `Switch ${subject} off? It stops sending, warming and syncing until you switch it back on, and keeps its settings and history. To stop campaign sending and keep warming, use Hold from campaigns in the mailbox's Overview instead.`; +} + +// The mailbox's own on/off switch (status active or inactive). +export default function useMailboxSwitch(id: string, email: string) { + const update = useUpdateEmail(id); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + + const apply = async (on: boolean) => { + try { + await update.mutateAsync({ status: on ? "active" : "inactive" }); + void queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] }); + toast.success(on ? `${email} is back on` : `${email} switched off`); + } catch (e) { + toast.error(buildError(e as AppError)); + } + }; + + return { + pending: update.isPending, + switchOn: () => void apply(true), + switchOff: () => confirm.show(switchOffPrompt(email), () => apply(false)), + }; +}