Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -136,7 +137,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();
Expand Down Expand Up @@ -420,12 +425,54 @@ export function MailShell() {
openThreadSelection,
readerSelectionSettled,
]);
const { archive, trash, markRead, markSpam, setReadState, snooze, undo } =
useThreadActions({
emailAccountId,
readerTarget,
threads,
});
const {
archive,
trash,
markSpam,
setReadState: queueReadState,
snooze,
undo,
} = useThreadActions({
emailAccountId,
readerTarget,
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 threadsBeforeQueue = threadsRef.current;
const queuedKeys = await queueReadState(threadKeys, read, notifySuccess);
if (!isAllAccounts) {
adjustInboxUnread(
getInboxUnreadDelta({
countByMessage: isOutlook,
inboxFolderId,
read,
threadKeys: queuedKeys,
threads: threadsBeforeQueue,
}),
);
}
return queuedKeys;
},
[
adjustInboxUnread,
inboxFolderId,
isAllAccounts,
isOutlook,
queueReadState,
],
);
const markRead = useCallback(
(threadKeys: string[]) => setReadState(threadKeys, true, false),
[setReadState],
);
const requestReaderReply = useCallback(() => {
const messageId = openMessages.at(-1)?.id;
if (messageId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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("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(-2);
});
});

function createThread(
id: string,
labelIds: string[],
parentFolderId?: string,
messageCount = 1,
): ListThread {
return {
id,
snippet: "snippet",
plan: undefined,
plans: [],
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" },
})),
};
}
46 changes: 46 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { GmailLabel } from "@/utils/gmail/label";
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[];
threads: ListThread[];
}) {
const targets = new Set(threadKeys);
let delta = 0;

for (const thread of threads) {
if (!targets.has(getListThreadKey(thread))) continue;
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;

const isUnread = isThreadUnread(thread.messages);
if (isUnread === !read) continue;
delta += read ? -1 : 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an Outlook conversation has multiple unread messages, this optimistic delta changes the Inbox badge by one instead of by each unread folder item. Make the delta provider-specific or count unread inbox messages so it matches Outlook's folder count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts, line 30:

<comment>When an Outlook conversation has multiple unread messages, this optimistic delta changes the Inbox badge by one instead of by each unread folder item. Make the delta provider-specific or count unread inbox messages so it matches Outlook's folder count.</comment>

<file context>
@@ -0,0 +1,34 @@
+
+    const isUnread = isThreadUnread(thread.messages);
+    if (isUnread === !read) continue;
+    delta += read ? -1 : 1;
+  }
+
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 19f405e: Outlook now adjusts the badge per affected inbox message, matching unreadItemCount, while Gmail continues to count unread conversations. The focused test covers multiple unread Outlook messages.

}

return delta;
}
20 changes: 17 additions & 3 deletions apps/web/app/api/labels/counts/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 () => {
Expand Down
46 changes: 45 additions & 1 deletion apps/web/app/api/labels/counts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`;
}
Expand Down
Loading
Loading