From 996fedc5a484cc959a49d9344d7910e34ab6f92d Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:11:30 +0000
Subject: [PATCH 1/8] fix: keep inbox unread count current
---
.../(app)/[emailAccountId]/mail/MailShell.tsx | 50 +++++++--
.../mail/inbox-unread-count.test.ts | 75 +++++++++++++
.../mail/inbox-unread-count.ts | 34 ++++++
apps/web/app/api/labels/counts/route.test.ts | 20 +++-
apps/web/app/api/labels/counts/route.ts | 46 +++++++-
apps/web/hooks/useLabelCounts.test.tsx | 100 ++++++++++++++++++
apps/web/hooks/useLabelCounts.ts | 36 ++++++-
7 files changed, 349 insertions(+), 12 deletions(-)
create mode 100644 apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
create mode 100644 apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
create mode 100644 apps/web/hooks/useLabelCounts.test.tsx
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
index e4ceda028b..eaf571fa81 100644
--- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
@@ -55,6 +55,7 @@ import { useThreadPrefetchCoordinator } from "@/app/(app)/[emailAccountId]/mail/
import { useThreadActions } from "@/app/(app)/[emailAccountId]/mail/use-thread-actions";
import { useThreadSelection } from "@/app/(app)/[emailAccountId]/mail/use-thread-selection";
import { isThreadUnread } from "@/app/(app)/[emailAccountId]/mail/read-state";
+import { getInboxUnreadDelta } from "@/app/(app)/[emailAccountId]/mail/inbox-unread-count";
import { MailLayout, MailSplitKind } from "@/generated/prisma/enums";
import { useChat } from "@/providers/ChatProvider";
import { Sidebar, useSidebar } from "@/components/ui/sidebar";
@@ -135,7 +136,11 @@ export function MailShell() {
const { userLabels } = useEmail();
const { visibleLabels, mutate: mutateLabels } = useSplitLabels();
const { folders, mutate: mutateFolders } = useFolders(provider);
- const { countsById, mutate: mutateCounts } = useLabelCounts();
+ const {
+ adjustInboxUnread,
+ countsById,
+ mutate: mutateCounts,
+ } = useLabelCounts({ emailAccountId });
const { data: settings, mutate: mutateSettings } = useMailSettings();
const { onOpen: openCompose } = useComposeModal();
const { setInput: setChatInput } = useChat();
@@ -312,11 +317,44 @@ export function MailShell() {
const orderedIds = useMemo(() => threads.map(getListThreadKey), [threads]);
const selection = useThreadSelection(orderedIds);
- const { archive, trash, markRead, setReadState, snooze, undo } =
- useThreadActions({
- emailAccountId,
- threads,
- });
+ const {
+ archive,
+ trash,
+ setReadState: queueReadState,
+ snooze,
+ undo,
+ } = useThreadActions({
+ emailAccountId,
+ threads,
+ });
+ const inboxFolderId = folders.find(
+ (folder) => folder.systemType === "INBOX",
+ )?.id;
+ // Behind a ref so setReadState stays referentially stable across thread-list
+ // refreshes, matching useThreadActions.
+ const threadsRef = useRef(threads);
+ threadsRef.current = threads;
+ const setReadState = useCallback(
+ async (threadKeys: string[], read: boolean, notifySuccess = true) => {
+ const queuedKeys = await queueReadState(threadKeys, read, notifySuccess);
+ if (!isAllAccounts) {
+ adjustInboxUnread(
+ getInboxUnreadDelta({
+ inboxFolderId,
+ read,
+ threadKeys: queuedKeys,
+ threads: threadsRef.current,
+ }),
+ );
+ }
+ return queuedKeys;
+ },
+ [adjustInboxUnread, inboxFolderId, isAllAccounts, queueReadState],
+ );
+ const markRead = useCallback(
+ (threadKeys: string[]) => setReadState(threadKeys, true, false),
+ [setReadState],
+ );
const clampIndex = useCallback(
(index: number) =>
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
new file mode 100644
index 0000000000..46d592b1e8
--- /dev/null
+++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import type { ListThread } from "./types";
+import { getInboxUnreadDelta } from "./inbox-unread-count";
+
+describe("getInboxUnreadDelta", () => {
+ it("decrements only unread inbox conversations marked as read", () => {
+ const threads = [
+ createThread("unread-inbox", ["INBOX", "UNREAD"]),
+ createThread("read-inbox", ["INBOX"]),
+ createThread("unread-archive", ["UNREAD"]),
+ ];
+
+ expect(
+ getInboxUnreadDelta({
+ read: true,
+ threadKeys: threads.map((thread) => thread.id),
+ threads,
+ }),
+ ).toBe(-1);
+ });
+
+ it("increments only read inbox conversations marked as unread", () => {
+ const threads = [
+ createThread("read-inbox", ["INBOX"]),
+ createThread("unread-inbox", ["INBOX", "UNREAD"]),
+ ];
+
+ expect(
+ getInboxUnreadDelta({
+ read: false,
+ threadKeys: threads.map((thread) => thread.id),
+ threads,
+ }),
+ ).toBe(1);
+ });
+
+ it("recognizes the Outlook inbox folder", () => {
+ const thread = createThread("outlook", ["UNREAD"], "outlook-inbox");
+
+ expect(
+ getInboxUnreadDelta({
+ inboxFolderId: "outlook-inbox",
+ read: true,
+ threadKeys: [thread.id],
+ threads: [thread],
+ }),
+ ).toBe(-1);
+ });
+});
+
+function createThread(
+ id: string,
+ labelIds: string[],
+ parentFolderId?: string,
+): ListThread {
+ return {
+ id,
+ snippet: "snippet",
+ plan: undefined,
+ plans: [],
+ messages: [
+ {
+ id: `message-${id}`,
+ threadId: id,
+ snippet: "snippet",
+ subject: "Subject",
+ date: "0",
+ internalDate: "0",
+ labelIds,
+ parentFolderId,
+ headers: { subject: "Subject" },
+ },
+ ],
+ };
+}
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
new file mode 100644
index 0000000000..570f3ca59a
--- /dev/null
+++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
@@ -0,0 +1,34 @@
+import { GmailLabel } from "@/utils/gmail/label";
+import { isThreadUnread } from "./read-state";
+import { getListThreadKey, type ListThread } from "./types";
+
+export function getInboxUnreadDelta({
+ inboxFolderId,
+ read,
+ threadKeys,
+ threads,
+}: {
+ inboxFolderId?: string;
+ read: boolean;
+ threadKeys: string[];
+ threads: ListThread[];
+}) {
+ const targets = new Set(threadKeys);
+ let delta = 0;
+
+ for (const thread of threads) {
+ if (!targets.has(getListThreadKey(thread))) continue;
+ const isInInbox = thread.messages.some(
+ (message) =>
+ message.labelIds?.includes(GmailLabel.INBOX) ||
+ (inboxFolderId && message.parentFolderId === inboxFolderId),
+ );
+ if (!isInInbox) continue;
+
+ const isUnread = isThreadUnread(thread.messages);
+ if (isUnread === !read) continue;
+ delta += read ? -1 : 1;
+ }
+
+ return delta;
+}
diff --git a/apps/web/app/api/labels/counts/route.test.ts b/apps/web/app/api/labels/counts/route.test.ts
index 54adeeff36..1118544404 100644
--- a/apps/web/app/api/labels/counts/route.test.ts
+++ b/apps/web/app/api/labels/counts/route.test.ts
@@ -113,7 +113,7 @@ describe("GET /api/labels/counts", () => {
expect(body.partial).toBe(false);
});
- it("caches the response and serves later requests from the cache", async () => {
+ it("caches label counts but refreshes the inbox count on later requests", async () => {
const first = await GET(request());
const firstBody = await first.json();
@@ -126,12 +126,26 @@ describe("GET /api/labels/counts", () => {
mockGetLabelById.mockClear();
mockGetLabels.mockClear();
mockRedisGet.mockResolvedValue(firstBody);
+ mockGetLabelById.mockResolvedValue({
+ id: "INBOX",
+ name: "Inbox",
+ threadsTotal: 8,
+ threadsUnread: 1,
+ });
const second = await GET(request());
+ const secondBody = await second.json();
- expect(await second.json()).toEqual(firstBody);
+ expect(secondBody.counts[0]).toEqual({
+ id: "INBOX",
+ name: "Inbox",
+ kind: "system",
+ total: 8,
+ unread: 1,
+ });
expect(mockGetLabels).not.toHaveBeenCalled();
- expect(mockGetLabelById).not.toHaveBeenCalled();
+ expect(mockGetLabelById).toHaveBeenCalledOnce();
+ expect(mockGetLabelById).toHaveBeenCalledWith("INBOX");
});
it("keeps the other counts when a single label lookup fails", async () => {
diff --git a/apps/web/app/api/labels/counts/route.ts b/apps/web/app/api/labels/counts/route.ts
index 827848b043..8696ac4bfb 100644
--- a/apps/web/app/api/labels/counts/route.ts
+++ b/apps/web/app/api/labels/counts/route.ts
@@ -64,7 +64,11 @@ export const GET = withEmailProvider(
const { emailAccountId } = request.auth;
const cached = await getCachedCounts(emailAccountId, logger);
- if (cached) return NextResponse.json(cached);
+ if (cached) {
+ return NextResponse.json(
+ await refreshCachedInboxCount({ cached, emailProvider, logger }),
+ );
+ }
const { anyFailed, ...response } = await getCounts({
emailProvider,
@@ -178,6 +182,46 @@ async function getGmailCounts({
};
}
+async function refreshCachedInboxCount({
+ cached,
+ emailProvider,
+ logger,
+}: {
+ cached: LabelCountsResponse;
+ emailProvider: EmailProvider;
+ logger: Logger;
+}) {
+ if (!cached.counts.some((count) => count.id === GmailLabel.INBOX)) {
+ return cached;
+ }
+
+ try {
+ let fresh: { total: number; unread: number };
+ if (isGoogleProvider(emailProvider.name)) {
+ // Not `getInboxStats`: for Gmail that reports message counts, while the
+ // sidebar shows thread counts.
+ const label = await emailProvider.getLabelById(GmailLabel.INBOX);
+ if (!label) return cached;
+ fresh = {
+ total: label.threadsTotal ?? 0,
+ unread: label.threadsUnread ?? 0,
+ };
+ } else {
+ fresh = await emailProvider.getInboxStats();
+ }
+
+ return {
+ ...cached,
+ counts: cached.counts.map((count) =>
+ count.id === GmailLabel.INBOX ? { ...count, ...fresh } : count,
+ ),
+ };
+ } catch (error) {
+ logger.warn("Failed to refresh cached inbox count", { error });
+ return cached;
+ }
+}
+
function getCacheKey(emailAccountId: string) {
return `${CACHE_KEY_PREFIX}:${emailAccountId}`;
}
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
new file mode 100644
index 0000000000..13666ff8c2
--- /dev/null
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment jsdom
+
+import type { PropsWithChildren } from "react";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { SWRConfig } from "swr";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mailbox = vi.hoisted(() => {
+ const listeners = new Set<(emailAccountId: string) => void>();
+ return {
+ emit(emailAccountId: string) {
+ for (const listener of listeners) listener(emailAccountId);
+ },
+ reset() {
+ listeners.clear();
+ },
+ subscribe: vi.fn((listener: (emailAccountId: string) => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }),
+ };
+});
+
+vi.mock("@/utils/email-cache/mailbox", () => ({
+ subscribeToMailboxStore: mailbox.subscribe,
+}));
+
+import { useLabelCounts } from "./useLabelCounts";
+
+const initialResponse = {
+ counts: [
+ {
+ id: "INBOX",
+ name: "Inbox",
+ kind: "system" as const,
+ total: 10,
+ unread: 4,
+ },
+ ],
+ partial: false,
+};
+
+describe("useLabelCounts", () => {
+ beforeEach(() => {
+ mailbox.reset();
+ vi.clearAllMocks();
+ });
+
+ it("refreshes when the active account mailbox changes", async () => {
+ const fetcher = vi.fn().mockResolvedValue(initialResponse);
+ renderHook(() => useLabelCounts({ emailAccountId: "account-1" }), {
+ wrapper: createWrapper(fetcher),
+ });
+
+ await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
+
+ act(() => mailbox.emit("account-2"));
+ expect(fetcher).toHaveBeenCalledOnce();
+
+ act(() => mailbox.emit("account-1"));
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
+ });
+
+ it("updates the inbox unread count without waiting for revalidation", async () => {
+ const fetcher = vi.fn().mockResolvedValue(initialResponse);
+ const { result } = renderHook(
+ () => useLabelCounts({ emailAccountId: "account-1" }),
+ { wrapper: createWrapper(fetcher) },
+ );
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
+ );
+
+ act(() => {
+ result.current.adjustInboxUnread(-1);
+ });
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(3),
+ );
+ expect(fetcher).toHaveBeenCalledOnce();
+ });
+});
+
+function createWrapper(fetcher: () => unknown) {
+ return function Wrapper({ children }: PropsWithChildren) {
+ return (
+ new Map(),
+ shouldRetryOnError: false,
+ }}
+ >
+ {children}
+
+ );
+ };
+}
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index 83ff2e5718..79fff5bc4d 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -1,17 +1,48 @@
-import { useMemo } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import useSWR from "swr";
import type { LabelCountsResponse } from "@/app/api/labels/counts/route";
+import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox";
+import { GmailLabel } from "@/utils/gmail/label";
/**
* Unread/total counts per label, Gmail category, or Outlook folder.
* Deliberately not blocking: the sidebar renders without counts and fills in.
*/
-export function useLabelCounts() {
+export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
const { data, error, isLoading, mutate } = useSWR(
"/api/labels/counts",
{ shouldRetryOnError: false },
);
+ useEffect(
+ () =>
+ subscribeToMailboxStore((changedAccountId) => {
+ if (changedAccountId === emailAccountId) mutate();
+ }),
+ [emailAccountId, mutate],
+ );
+
+ const adjustInboxUnread = useCallback(
+ (delta: number) => {
+ if (!delta) return;
+ mutate(
+ (current) => {
+ if (!current) return current;
+ return {
+ ...current,
+ counts: current.counts.map((count) =>
+ count.id === GmailLabel.INBOX
+ ? { ...count, unread: Math.max(0, count.unread + delta) }
+ : count,
+ ),
+ };
+ },
+ { revalidate: false },
+ );
+ },
+ [mutate],
+ );
+
const countsById = useMemo(
() => new Map((data?.counts ?? []).map((count) => [count.id, count])),
[data?.counts],
@@ -23,5 +54,6 @@ export function useLabelCounts() {
isLoading,
error,
mutate,
+ adjustInboxUnread,
};
}
From 9a10a4edf2286476ade0dcc593ebd534117335d4 Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:16:01 +0000
Subject: [PATCH 2/8] fix: retain folder IDs in thread lists
---
apps/web/utils/email-cache/mailbox.ts | 1 +
apps/web/utils/threads/load.ts | 1 +
2 files changed, 2 insertions(+)
diff --git a/apps/web/utils/email-cache/mailbox.ts b/apps/web/utils/email-cache/mailbox.ts
index 971ac6001e..76267e7bfd 100644
--- a/apps/web/utils/email-cache/mailbox.ts
+++ b/apps/web/utils/email-cache/mailbox.ts
@@ -577,6 +577,7 @@ function toListMessage(message: ParsedMessage) {
date: message.date,
internalDate: message.internalDate,
labelIds: message.labelIds,
+ parentFolderId: message.parentFolderId,
headers: message.headers,
};
}
diff --git a/apps/web/utils/threads/load.ts b/apps/web/utils/threads/load.ts
index 3a91392063..3e6c85270a 100644
--- a/apps/web/utils/threads/load.ts
+++ b/apps/web/utils/threads/load.ts
@@ -116,6 +116,7 @@ export function toListThreads({ threads, nextPageToken }: LoadedThreads) {
date: message.date,
internalDate: message.internalDate,
labelIds: message.labelIds,
+ parentFolderId: message.parentFolderId,
headers: message.headers,
})),
})),
From 9897eef53bc67f3e85fd6329b7ff644de41926b6 Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:22:25 +0000
Subject: [PATCH 3/8] fix: stabilize unread count updates
---
.../(app)/[emailAccountId]/mail/MailShell.tsx | 3 +-
apps/web/hooks/useLabelCounts.test.tsx | 33 ++++++++++---------
apps/web/utils/email-cache/mailbox.ts | 4 ++-
3 files changed, 22 insertions(+), 18 deletions(-)
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
index eaf571fa81..d1d224d260 100644
--- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
@@ -336,6 +336,7 @@ export function MailShell() {
threadsRef.current = threads;
const setReadState = useCallback(
async (threadKeys: string[], read: boolean, notifySuccess = true) => {
+ const threadsBeforeQueue = threadsRef.current;
const queuedKeys = await queueReadState(threadKeys, read, notifySuccess);
if (!isAllAccounts) {
adjustInboxUnread(
@@ -343,7 +344,7 @@ export function MailShell() {
inboxFolderId,
read,
threadKeys: queuedKeys,
- threads: threadsRef.current,
+ threads: threadsBeforeQueue,
}),
);
}
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 13666ff8c2..565fc95ce3 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { SWRConfig } from "swr";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useLabelCounts } from "./useLabelCounts";
const mailbox = vi.hoisted(() => {
const listeners = new Set<(emailAccountId: string) => void>();
@@ -16,7 +17,9 @@ const mailbox = vi.hoisted(() => {
},
subscribe: vi.fn((listener: (emailAccountId: string) => void) => {
listeners.add(listener);
- return () => listeners.delete(listener);
+ return () => {
+ listeners.delete(listener);
+ };
}),
};
});
@@ -25,8 +28,6 @@ vi.mock("@/utils/email-cache/mailbox", () => ({
subscribeToMailboxStore: mailbox.subscribe,
}));
-import { useLabelCounts } from "./useLabelCounts";
-
const initialResponse = {
counts: [
{
@@ -84,17 +85,17 @@ describe("useLabelCounts", () => {
});
function createWrapper(fetcher: () => unknown) {
- return function Wrapper({ children }: PropsWithChildren) {
- return (
- new Map(),
- shouldRetryOnError: false,
- }}
- >
- {children}
-
- );
- };
+ const Wrapper = ({ children }: PropsWithChildren) => (
+ new Map(),
+ shouldRetryOnError: false,
+ }}
+ >
+ {children}
+
+ );
+
+ return Wrapper;
}
diff --git a/apps/web/utils/email-cache/mailbox.ts b/apps/web/utils/email-cache/mailbox.ts
index 76267e7bfd..bfa1a9994a 100644
--- a/apps/web/utils/email-cache/mailbox.ts
+++ b/apps/web/utils/email-cache/mailbox.ts
@@ -433,7 +433,9 @@ export function subscribeToMailboxStore(
listener: (emailAccountId: string) => void,
) {
mailboxListeners.add(listener);
- return () => mailboxListeners.delete(listener);
+ return () => {
+ mailboxListeners.delete(listener);
+ };
}
export function notifyMailboxStoreChange(emailAccountId: string) {
From 19f405e100d634cb0491a70ad357617fb1468dfa Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:27:50 +0000
Subject: [PATCH 4/8] fix: handle unread count edge cases
---
.../(app)/[emailAccountId]/mail/MailShell.tsx | 9 +++-
.../mail/inbox-unread-count.test.ts | 32 ++++++------
.../mail/inbox-unread-count.ts | 20 +++++--
apps/web/hooks/useLabelCounts.test.tsx | 24 +++++++++
apps/web/hooks/useLabelCounts.ts | 52 +++++++++++++------
5 files changed, 101 insertions(+), 36 deletions(-)
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
index d1d224d260..88505c9fae 100644
--- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
@@ -341,6 +341,7 @@ export function MailShell() {
if (!isAllAccounts) {
adjustInboxUnread(
getInboxUnreadDelta({
+ countByMessage: isOutlook,
inboxFolderId,
read,
threadKeys: queuedKeys,
@@ -350,7 +351,13 @@ export function MailShell() {
}
return queuedKeys;
},
- [adjustInboxUnread, inboxFolderId, isAllAccounts, queueReadState],
+ [
+ adjustInboxUnread,
+ inboxFolderId,
+ isAllAccounts,
+ isOutlook,
+ queueReadState,
+ ],
);
const markRead = useCallback(
(threadKeys: string[]) => setReadState(threadKeys, true, false),
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
index 46d592b1e8..bf0ebe05d4 100644
--- a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
+++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts
@@ -34,17 +34,18 @@ describe("getInboxUnreadDelta", () => {
).toBe(1);
});
- it("recognizes the Outlook inbox folder", () => {
- const thread = createThread("outlook", ["UNREAD"], "outlook-inbox");
+ it("counts each unread Outlook inbox message", () => {
+ const thread = createThread("outlook", ["UNREAD"], "outlook-inbox", 2);
expect(
getInboxUnreadDelta({
+ countByMessage: true,
inboxFolderId: "outlook-inbox",
read: true,
threadKeys: [thread.id],
threads: [thread],
}),
- ).toBe(-1);
+ ).toBe(-2);
});
});
@@ -52,24 +53,23 @@ function createThread(
id: string,
labelIds: string[],
parentFolderId?: string,
+ messageCount = 1,
): ListThread {
return {
id,
snippet: "snippet",
plan: undefined,
plans: [],
- messages: [
- {
- id: `message-${id}`,
- threadId: id,
- snippet: "snippet",
- subject: "Subject",
- date: "0",
- internalDate: "0",
- labelIds,
- parentFolderId,
- headers: { subject: "Subject" },
- },
- ],
+ messages: Array.from({ length: messageCount }, (_, index) => ({
+ id: `message-${id}-${index}`,
+ threadId: id,
+ snippet: "snippet",
+ subject: "Subject",
+ date: "0",
+ internalDate: "0",
+ labelIds,
+ parentFolderId,
+ headers: { subject: "Subject" },
+ })),
};
}
diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
index 570f3ca59a..37bb2bec6e 100644
--- a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
+++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
@@ -3,11 +3,13 @@ import { isThreadUnread } from "./read-state";
import { getListThreadKey, type ListThread } from "./types";
export function getInboxUnreadDelta({
+ countByMessage,
inboxFolderId,
read,
threadKeys,
threads,
}: {
+ countByMessage?: boolean;
inboxFolderId?: string;
read: boolean;
threadKeys: string[];
@@ -18,10 +20,20 @@ export function getInboxUnreadDelta({
for (const thread of threads) {
if (!targets.has(getListThreadKey(thread))) continue;
- const isInInbox = thread.messages.some(
- (message) =>
- message.labelIds?.includes(GmailLabel.INBOX) ||
- (inboxFolderId && message.parentFolderId === inboxFolderId),
+ if (countByMessage) {
+ const affectedMessages = thread.messages.filter((message) => {
+ const isInInbox =
+ message.labelIds?.includes(GmailLabel.INBOX) ||
+ (inboxFolderId && message.parentFolderId === inboxFolderId);
+ const isUnread = message.labelIds?.includes(GmailLabel.UNREAD) ?? false;
+ return isInInbox && isUnread !== !read;
+ });
+ delta += affectedMessages.length * (read ? -1 : 1);
+ continue;
+ }
+
+ const isInInbox = thread.messages.some((message) =>
+ message.labelIds?.includes(GmailLabel.INBOX),
);
if (!isInInbox) continue;
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 565fc95ce3..0acfcfc453 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -82,6 +82,30 @@ describe("useLabelCounts", () => {
);
expect(fetcher).toHaveBeenCalledOnce();
});
+
+ it("applies an unread delta queued before counts load", async () => {
+ let resolveResponse:
+ | ((response: typeof initialResponse) => void)
+ | undefined;
+ const fetcher = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveResponse = resolve;
+ }),
+ );
+ const { result } = renderHook(
+ () => useLabelCounts({ emailAccountId: "account-1" }),
+ { wrapper: createWrapper(fetcher) },
+ );
+
+ await waitFor(() => expect(resolveResponse).toBeTypeOf("function"));
+ act(() => result.current.adjustInboxUnread(-1));
+ await act(async () => resolveResponse?.(initialResponse));
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(3),
+ );
+ });
});
function createWrapper(fetcher: () => unknown) {
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index 79fff5bc4d..9b72dac9ba 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
import type { LabelCountsResponse } from "@/app/api/labels/counts/route";
import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox";
@@ -13,6 +13,9 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
"/api/labels/counts",
{ shouldRetryOnError: false },
);
+ const dataRef = useRef(data);
+ const pendingInboxUnreadDelta = useRef(0);
+ dataRef.current = data;
useEffect(
() =>
@@ -22,23 +25,27 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
[emailAccountId, mutate],
);
+ useEffect(() => {
+ if (!data || !pendingInboxUnreadDelta.current) return;
+ const delta = pendingInboxUnreadDelta.current;
+ pendingInboxUnreadDelta.current = 0;
+ mutate((current) => applyInboxUnreadDelta(current, delta), {
+ revalidate: false,
+ });
+ }, [data, mutate]);
+
const adjustInboxUnread = useCallback(
(delta: number) => {
if (!delta) return;
- mutate(
- (current) => {
- if (!current) return current;
- return {
- ...current,
- counts: current.counts.map((count) =>
- count.id === GmailLabel.INBOX
- ? { ...count, unread: Math.max(0, count.unread + delta) }
- : count,
- ),
- };
- },
- { revalidate: false },
- );
+ if (!dataRef.current) {
+ pendingInboxUnreadDelta.current += delta;
+ return;
+ }
+ const totalDelta = pendingInboxUnreadDelta.current + delta;
+ pendingInboxUnreadDelta.current = 0;
+ mutate((current) => applyInboxUnreadDelta(current, totalDelta), {
+ revalidate: false,
+ });
},
[mutate],
);
@@ -57,3 +64,18 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
adjustInboxUnread,
};
}
+
+function applyInboxUnreadDelta(
+ current: LabelCountsResponse | undefined,
+ delta: number,
+) {
+ if (!current) return current;
+ return {
+ ...current,
+ counts: current.counts.map((count) =>
+ count.id === GmailLabel.INBOX
+ ? { ...count, unread: Math.max(0, count.unread + delta) }
+ : count,
+ ),
+ };
+}
From 6899b4688dfd6563ff457d399e73ff05285c2029 Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:39:15 +0000
Subject: [PATCH 5/8] fix: retain pending unread deltas
---
apps/web/hooks/useLabelCounts.test.tsx | 19 ++++++++++++++
apps/web/hooks/useLabelCounts.ts | 35 ++++++++++++++++----------
2 files changed, 41 insertions(+), 13 deletions(-)
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 0acfcfc453..6cc7344827 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -106,6 +106,25 @@ describe("useLabelCounts", () => {
expect(result.current.countsById.get("INBOX")?.unread).toBe(3),
);
});
+
+ it("retains an unread delta when a partial response omits the inbox", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce({ counts: [], partial: true })
+ .mockResolvedValueOnce(initialResponse);
+ const { result } = renderHook(
+ () => useLabelCounts({ emailAccountId: "account-1" }),
+ { wrapper: createWrapper(fetcher) },
+ );
+
+ await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
+ act(() => result.current.adjustInboxUnread(-1));
+ act(() => mailbox.emit("account-1"));
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(3),
+ );
+ });
});
function createWrapper(fetcher: () => unknown) {
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index 9b72dac9ba..8346335316 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -25,29 +25,38 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
[emailAccountId, mutate],
);
+ const applyPendingInboxUnreadDelta = useCallback(() => {
+ mutate(
+ (current) => {
+ if (
+ !pendingInboxUnreadDelta.current ||
+ !current?.counts.some((count) => count.id === GmailLabel.INBOX)
+ ) {
+ return current;
+ }
+ const delta = pendingInboxUnreadDelta.current;
+ pendingInboxUnreadDelta.current = 0;
+ return applyInboxUnreadDelta(current, delta);
+ },
+ { revalidate: false },
+ );
+ }, [mutate]);
+
useEffect(() => {
if (!data || !pendingInboxUnreadDelta.current) return;
- const delta = pendingInboxUnreadDelta.current;
- pendingInboxUnreadDelta.current = 0;
- mutate((current) => applyInboxUnreadDelta(current, delta), {
- revalidate: false,
- });
- }, [data, mutate]);
+ applyPendingInboxUnreadDelta();
+ }, [applyPendingInboxUnreadDelta, data]);
const adjustInboxUnread = useCallback(
(delta: number) => {
if (!delta) return;
+ pendingInboxUnreadDelta.current += delta;
if (!dataRef.current) {
- pendingInboxUnreadDelta.current += delta;
return;
}
- const totalDelta = pendingInboxUnreadDelta.current + delta;
- pendingInboxUnreadDelta.current = 0;
- mutate((current) => applyInboxUnreadDelta(current, totalDelta), {
- revalidate: false,
- });
+ applyPendingInboxUnreadDelta();
},
- [mutate],
+ [applyPendingInboxUnreadDelta],
);
const countsById = useMemo(
From c4ee7f6ed37a81c21362d74036c5a03eac2106ca Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:46:26 +0000
Subject: [PATCH 6/8] fix: scope unread deltas by account
---
apps/web/hooks/useLabelCounts.test.tsx | 23 +++++++++++++++++++++++
apps/web/hooks/useLabelCounts.ts | 15 +++++++++------
2 files changed, 32 insertions(+), 6 deletions(-)
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 6cc7344827..03d34b4849 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -125,6 +125,29 @@ describe("useLabelCounts", () => {
expect(result.current.countsById.get("INBOX")?.unread).toBe(3),
);
});
+
+ it("discards a pending unread delta when the account changes", async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce({ counts: [], partial: true })
+ .mockResolvedValueOnce(initialResponse);
+ const { result, rerender } = renderHook(
+ ({ emailAccountId }) => useLabelCounts({ emailAccountId }),
+ {
+ initialProps: { emailAccountId: "account-1" },
+ wrapper: createWrapper(fetcher),
+ },
+ );
+
+ await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
+ act(() => result.current.adjustInboxUnread(-1));
+ rerender({ emailAccountId: "account-2" });
+ act(() => mailbox.emit("account-2"));
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
+ );
+ });
});
function createWrapper(fetcher: () => unknown) {
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index 8346335316..a5bac4e75b 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -14,7 +14,10 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
{ shouldRetryOnError: false },
);
const dataRef = useRef(data);
- const pendingInboxUnreadDelta = useRef(0);
+ const pendingInboxUnread = useRef({ emailAccountId, delta: 0 });
+ if (pendingInboxUnread.current.emailAccountId !== emailAccountId) {
+ pendingInboxUnread.current = { emailAccountId, delta: 0 };
+ }
dataRef.current = data;
useEffect(
@@ -29,13 +32,13 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
mutate(
(current) => {
if (
- !pendingInboxUnreadDelta.current ||
+ !pendingInboxUnread.current.delta ||
!current?.counts.some((count) => count.id === GmailLabel.INBOX)
) {
return current;
}
- const delta = pendingInboxUnreadDelta.current;
- pendingInboxUnreadDelta.current = 0;
+ const delta = pendingInboxUnread.current.delta;
+ pendingInboxUnread.current.delta = 0;
return applyInboxUnreadDelta(current, delta);
},
{ revalidate: false },
@@ -43,14 +46,14 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
}, [mutate]);
useEffect(() => {
- if (!data || !pendingInboxUnreadDelta.current) return;
+ if (!data || !pendingInboxUnread.current.delta) return;
applyPendingInboxUnreadDelta();
}, [applyPendingInboxUnreadDelta, data]);
const adjustInboxUnread = useCallback(
(delta: number) => {
if (!delta) return;
- pendingInboxUnreadDelta.current += delta;
+ pendingInboxUnread.current.delta += delta;
if (!dataRef.current) {
return;
}
From 62980eb4d2737d4f1d7c9793a1bc25ccf93fbaf4 Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:54:48 +0000
Subject: [PATCH 7/8] fix: ignore stale account count updates
---
apps/web/hooks/useLabelCounts.test.tsx | 20 ++++++++++++++++++++
apps/web/hooks/useLabelCounts.ts | 12 +++++++++---
2 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 03d34b4849..429b04cdf3 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -148,6 +148,26 @@ describe("useLabelCounts", () => {
expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
);
});
+
+ it("ignores an unread update resumed from the previous account", async () => {
+ const fetcher = vi.fn().mockResolvedValue(initialResponse);
+ const { result, rerender } = renderHook(
+ ({ emailAccountId }) => useLabelCounts({ emailAccountId }),
+ {
+ initialProps: { emailAccountId: "account-1" },
+ wrapper: createWrapper(fetcher),
+ },
+ );
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
+ );
+ const previousAccountAdjustInboxUnread = result.current.adjustInboxUnread;
+ rerender({ emailAccountId: "account-2" });
+ act(() => previousAccountAdjustInboxUnread(-1));
+
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(4);
+ });
});
function createWrapper(fetcher: () => unknown) {
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index a5bac4e75b..4ad70fbabe 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -32,6 +32,7 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
mutate(
(current) => {
if (
+ pendingInboxUnread.current.emailAccountId !== emailAccountId ||
!pendingInboxUnread.current.delta ||
!current?.counts.some((count) => count.id === GmailLabel.INBOX)
) {
@@ -43,7 +44,7 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
},
{ revalidate: false },
);
- }, [mutate]);
+ }, [emailAccountId, mutate]);
useEffect(() => {
if (!data || !pendingInboxUnread.current.delta) return;
@@ -52,14 +53,19 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
const adjustInboxUnread = useCallback(
(delta: number) => {
- if (!delta) return;
+ if (
+ !delta ||
+ pendingInboxUnread.current.emailAccountId !== emailAccountId
+ ) {
+ return;
+ }
pendingInboxUnread.current.delta += delta;
if (!dataRef.current) {
return;
}
applyPendingInboxUnreadDelta();
},
- [applyPendingInboxUnreadDelta],
+ [applyPendingInboxUnreadDelta, emailAccountId],
);
const countsById = useMemo(
From 92fabcd914f301c6bb17ad52a220c4972a58957e Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Wed, 2 Sep 2026 00:04:40 +0000
Subject: [PATCH 8/8] fix: reset unread state after commit
---
apps/web/hooks/useLabelCounts.test.tsx | 10 +++++++++-
apps/web/hooks/useLabelCounts.ts | 15 +++++++++++----
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx
index 429b04cdf3..e6088315ea 100644
--- a/apps/web/hooks/useLabelCounts.test.tsx
+++ b/apps/web/hooks/useLabelCounts.test.tsx
@@ -111,7 +111,7 @@ describe("useLabelCounts", () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce({ counts: [], partial: true })
- .mockResolvedValueOnce(initialResponse);
+ .mockResolvedValue(initialResponse);
const { result } = renderHook(
() => useLabelCounts({ emailAccountId: "account-1" }),
{ wrapper: createWrapper(fetcher) },
@@ -147,6 +147,14 @@ describe("useLabelCounts", () => {
await waitFor(() =>
expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
);
+
+ rerender({ emailAccountId: "account-1" });
+ act(() => mailbox.emit("account-1"));
+
+ await waitFor(() =>
+ expect(result.current.countsById.get("INBOX")?.unread).toBe(4),
+ );
+ expect(fetcher).toHaveBeenCalledTimes(3);
});
it("ignores an unread update resumed from the previous account", async () => {
diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts
index 4ad70fbabe..f1aee893f8 100644
--- a/apps/web/hooks/useLabelCounts.ts
+++ b/apps/web/hooks/useLabelCounts.ts
@@ -1,4 +1,10 @@
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from "react";
import useSWR from "swr";
import type { LabelCountsResponse } from "@/app/api/labels/counts/route";
import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox";
@@ -15,11 +21,12 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) {
);
const dataRef = useRef(data);
const pendingInboxUnread = useRef({ emailAccountId, delta: 0 });
- if (pendingInboxUnread.current.emailAccountId !== emailAccountId) {
- pendingInboxUnread.current = { emailAccountId, delta: 0 };
- }
dataRef.current = data;
+ useLayoutEffect(() => {
+ pendingInboxUnread.current = { emailAccountId, delta: 0 };
+ }, [emailAccountId]);
+
useEffect(
() =>
subscribeToMailboxStore((changedAccountId) => {