From 5a542e12226097a4bc84d9dad10cf2b7c4fc584c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 16:20:21 +0000 Subject: [PATCH] feat: show failed or partial Accounts mailbox inventory as unavailable instead of measured zero counts or an empty list --- docs/content/docs/guides/mailboxes.mdx | 2 +- web/src/app/app/emails/page.test.tsx | 22 ++++++++++++++++++++-- web/src/app/app/emails/page.tsx | 19 ++++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 69c97fff9..0de4d8b96 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -7,7 +7,7 @@ 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 previously reported issue may remain marked **Last observed** for up to five minutes during a refresh; this is historical evidence, not a claim that it is still failing. A newly measured healthy result replaces it. 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. +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. If the mailbox list itself cannot be loaded, or only part of it loaded, the summary counts show **Unavailable** too, rather than an empty account list. A previously reported issue may remain marked **Last observed** for up to five minutes during a refresh; this is historical evidence, not a claim that it is still failing. A newly measured healthy result replaces it. 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 diff --git a/web/src/app/app/emails/page.test.tsx b/web/src/app/app/emails/page.test.tsx index 36108d8c3..bb05ac0ea 100644 --- a/web/src/app/app/emails/page.test.tsx +++ b/web/src/app/app/emails/page.test.tsx @@ -9,7 +9,7 @@ import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import AddressesPage from "./page"; const { tags, rows, statusState } = vi.hoisted(() => ({ - statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number; current: boolean }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false, emailsLoading: false }, + statusState: { data: [] as AccountStatus[], observations: [] as { status: AccountStatus; observedAt: number; current: boolean }[], isLoading: false, isFetching: false, isError: false, cloudLoading: false, cloudUnavailable: false, emailsLoading: false, emailsError: false, emailsIncomplete: false }, tags: [ { id: "sending", title: "Sending account", color: "#0088cc", position: 0 }, { id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 }, @@ -30,7 +30,7 @@ vi.mock("@/hooks/context/user", async (original) => ({ vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) })); vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true })); vi.mock("@/lib/api/hooks/app/emails/useEmails", () => ({ - default: ({ tag }: { tag: string }) => ({ emails: statusState.emailsLoading ? [] : tag ? rows.filter((row) => row.tags.includes(tag)) : rows, isLoading: statusState.emailsLoading, isPending: statusState.emailsLoading }), + default: ({ tag }: { tag: string }) => ({ emails: statusState.emailsLoading || statusState.emailsError ? [] : tag ? rows.filter((row) => row.tags.includes(tag)) : rows, isLoading: statusState.emailsLoading, isPending: statusState.emailsLoading, isError: statusState.emailsError || statusState.emailsIncomplete, isIncomplete: statusState.emailsIncomplete, refetch: vi.fn() }), })); vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => statusState })); vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) })); @@ -149,6 +149,8 @@ describe("mailbox status while account and Cloud checks settle", () => { statusState.cloudLoading = false; statusState.cloudUnavailable = false; statusState.emailsLoading = false; + statusState.emailsError = false; + statusState.emailsIncomplete = false; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); @@ -159,6 +161,22 @@ describe("mailbox status while account and Cloud checks settle", () => { expect(screen.queryByText("Healthy 100")).not.toBeInTheDocument(); }); + it("shows failed or partial mailbox inventory as unavailable rather than measured zeros", async () => { + statusState.emailsError = true; + await renderPage(); + expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(4); + expect(screen.getAllByText("Mailboxes unavailable").length).toBeGreaterThan(0); + expect(screen.queryByText("No email accounts yet")).not.toBeInTheDocument(); + expect(screen.queryByText("0 mailboxes")).not.toBeInTheDocument(); + cleanup(); + + statusState.emailsError = false; + statusState.emailsIncomplete = true; + statusState.data = rows.map(measuredStatus); + await renderPage(); + expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(4); + }); + 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); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 96adddb45..63316ca6b 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -206,7 +206,8 @@ export default function AddressesPage() { 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))); + const inventoryUnavailable = !emailsData.isLoading && (emailsData.isError || emailsData.isIncomplete); + const statusUnavailable = inventoryUnavailable || cloud.unavailable || cloudStandingUnavailable || statuses.isError || (!statusChecking && visibleEmailIds.some((id) => !statusById.has(id))); const [lastReportedIssues, setLastReportedIssues] = React.useState>(new Map()); useEffect(() => { setLastReportedIssues((previous) => { @@ -443,7 +444,9 @@ export default function AddressesPage() { subtitle={ emailsData.isPending ? "Loading…" - : `${stats.total.toLocaleString()}${emailsData.isLoadingRest || emailsData.isIncomplete ? "+" : ""} mailbox${stats.total === 1 ? "" : "es"}` + : emailsData.isError && emailsData.emails.length === 0 + ? "Mailboxes unavailable" + : `${stats.total.toLocaleString()}${emailsData.isLoadingRest || emailsData.isIncomplete ? "+" : ""} mailbox${stats.total === 1 ? "" : "es"}` } > @@ -459,7 +462,7 @@ export default function AddressesPage() { - } sub="connected" /> + } sub="connected" /> } sub="no reported health issues" accent={!statusChecking && !statusUnavailable && stats.healthy > 0} /> } sub="ramping up" /> } sub="paused or failing" last /> @@ -540,6 +543,16 @@ export default function AddressesPage() { ))} + ) : emailsData.isError && emailsData.emails.length === 0 ? ( + void emailsData.refetch()}> + Try again + + } + /> ) : !emailsData.emails || emailsData.emails.length === 0 ? ( cloud.selfHosted || authConfigLoading ? (