From bfc8e0e52e17f2191b77d231de0b9086dd978d4e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:49:13 +0200 Subject: [PATCH] fix: show only that folder's unread mail when its unread count is clicked Clicking a folder's unread count turns on the unread filter and runs the search. Since the search scope stopped following the open folder (#788) its default is "all folders", and since #1082 that fans out over every shared account too, so the Inbox badge listed unread mail from every folder of every account the login reaches. The badge now scopes the search to its folder. That scope moves with the open folder, the way the search itself carries across folders (#553); picking a scope in the dropdown or clearing the filters stops it. Clicking the badge from a tag view or the Scheduled list now also leaves that view, as clicking the folder does. --- components/mail/mail-app.tsx | 13 ++- .../email-store-search-scope.test.ts | 94 +++++++++++++++++++ stores/email-store.ts | 38 ++++++-- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..82715bf56 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -379,6 +379,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isAdvancedSearchOpen, setSearchFilters, setSearchMailboxId, + scopeSearchToOpenFolder, clearSearchFilters, toggleAdvancedSearch, advancedSearch, @@ -2714,10 +2715,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleUnreadFilterClick = async (mailboxId: string) => { - const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true; + const isTogglingOff = selectedMailbox === mailboxId && !selectedKeyword && searchFilters.isUnread === true; - // Select the mailbox if not already selected - if (selectedMailbox !== mailboxId) { + // Open the mailbox unless it is already the one listed. A tag view keeps + // selectedMailbox, so it counts as another view. + if (selectedMailbox !== mailboxId || selectedKeyword) { + if (isUnifiedView) exitUnifiedView(); + setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); } @@ -2740,8 +2744,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await fetchEmails(client, mailboxId); } } else { - // Enable unread filter + // Enable unread filter, in this folder only clearSearchFilters(); + scopeSearchToOpenFolder(); setSearchFilters({ isUnread: true }); if (client) { await advancedSearch(client); diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 4ae3c56b8..2d114d18f 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -152,3 +152,97 @@ describe('search folder scope (#788)', () => { expect(useEmailStore.getState().searchMailboxId).toBe(''); }); }); + +describe('unread badge scope', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + isUnifiedView: false, + unifiedRole: null, + crossView: null, + viewingAccountId: null, + selectedKeyword: null, + selectedMailbox: 'sent', + searchMailboxId: '', + searchScopeFollowsFolder: false, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchAbortController: null, + emails: [], + mailboxes: [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent' }), + makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }), + ], + accountMailboxes: {}, + }); + }); + + // What mail-app's handleUnreadFilterClick does with the store. + async function clickUnreadBadge(mailboxId: string) { + const store = useEmailStore.getState(); + store.selectMailbox(mailboxId); + store.clearSearchFilters(); + store.scopeSearchToOpenFolder(); + store.setSearchFilters({ isUnread: true }); + await useEmailStore.getState().advancedSearch(client); + } + + const searchCalls = () => (client.advancedSearchEmails as ReturnType).mock.calls; + + it('lists the unread mail of that folder, not of every folder and account', async () => { + await clickUnreadBadge('inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$seen' }, { inMailbox: 'inbox' }], + }); + expect(accountId).toBeUndefined(); + }); + + it('lists a shared folder in its owner account', async () => { + await clickUnreadBadge('owner-x:x-inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"x-inbox"'); + expect(accountId).toBe('owner-x'); + }); + + it('moves to the next folder the user opens instead of listing the old one there', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + + await useEmailStore.getState().advancedSearch(client); + const [filter] = searchCalls()[1]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"sent"'); + expect(JSON.stringify(filter)).not.toContain('"inMailbox":"inbox"'); + }); + + it('stops following once the user picks a scope', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().setSearchMailboxId('owner-x:x-inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('owner-x:x-inbox'); + }); + + it('stops following once the filters are cleared', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().clearSearchFilters(); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9541cbebd..a59e7c2be 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -136,6 +136,10 @@ interface EmailStore { // folder does not silently narrow the search to it, and so an explicit // choice survives navigating the mail list (#788). searchMailboxId: string; + // Set when a folder's unread count scoped the search to that folder: the + // scope then moves with the open folder, the way the search itself does + // (#553). A scope picked in the dropdown or a filter reset clears it. + searchScopeFollowsFolder: boolean; isAdvancedSearchOpen: boolean; searchAbortController: AbortController | null; /** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */ @@ -281,6 +285,7 @@ interface EmailStore { advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; setSearchMailboxId: (mailboxId: string) => void; + scopeSearchToOpenFolder: () => void; clearSearchFilters: () => void; toggleAdvancedSearch: () => void; toggleStar: (client: IJMAPClient, emailId: string) => Promise; @@ -1535,6 +1540,7 @@ export const useEmailStore = create((set, get) => ({ // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: "", + searchScopeFollowsFolder: false, isAdvancedSearchOpen: false, searchAbortController: null, externalSearchResults: [], @@ -1582,11 +1588,14 @@ export const useEmailStore = create((set, get) => ({ // back empty, while the dropdown (which renders no matching option) reads // "All folders". Reset it so the scope matches what is shown. (#1082) // Only the new account's own list names its Spam/Trash: another - // account's mailbox ids can collide with them. - searchMailboxId: defaultSearchScopeFor( - accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, - mailboxId, - ), + // account's mailbox ids can collide with them. A scope that follows + // the open folder moves to the new one. + searchMailboxId: state.searchScopeFollowsFolder + ? mailboxId + : defaultSearchScopeFor( + accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, + mailboxId, + ), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -1689,11 +1698,15 @@ export const useEmailStore = create((set, get) => ({ selectMailbox: (mailboxId) => set(state => { const mailboxes = mailboxesInView(state); // The folder's default search scope follows it (Spam and Trash search - // themselves); a scope the user picked in the dropdown stays. + // themselves); a scope the user picked in the dropdown stays. The + // unread badge's scope follows any real folder. const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox); + const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId); return { selectedMailbox: mailboxId, - ...(scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), + ...(scopeFollows + ? { searchMailboxId: mailboxId } + : scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -3200,13 +3213,21 @@ export const useEmailStore = create((set, get) => ({ }, setSearchMailboxId: (mailboxId) => { - set({ searchMailboxId: mailboxId }); + set({ searchMailboxId: mailboxId, searchScopeFollowsFolder: false }); + }, + + // The unread badge filters its own folder. The default scope searches + // every folder of every account (#788, #1082), so it has to name the + // folder, and keep naming whichever folder is open. + scopeSearchToOpenFolder: () => { + set((state) => ({ searchMailboxId: state.selectedMailbox, searchScopeFollowsFolder: true })); }, clearSearchFilters: () => { set((state) => ({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox), + searchScopeFollowsFolder: false, })); }, @@ -5189,6 +5210,7 @@ export const useEmailStore = create((set, get) => ({ searchQuery: isScheduledView ? "" : state.searchQuery, searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters, searchMailboxId: isScheduledView ? "" : state.searchMailboxId, + searchScopeFollowsFolder: isScheduledView ? false : state.searchScopeFollowsFolder, }; }), clearPendingUndoSend: () => set({ pendingUndoSend: null }),