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"
>