diff --git a/stores/__tests__/email-store-unified-actions.test.ts b/stores/__tests__/email-store-unified-actions.test.ts index de633ed3..b93e3ea9 100644 --- a/stores/__tests__/email-store-unified-actions.test.ts +++ b/stores/__tests__/email-store-unified-actions.test.ts @@ -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 email’s 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. diff --git a/stores/email-store.ts b/stores/email-store.ts index b50a3556..4f943d68 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -550,6 +550,60 @@ function applyMailboxCounterUpdate( return { mailboxes: state.mailboxes.map(adjust) }; } +// Batch variant of applyMailboxCounterUpdate: a set of emails may span accounts, +// so group them by the list that holds them and run `mapMailbox` per list using +// only that group's emails. Each account's counters are adjusted in its own list +// (active account → `mailboxes`, others → `accountMailboxes[sourceClientAccountId]`), +// avoiding cross-account id collisions. (#281) +function applyBatchMailboxCounterUpdate( + state: { mailboxes: Mailbox[]; accountMailboxes: Record }, + emails: Array<{ sourceClientAccountId?: string }>, + mapMailbox: (mb: Mailbox, emailsForList: Array<{ sourceClientAccountId?: string }>) => Mailbox, +): { mailboxes: Mailbox[]; accountMailboxes: Record } { + const activeId = useAuthStore.getState().activeAccountId; + const ACTIVE = '__active__'; + const groups = new Map>(); + for (const e of emails) { + const k = e.sourceClientAccountId && e.sourceClientAccountId !== activeId ? e.sourceClientAccountId : ACTIVE; + const g = groups.get(k); + if (g) g.push(e); else groups.set(k, [e]); + } + let mailboxes = state.mailboxes; + const accountMailboxes = { ...state.accountMailboxes }; + for (const [k, es] of groups) { + if (k === ACTIVE) { + mailboxes = mailboxes.map((mb) => mapMailbox(mb, es)); + } else if (accountMailboxes[k]) { + accountMailboxes[k] = accountMailboxes[k].map((mb) => mapMailbox(mb, es)); + } + } + return { mailboxes, accountMailboxes }; +} + +// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a +// group of emails from a folder: decrement total (and unread for unseen) for +// each group email that lives in the mailbox. +function applyDeleteCounters( + mailbox: Mailbox, + group: Array<{ sourceClientAccountId?: string }>, +): Mailbox { + let dTotal = 0; + let dUnread = 0; + for (const email of group as Email[]) { + if (emailInMailbox(email, mailbox)) { + dTotal--; + if (!email.keywords?.$seen) dUnread--; + } + } + return dTotal === 0 && dUnread === 0 ? mailbox : { + ...mailbox, + totalEmails: Math.max(0, mailbox.totalEmails + dTotal), + unreadEmails: Math.max(0, mailbox.unreadEmails + dUnread), + totalThreads: Math.max(0, mailbox.totalThreads + dTotal), + unreadThreads: Math.max(0, mailbox.unreadThreads + dUnread), + }; +} + // 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. @@ -1217,40 +1271,38 @@ export const useEmailStore = create((set, get) => ({ // After marking read in the same request, the email arrives in trash as read. const arrivesUnread = isUnread && !alsoMarkRead; - // Remove from local state (email moved to trash, not in current view) + // Remove from local state (email moved to trash, not in current view). + // Counter changes go to the email's own account list (#281). Source and + // trash live in the same account. set((state) => { - let updatedMailboxes = state.mailboxes; - - // Update counters for source mailbox (email leaving) - if (email.mailboxIds) { - updatedMailboxes = state.mailboxes.map(mailbox => { - if (emailInMailbox(email, mailbox)) { - return { - ...mailbox, - totalEmails: Math.max(0, mailbox.totalEmails - 1), - unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, - totalThreads: Math.max(0, mailbox.totalThreads - 1), - unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads - }; - } - // Update trash mailbox counters (email arriving) - if (mailbox.id === trashMailbox.id) { - return { - ...mailbox, - totalEmails: mailbox.totalEmails + 1, - unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, - totalThreads: mailbox.totalThreads + 1, - unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads - }; - } - return mailbox; - }); - } + const mailboxPatch = email.mailboxIds + ? applyMailboxCounterUpdate(state, email, (mailbox) => { + if (emailInMailbox(email, mailbox)) { + return { + ...mailbox, + totalEmails: Math.max(0, mailbox.totalEmails - 1), + unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, + totalThreads: Math.max(0, mailbox.totalThreads - 1), + unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads, + }; + } + if (mailbox.id === trashMailbox.id) { + return { + ...mailbox, + totalEmails: mailbox.totalEmails + 1, + unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, + totalThreads: mailbox.totalThreads + 1, + unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads, + }; + } + return mailbox; + }) + : {}; return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), - mailboxes: updatedMailboxes + ...mailboxPatch, }; }); return; @@ -1264,42 +1316,26 @@ export const useEmailStore = create((set, get) => ({ // Permanent delete await effectiveClient.deleteEmail(emailId); - // Remove from local state and update mailbox counters if needed + // Remove from local state and update mailbox counters (in the email's own + // account list). Unread emails also decrement the unread counters. (#281) set((state) => { - let updatedMailboxes = state.mailboxes; - - // If the email was unread, decrement the unread counters - if (isUnread && email.mailboxIds) { - updatedMailboxes = state.mailboxes.map(mailbox => { - if (emailInMailbox(email, mailbox)) { - return { - ...mailbox, - totalEmails: Math.max(0, mailbox.totalEmails - 1), - unreadEmails: Math.max(0, mailbox.unreadEmails - 1), - totalThreads: Math.max(0, mailbox.totalThreads - 1), - unreadThreads: Math.max(0, mailbox.unreadThreads - 1) - }; - } - return mailbox; - }); - } else if (email.mailboxIds) { - // If email was read, only decrement total counters - updatedMailboxes = state.mailboxes.map(mailbox => { - if (emailInMailbox(email, mailbox)) { - return { - ...mailbox, - totalEmails: Math.max(0, mailbox.totalEmails - 1), - totalThreads: Math.max(0, mailbox.totalThreads - 1) - }; - } - return mailbox; - }); - } + const mailboxPatch = email.mailboxIds + ? applyMailboxCounterUpdate(state, email, (mailbox) => + emailInMailbox(email, mailbox) + ? { + ...mailbox, + totalEmails: Math.max(0, mailbox.totalEmails - 1), + unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, + totalThreads: Math.max(0, mailbox.totalThreads - 1), + unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads, + } + : mailbox) + : {}; return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), - mailboxes: updatedMailboxes + ...mailboxPatch, }; }); } catch (error) { @@ -1415,7 +1451,6 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isUnread = !email.keywords?.$seen; - const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; // In unified view route to the email's own account (client + that // account's mailbox list, where the destination id lives); otherwise the @@ -1428,14 +1463,16 @@ export const useEmailStore = create((set, get) => ({ await actionClient.moveEmail(emailId, jmapDestId, accountId); set((state) => { - const updatedMailboxes = state.mailboxes.map(mailbox => { - if (currentMailboxIds.includes(mailbox.id) || (!mailbox.isShared && mailbox.originalId ? currentMailboxIds.includes(mailbox.originalId) : false)) { + // Counter changes go to the email's own account list; source and + // destination live in the same account. (#281) + const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => { + if (emailInMailbox(email, mailbox)) { return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, totalThreads: Math.max(0, mailbox.totalThreads - 1), - unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads + unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads, }; } if (mailbox.id === destinationMailboxId) { @@ -1444,7 +1481,7 @@ export const useEmailStore = create((set, get) => ({ totalEmails: mailbox.totalEmails + 1, unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, totalThreads: mailbox.totalThreads + 1, - unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads + unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads, }; } return mailbox; @@ -1453,7 +1490,7 @@ export const useEmailStore = create((set, get) => ({ return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), - mailboxes: updatedMailboxes + ...mailboxPatch, }; }); } catch (error) { @@ -1506,39 +1543,43 @@ export const useEmailStore = create((set, get) => ({ // Adjust counters and drop moved emails from the current view. let unreadDelta = 0; - const sourceMailboxIds = new Set(); for (const e of affected) { if (!e.keywords?.$seen) unreadDelta += 1; - if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid); } const movedCount = affected.length; - set((state) => ({ - emails: state.emails.filter(e => !idSet.has(e.id)), - selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail, - selectedEmailIds: (() => { - const next = new Set(state.selectedEmailIds); - for (const id of idSet) next.delete(id); - return next; - })(), - mailboxes: state.mailboxes.map(mb => { - if (sourceMailboxIds.has(mb.id) || (!mb.isShared && mb.originalId ? sourceMailboxIds.has(mb.originalId) : false)) { - return { - ...mb, - totalEmails: Math.max(0, mb.totalEmails - movedCount), - unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta), - }; + set((state) => { + // Decrement source folders per the email's own account list (#281). + const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => { + let dTotal = 0; + let dUnread = 0; + for (const e of group as Email[]) { + if (emailInMailbox(e, mb)) { + dTotal--; + if (!e.keywords?.$seen) dUnread--; + } } - if (mb.id === destinationMailboxId) { - return { - ...mb, - totalEmails: mb.totalEmails + movedCount, - unreadEmails: mb.unreadEmails + unreadDelta, - }; - } - return mb; - }), - })); + return dTotal === 0 && dUnread === 0 ? mb : { + ...mb, + totalEmails: Math.max(0, mb.totalEmails + dTotal), + unreadEmails: Math.max(0, mb.unreadEmails + dUnread), + }; + }); + // The destination folder (picked from the active/viewing list) gains them. + patch.mailboxes = patch.mailboxes.map(mb => mb.id === destinationMailboxId + ? { ...mb, totalEmails: mb.totalEmails + movedCount, unreadEmails: mb.unreadEmails + unreadDelta } + : mb); + return { + emails: state.emails.filter(e => !idSet.has(e.id)), + selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail, + selectedEmailIds: (() => { + const next = new Set(state.selectedEmailIds); + for (const id of idSet) next.delete(id); + return next; + })(), + ...patch, + }; + }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); throw error; @@ -1928,7 +1969,6 @@ export const useEmailStore = create((set, get) => ({ // Batch operations batchMarkAsRead: async (client, read) => { const { selectedEmailIds, emails } = get(); - const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1965,29 +2005,25 @@ export const useEmailStore = create((set, get) => ({ : email ); - // Update mailbox counters + // Update mailbox counters per the email's own account list (#281). const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id)); - const updatedMailboxes = mailboxes.map(mailbox => { + const mailboxPatch = applyBatchMailboxCounterUpdate(get(), affectedEmails, (mailbox, group) => { let deltaUnread = 0; - affectedEmails.forEach(email => { - if (emailInMailbox(email, mailbox)) { - const wasRead = email.keywords?.$seen; - if (wasRead !== read) { - deltaUnread += read ? -1 : 1; - } + for (const email of group as Email[]) { + if (emailInMailbox(email, mailbox) && (email.keywords?.$seen ?? false) !== read) { + deltaUnread += read ? -1 : 1; } - }); - - return { + } + return deltaUnread === 0 ? mailbox : { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread), - unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread) + unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread), }; }); set({ emails: updatedEmails, - mailboxes: updatedMailboxes, + ...mailboxPatch, selectedEmailIds: new Set(), isLoading: false }); @@ -2079,26 +2115,10 @@ export const useEmailStore = create((set, get) => ({ if (movedEmailIds.size < emailIdsArray.length) { const deletedEmails = emails.filter(e => movedEmailIds.has(e.id)); const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id)); - const updatedMailboxes = mailboxes.map(mailbox => { - let deltaTotalEmails = 0; - let deltaUnreadEmails = 0; - deletedEmails.forEach(email => { - if (emailInMailbox(email, mailbox)) { - deltaTotalEmails--; - if (!email.keywords?.$seen) deltaUnreadEmails--; - } - }); - return { - ...mailbox, - totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails), - unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails), - totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails), - unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails), - }; - }); + const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters); set({ emails: remainingEmails, - mailboxes: updatedMailboxes, + ...mailboxPatch, selectedEmailIds: new Set(), selectedEmail: null, isLoading: false, @@ -2111,33 +2131,13 @@ export const useEmailStore = create((set, get) => ({ // Remove deleted emails from local state const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); - // Update mailbox counters + // Update mailbox counters per the email's own account list (#281). const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id)); - const updatedMailboxes = mailboxes.map(mailbox => { - let deltaTotalEmails = 0; - let deltaUnreadEmails = 0; - - deletedEmails.forEach(email => { - if (emailInMailbox(email, mailbox)) { - deltaTotalEmails--; - if (!email.keywords?.$seen) { - deltaUnreadEmails--; - } - } - }); - - return { - ...mailbox, - totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails), - unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails), - totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails), - unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails) - }; - }); + const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters); set({ emails: remainingEmails, - mailboxes: updatedMailboxes, + ...mailboxPatch, selectedEmailIds: new Set(), selectedEmail: null, isLoading: false @@ -2795,15 +2795,14 @@ export const useEmailStore = create((set, get) => ({ )); } - // Update mailbox unread counters + // Update mailbox unread counters in the thread's own account list (#281). const affectedEmails = state.emails.filter(e => unreadSet.has(e.id)); - const updatedMailboxes = state.mailboxes.map(mailbox => { + const mailboxPatch = applyBatchMailboxCounterUpdate(state, affectedEmails, (mailbox, group) => { let delta = 0; - for (const email of affectedEmails) { + for (const email of group as Email[]) { if (emailInMailbox(email, mailbox)) delta -= 1; } - if (delta === 0) return mailbox; - return { + return delta === 0 ? mailbox : { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + delta), unreadThreads: Math.max(0, mailbox.unreadThreads + delta), @@ -2813,7 +2812,7 @@ export const useEmailStore = create((set, get) => ({ return { emails: updatedEmails, threadEmailsCache: newCache, - mailboxes: updatedMailboxes, + ...mailboxPatch, selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id) ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } } : state.selectedEmail,