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,