feat: leave Spam and Trash out of search unless All folders is picked

This commit is contained in:
Linus Rath
2026-10-29 03:28:34 +01:00
parent 58bc4acac5
commit a412b5163a
4 changed files with 145 additions and 16 deletions
+21 -5
View File
@@ -28,6 +28,7 @@ import type { LoadListAttachments } from '../lib/list-attachments';
import { useNetworkStore } from '../stores/network-store'; import { useNetworkStore } from '../stores/network-store';
import { import {
useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts, useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts,
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK,
type EmailFilters, type EmailFilters,
} from '../stores/email-store'; } from '../stores/email-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store'; import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
@@ -1003,9 +1004,12 @@ export default function EmailListScreen({
} }
}, [backStep, clearSelection, clearSearchAndFilters]); }, [backStep, clearSelection, clearSearchAndFilters]);
useBackWhileFocused(backStep !== 'leave', handleBack); useBackWhileFocused(backStep !== 'leave', handleBack);
const folderScope = effectiveFolderScope(storeSearchQuery, filters); const openRole = currentMailbox?.role;
const folderScope = effectiveFolderScope(storeSearchQuery, filters, openRole);
// The default search scope leaves Spam and Trash out; "All folders" doesn't.
const skipsTrashAndJunk = searchSkipsTrashAndJunk(storeSearchQuery, filters, openRole);
// Accounts an "All folders" list could not reach (#1082). // Accounts an "All folders" list could not reach (#1082).
const unreachedAccounts = spansAccounts({ searchQuery: storeSearchQuery, filters }) const unreachedAccounts = spansAccounts({ searchQuery: storeSearchQuery, filters, mailboxes, currentMailboxId })
? Object.values(accountErrors) ? Object.values(accountErrors)
: []; : [];
const scopeFolderName = React.useMemo(() => { const scopeFolderName = React.useMemo(() => {
@@ -1020,7 +1024,12 @@ export default function EmailListScreen({
}, [filters.keyword, keywordDefs]); }, [filters.keyword, keywordDefs]);
const setFolderScope = (scope: string) => { const setFolderScope = (scope: string) => {
const updated: EmailFilters = { ...filters }; const updated: EmailFilters = { ...filters };
if (scope === 'current') delete updated.folder; // The scope a search gets by default stays unset, so it keeps following
// the open folder; any other pick is kept as it is.
const isDefault = scope === SCOPE_EXCEPT_TRASH_JUNK
? !isTrashOrJunkRole(openRole)
: scope === 'current' && isTrashOrJunkRole(openRole);
if (isDefault) delete updated.folder;
else updated.folder = scope; else updated.folder = scope;
setFilters(updated); setFilters(updated);
}; };
@@ -1402,7 +1411,9 @@ export default function EmailListScreen({
<FilterChip <FilterChip
icon={<Folder size={12} color={c.textSecondary} />} icon={<Folder size={12} color={c.textSecondary} />}
label={ label={
folderScope === 'all' skipsTrashAndJunk
? t('advanced_search.all_folders_except_spam_trash', 'All folders except Spam and Trash')
: folderScope === 'all'
? t('email_list.scope_all_folders', 'All folders') ? t('email_list.scope_all_folders', 'All folders')
: folderScope === 'current' : folderScope === 'current'
? t('email_list.scope_this_folder', 'This folder') ? t('email_list.scope_this_folder', 'This folder')
@@ -1719,9 +1730,14 @@ export default function EmailListScreen({
<View> <View>
<Text style={styles.filterFieldLabel}>{t('advanced_search.folder', 'Folder')}</Text> <Text style={styles.filterFieldLabel}>{t('advanced_search.folder', 'Folder')}</Text>
<View style={styles.filterToggleGroup}> <View style={styles.filterToggleGroup}>
<ScopeChip
label={t('advanced_search.all_folders_except_spam_trash', 'All folders except Spam and Trash')}
active={skipsTrashAndJunk}
onPress={() => setFolderScope(SCOPE_EXCEPT_TRASH_JUNK)}
/>
<ScopeChip <ScopeChip
label={t('email_list.scope_all_folders', 'All folders')} label={t('email_list.scope_all_folders', 'All folders')}
active={folderScope === 'all'} active={folderScope === 'all' && !skipsTrashAndJunk}
onPress={() => setFolderScope('all')} onPress={() => setFolderScope('all')}
/> />
<ScopeChip <ScopeChip
@@ -155,7 +155,15 @@ function seed() {
const ids = () => useEmailStore.getState().emails.map((e) => e.id); const ids = () => useEmailStore.getState().emails.map((e) => e.id);
// "All folders" picked explicitly, unless a test picked a folder: the default
// scope also leaves Trash and Junk out (see "the default search scope").
async function search(query: string) { async function search(query: string) {
const { filters } = useEmailStore.getState();
if (!filters.folder) useEmailStore.setState({ filters: { ...filters, folder: 'all' } });
await searchDefault(query);
}
async function searchDefault(query: string) {
useEmailStore.getState().setSearchQuery(query); useEmailStore.getState().setSearchQuery(query);
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false)); await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
} }
@@ -182,6 +190,36 @@ beforeEach(() => {
}); });
}); });
// Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless
// "All folders" is picked, and one started inside them searches them.
describe('the default search scope', () => {
it("leaves every account's Trash and Junk out", async () => {
await searchDefault('zephyr');
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
['team', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] }],
]);
});
it('searches Trash itself when the search starts there', async () => {
useEmailStore.setState({ currentMailboxId: 'team:t-trash' });
await searchDefault('zephyr');
expect(ids()).toEqual(['m5']);
const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => args.accountId)).toEqual(['team']);
});
it('keeps Trash and Junk in when "All folders" is picked', async () => {
useEmailStore.setState({ filters: { folder: 'all' } });
await searchDefault('zephyr');
expect(ids()).toContain('m5');
});
});
describe('"All folders" search across the own and the team account (#1082)', () => { describe('"All folders" search across the own and the team account (#1082)', () => {
it('finds the team inbox matches too, in one request', async () => { it('finds the team inbox matches too, in one request', async () => {
await search('zephyr'); await search('zephyr');
+1
View File
@@ -20,6 +20,7 @@ function matches(email: FakeEmail, filter: Filter | undefined, textModel: Stalwa
if (!filter) return true; if (!filter) return true;
return textFilterMatches(textModel, filter, { subject: email.subject ?? '', body: email.preview ?? '' }, (key, value) => { return textFilterMatches(textModel, filter, { subject: email.subject ?? '', body: email.preview ?? '' }, (key, value) => {
if (key === 'inMailbox') return !!email.mailboxIds[value as string]; if (key === 'inMailbox') return !!email.mailboxIds[value as string];
if (key === 'inMailboxOtherThan') return !(value as string[]).some((id) => email.mailboxIds[id]);
if (key === 'hasKeyword') return !!email.keywords[value as string]; if (key === 'hasKeyword') return !!email.keywords[value as string];
if (key === 'notKeyword') return !email.keywords[value as string]; if (key === 'notKeyword') return !email.keywords[value as string];
throw new Error(`fake server: unsupported filter ${key}`); throw new Error(`fake server: unsupported filter ${key}`);
+85 -11
View File
@@ -264,11 +264,55 @@ export interface EmailFilters {
keyword?: string; keyword?: string;
} }
/** The folder scope a query runs in, resolving the unset default. */ /**
export function effectiveFolderScope(searchQuery: string, filters: EmailFilters): SearchFolderScope { * The `folder` value of the "All folders except Spam and Trash" scope when
* it is picked explicitly (from inside Spam or Trash, where it is not the
* default). It spans the folders like "All folders".
*/
export const SCOPE_EXCEPT_TRASH_JUNK = 'all-except-trash-junk';
/**
* The folder scope a query runs in, resolving the unset default. A search
* from inside Spam or Trash looks there; anywhere else it spans every
* folder (and leaves Spam and Trash out, see `searchSkipsTrashAndJunk`).
* Webmail 763732fdc, 3904af2cb.
*/
export function effectiveFolderScope(
searchQuery: string,
filters: EmailFilters,
openRole?: string | null,
): SearchFolderScope {
if (filters.keyword) return 'all'; if (filters.keyword) return 'all';
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return 'all';
if (filters.folder) return filters.folder; if (filters.folder) return filters.folder;
return searchQuery.trim() ? 'all' : 'current'; if (!searchQuery.trim()) return 'current';
return isTrashOrJunkRole(openRole) ? 'current' : 'all';
}
/** Trash, or Junk under either role name servers use. */
export function isTrashOrJunkRole(role: string | null | undefined): boolean {
return role === 'trash' || role === 'junk' || role === 'spam';
}
/**
* Whether a search across folders leaves every account's Trash and Junk
* out: the default scope does, "All folders" picked explicitly doesn't.
*/
export function searchSkipsTrashAndJunk(
searchQuery: string,
filters: EmailFilters,
openRole?: string | null,
): boolean {
if (filters.keyword) return false;
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
if (filters.folder) return false;
return effectiveFolderScope(searchQuery, filters, openRole) === 'all';
}
// The role of the folder on screen.
function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailboxId'>>): string | undefined {
if (!state.currentMailboxId || !state.mailboxes) return undefined;
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
} }
// Snapshot of an action that can still be reversed via the undo snackbar. // Snapshot of an action that can still be reversed via the undo snackbar.
@@ -487,7 +531,7 @@ function buildJmapFilter(
// The raw mailbox id an Email/query is scoped to: the open folder, an // The raw mailbox id an Email/query is scoped to: the open folder, an
// explicitly picked folder, or undefined for "all folders" (#788). // explicitly picked folder, or undefined for "all folders" (#788).
function queryScope(state: EmailState, current: MailboxRef): { mailboxId: string | undefined; accountId?: string } { function queryScope(state: EmailState, current: MailboxRef): { mailboxId: string | undefined; accountId?: string } {
const scope = effectiveFolderScope(state.searchQuery, state.filters); const scope = effectiveFolderScope(state.searchQuery, state.filters, openRoleOf(state));
if (scope === 'current') return { mailboxId: current.id, accountId: current.accountId }; if (scope === 'current') return { mailboxId: current.id, accountId: current.accountId };
if (scope === 'all') return { mailboxId: undefined, accountId: current.accountId }; if (scope === 'all') return { mailboxId: undefined, accountId: current.accountId };
const ref = refFor(state.mailboxes, scope); const ref = refFor(state.mailboxes, scope);
@@ -552,8 +596,29 @@ function isUnsupportedSort(err: unknown): boolean {
// to one folder stays in its account. // to one folder stays in its account.
/** Whether the list on screen spans the own and the shared accounts. */ /** Whether the list on screen spans the own and the shared accounts. */
export function spansAccounts(state: Pick<EmailState, 'searchQuery' | 'filters'>): boolean { export function spansAccounts(
return effectiveFolderScope(state.searchQuery, state.filters) === 'all'; state: Pick<EmailState, 'searchQuery' | 'filters'> & Partial<Pick<EmailState, 'mailboxes' | 'currentMailboxId'>>,
): boolean {
return effectiveFolderScope(state.searchQuery, state.filters, openRoleOf(state)) === 'all';
}
// The raw JMAP ids of one account's Trash and Junk (undefined = the user's own).
function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
return accountMailboxes(mailboxes, accountId)
.filter((m) => isTrashOrJunkRole(m.role))
.map((m) => m.originalId ?? m.id);
}
// `filter` AND `extra`, flattened into an existing top-level AND.
function andFilter(
filter: Record<string, unknown> | undefined,
extra: Record<string, unknown>,
): Record<string, unknown> {
if (!filter || Object.keys(filter).length === 0) return extra;
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
return { operator: 'AND', conditions: [...(filter.conditions as unknown[]), extra] };
}
return { operator: 'AND', conditions: [filter, extra] };
} }
/** /**
@@ -675,14 +740,23 @@ async function fetchSpanningPage(
): Promise<SpanningPage> { ): Promise<SpanningPage> {
const primary = jmapClient.accountId; const primary = jmapClient.accountId;
const threads = !useSettingsStore.getState().disableThreading; const threads = !useSettingsStore.getState().disableThreading;
const run = async (accounts: Array<string | undefined>) => queryEmailPagesAcrossAccounts( const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state));
await Promise.all(accounts.map(async (accountId) => ({ const run = async (accounts: Array<string | undefined>) => {
const targets = await Promise.all(accounts.map(async (accountId) => ({
accountId, accountId,
position: loaded[accountId ?? primary] ?? 0, position: loaded[accountId ?? primary] ?? 0,
sort: await resolveSort(state, accountId), sort: await resolveSort(state, accountId),
}))), })));
{ limit, filter, threads }, if (!skipTrashAndJunk) return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads });
); // Each account leaves its own Trash and Junk out, so each gets its own
// filter (and request).
const pages = await Promise.all(targets.map((target) => {
const ids = trashAndJunkIds(state.mailboxes, target.accountId);
const scoped = ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter;
return queryEmailPagesAcrossAccounts([target], { limit, filter: scoped, threads });
}));
return pages.map((p) => p[0]);
};
let pages = await run(spannedAccounts(state.mailboxes)); let pages = await run(spannedAccounts(state.mailboxes));
const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error)); const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error));
if (refused.length > 0) { if (refused.length > 0) {