diff --git a/stores/__tests__/email-store-unified-actions.test.ts b/stores/__tests__/email-store-unified-actions.test.ts index 5256ee0e..de633ed3 100644 --- a/stores/__tests__/email-store-unified-actions.test.ts +++ b/stores/__tests__/email-store-unified-actions.test.ts @@ -68,6 +68,7 @@ describe('unified-view single-email action routing (#281)', () => { // account-a is the active login; account-b is a second direct login; the // active login (account-a) also delegates access to the shared owner 'owner-x'. useAuthStore.setState({ + activeAccountId: 'account-a', getClientForAccount: (id: string) => (id === 'account-b' ? accountBClient : id === 'account-a' ? activeClient : undefined) as never, } as never); @@ -124,6 +125,27 @@ describe('unified-view single-email action routing (#281)', () => { expect(activeClient.moveEmail).not.toHaveBeenCalled(); }); + it('updates the unread counter on the email’s own account, not the active one (id collision)', async () => { + // Real per-account JMAP ids can collide; here both inboxes use the same id. + 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: 'b1', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { inbox: true } }), + ], + }); + + await useEmailStore.getState().markAsRead(activeClient, 'b1', true); + + const s = useEmailStore.getState(); + expect(s.accountMailboxes['account-b'][0].unreadEmails).toBe(2); // account-b decremented + expect(s.mailboxes[0].unreadEmails).toBe(5); // active account untouched + expect(s.accountMailboxes['account-a'][0].unreadEmails).toBe(5); // active list untouched + }); + 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. diff --git a/stores/email-store.ts b/stores/email-store.ts index 6e4a764a..b50a3556 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -522,6 +522,34 @@ function emailInMailbox( return false; } +// Apply a per-mailbox counter adjustment to the mailbox list that actually holds +// the email's folders, and return the matching `set()` partial. +// +// In aggregate views an email may belong to another logged-in account. The +// sidebar shows the active account's folders from `mailboxes` (its +// getAllMailboxes, incl. its delegated shared folders) and every *other* +// account's folders from `accountMailboxes[]`. Updating the +// wrong list silently corrupts counters - and because JMAP mailbox ids can +// collide across accounts, blindly matching `mailboxes` would even decrement the +// *active* account's folder for a different account's email. So route by the +// email's source login: a different account → its `accountMailboxes` entry, +// otherwise (active account, its shared folders, or a non-aggregate view) → +// `mailboxes`. (#281) +function applyMailboxCounterUpdate( + state: { mailboxes: Mailbox[]; accountMailboxes: Record }, + email: { sourceClientAccountId?: string }, + adjust: (mb: Mailbox) => Mailbox, +): { mailboxes?: Mailbox[]; accountMailboxes?: Record } { + const srcClient = email.sourceClientAccountId; + const activeId = useAuthStore.getState().activeAccountId; + if (srcClient && srcClient !== activeId) { + const list = state.accountMailboxes[srcClient]; + if (!list) return {}; + return { accountMailboxes: { ...state.accountMailboxes, [srcClient]: list.map(adjust) } }; + } + return { mailboxes: state.mailboxes.map(adjust) }; +} + // Find the trash mailbox for a given account scope. Prefers JMAP role, but // falls back to name matching ("trash" / "deleted") so users with custom or // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. @@ -1327,21 +1355,18 @@ export const useEmailStore = create((set, get) => ({ return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update } - const updatedMailboxes = state.mailboxes.map(mailbox => { - // Check if this email belongs to this mailbox. Shared mailboxes are - // stored under a namespaced id but the email's mailboxIds are keyed by - // the owner-side JMAP id (originalId), so match on originalId first. - if (emailInMailbox(emailInState, mailbox)) { - // Adjust unread counter: -1 if marking as read, +1 if marking as unread - const delta = read ? -1 : 1; - return { - ...mailbox, - unreadEmails: Math.max(0, mailbox.unreadEmails + delta), - unreadThreads: Math.max(0, mailbox.unreadThreads + delta) - }; - } - return mailbox; - }); + // Adjust the unread counter on the folder(s) holding this email, in the + // email's *own* account's mailbox list (#281). -1 marking read, +1 unread. + const delta = read ? -1 : 1; + const mailboxPatch = applyMailboxCounterUpdate(state, emailInState, (mailbox) => + emailInMailbox(emailInState, mailbox) + ? { + ...mailbox, + unreadEmails: Math.max(0, mailbox.unreadEmails + delta), + unreadThreads: Math.max(0, mailbox.unreadThreads + delta), + } + : mailbox, + ); return { emails: state.emails.map(e => @@ -1350,7 +1375,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmail: state.selectedEmail?.id === emailId ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } } : state.selectedEmail, - mailboxes: updatedMailboxes, + ...mailboxPatch, processingReadStatus: newProcessingSet, // Also update threadEmailsCache so expanded dropdowns reflect the change threadEmailsCache: (() => {