fix: route all counter updates to the email's own account in aggregate views

Extend the counter-routing fix beyond markAsRead to every optimistic mailbox
counter update, so a different account's email never adjusts the active
account's folder counters (JMAP ids can collide across accounts).

- Add applyBatchMailboxCounterUpdate() + applyDeleteCounters() and apply the
  per-account routing to: deleteEmail (trash + permanent), moveToMailbox,
  moveEmailsToMailbox, batchMarkAsRead, batchDelete, and markThreadAsRead.
- markAsSpam/batchMarkAsSpam/batchMoveToMailbox don't touch counters (rely on
  refresh) and folder-level ops (rename/empty/markMailboxAsRead) are already
  account-scoped — left as-is.
- Test: batchMarkAsRead adjusts each account's counter in its own list.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:23 +02:00
parent befee332d2
commit fa3c57467b
2 changed files with 175 additions and 154 deletions
@@ -53,6 +53,7 @@ function makeClient() {
markAsRead: vi.fn().mockResolvedValue(undefined),
toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient;
}
@@ -146,6 +147,27 @@ describe('unified-view single-email action routing (#281)', () => {
expect(s.accountMailboxes['account-a'][0].unreadEmails).toBe(5); // active list untouched
});
it('batchMarkAsRead adjusts each emails own account counter (cross-account)', async () => {
useEmailStore.setState({
mailboxes: [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
accountMailboxes: {
'account-a': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 5 })],
'account-b': [makeMailbox({ id: 'inbox', role: 'inbox', unreadEmails: 3 })],
},
emails: [
makeEmail({ id: 'a1', sourceClientAccountId: 'account-a', sourceAccountId: 'account-a', keywords: {}, mailboxIds: { inbox: true } }),
makeEmail({ id: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { inbox: true } }),
],
selectedEmailIds: new Set(['a1', 'b1']),
});
await useEmailStore.getState().batchMarkAsRead(activeClient, true);
const s = useEmailStore.getState();
expect(s.mailboxes[0].unreadEmails).toBe(4); // active account: -1
expect(s.accountMailboxes['account-b'][0].unreadEmails).toBe(2); // account-b: -1
});
it('routes a shared/group email through the delegating login client + owner accountId', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true);
// Reached via account-a's client (the active one), targeting the owner account.