From d5db3d4051f19adc56f96fac456569938cb23131 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:32 +0100 Subject: [PATCH] fix: drop ticked messages when the search changes, so Delete can't act on mail no longer listed --- stores/__tests__/email-selection.test.ts | 46 ++++++++++++++++++++++++ stores/email-store.ts | 13 +++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/stores/__tests__/email-selection.test.ts b/stores/__tests__/email-selection.test.ts index b5dbc7695..20c088cc6 100644 --- a/stores/__tests__/email-selection.test.ts +++ b/stores/__tests__/email-selection.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useEmailStore } from '../email-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; function makeEmail(id: string, threadId = `thread-${id}`) { return { @@ -31,6 +32,51 @@ describe('email-store selection', () => { }); }); + describe('a different search', () => { + const ticked = () => useEmailStore.getState().selectedEmailKeys.size; + beforeEach(() => { + useEmailStore.setState({ searchQuery: 'beacon', searchFilters: { ...DEFAULT_SEARCH_FILTERS } }); + useEmailStore.getState().toggleEmailSelection({ id: 'b' }); + expect(ticked()).toBe(1); + }); + + it('drops the ticks when the query changes, also to nothing', () => { + useEmailStore.getState().setSearchQuery(''); + expect(ticked()).toBe(0); + expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull(); + }); + + it('keeps them when the same query is set again', () => { + useEmailStore.getState().setSearchQuery('beacon'); + expect(ticked()).toBe(1); + }); + + it('drops them when a search runs for another query, before its results land', () => { + const pending = new Promise(() => {}); + void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'rollback'); + expect(ticked()).toBe(0); + }); + + it('keeps them when the same search runs again', () => { + const pending = new Promise(() => {}); + void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'beacon'); + expect(ticked()).toBe(1); + }); + + it('drops them when a filter changes or the filters are cleared', () => { + useEmailStore.getState().setSearchFilters({ isUnread: true }); + expect(ticked()).toBe(0); + useEmailStore.getState().toggleEmailSelection({ id: 'b' }); + useEmailStore.getState().clearSearchFilters(); + expect(ticked()).toBe(0); + }); + + it('keeps them when clearing filters that were not set', () => { + useEmailStore.getState().clearSearchFilters(); + expect(ticked()).toBe(1); + }); + }); + describe('toggleEmailSelection', () => { it('should add email to selection', () => { useEmailStore.getState().toggleEmailSelection({ id: 'b' }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 97d75caca..7bfa2e074 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -69,6 +69,8 @@ type PendingUndoSend = { submissionAccountId?: string; }; +const NO_SELECTION = () => ({ selectedEmailKeys: new Set(), lastSelectedEmailKey: null }); + /** Drop the matching emails from a key-based selection. */ function dropFromSelection( state: { emails: Email[]; selectedEmailKeys: Set }, @@ -3603,7 +3605,12 @@ export const useEmailStore = create((set, get) => ({ setLoading: (loading) => set({ isLoading: loading }), setLoadingEmail: (loading) => set({ isLoadingEmail: loading }), setError: (error) => set({ error }), - setSearchQuery: (query) => set({ searchQuery: query }), + // Another search lists other mail: ticks kept from the last list would stay + // counted in the selection bar, with Delete acting on rows nobody can see. + setSearchQuery: (query) => set((state) => ({ + searchQuery: query, + ...(query === state.searchQuery ? {} : NO_SELECTION()), + })), setQuota: (quota) => set({ quota }), toggleEmailSelection: (email) => { @@ -5360,7 +5367,7 @@ export const useEmailStore = create((set, get) => ({ // trip later. What the mail view renders from is written only once the // request is on its way (see afterRequestDispatch); the controller is // recorded at once so a superseding search can still cancel this one. - set({ searchAbortController: controller }); + set({ searchAbortController: controller, ...(typedQuery === get().searchQuery ? {} : NO_SELECTION()) }); afterRequestDispatch(() => { if (get().searchAbortController === controller) set({ isLoading: true, error: null, searchQuery: typedQuery }); }); @@ -5598,6 +5605,7 @@ export const useEmailStore = create((set, get) => ({ setSearchFilters: (filters) => { set((state) => ({ searchFilters: { ...state.searchFilters, ...filters }, + ...NO_SELECTION(), })); }, @@ -5617,6 +5625,7 @@ export const useEmailStore = create((set, get) => ({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox), searchScopeFollowsFolder: false, + ...(isFilterEmpty(state.searchFilters) ? {} : NO_SELECTION()), })); },