diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 80b562d78..4e0d7e479 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -7,6 +7,8 @@ Browsing filters, searches and view choices are [remembered in your browser tab] A mailbox is a sender account you connect to Warmbly. Every warmup message and campaign email goes out through one you own. +The Accounts list checks mailbox health and, on linked self-hosted instances, Cloud warmup standing separately from the mailbox inventory. While those checks are loading or refreshing, it shows **Checking…** rather than treating missing results as Idle, Healthy, or zero sent. A failed or incomplete check shows **Unavailable**, not a measured zero or a claim that the mailbox recovered. A displayed health score is an account-health assessment, not proof that the provider confirmed a send; check send records and the mailbox's error details when investigating a stopped mailbox. + ### Diagnostic participation and stopping New mailboxes default to diagnostic participation **off**. The organization-authorized mailbox PATCH exposes `test_mode` (`off`, `diagnostic`, `legacy`), separate `test_send_enabled` and `test_receive_enabled` flags, and optional positive `shared_daily_limit`/`rolling_recipient_limit`. These are API controls, not new dashboard switches. Diagnostic mode sends disclosed tests but does not manufacture reads, stars, importance or spam rescue. Existing upgraded mailboxes with NULL participation retain legacy behavior until explicitly changed. Set `test_mode=off` to stop future diagnostic send/receive admission; current protocol-2 workers also recheck queued sends/actions. Already accepted old-worker commands or a provider send underway cannot be recalled. A local Cloud alias unlink stops local authority, not an independently cloud-owned mailbox or provider grant: pause/remove that mailbox on Cloud separately. See [send safety, repair evidence and rollout](/development/send-safety/). diff --git a/web/src/app/app/emails/page.test.tsx b/web/src/app/app/emails/page.test.tsx index 77f4dff32..73c98d6c9 100644 --- a/web/src/app/app/emails/page.test.tsx +++ b/web/src/app/app/emails/page.test.tsx @@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; import type Tag from "@/lib/api/models/app/Tag"; import type * as UserModule from "@/hooks/context/user"; +import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import AddressesPage from "./page"; -const { tags, rows } = vi.hoisted(() => ({ +const { tags, rows, statusState } = vi.hoisted(() => ({ + statusState: { data: [] as AccountStatus[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false }, tags: [ { id: "sending", title: "Sending account", color: "#0088cc", position: 0 }, { id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 }, @@ -30,12 +32,12 @@ vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true })); vi.mock("@/lib/api/hooks/app/emails/useEmails", () => ({ default: ({ tag }: { tag: string }) => ({ emails: tag ? rows.filter((row) => row.tags.includes(tag)) : rows }), })); -vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => ({ data: [] }) })); +vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => statusState })); vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) })); vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ default: () => ({ data: {}, isLoading: false }) })); vi.mock("@/lib/api/hooks/app/emails/useMailboxGrants", () => ({ useSigninMigration: () => ({ data: { data: [] } }) })); vi.mock("@/lib/api/hooks/app/advisor/useAdvisor", () => ({ useAdvisorEntityIndex: () => ({ get: () => [] }) })); -vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ selfHosted: false, connected: false }) })); +vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ selfHosted: false, connected: false, loading: statusState.cloudLoading, unavailable: statusState.cloudUnavailable, observedAt: 0 }) })); vi.mock("@/lib/api/hooks/app/cloudlink/useCloudLink", () => ({ useEnrollCloudLinkMailbox: () => ({}), useUnenrollCloudLinkMailbox: () => ({}), useCloudLinkMailboxLifecycle: () => ({}), })); @@ -70,9 +72,19 @@ function orderedEmails() { return screen.getAllByRole("row").slice(1).map((row) => within(row).getByText(/@/).textContent); } +function measuredStatus(row: Inbox): AccountStatus { + return { id: row.id, email: row.email, provider: "gmail", status: "active", last_synced_at: null, health: { status: "healthy", score: 100 }, errors: [], daily_usage: { date: "2026-10-09", campaign_sent: 0, campaign_limit: 50 }, in_campaign: false }; +} + describe("mailbox ordering with a tag filter", () => { beforeEach(() => { sessionStorage.clear(); + statusState.data = []; + statusState.isLoading = false; + statusState.isFetching = false; + statusState.isError = false; + statusState.cloudLoading = false; + statusState.cloudUnavailable = false; vi.spyOn(window, "scrollTo").mockImplementation(() => {}); }); afterEach(() => { @@ -123,3 +135,48 @@ describe("mailbox ordering with a tag filter", () => { expect(screen.getByRole("columnheader", { name: "Mailbox" })).toHaveAttribute("aria-sort", "descending"); }); }); + +describe("mailbox status while account and Cloud checks settle", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + statusState.data = []; + statusState.isLoading = false; + statusState.isFetching = false; + statusState.isError = false; + statusState.cloudLoading = false; + statusState.cloudUnavailable = false; + }); + afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + + it("does not show Idle or Healthy while Cloud and account status are loading, then shows the real error", async () => { + statusState.cloudLoading = true; + statusState.data = rows.map(measuredStatus); + await renderPage(); + expect(screen.getAllByText("Checking…").length).toBeGreaterThan(4); + expect(screen.queryByText("Idle")).not.toBeInTheDocument(); + expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument(); + expect(screen.queryByTitle("0 of 50 campaign emails sent today")).not.toBeInTheDocument(); + + statusState.cloudLoading = false; + statusState.data[0] = { ...measuredStatus(rows[0]), health: { status: "error", score: 40 }, errors: [{ id: "error-1", error_code: "PROVIDER_UNAVAILABLE", severity: "critical", title: "Provider unavailable", message: "Provider refused the last attempt", created_at: new Date("2026-10-09T00:00:00Z") }] }; + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + const sara = screen.getAllByRole("row").find((row) => row.textContent?.includes(rows[0].email)); + expect(sara).toBeDefined(); + expect(within(sara!).getByText("Issue 40")).toBeInTheDocument(); + expect(within(sara!).getAllByText("Error").length).toBeGreaterThan(0); + }); + + it("leaves partial, stale and failed status checks unknown instead of counting them healthy", async () => { + statusState.data = [measuredStatus(rows[0])]; + statusState.isFetching = true; + await renderPage(); + expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument(); + statusState.isFetching = false; + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(screen.getAllByText("Unavailable").length).toBeGreaterThan(0); + statusState.isError = true; + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 28840be89..c547239f6 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -50,7 +50,6 @@ import { useSigninMigration } from "@/lib/api/hooks/app/emails/useMailboxGrants" import { mailboxBrand, mailboxSource } from "@/lib/mailboxSource"; import type Tag from "@/lib/api/models/app/Tag"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; -import mailboxDisplayStatus from "@/lib/mailboxStatus"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import { ActivityIcon, @@ -184,18 +183,28 @@ export default function AddressesPage() { () => (emailsData.emails ?? []).map((e) => e.id), [emailsData.emails], ); - const statuses = useAccountStatuses(visibleEmailIds); + const cloudChecking = authConfigLoading || cloud.loading || cloud.refreshing; + const statuses = useAccountStatuses(visibleEmailIds, { + enabled: !cloudChecking, + sourceRevision: cloud.observedAt, + }); // 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], + () => (!cloudChecking && !statuses.isError && !statuses.isFetching && Array.isArray(statuses.data) ? statuses.data : []), + [cloudChecking, statuses.data, statuses.isError, statuses.isFetching], ); const statusById = useMemo(() => { const m = new Map(); for (const s of accountStatuses) m.set(s.id, s); return m; }, [accountStatuses]); + const statusChecking = cloudChecking || statuses.isLoading || statuses.isFetching; + const cloudStandingUnavailable = cloud.connected && (emailsData.emails ?? []).some((box) => { + const row = cloud.rowFor(box.id); + return row?.enrolled && !row.cloud?.health; + }); + const statusUnavailable = cloud.unavailable || cloudStandingUnavailable || statuses.isError || (!statusChecking && visibleEmailIds.some((id) => !statusById.has(id))); // Proactively notify the user when a mailbox's health drops. const prevHealth = useRef>(new Map()); @@ -344,13 +353,21 @@ export default function AddressesPage() { const s = { total: 0, healthy: 0, warming: 0, issues: 0 }; for (const e of emailsData.emails ?? []) { s.total++; - const st = mailboxDisplayStatus(e); - if (st === "healthy") s.healthy++; - else if (st === "warming") s.warming++; - else s.issues++; + const current = statusById.get(e.id); + if (!current) continue; + if (e.status !== "active" || e.auth_state === "failing" || current.health?.status !== "healthy" || current.errors?.length) { + s.issues++; + continue; + } + const linked = cloud.connected ? cloud.rowFor(e.id) : undefined; + if (linked?.enrolled ? linked.cloud?.warmup?.enabled && !cloudWarmupPaused(linked.cloud) : diagnosticWarmupActive(e)) { + s.warming++; + } else { + s.healthy++; + } } return s; - }, [emailsData.emails]); + }, [emailsData.emails, statusById, cloud.connected, cloud.rowFor]); // Mailboxes actively warming (enabled and not paused). Warmup pairs mailboxes // with each other, so too few starves it; the notice below warns on that. @@ -413,9 +430,9 @@ export default function AddressesPage() { } sub="connected" /> - } sub="sending now" accent={stats.healthy > 0} /> - } sub="ramping up" /> - } sub="paused or failing" last /> + } sub="no reported health issues" accent={!statusChecking && !statusUnavailable && stats.healthy > 0} /> + } sub="ramping up" /> + } sub="paused or failing" last /> @@ -547,7 +564,9 @@ export default function AddressesPage() { key={box.id} box={box} tags={p?.user.tags ?? []} - status={statusById.get(box.id)} + status={(cloud.unavailable || cloudStandingUnavailable) && cloud.connected && statusById.get(box.id)?.health?.status === "healthy" && !statusById.get(box.id)?.errors?.length ? undefined : statusById.get(box.id)} + statusPending={statusChecking && !statusUnavailable} + cloudStatusPending={cloudChecking || cloud.unavailable || cloudStandingUnavailable} findings={advisor.get(box.id)} canWarmup={canWarmup} cloud={cloud.connected ? cloud.rowFor(box.id) : undefined} @@ -723,6 +742,8 @@ function MailboxRow({ box, tags, status, + statusPending, + cloudStatusPending, findings, canWarmup, cloud, @@ -736,6 +757,8 @@ function MailboxRow({ box: Inbox; tags: Tag[]; status?: AccountStatus; + statusPending: boolean; + cloudStatusPending: boolean; findings: AdvisorFinding[]; canWarmup: boolean; cloud?: CloudLinkMailboxRow; @@ -1008,7 +1031,7 @@ function MailboxRow({ {/* Too narrow for the metric columns: the same readings, labelled, under the address. */}
- + Warmup {warmupCell} @@ -1032,7 +1055,7 @@ function MailboxRow({
- + {status?.daily_usage ? ( @@ -1051,7 +1074,7 @@ function MailboxRow({ )} - {warmupCell} + {cloudStatusPending && !inCloud ? {statusPending ? "Checking…" : "Unavailable"} : warmupCell}