From 3904af2cbb1f716ae82ea402e4631333fcad37db Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:09:26 +0200 Subject: [PATCH] feat: search spam and trash themselves while they are open --- components/mail/mail-app.tsx | 17 +++-- lib/search-scope-folders.ts | 10 +++ .../email-store-search-accounts.test.ts | 74 ++++++++++++++++++- stores/email-store.ts | 72 ++++++++++++------ 4 files changed, 141 insertions(+), 32 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 8f92728df..367bc6acc 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -4006,14 +4006,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Folder selector. Scopes the search only - it does not - navigate the mail list, so it defaults to "All folders - except Spam and Trash" regardless of which folder is - open and keeps whatever the user picked. Both - folder-less scopes span the own AND the group/shared - accounts' folders (#1082); "All folders" also searches - Trash and Junk. The shared folders are listed under - their owner. The unified views already search across - every account's folders, so it is hidden there. */} + navigate the mail list. It defaults to "All folders + except Spam and Trash", or to Spam/Trash itself while + that folder is open (defaultSearchScopeFor), and keeps + whatever the user picked. Both folder-less scopes span + the own AND the group/shared accounts' folders (#1082); + "All folders" also searches Trash and Junk. The shared + folders are listed under their owner. The unified + views already search across every account's folders, + so it is hidden there. */} {!isUnifiedView && (
diff --git a/lib/search-scope-folders.ts b/lib/search-scope-folders.ts index 464f68762..7834744b7 100644 --- a/lib/search-scope-folders.ts +++ b/lib/search-scope-folders.ts @@ -16,6 +16,16 @@ export function isAllFoldersSearchScope(searchMailboxId: string): boolean { return searchMailboxId === '' || searchMailboxId === SEARCH_SCOPE_ALL_FOLDERS; } +/** + * The search scope a folder starts with: Spam and Trash search themselves, + * since that is what a search from inside them looks for; every other folder + * searches all folders except Spam and Trash (""). + */ +export function defaultSearchScopeFor(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string { + const open = openMailboxId ? mailboxes.find((mb) => mb.id === openMailboxId) : undefined; + return open && (open.role === 'trash' || open.role === 'junk') ? open.id : ''; +} + export interface SearchScopeFolderGroup { /** Owner JMAP account id; the React key. */ ownerId: string; diff --git a/stores/__tests__/email-store-search-accounts.test.ts b/stores/__tests__/email-store-search-accounts.test.ts index d6508e8fe..a77f54787 100644 --- a/stores/__tests__/email-store-search-accounts.test.ts +++ b/stores/__tests__/email-store-search-accounts.test.ts @@ -206,7 +206,8 @@ describe('"All folders" search across the own and group accounts (#1082)', () => // The default scope, "All folders except Spam and Trash" (searchMailboxId // ""), leaves every account's Trash and Junk out; "All folders" (the -// SEARCH_SCOPE_ALL_FOLDERS sentinel) searches them too. +// SEARCH_SCOPE_ALL_FOLDERS sentinel) searches them too. While Spam or Trash +// is open, the default scope is that folder itself. describe('Spam and Trash in the folder-less search scopes', () => { const ownTrash = { id: 'trash', name: 'Trash', role: 'trash', isShared: false } as Mailbox; const ownJunk = { id: 'junk', name: 'Junk', role: 'junk', isShared: false } as Mailbox; @@ -301,4 +302,75 @@ describe('Spam and Trash in the folder-less search scopes', () => { useEmailStore.getState().clearSearchFilters(); expect(useEmailStore.getState().searchMailboxId).toBe(''); }); + + it('searches Spam or Trash alone while that folder is open, and all but them after leaving', async () => { + useEmailStore.getState().selectMailbox(ownTrash.id); + expect(useEmailStore.getState().searchMailboxId).toBe('trash'); + + await useEmailStore.getState().searchEmails(client, 'fatura'); + expect(client.searchEmails).toHaveBeenCalledTimes(1); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0); + + useEmailStore.getState().selectMailbox(ownJunk.id); + expect(useEmailStore.getState().searchMailboxId).toBe('junk'); + + useEmailStore.getState().selectMailbox(ownInbox.id); + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); + + it('searches an open group Trash in the group account', async () => { + useEmailStore.getState().selectMailbox(groupTrash.id); + + await useEmailStore.getState().searchEmails(client, 'acesso'); + + expect(client.searchEmails).toHaveBeenCalledTimes(1); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0); + }); + + it('keeps a scope the user picked when entering or leaving Spam and Trash', () => { + const { selectMailbox, setSearchMailboxId } = useEmailStore.getState(); + + setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS); + selectMailbox(ownTrash.id); + expect(useEmailStore.getState().searchMailboxId).toBe(SEARCH_SCOPE_ALL_FOLDERS); + selectMailbox(ownInbox.id); + expect(useEmailStore.getState().searchMailboxId).toBe(SEARCH_SCOPE_ALL_FOLDERS); + + setSearchMailboxId(groupInbox.id); + selectMailbox(ownJunk.id); + expect(useEmailStore.getState().searchMailboxId).toBe(groupInbox.id); + + // "All folders except Spam and Trash" picked while inside Trash stays too. + selectMailbox(ownTrash.id); + setSearchMailboxId(''); + selectMailbox(ownInbox.id); + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); + + it('resets to the open folder\'s default when the filters are cleared', () => { + useEmailStore.getState().selectMailbox(ownTrash.id); + useEmailStore.getState().setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS); + + useEmailStore.getState().clearSearchFilters(); + + expect(useEmailStore.getState().searchMailboxId).toBe('trash'); + }); + + it('scopes to another account\'s Spam by that account\'s own folder list', () => { + useEmailStore.setState({ + accountMailboxes: { + 'account-b': [ + { id: 'b-junk', name: 'Spam', role: 'junk', isShared: false } as Mailbox, + // Same id as the primary account's Trash, but an ordinary folder here. + { id: 'trash', name: 'Old', role: null, isShared: false } as unknown as Mailbox, + ], + }, + }); + + useEmailStore.getState().selectAccountMailbox('account-b', 'b-junk'); + expect(useEmailStore.getState().searchMailboxId).toBe('b-junk'); + + useEmailStore.getState().selectAccountMailbox('account-b', 'trash'); + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); }); diff --git a/stores/email-store.ts b/stores/email-store.ts index d35e910fa..48e1f83d7 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -11,7 +11,7 @@ import { resolveThreadRoute } from "@/lib/thread-routing"; import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils"; import type { ExternalSearchResult } from "@/lib/plugin-types"; import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; -import { isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; +import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { useAuthStore } from "@/stores/auth-store"; import { currentStoreEpoch } from "@/lib/store-epoch"; import { keywordPointer } from "@/lib/jmap/patch-pointer"; @@ -694,14 +694,17 @@ export function resolveUnstampedEmailAccountId(opts: { return mailbox?.isShared ? mailbox.accountId : undefined; } -function resolveActionMailboxes(): Mailbox[] { - const state = useEmailStore.getState(); +function mailboxesInView(state: Pick): Mailbox[] { if (state.viewingAccountId) { return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes; } return state.mailboxes; } +function resolveActionMailboxes(): Mailbox[] { + return mailboxesInView(useEmailStore.getState()); +} + // List requests can finish after navigation. Never apply an old folder/tag // response (or error) to the view the user has since selected. function captureEmailListView(): () => boolean { @@ -1531,7 +1534,12 @@ export const useEmailStore = create((set, get) => ({ // would query that foreign folder id against the wrong account and come // back empty, while the dropdown (which renders no matching option) reads // "All folders". Reset it so the scope matches what is shown. (#1082) - searchMailboxId: "", + // 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, + ), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -1622,21 +1630,28 @@ export const useEmailStore = create((set, get) => ({ console.error('Failed to fetch tag counts:', error); } }), - selectMailbox: (mailboxId) => set(state => ({ - selectedMailbox: mailboxId, - isLoadingMore: false, - selectedEmail: null, - selectedEmailIds: new Set(), - selectedKeyword: null, - expandedThreadIds: new Set(), - threadEmailsCache: new Map(), - threadEmailCounts: new Map(), - isLoadingThread: null, - viewToken: state.viewToken + 1, - isUnifiedView: false, - unifiedRole: null, - crossView: null, - })), + 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. + const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox); + return { + selectedMailbox: mailboxId, + ...(scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), + isLoadingMore: false, + selectedEmail: null, + selectedEmailIds: new Set(), + selectedKeyword: null, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + threadEmailCounts: new Map(), + isLoadingThread: null, + viewToken: state.viewToken + 1, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + }; + }), setLoading: (loading) => set({ isLoading: loading }), setLoadingEmail: (loading) => set({ isLoadingEmail: loading }), setError: (error) => set({ error }), @@ -1730,12 +1745,18 @@ export const useEmailStore = create((set, get) => ({ ? { isLoading: false, mailboxSyncStates: fetched.states } : { mailboxSyncStates: fetched.states }; if (!selectionValid) { + // A search scope naming a folder that is gone (e.g. the Spam/Trash + // the default scope followed) falls back to the default. + const scope = get().searchMailboxId; + const scopePatch = !isAllFoldersSearchScope(scope) && !mailboxes.some(m => m.id === scope) + ? { searchMailboxId: '' } + : {}; // Find inbox from PRIMARY account (not shared accounts) const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); if (inboxMailbox) { - set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch }); + set({ mailboxes, selectedMailbox: inboxMailbox.id, ...scopePatch, ...loadingPatch }); } else { - set({ mailboxes, selectedMailbox: '', ...loadingPatch }); + set({ mailboxes, selectedMailbox: '', ...scopePatch, ...loadingPatch }); } } else { set({ mailboxes, ...loadingPatch }); @@ -3127,7 +3148,10 @@ export const useEmailStore = create((set, get) => ({ }, clearSearchFilters: () => { - set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: "" }); + set((state) => ({ + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox), + })); }, toggleAdvancedSearch: () => { @@ -5517,9 +5541,11 @@ if (typeof window !== 'undefined') { Array.isArray(snap.mailboxes) && snap.mailboxes.length > 0 ) { + const selectedMailbox = typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : ''; useEmailStore.setState({ mailboxes: snap.mailboxes, - selectedMailbox: typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '', + selectedMailbox, + searchMailboxId: defaultSearchScopeFor(snap.mailboxes, selectedMailbox), ...(Array.isArray(snap.emails) && snap.emails.length > 0 ? { emails: snap.emails,