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,