feat: keep mailbox health and usage unknown until Cloud and account status checks finish with refresh and incomplete-data regressions

This commit is contained in:
Matthew Meszaros
2026-10-09 16:27:20 +00:00
committed by Devin AI
parent 2cf19ebedf
commit cdb102ca56
5 changed files with 115 additions and 26 deletions
+60 -3
View File
@@ -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();
});
});
+42 -17
View File
@@ -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<string, AccountStatus>();
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<Map<string, string>>(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() {
<StatStrip cols={4}>
<Stat label="Total" value={<AnimatedNumber value={stats.total} />} sub="connected" />
<Stat label="Healthy" value={<AnimatedNumber value={stats.healthy} />} sub="sending now" accent={stats.healthy > 0} />
<Stat label="Warming" value={<AnimatedNumber value={stats.warming} />} sub="ramping up" />
<Stat label="Needs attention" value={<AnimatedNumber value={stats.issues} />} sub="paused or failing" last />
<Stat label="Healthy" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.healthy} />} sub="no reported health issues" accent={!statusChecking && !statusUnavailable && stats.healthy > 0} />
<Stat label="Warming" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.warming} />} sub="ramping up" />
<Stat label="Needs attention" value={statusChecking ? "Checking…" : statusUnavailable ? "Unavailable" : <AnimatedNumber value={stats.issues} />} sub="paused or failing" last />
</StatStrip>
<SectionBar label="Mailboxes" count={emailsData.emails?.length ?? 0}>
@@ -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({
</div>
{/* Too narrow for the metric columns: the same readings, labelled, under the address. */}
<div className="@2xl:hidden mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 leading-tight">
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} compact />
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} compact pending={statusPending} />
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[10.5px] text-slate-400">Warmup</span>
<span className={`font-mono text-[11.5px] tabular-nums ${warmupTone}`}>{warmupCell}</span>
@@ -1032,7 +1055,7 @@ function MailboxRow({
</div>
</td>
<td className={`px-3 overflow-hidden ${colShow("status")}`}>
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} />
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} pending={statusPending} />
</td>
<td className={`px-3 overflow-hidden ${colShow("sent")}`}>
{status?.daily_usage ? (
@@ -1051,7 +1074,7 @@ function MailboxRow({
)}
</td>
<td className={`px-3 overflow-hidden font-mono text-[12px] tabular-nums ${warmupTone} ${colShow("warmup")}`}>
{warmupCell}
{cloudStatusPending && !inCloud ? <span className="text-slate-500">{statusPending ? "Checking…" : "Unavailable"}</span> : warmupCell}
</td>
<td className={`px-3 ${colShow("inbox")}`}>
<button
@@ -1337,7 +1360,7 @@ function MailboxTh({ col, sort, onSort }: { col: MailboxColumn; sort: MailboxSor
// What the mailbox is doing right now. Cold sending and warmup run side by
// side, so both show when both are on; a problem that stops it wins.
// compact spells a problem out, since it is the one reading a phone must not miss.
function MailboxStatusPill({ box, status, warming, compact = false }: { box: Inbox; status?: AccountStatus; warming: boolean; compact?: boolean }) {
function MailboxStatusPill({ box, status, warming, compact = false, pending = false }: { box: Inbox; status?: AccountStatus; warming: boolean; compact?: boolean; pending?: boolean }) {
const error = status?.errors?.[0];
const lifecycle = status?.send_lifecycle;
const inCampaign = !!status?.in_campaign;
@@ -1359,6 +1382,8 @@ function MailboxStatusPill({ box, status, warming, compact = false }: { box: Inb
);
}
if (!status) return <span className="text-[10.5px] text-slate-500">{pending ? "Checking…" : "Unavailable"}</span>;
// One word; the icons beside it say which activities are on.
const label = sending && warming ? "Active" : sending ? "Sending" : resting ? (lifecycle?.state === "reserve" ? "Reserve" : "Resting") : warming ? "Warming" : "Idle";
const title = [
+7 -3
View File
@@ -3,7 +3,7 @@
// warms. Members who manage mailboxes see the link and use it for their own
// mailboxes (`manageable`); linking it is the instance administrator's.
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { useInstanceAdmin, usePermission } from "@/hooks/usePermission";
import { useCloudLinkMailboxes, useCloudLinkStatus } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
@@ -27,6 +27,7 @@ export default function useCloudPool() {
const enrolledCount = useMemo(() => (mailboxes.data ?? []).filter((r) => r.enrolled).length, [mailboxes.data]);
const plan: PoolLinkPlan | undefined = status.data?.info?.plan;
const rowFor = useCallback((id: string) => byId.get(id), [byId]);
return {
selfHosted,
@@ -37,8 +38,11 @@ export default function useCloudPool() {
orgName: status.data?.link?.organization_name ?? "",
plan,
enrolledCount,
rowFor: (id: string) => byId.get(id),
rowFor,
isEnrolled: (id: string) => byId.get(id)?.enrolled === true,
loading: manageable && (status.isLoading || (connected && mailboxes.isLoading)),
loading: manageable && (status.isPending || (connected && mailboxes.isPending)),
refreshing: manageable && (status.isFetching || (connected && mailboxes.isFetching)),
unavailable: manageable && (status.isError || (connected && mailboxes.isError)),
observedAt: connected ? mailboxes.dataUpdatedAt : 0,
};
}
@@ -18,7 +18,7 @@ function chunk(ids: string[]): string[][] {
return out;
}
export default function useAccountStatuses(emailIds: string[]) {
export default function useAccountStatuses(emailIds: string[], options?: { enabled?: boolean; sourceRevision?: number }) {
// Sort so a chunk's id set (and its query key) is stable regardless of the
// order the mailbox list arrived in; adding a mailbox only refetches the
// chunk it lands in.
@@ -26,15 +26,16 @@ export default function useAccountStatuses(emailIds: string[]) {
return useQueries({
queries: chunks.map((ids) => ({
queryKey: ["analytics", "accounts", "list", ids],
queryKey: ["analytics", "accounts", "list", ids, options?.sourceRevision ?? 0],
queryFn: ({ signal }: { signal: AbortSignal }) => getAccountStatuses(ids, signal),
enabled: ids.length > 0,
enabled: ids.length > 0 && (options?.enabled ?? true),
})),
// react-query memoizes this by result reference, so the merged array is
// stable across renders until a chunk actually updates.
combine: (results) => ({
data: results.flatMap((r) => (r.data ?? []) as AccountStatus[]),
isLoading: results.some((r) => r.isLoading),
isFetching: results.some((r) => r.isFetching),
isError: results.some((r) => r.isError),
}),
});