From 8034b005b830982995aa0e9bf3fda583855dc8cf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:53:35 +0100 Subject: [PATCH] fix: keep archived messages in the Archive and in search results after a plugin's batch, and update folder counts --- .../email-store-batch-target.test.ts | 16 ++++++++++++ stores/email-store.ts | 25 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/stores/__tests__/email-store-batch-target.test.ts b/stores/__tests__/email-store-batch-target.test.ts index b6c99f5ac..b2ad83e0f 100644 --- a/stores/__tests__/email-store-batch-target.test.ts +++ b/stores/__tests__/email-store-batch-target.test.ts @@ -32,7 +32,10 @@ describe('batch functions with an explicit target', () => { // The selection names another message: a targeted call must leave it alone. selectedEmailKeys: new Set(['keep']), isUnifiedView: true, + selectedMailbox: 'inbox', + searchQuery: '', fetchTagCounts: vi.fn(), + fetchMailboxes: vi.fn(async () => {}), } as never); }); @@ -48,6 +51,19 @@ describe('batch functions with an explicit target', () => { expect([...useEmailStore.getState().selectedEmailKeys]).toEqual(['keep']); }); + it('keeps the rows where they are still listed, and refreshes the folder counts', async () => { + const target = { emailIds: ['m1'], client, emails: [email('m1')] }; + // The Archive is the open folder: the archived message belongs there. + useEmailStore.setState({ selectedMailbox: 'arch' } as never); + await useEmailStore.getState().batchArchive(other, target); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['m1', 'keep']); + // A search still finds it. + useEmailStore.setState({ selectedMailbox: 'inbox', searchQuery: 'invoice' } as never); + await useEmailStore.getState().batchArchive(other, target); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['m1', 'keep']); + expect(useEmailStore.getState().fetchMailboxes).toHaveBeenCalledTimes(2); + }); + it('refuses an Archive someone else reads, asking the server for its share map', async () => { Object.assign(client, { supportsMailboxSharing: () => true, diff --git a/stores/email-store.ts b/stores/email-store.ts index 519b764aa..97d75caca 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1415,11 +1415,28 @@ async function archiveTarget(client: IJMAPClient, target: BatchActionTarget): Pr ); emitEmailMove(move, hookMailboxes); const moved = new Set(move.emailIds); - useEmailStore.setState(state => ({ - emails: state.emails.filter(e => !moved.has(e.id)), - selectedEmailKeys: dropFromSelection(state, e => moved.has(e.id)), - })); + // The rows leave the open list only where they no longer belong: not the + // Archive itself (or a year or month folder in it), not a search, which + // still finds them. Elsewhere the refresh below settles the rest. + const state = useEmailStore.getState(); + const archiveTree = new Set([archive.id]); + for (let grew = true; grew;) { + grew = false; + for (const m of [...state.mailboxes, ...mailboxes]) { + if (m.parentId && archiveTree.has(m.parentId) && !archiveTree.has(m.id)) { archiveTree.add(m.id); grew = true; } + } + } + const searching = !!state.searchQuery || !isFilterEmpty(state.searchFilters); + const stillListed = searching || (!!state.selectedMailbox && archiveTree.has(state.selectedMailbox)); + if (!stillListed) { + useEmailStore.setState(current => ({ + emails: current.emails.filter(e => !moved.has(e.id)), + selectedEmailKeys: dropFromSelection(current, e => moved.has(e.id)), + })); + } if (!useEmailStore.getState().isUnifiedView) await useEmailStore.getState().refreshCurrentMailbox(client); + // The folders' message and unread counts changed. + await useEmailStore.getState().fetchMailboxes(client); return true; }