mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 00:02:43 +00:00
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:
committed by
Devin AI
parent
2cf19ebedf
commit
cdb102ca56
@@ -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/).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user