feat: search spam and trash themselves while they are open

This commit is contained in:
Linus Rath
2026-09-29 20:09:26 +02:00
parent 763732fdc9
commit 3904af2cbb
4 changed files with 141 additions and 32 deletions
+9 -8
View File
@@ -4006,14 +4006,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</div>
{/* 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 && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
+10
View File
@@ -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;
@@ -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('');
});
});
+49 -23
View File
@@ -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<EmailStore, 'viewingAccountId' | 'accountMailboxes' | 'mailboxes'>): 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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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,