fix(unified-mailbox): update shared/group folder counters on delete/move/read

In the unified All mail / Unread / Starred views, deleting (or moving /
marking read) a message from a shared/group folder left that folder's
sidebar counter at its old value.

Root cause: lib/unified-mailbox.ts decorates shared emails WITHOUT
namespacing their `mailboxIds`, so they carry the owner's bare JMAP ids,
while the shared mailbox is stored with a namespaced id (`${ownerId}:${origId}`)
and `isShared: true`. `emailInMailbox` only matched the namespaced `mailbox.id`
and disabled the `originalId` fallback for shared mailboxes, so no shared
email ever matched its folder and the counter math skipped it.

Match shared mailboxes via `originalId` too, scoped to the owning account
(`sourceAccountId === mailbox.accountId`) so a bare owner id can't collide
with another account's folder. This is the single matching helper used by all
counter paths (delete/move/markRead/spam), so they're all fixed at once.

Adds a regression test covering deletion of a shared-folder email in the
unified view.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:14:44 +02:00
parent dc72122ed8
commit fdad60cf03
2 changed files with 55 additions and 6 deletions
@@ -54,6 +54,8 @@ function makeClient() {
toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient;
}
@@ -180,6 +182,40 @@ describe('unified-view single-email action routing (#281)', () => {
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
});
it('decrements a shared/group folder counter when deleting from the unified view', async () => {
// Real app: the active account's `mailboxes` includes its delegated shared
// folders (namespaced id + originalId + owner accountId). Unified-fetched
// shared emails carry the owner's BARE mailboxIds and sourceAccountId=owner.
// Regression: emailInMailbox missed these, so the shared folder's badge
// stayed at its old value after deleting in All mail / All unread.
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
role: 'inbox', isShared: true, accountId: 'owner-x',
unreadEmails: 4, totalEmails: 10,
}),
],
emails: [
makeEmail({
id: 'email-shared', accountId: 'owner-x',
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
keywords: {}, // unread
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
});
await useEmailStore.getState().batchDelete(activeClient, true);
expect(activeClient.batchDeleteEmails).toHaveBeenCalledWith(['email-shared'], 'owner-x');
const shared = useEmailStore.getState().mailboxes.find(m => m.id === 'owner-x:x-inbox')!;
expect(shared.unreadEmails).toBe(3); // was 4
expect(shared.totalEmails).toBe(9); // was 10
});
it('still uses the active/passed client outside unified view', async () => {
useEmailStore.setState({
isUnifiedView: false,
+19 -6
View File
@@ -507,19 +507,32 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P
}
// Whether an email belongs to a given mailbox, for local counter math.
// Shared/group-account emails carry NAMESPACED mailboxIds (`${ownerId}:${origId}`,
// which equals the shared mailbox's `id`), while own-account emails carry bare ids
// (equal to both `id` and `originalId`). Matching `mailbox.id` covers both; the
// `originalId` fallback is restricted to non-shared mailboxes so a bare own-account
// id can't collide with another account's shared folder. (#281)
// Own-account emails carry bare ids (equal to both `id` and `originalId`).
// Shared/group emails fetched for the unified/cross views are decorated by
// lib/unified-mailbox.ts WITHOUT namespacing their `mailboxIds`, so they carry
// the owner's BARE JMAP ids while the shared mailbox is stored with a namespaced
// `id` (`${ownerId}:${origId}`), `isShared: true` and `originalId`/`accountId`
// (the owner). Matching only `mailbox.id` therefore missed every shared email,
// so a shared folder's counter never moved when mail was deleted/moved/read from
// the unified views. We match shared mailboxes via `originalId` too, but scope it
// to the owning account (`sourceAccountId === mailbox.accountId`) so a bare owner
// id can't collide with another account's folder. (#281, shared-counter fix)
function emailInMailbox(
email: { mailboxIds?: Record<string, boolean> },
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
mailbox: Mailbox,
): boolean {
const ids = email.mailboxIds;
if (!ids) return false;
if (ids[mailbox.id]) return true;
if (!mailbox.isShared && mailbox.originalId) return !!ids[mailbox.originalId];
if (
mailbox.isShared &&
mailbox.originalId &&
email.sourceAccountId &&
email.sourceAccountId === mailbox.accountId
) {
return !!ids[mailbox.originalId];
}
return false;
}