mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
feat: let a switched-off mailbox be switched back on from its row menu, warmup menu, drawer, settings and the selection bar, and make the Advisor's stop-cold-sending fixes hold the mailbox from campaigns instead of switching it off
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<PauseIcon className="w-3.5 h-3.5" />
|
||||
Pause
|
||||
Pause warmup
|
||||
</button>
|
||||
{selectedStatuses.off > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulkSwitch(true)}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded text-[12px] font-medium text-sky-700 hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
<PowerIcon className="w-3.5 h-3.5" />
|
||||
Switch on
|
||||
</button>
|
||||
)}
|
||||
{selectedStatuses.on > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulkSwitch(false)}
|
||||
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"
|
||||
>
|
||||
<PowerOffIcon className="w-3.5 h-3.5" />
|
||||
Switch off
|
||||
</button>
|
||||
)}
|
||||
<BulkTagPopover ids={selected} />
|
||||
<div className="w-px h-4 bg-slate-200 mx-0.5" />
|
||||
<button
|
||||
@@ -662,6 +716,9 @@ function MailboxRow({
|
||||
const life = useWarmupLifecycle(box.id);
|
||||
const confirm = useConfirm();
|
||||
const remove = useRemoveEmail(box.id);
|
||||
const power = useMailboxSwitch(box.id, box.email);
|
||||
// Switched off by its owner or the platform; a revoked one needs a reconnect instead.
|
||||
const switchedOff = box.status === "inactive";
|
||||
|
||||
// Disconnecting is unrecoverable and takes the mailbox's stored mail with
|
||||
// it, so the prompt says that rather than "are you sure". What happens to
|
||||
@@ -697,6 +754,8 @@ function MailboxRow({
|
||||
const off = !box.warmup;
|
||||
const paused = !!box.warmup && !!box.warmup_paused_at;
|
||||
const active = !!box.warmup && !box.warmup_paused_at;
|
||||
// Warmup only runs on a mailbox that is on, whatever its warmup setting says.
|
||||
const warming = active && box.status === "active";
|
||||
|
||||
const tone = healthTone(status);
|
||||
const ws = status?.warmup_status;
|
||||
@@ -722,8 +781,10 @@ function MailboxRow({
|
||||
? cloud?.cloud
|
||||
? `${cloud.cloud.sent_today}/${cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base}`
|
||||
: "Cloud"
|
||||
: active
|
||||
: warming
|
||||
? `${ws?.current_volume ?? 0}/${ws?.target_volume ?? box.warmup_base}`
|
||||
: active
|
||||
? "Stopped"
|
||||
: paused
|
||||
? "Paused"
|
||||
: inCampaign
|
||||
@@ -733,8 +794,10 @@ function MailboxRow({
|
||||
? cloudPaused
|
||||
? "text-amber-600"
|
||||
: "text-sky-600"
|
||||
: active
|
||||
: warming
|
||||
? "text-orange-600"
|
||||
: active
|
||||
? "text-slate-400"
|
||||
: paused
|
||||
? "text-amber-600"
|
||||
: inCampaign
|
||||
@@ -828,7 +891,7 @@ function MailboxRow({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 overflow-hidden">
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : active} />
|
||||
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} />
|
||||
</td>
|
||||
<td className="px-3 overflow-hidden hidden md:table-cell">
|
||||
{status?.daily_usage ? (
|
||||
@@ -852,7 +915,7 @@ function MailboxRow({
|
||||
<CloudIcon className="w-3 h-3 shrink-0" />
|
||||
<span>{warmupLabel}</span>
|
||||
</span>
|
||||
) : active ? (
|
||||
) : warming ? (
|
||||
<span className="inline-flex items-center gap-1.5" title={`${ws?.current_volume ?? 0} of ${ws?.target_volume ?? box.warmup_base} warmup emails sent today`}>
|
||||
<span className="campaign-grid shrink-0" aria-hidden />
|
||||
<span>
|
||||
@@ -861,8 +924,13 @@ function MailboxRow({
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 font-sans text-[11.5px] font-medium">
|
||||
{paused ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 font-sans text-[11.5px] font-medium"
|
||||
title={active ? "Warmup is on, but the mailbox is not, so nothing is sent. Switch the mailbox back on to resume." : undefined}
|
||||
>
|
||||
{active ? (
|
||||
<PowerOffIcon className="w-3 h-3 shrink-0" />
|
||||
) : paused ? (
|
||||
<PauseIcon className="w-3 h-3 shrink-0" />
|
||||
) : inCampaign ? (
|
||||
<ActivityIcon className="w-3 h-3 shrink-0" />
|
||||
@@ -905,11 +973,19 @@ function MailboxRow({
|
||||
disabled={life.isPending}
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-orange-600 transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{inCloud ? <CloudIcon className={`w-3.5 h-3.5 ${cloudPaused ? "text-amber-500" : "text-sky-600"}`} /> : <RiFireLine className={`w-3.5 h-3.5 ${active ? "text-orange-500" : paused ? "text-amber-500" : ""}`} />}
|
||||
{inCloud ? <CloudIcon className={`w-3.5 h-3.5 ${cloudPaused ? "text-amber-500" : "text-sky-600"}`} /> : <RiFireLine className={`w-3.5 h-3.5 ${warming ? "text-orange-500" : paused ? "text-amber-500" : ""}`} />}
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={208}>
|
||||
<PopoverMenuLabel>Warmup · {inCloud ? (cloudPaused ? "Paused in cloud" : "Warmbly Cloud") : active ? "Active" : paused ? "Paused" : "Off"}</PopoverMenuLabel>
|
||||
<PopoverMenuLabel>Warmup · {inCloud ? (cloudPaused ? "Paused in cloud" : "Warmbly Cloud") : switchedOff && !off ? "Mailbox off" : active ? "Active" : paused ? "Paused" : "Off"}</PopoverMenuLabel>
|
||||
{switchedOff && (
|
||||
<>
|
||||
<PopoverMenuItem onSelect={power.switchOn} icon={<PowerIcon className="w-3 h-3" />}>
|
||||
Switch the mailbox back on
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{inCloud && (
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
@@ -993,6 +1069,16 @@ function MailboxRow({
|
||||
<PopoverMenuItem onSelect={() => onOpen(box.id, "settings")} icon={<Settings2Icon className="w-3 h-3" />}>
|
||||
Mailbox settings
|
||||
</PopoverMenuItem>
|
||||
{switchedOff && (
|
||||
<PopoverMenuItem onSelect={power.switchOn} icon={<PowerIcon className="w-3 h-3" />}>
|
||||
Switch back on
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
{box.status === "active" && (
|
||||
<PopoverMenuItem onSelect={power.switchOff} icon={<PowerOffIcon className="w-3 h-3" />}>
|
||||
Switch off
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
<PopoverMenuSeparator />
|
||||
{/* 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;
|
||||
|
||||
@@ -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 }:
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("h-5 px-2 rounded-full border text-[10px] font-semibold uppercase tracking-wide inline-flex items-center shrink-0", statusTone(mailbox.status))}>
|
||||
{mailbox.status}
|
||||
{mailbox.status === "inactive" ? "Off" : mailbox.status}
|
||||
</span>
|
||||
<ResourceViewers resource={mailbox.id ? `mailbox:${mailbox.id}` : null} className="shrink-0" />
|
||||
<button onClick={onClose} 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 shrink-0">
|
||||
@@ -700,6 +703,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
{mailbox.status === "inactive" && <SwitchedOffNotice mailbox={mailbox} />}
|
||||
{mailbox.provider === "gmail" && mailbox.auth_method !== "delegated" && <SigninRetiringNotice mailbox={mailbox} />}
|
||||
{/* Whatever the Advisor has on this mailbox, above the numbers that
|
||||
produced it. This is where a row flag and a deep link both land. */}
|
||||
@@ -1975,6 +1979,62 @@ function SendIdentityCard({
|
||||
* takes before asking, and the copy differs by provider because what happens to
|
||||
* the connection does: Google accepts a revocation and Microsoft does not.
|
||||
*/
|
||||
// Off stops everything, so the way back sits where the drawer opens.
|
||||
function SwitchedOffNotice({ mailbox }: { mailbox: Inbox }) {
|
||||
const power = useMailboxSwitch(mailbox.id, mailbox.email);
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2.5 flex items-start gap-2">
|
||||
<PowerOffIcon className="w-3.5 h-3.5 mt-px shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12.5px] font-medium text-slate-900">This mailbox is switched off</p>
|
||||
<p className="text-[11.5px] text-slate-600 leading-relaxed mt-0.5">
|
||||
It is not sending campaigns, warming up or syncing mail, whatever its warmup setting says. Its
|
||||
settings, history and worker are kept, so switching it back on picks up where it stopped.
|
||||
To keep warming while it stays out of campaigns, switch it on and turn on Hold from campaigns below.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={power.switchOn}
|
||||
disabled={power.pending}
|
||||
className="mt-2 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium disabled:opacity-60 transition-colors"
|
||||
>
|
||||
<PowerIcon className="w-3.5 h-3.5" />
|
||||
{power.pending ? "Switching on…" : "Switch back on"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<Eyebrow>Mailbox</Eyebrow>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] font-medium text-slate-900">Mailbox on</div>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
value={on}
|
||||
onChange={(v) => (v ? power.switchOn() : power.switchOff())}
|
||||
disabled={power.pending}
|
||||
ariaLabel="Mailbox on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
<DirectMailTrackingControl mailbox={mailbox} />
|
||||
|
||||
<MailboxPowerCard mailbox={mailbox} />
|
||||
|
||||
<DisconnectCard mailbox={mailbox} onDisconnected={onDisconnected} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-5 py-3 text-[11px] text-slate-400">
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user