diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index df2c339f0..8f92728df 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -32,7 +32,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store";
-import { groupSearchScopeFolders } from "@/lib/search-scope-folders";
+import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { toast } from "@/stores/toast-store";
import { runBatchEmailAction } from "@/lib/email-action-toast";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
@@ -4006,13 +4006,14 @@ 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"
- regardless of which folder is open and keeps whatever
- the user picked. "All folders" spans the own AND the
- group/shared accounts' folders (#1082); the shared
- ones are listed under their owner. The unified views
- already search across every account's folders, so it
- is hidden there. */}
+ 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. */}
{!isUnifiedView && (
{t("advanced_search.folder")}
@@ -4026,7 +4027,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
className="w-full h-8 text-sm rounded-md border border-input bg-background px-3 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
>
- {t("advanced_search.all_folders")}
+ {t("advanced_search.all_folders_except_spam_trash")}
+ {t("advanced_search.all_folders")}
{searchScopeFolders.own.map((mb) => (
{mb.name}
diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts
index b9c4dcb09..9008e8181 100644
--- a/lib/__tests__/unified-mailbox-search-accounts.test.ts
+++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts
@@ -9,6 +9,8 @@ import { advancedSearchAcrossAccounts, searchAcrossAccounts, type UnifiedAccount
const mb = (id: string, name: string, originalId?: string): Mailbox =>
({ id, name, role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId } as unknown as Mailbox);
+const roleMb = (id: string, role: string, originalId?: string): Mailbox =>
+ ({ id, name: role, role, unreadEmails: 0, totalEmails: 0, originalId } as unknown as Mailbox);
function email(id: string, receivedAt: string, mailbox: string): Email {
return {
@@ -86,6 +88,33 @@ describe('searchAcrossAccounts', () => {
expect(result.emails.map((e) => e.id)).toEqual(['grp-1']);
expect(result.errors.get('login')).toBe('boom');
});
+
+ it('leaves each account\'s Trash and Junk out when asked, by the owner\'s raw ids', async () => {
+ const ownAdvanced = vi.fn(async () => page([]));
+ const groupAdvanced = vi.fn(async () => page([]));
+ const plainSearch = vi.fn(async () => page([]));
+ const own = makeAccount(
+ { accountId: 'login', jmapAccountId: 'me', mailboxes: [...ownMailboxes, roleMb('trash', 'trash'), roleMb('junk', 'junk')] },
+ { advancedSearchEmails: ownAdvanced, searchEmails: plainSearch },
+ );
+ const group = makeAccount(
+ { accountId: 'group', isShared: true, mailboxes: [...groupMailboxes, roleMb('group:g-trash', 'trash', 'g-trash')] },
+ { advancedSearchEmails: groupAdvanced, searchEmails: plainSearch },
+ );
+ // No Trash or Junk to leave out: the plain text search.
+ const bare = makeAccount({ accountId: 'bare', isShared: true, mailboxes: [] }, { searchEmails: plainSearch });
+
+ await searchAcrossAccounts([own, group, bare], ' acesso ', 50, 0, { excludeTrashAndJunk: true });
+
+ expect(ownAdvanced).toHaveBeenCalledWith(
+ { operator: 'AND', conditions: [{ text: 'acesso' }, { inMailboxOtherThan: ['trash', 'junk'] }] }, undefined, 50, 0,
+ );
+ expect(groupAdvanced).toHaveBeenCalledWith(
+ { operator: 'AND', conditions: [{ text: 'acesso' }, { inMailboxOtherThan: ['g-trash'] }] }, 'group', 50, 0,
+ );
+ expect(plainSearch).toHaveBeenCalledTimes(1);
+ expect(plainSearch).toHaveBeenCalledWith(' acesso ', undefined, 'bare', 50, 0);
+ });
});
describe('advancedSearchAcrossAccounts', () => {
@@ -103,4 +132,22 @@ describe('advancedSearchAcrossAccounts', () => {
expect(result.emails.map((e) => e.id)).toEqual(['grp-1', 'own-1']);
expect(result.emails[0]).toMatchObject({ sourceAccountId: 'group', sourceFolder: 'Abrir Chamados' });
});
+
+ it('adds the Trash/Junk exclusion to the filter when asked', async () => {
+ const filter = { operator: 'AND', conditions: [{ text: 'acesso' }, { from: 'bob@example.com' }] };
+ const search = vi.fn(async () => page([]));
+ const own = makeAccount(
+ { accountId: 'login', jmapAccountId: 'me', mailboxes: [...ownMailboxes, roleMb('junk', 'junk')] },
+ { advancedSearchEmails: search },
+ );
+
+ await advancedSearchAcrossAccounts([own], filter, 25, 0, { excludeTrashAndJunk: true });
+ await advancedSearchAcrossAccounts([own], filter, 25, 0);
+
+ expect(search).toHaveBeenNthCalledWith(1, {
+ operator: 'AND',
+ conditions: [{ text: 'acesso' }, { from: 'bob@example.com' }, { inMailboxOtherThan: ['junk'] }],
+ }, undefined, 25, 0);
+ expect(search).toHaveBeenNthCalledWith(2, filter, undefined, 25, 0);
+ });
});
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 9e5ea6c72..53b33df4a 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -172,8 +172,9 @@ export class DemoJMAPClient implements IJMAPClient {
/**
* Minimal JMAP filter evaluator for demo mode: supports the conditions the
- * message-list category tabs use (hasKeyword / notKeyword / from and
- * AND / OR / NOT operators). Unknown conditions match nothing.
+ * message-list category tabs and the folder-less search scopes use
+ * (hasKeyword / notKeyword / from / text / inMailbox / inMailboxOtherThan
+ * and AND / OR / NOT operators). Unknown conditions are ignored.
*/
private matchesFilter(e: Email, filter: Record): boolean {
if (typeof filter.operator === 'string') {
@@ -193,6 +194,16 @@ export class DemoJMAPClient implements IJMAPClient {
(f.email || '').toLowerCase().includes(q) || (f.name || '').toLowerCase().includes(q));
if (!match) return false;
}
+ if (typeof filter.text === 'string') {
+ const q = filter.text.toLowerCase();
+ const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
+ if (!text.includes(q)) return false;
+ }
+ if (typeof filter.inMailbox === 'string' && !e.mailboxIds[filter.inMailbox]) return false;
+ if (Array.isArray(filter.inMailboxOtherThan)) {
+ const excluded = new Set(filter.inMailboxOtherThan as string[]);
+ if (!Object.keys(e.mailboxIds).some(id => e.mailboxIds[id] && !excluded.has(id))) return false;
+ }
return true;
}
@@ -315,13 +326,7 @@ export class DemoJMAPClient implements IJMAPClient {
}
async advancedSearchEmails(filter: Record, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
- // Simplified: just return all emails for any advanced filter
- let filtered = [...this.data.emails];
- if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]);
- if (filter.text) {
- const q = (filter.text as string).toLowerCase();
- filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q));
- }
+ const filtered = this.data.emails.filter(e => this.matchesFilter(e, filter));
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts
index 3335ae30f..da6ab5737 100644
--- a/lib/global-search/__tests__/providers.test.ts
+++ b/lib/global-search/__tests__/providers.test.ts
@@ -37,7 +37,8 @@ vi.mock('@/stores/auth-store', () => ({ useAuthStore: storeHook(authState) }));
vi.mock('@/stores/account-store', () => ({ useAccountStore: storeHook({ accounts: [] }) }));
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: storeHook({ includeGroupInUnified: true }) }));
-const { mailProvider, andFilters, emailMatchesFilters, mailFilterFor } = await import('../providers/mail');
+const { mailProvider, emailMatchesFilters, mailFilterFor } = await import('../providers/mail');
+const { andFilters } = await import('@/lib/jmap/search-utils');
const { contactsProvider } = await import('../providers/contacts');
const { calendarProvider, calendarFilterFor } = await import('../providers/calendar');
const { filesProvider, invalidateFileSearchCache, pathOfNode, rawFileNodeId } = await import('../providers/files');
diff --git a/lib/global-search/providers/mail.ts b/lib/global-search/providers/mail.ts
index 96dac1297..14850795c 100644
--- a/lib/global-search/providers/mail.ts
+++ b/lib/global-search/providers/mail.ts
@@ -1,9 +1,9 @@
import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser';
import { getActiveLocalAccountId, indexAccounts } from '@/lib/global-search/accounts';
import type { MailHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
-import { buildJMAPFilter } from '@/lib/jmap/search-utils';
+import { andFilters, buildJMAPFilter } from '@/lib/jmap/search-utils';
import type { Email, Mailbox } from '@/lib/jmap/types';
-import { findMailboxByRole, resolveSourceFolderName, type UnifiedAccountClient } from '@/lib/unified-mailbox';
+import { findMailboxByRole, jmapMailboxIdOf, resolveSourceFolderName, trashAndJunkExclusion, type UnifiedAccountClient } from '@/lib/unified-mailbox';
import { buildUnifiedAccountClients, useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -64,20 +64,6 @@ function toHit(email: Email, account: SearchAccount, jmapAccountId: string, fold
type JmapFilter = Record;
-/** `AND` two JMAP filters, flattening into an existing top-level `AND`. */
-export function andFilters(base: JmapFilter, extra: JmapFilter | null): JmapFilter {
- if (!extra || Object.keys(extra).length === 0) return base;
- if (Object.keys(base).length === 0) return extra;
- if (base.operator === 'AND' && Array.isArray(base.conditions)) {
- return { operator: 'AND', conditions: [...(base.conditions as JmapFilter[]), extra] };
- }
- return { operator: 'AND', conditions: [base, extra] };
-}
-
-function jmapMailboxId(entry: UnifiedAccountClient, mailbox: Mailbox): string {
- return entry.isShared ? (mailbox.originalId ?? mailbox.id) : mailbox.id;
-}
-
/**
* The default scope is "everything but Trash and Junk" (#641's "Most");
* `in:trash` / `in:junk` search that folder alone and `is:anything` lifts the
@@ -87,14 +73,10 @@ export function mailFilterFor(parsed: ParsedQuery, entry: UnifiedAccountClient):
const base = buildJMAPFilter(parsed.text, parsed.mail);
if (parsed.mailboxRole) {
const mailbox = findMailboxByRole(entry.mailboxes, parsed.mailboxRole);
- return mailbox ? andFilters(base, { inMailbox: jmapMailboxId(entry, mailbox) }) : base;
+ return mailbox ? andFilters(base, { inMailbox: jmapMailboxIdOf(entry, mailbox) }) : base;
}
if (parsed.includeTrashAndJunk) return base;
- const excluded = (['trash', 'junk'] as const)
- .map((role) => findMailboxByRole(entry.mailboxes, role))
- .filter((mailbox): mailbox is Mailbox => Boolean(mailbox))
- .map((mailbox) => jmapMailboxId(entry, mailbox));
- return excluded.length > 0 ? andFilters(base, { inMailboxOtherThan: excluded }) : base;
+ return andFilters(base, trashAndJunkExclusion(entry));
}
export const mailProvider: SearchProvider = {
diff --git a/lib/jmap/search-utils.ts b/lib/jmap/search-utils.ts
index 9d3f6b626..19204a867 100644
--- a/lib/jmap/search-utils.ts
+++ b/lib/jmap/search-utils.ts
@@ -125,6 +125,18 @@ export function buildJMAPFilter(
};
}
+type JmapFilter = Record;
+
+/** `AND` two JMAP filters, flattening into an existing top-level `AND`. */
+export function andFilters(base: JmapFilter, extra: JmapFilter | null): JmapFilter {
+ if (!extra || Object.keys(extra).length === 0) return base;
+ if (Object.keys(base).length === 0) return extra;
+ if (base.operator === 'AND' && Array.isArray(base.conditions)) {
+ return { operator: 'AND', conditions: [...(base.conditions as JmapFilter[]), extra] };
+ }
+ return { operator: 'AND', conditions: [base, extra] };
+}
+
export function isFilterEmpty(filters: SearchFilters): boolean {
return (
!filters.from &&
diff --git a/lib/search-scope-folders.ts b/lib/search-scope-folders.ts
index 474971965..464f68762 100644
--- a/lib/search-scope-folders.ts
+++ b/lib/search-scope-folders.ts
@@ -1,5 +1,21 @@
import type { Mailbox } from '@/lib/jmap/types';
+/**
+ * `searchMailboxId` of the "All folders" search scope: every folder of every
+ * account, Trash and Junk included. The default scope, "" ("All folders
+ * except Spam and Trash"), leaves those two out. JMAP ids never contain "*",
+ * so this cannot collide with a folder id.
+ */
+export const SEARCH_SCOPE_ALL_FOLDERS = '*';
+
+/**
+ * Whether `searchMailboxId` searches across folders (the default scope or
+ * "All folders") rather than inside the one folder picked in the dropdown.
+ */
+export function isAllFoldersSearchScope(searchMailboxId: string): boolean {
+ return searchMailboxId === '' || searchMailboxId === SEARCH_SCOPE_ALL_FOLDERS;
+}
+
export interface SearchScopeFolderGroup {
/** Owner JMAP account id; the React key. */
ownerId: string;
diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts
index 436639150..062208fe9 100644
--- a/lib/unified-mailbox.ts
+++ b/lib/unified-mailbox.ts
@@ -1,6 +1,7 @@
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { andFilters } from '@/lib/jmap/search-utils';
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
export interface UnifiedAccountClient {
@@ -82,6 +83,23 @@ export function findMailboxByRole(
return mailboxes.find((m) => m.role === role);
}
+/** The id a JMAP request names this entry's mailbox by (the owner's raw id for shared entries). */
+export function jmapMailboxIdOf(account: UnifiedAccountClient, mailbox: Mailbox): string {
+ return account.isShared ? (mailbox.originalId ?? mailbox.id) : mailbox.id;
+}
+
+/**
+ * The condition that leaves an account's Trash and Junk out of a folder-less
+ * search, or null when the account has neither folder.
+ */
+export function trashAndJunkExclusion(account: UnifiedAccountClient): Record | null {
+ const ids = (['trash', 'junk'] as const)
+ .map((role) => findMailboxByRole(account.mailboxes, role))
+ .filter((mailbox): mailbox is Mailbox => Boolean(mailbox))
+ .map((mailbox) => jmapMailboxIdOf(account, mailbox));
+ return ids.length > 0 ? { inMailboxOtherThan: ids } : null;
+}
+
/**
* Where each account's next page starts. The merged list interleaves the
* accounts, so its length is no single account's position: passing it made
@@ -545,9 +563,18 @@ export async function fetchTagEmails(
);
}
+export interface AcrossAccountsSearchOptions {
+ /**
+ * Leave every account's Trash and Junk out: the search panel's default
+ * scope, "All folders except Spam and Trash". Its "All folders" scope
+ * searches them too.
+ */
+ excludeTrashAndJunk?: boolean;
+}
+
/**
- * Text search over every folder of every given account: the "All folders"
- * scope of the standard search panel. One `searchEmails` per account with no
+ * Text search over every folder of every given account: the folder-less
+ * scopes of the standard search panel. One query per account with no
* `inMailbox` constraint, merged newest first.
*
* The unscoped search used to ask only the login's own account, so mail in
@@ -560,10 +587,18 @@ export async function searchAcrossAccounts(
query: string,
limit: number,
position: FanOutPosition,
+ options: AcrossAccountsSearchOptions = {},
): Promise {
return fanOutAccountQuery(
accounts,
- (account, jmapAccountId) => account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account)),
+ (account, jmapAccountId) => {
+ const exclusion = options.excludeTrashAndJunk ? trashAndJunkExclusion(account) : null;
+ return exclusion
+ ? account.client.advancedSearchEmails(
+ andFilters({ text: query.trim() }, exclusion), jmapAccountId, limit, positionFor(position, account),
+ )
+ : account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account));
+ },
newestFirst,
);
}
@@ -577,10 +612,16 @@ export async function advancedSearchAcrossAccounts(
filter: Record,
limit: number,
position: FanOutPosition,
+ options: AcrossAccountsSearchOptions = {},
): Promise {
return fanOutAccountQuery(
accounts,
- (account, jmapAccountId) => account.client.advancedSearchEmails(filter, jmapAccountId, limit, positionFor(position, account)),
+ (account, jmapAccountId) => account.client.advancedSearchEmails(
+ options.excludeTrashAndJunk ? andFilters(filter, trashAndJunkExclusion(account)) : filter,
+ jmapAccountId,
+ limit,
+ positionFor(position, account),
+ ),
newestFirst,
);
}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 40edbe4a4..1e921e1c2 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "يحتوي النص على...",
"folder": "المجلد",
"all_folders": "جميع المجلدات",
+ "all_folders_except_spam_trash": "جميع المجلدات باستثناء البريد المزعج والمهملات",
"has_attachment": "المرفقات",
"date_after": "بعد",
"date_before": "قبل",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index d6ab95b44..e9c1395d8 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "El cos conté...",
"folder": "Carpeta",
"all_folders": "Totes les carpetes",
+ "all_folders_except_spam_trash": "Totes les carpetes excepte Correu brossa i Paperera",
"has_attachment": "Fitxers adjunts",
"date_after": "Després de",
"date_before": "Abans de",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index c3d7d2516..6260543f1 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Tělo zprávy obsahuje...",
"folder": "Složka",
"all_folders": "Všechny složky",
+ "all_folders_except_spam_trash": "Všechny složky kromě spamu a koše",
"has_attachment": "Obsahuje přílohu",
"date_after": "Po",
"date_before": "Před",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9bdaf8200..30180ae7a 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Brødtekst indeholder...",
"folder": "Mappe",
"all_folders": "Alle mapper",
+ "all_folders_except_spam_trash": "Alle mapper undtagen spam og papirkurv",
"has_attachment": "Vedhæftninger",
"date_after": "Efter",
"date_before": "Før",
diff --git a/locales/de/common.json b/locales/de/common.json
index 32a34dce9..71da3ee1d 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Inhalt enthält...",
"folder": "Ordner",
"all_folders": "Alle Ordner",
+ "all_folders_except_spam_trash": "Alle Ordner außer Spam und Papierkorb",
"has_attachment": "Anhänge",
"date_after": "Nach dem",
"date_before": "Vor dem",
diff --git a/locales/en/common.json b/locales/en/common.json
index 6351deb09..76d715280 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Body contains...",
"folder": "Folder",
"all_folders": "All folders",
+ "all_folders_except_spam_trash": "All folders except Spam and Trash",
"has_attachment": "Attachments",
"date_after": "After",
"date_before": "Before",
diff --git a/locales/es/common.json b/locales/es/common.json
index 19277a354..c8dcd1dd9 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "El cuerpo contiene...",
"folder": "Carpeta",
"all_folders": "Todas las carpetas",
+ "all_folders_except_spam_trash": "Todas las carpetas excepto Spam y Papelera",
"has_attachment": "Adjuntos",
"date_after": "Después de",
"date_before": "Antes de",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index e04947863..365a02908 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "بدنه شامل...",
"folder": "پوشه",
"all_folders": "همه پوشهها",
+ "all_folders_except_spam_trash": "همه پوشهها بهجز هرزنامه و زبالهدان",
"has_attachment": "پیوست",
"date_after": "بعد از",
"date_before": "قبل از",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index cb3a83a84..f089d8ae7 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Le corps contient...",
"folder": "Dossier",
"all_folders": "Tous les dossiers",
+ "all_folders_except_spam_trash": "Tous les dossiers sauf Spam et Corbeille",
"has_attachment": "Pièces jointes",
"date_after": "Après le",
"date_before": "Avant le",
diff --git a/locales/he/common.json b/locales/he/common.json
index facbf4196..f95b0f83d 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3204,6 +3204,7 @@
"body_placeholder": "הגוף מכיל...",
"folder": "תיקיה",
"all_folders": "כל התיקיות",
+ "all_folders_except_spam_trash": "כל התיקיות מלבד ספאם ואשפה",
"has_attachment": "קבצים מצורפים",
"date_after": "אחרי",
"date_before": "לפני",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 918cbb841..29261afc4 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Törzs tartalma...",
"folder": "Mappa",
"all_folders": "Minden mappa",
+ "all_folders_except_spam_trash": "Minden mappa, kivéve a Spam és a Kuka",
"has_attachment": "Csatolmányok",
"date_after": "Ettől",
"date_before": "Eddig",
diff --git a/locales/it/common.json b/locales/it/common.json
index bda0dbe9e..b326a639e 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Il corpo contiene...",
"folder": "Cartella",
"all_folders": "Tutte le cartelle",
+ "all_folders_except_spam_trash": "Tutte le cartelle tranne Spam e Cestino",
"has_attachment": "Allegati",
"date_after": "Dopo il",
"date_before": "Prima del",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index c8c24d96e..6a56e60ff 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "本文に含む...",
"folder": "フォルダー",
"all_folders": "すべてのフォルダー",
+ "all_folders_except_spam_trash": "迷惑メールとゴミ箱を除くすべてのフォルダー",
"has_attachment": "添付ファイル",
"date_after": "以降",
"date_before": "以前",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 980ce19d1..826bd3934 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "내용에 포함된 단어...",
"folder": "폴더",
"all_folders": "모든 폴더",
+ "all_folders_except_spam_trash": "스팸함과 휴지통을 제외한 모든 폴더",
"has_attachment": "첨부파일 있음",
"date_after": "이후",
"date_before": "이전",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index f3ab666f1..44dc45a2f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Teksts satur...",
"folder": "Mape",
"all_folders": "Visas mapes",
+ "all_folders_except_spam_trash": "Visas mapes, izņemot mēstules un atkritnes",
"has_attachment": "Ar pielikumu",
"date_after": "Pēc",
"date_before": "Pirms",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 7181171d0..355e4afc5 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Биед агуулагдах...",
"folder": "Хавтас",
"all_folders": "Бүх хавтаснууд",
+ "all_folders_except_spam_trash": "Спам болон Хогийн савнаас бусад бүх хавтас",
"has_attachment": "Хавсралт",
"date_after": "Дараа",
"date_before": "Өмнө нь",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index c859c67c0..472b61f76 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Meldingsteksten inneholder...",
"folder": "Mappe",
"all_folders": "Alle mapper",
+ "all_folders_except_spam_trash": "Alle mapper unntatt søppelpost og papirkurv",
"has_attachment": "Vedlegg",
"date_after": "Etter",
"date_before": "Før",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 72110bb08..c8d3ed2a8 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Inhoud bevat...",
"folder": "Map",
"all_folders": "Alle mappen",
+ "all_folders_except_spam_trash": "Alle mappen behalve Spam en Prullenbak",
"has_attachment": "Bijlagen",
"date_after": "Na",
"date_before": "Voor",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 35adb3a71..43784a6e9 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Treść zawiera...",
"folder": "Folder",
"all_folders": "Wszystkie foldery",
+ "all_folders_except_spam_trash": "Wszystkie foldery oprócz Spamu i Kosza",
"has_attachment": "Załączniki",
"date_after": "Po",
"date_before": "Przed",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index d32107790..0b85d3537 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "O corpo contém...",
"folder": "Pasta",
"all_folders": "Todas as pastas",
+ "all_folders_except_spam_trash": "Todas as pastas exceto Spam e Lixeira",
"has_attachment": "Anexos",
"date_after": "Após",
"date_before": "Antes de",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index dbfb3d78f..05b473546 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Corpul conține...",
"folder": "Dosar",
"all_folders": "Toate folderele",
+ "all_folders_except_spam_trash": "Toate folderele, cu excepția Spam și Coș de gunoi",
"has_attachment": "Anexe",
"date_after": "După",
"date_before": "Înainte",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 6c5be0f5a..a6e5f6485 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Текст содержит...",
"folder": "Папка",
"all_folders": "Все папки",
+ "all_folders_except_spam_trash": "Все папки, кроме спама и корзины",
"has_attachment": "Вложения",
"date_after": "После",
"date_before": "До",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 7234cbf76..c7dcd0cf7 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Telo správy obsahuje...",
"folder": "Priečinok",
"all_folders": "Všetky priečinky",
+ "all_folders_except_spam_trash": "Všetky priečinky okrem spamu a koša",
"has_attachment": "Prílohy",
"date_after": "Po",
"date_before": "Pred",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 0cdabeaab..224d2ff06 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "Gövde içerir...",
"folder": "Klasör",
"all_folders": "Tüm klasörler",
+ "all_folders_except_spam_trash": "Spam ve Çöp Kutusu dışındaki tüm klasörler",
"has_attachment": "Ekler",
"date_after": "Sonra",
"date_before": "Önce",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index e34a75da5..46ce48376 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "Тіло містить...",
"folder": "Папка",
"all_folders": "Всі папки",
+ "all_folders_except_spam_trash": "Всі папки, крім спаму та кошика",
"has_attachment": "Додатки",
"date_after": "Після",
"date_before": "Раніше",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index ae3679bba..7c4a02241 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3322,6 +3322,7 @@
"body_placeholder": "內文包含…",
"folder": "信件匣",
"all_folders": "所有信件匣",
+ "all_folders_except_spam_trash": "除垃圾郵件和垃圾桶外的所有信件匣",
"has_attachment": "附件",
"date_after": "後",
"date_before": "前",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 196c79310..d0bd048fd 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3280,6 +3280,7 @@
"body_placeholder": "正文包含...",
"folder": "文件夹",
"all_folders": "所有文件夹",
+ "all_folders_except_spam_trash": "除垃圾邮件和已删除外的所有文件夹",
"has_attachment": "附件",
"date_after": "后",
"date_before": "前",
diff --git a/stores/__tests__/email-store-search-accounts.test.ts b/stores/__tests__/email-store-search-accounts.test.ts
index 73edf5b04..d6508e8fe 100644
--- a/stores/__tests__/email-store-search-accounts.test.ts
+++ b/stores/__tests__/email-store-search-accounts.test.ts
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../settings-store';
import { useAuthStore } from '../auth-store';
import { useMessageListTabsStore } from '../message-list-tabs-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { SEARCH_SCOPE_ALL_FOLDERS } from '@/lib/search-scope-folders';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -202,3 +203,102 @@ describe('"All folders" search across the own and group accounts (#1082)', () =>
expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1', 'grp-2']);
});
});
+
+// 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.
+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;
+ const groupTrash = {
+ id: 'group:trash', originalId: 'trash', name: 'Lixeira', role: 'trash',
+ isShared: true, accountId: 'group', accountName: 'chamados@server.tld',
+ } as Mailbox;
+ let client: ReturnType;
+
+ const exclusionsSent = () => client.advancedSearchEmails.mock.calls.map(([filter, accountId]) => [
+ accountId,
+ ((filter.conditions ?? []) as Record[]).find(c => 'inMailboxOtherThan' in c)?.inMailboxOtherThan,
+ ]);
+
+ beforeEach(() => {
+ client = makeClient();
+ useAuthStore.setState({
+ activeAccountId: 'login',
+ getClientForAccount: ((id: string) => (id === 'login' ? client : undefined)) as never,
+ getAllConnectedClients: (() => new Map([['login', client]])) as never,
+ } as never);
+ useMessageListTabsStore.setState(useMessageListTabsStore.getInitialState());
+ useSettingsStore.setState({
+ emailsPerPage: 50, messageListOrder: [], messageListOrderScope: 'inbox', emailKeywords: [],
+ } as never);
+ useEmailStore.setState({
+ ...useEmailStore.getInitialState(),
+ selectedMailbox: ownInbox.id,
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS },
+ mailboxes: [ownInbox, ownTrash, ownJunk, groupInbox, groupTrash],
+ });
+ });
+
+ it('defaults to leaving every account\'s Spam and Trash out', async () => {
+ expect(useEmailStore.getState().searchMailboxId).toBe('');
+
+ await useEmailStore.getState().searchEmails(client, 'acesso');
+
+ expect(client.searchEmails).not.toHaveBeenCalled();
+ expect(client.advancedSearchEmails.mock.calls.map(([filter, accountId]) => [accountId, filter])).toEqual([
+ [undefined, { operator: 'AND', conditions: [{ text: 'acesso' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
+ ['group', { operator: 'AND', conditions: [{ text: 'acesso' }, { inMailboxOtherThan: ['trash'] }] }],
+ ]);
+ expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['grp-1', 'grp-2']);
+ });
+
+ it('keeps them out of the advanced search, the next page and a push refresh', async () => {
+ useEmailStore.setState({ searchQuery: 'a', searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } });
+ await useEmailStore.getState().advancedSearch(client);
+
+ useSettingsStore.setState({ emailsPerPage: 1 } as never);
+ await useEmailStore.getState().advancedSearch(client);
+ await useEmailStore.getState().loadMoreEmails(client);
+
+ await useEmailStore.getState().handleStateChange({
+ '@type': 'StateChange', changed: { group: { Email: '2' } },
+ }, client);
+
+ const sent = exclusionsSent();
+ expect(sent.length).toBeGreaterThanOrEqual(8);
+ for (const [accountId, excluded] of sent) {
+ expect(excluded).toEqual(accountId === 'group' ? ['trash'] : ['trash', 'junk']);
+ }
+ });
+
+ it('searches them too under "All folders"', async () => {
+ useEmailStore.getState().setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS);
+
+ await useEmailStore.getState().searchEmails(client, 'acesso');
+
+ expect(client.advancedSearchEmails).not.toHaveBeenCalled();
+ expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0);
+ expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0);
+
+ client.searchEmails.mockClear();
+ useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } });
+ await useEmailStore.getState().advancedSearch(client);
+ expect(exclusionsSent()).toEqual([[undefined, undefined], ['group', undefined]]);
+ });
+
+ it('searches a picked Trash folder alone', async () => {
+ useEmailStore.getState().setSearchMailboxId(ownTrash.id);
+
+ await useEmailStore.getState().searchEmails(client, 'fatura');
+
+ expect(client.searchEmails).toHaveBeenCalledTimes(1);
+ expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0);
+ });
+
+ it('goes back to the default scope when the filters are cleared', () => {
+ useEmailStore.getState().setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS);
+ useEmailStore.getState().clearSearchFilters();
+ expect(useEmailStore.getState().searchMailboxId).toBe('');
+ });
+});
diff --git a/stores/__tests__/email-store-search-hit-account.test.ts b/stores/__tests__/email-store-search-hit-account.test.ts
index 83b0aa101..0c13ce67f 100644
--- a/stores/__tests__/email-store-search-hit-account.test.ts
+++ b/stores/__tests__/email-store-search-hit-account.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { resolveUnstampedEmailAccountId } from '../email-store';
import type { Mailbox } from '@/lib/jmap/types';
+import { SEARCH_SCOPE_ALL_FOLDERS } from '@/lib/search-scope-folders';
// #923: with an unscoped ("All folders") search active while a shared folder
// is selected, the hit belongs to the primary account. Deriving the owner
@@ -12,13 +13,13 @@ const mailboxes = [
] as unknown as Mailbox[];
describe('resolveUnstampedEmailAccountId (#923)', () => {
- it('uses the primary account for an unscoped search even with a shared folder selected', () => {
+ it.each(['', SEARCH_SCOPE_ALL_FOLDERS])('uses the primary account for an unscoped search (%j) even with a shared folder selected', (scope) => {
expect(
resolveUnstampedEmailAccountId({
mailboxes,
selectedMailbox: 'owner-x:x-inbox',
searchActive: true,
- searchMailboxId: '',
+ searchMailboxId: scope,
}),
).toBeUndefined();
});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 7dc336b38..d35e910fa 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -10,7 +10,8 @@ import { emailHooks } from "@/lib/plugin-hooks";
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 UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
+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 { useAuthStore } from "@/stores/auth-store";
import { currentStoreEpoch } from "@/lib/store-epoch";
import { keywordPointer } from "@/lib/jmap/patch-pointer";
@@ -688,7 +689,7 @@ export function resolveUnstampedEmailAccountId(opts: {
searchActive: boolean;
searchMailboxId: string;
}): string | undefined {
- if (opts.searchActive && opts.searchMailboxId === '') return undefined;
+ if (opts.searchActive && isAllFoldersSearchScope(opts.searchMailboxId)) return undefined;
const mailbox = opts.mailboxes.find(mb => mb.id === opts.selectedMailbox);
return mailbox?.isShared ? mailbox.accountId : undefined;
}
@@ -1091,16 +1092,26 @@ export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAc
}
/**
- * Whether a search is running under the "All folders" scope (`searchMailboxId`
- * "") of the standard (non-unified) list. That scope spans every folder of the
- * own account AND of the group/shared accounts this login reaches, so the
- * search fans out like a tag view and stamps its hits with their source
- * account (#1082). A search scoped to one folder stays single-account.
+ * Whether a search is running under a folder-less scope (the default "All
+ * folders except Spam and Trash" or "All folders", see
+ * isAllFoldersSearchScope) of the standard (non-unified) list. Those scopes
+ * span the folders of the own account AND of the group/shared accounts this
+ * login reaches, so the search fans out like a tag view and stamps its hits
+ * with their source account (#1082). A search scoped to one folder stays
+ * single-account.
*/
function isUnscopedSearchActive(
s: Pick,
): boolean {
- return !s.isUnifiedView && s.searchMailboxId === '' && (!!s.searchQuery || !isFilterEmpty(s.searchFilters));
+ return !s.isUnifiedView && isAllFoldersSearchScope(s.searchMailboxId) && (!!s.searchQuery || !isFilterEmpty(s.searchFilters));
+}
+
+/**
+ * The fan-out options of a folder-less search scope: only "All folders"
+ * searches Trash and Junk, the default scope ("") leaves them out.
+ */
+function acrossAccountsSearchOptions(searchMailboxId: string): AcrossAccountsSearchOptions {
+ return { excludeTrashAndJunk: searchMailboxId !== SEARCH_SCOPE_ALL_FOLDERS };
}
/**
@@ -2072,13 +2083,14 @@ export const useEmailStore = create((set, get) => ({
// Paginate with the same scope the search itself ran under, not the
// folder that happens to be open in the list.
const { searchMailboxId } = get();
- if (searchMailboxId === '') {
- // "All folders": the next page of the same own + group account
+ if (isAllFoldersSearchScope(searchMailboxId)) {
+ // Folder-less scope: the next page of the same own + group account
// fan-out the search ran (#1082).
const built = buildTagViewAccountClients(client);
+ const scope = acrossAccountsSearchOptions(searchMailboxId);
result = hasFilters
- ? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails))
- : await searchAcrossAccounts(built, searchQuery, emailsPerPage, positionsByAccount(emails));
+ ? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails), scope)
+ : await searchAcrossAccounts(built, searchQuery, emailsPerPage, positionsByAccount(emails), scope);
set({ unifiedErrors: result.errors });
} else {
const mailboxes = resolveActionMailboxes();
@@ -2912,13 +2924,14 @@ export const useEmailStore = create((set, get) => ({
);
unifiedErrors = result.errors;
- } else if (searchMailboxId === '') {
- // "All folders" (the default): every folder of the own account AND of
- // every group/shared account this login reaches - the same fan-out the
- // tag views use (#1038). A single own-account query silently skipped
- // the shared accounts' mail (#1082).
+ } else if (isAllFoldersSearchScope(searchMailboxId)) {
+ // Folder-less scope: the folders of the own account AND of every
+ // group/shared account this login reaches - the same fan-out the tag
+ // views use (#1038). A single own-account query silently skipped the
+ // shared accounts' mail (#1082). The default scope leaves every
+ // account's Trash and Junk out; "All folders" searches them too.
const built = buildTagViewAccountClients(client);
- result = await searchAcrossAccounts(built, query, emailsPerPage, 0);
+ result = await searchAcrossAccounts(built, query, emailsPerPage, 0, acrossAccountsSearchOptions(searchMailboxId));
unifiedErrors = result.errors;
} else {
@@ -3035,12 +3048,13 @@ export const useEmailStore = create((set, get) => ({
);
unifiedErrors = result.errors;
- } else if (searchMailboxId === '') {
- // "All folders": fan out over the own + group/shared accounts with a
- // filter that carries no inMailbox clause (#1082).
+ } else if (isAllFoldersSearchScope(searchMailboxId)) {
+ // Folder-less scope: fan out over the own + group/shared accounts with
+ // a filter that carries no inMailbox clause (#1082).
const built = buildTagViewAccountClients(client);
result = await advancedSearchAcrossAccounts(
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
+ acrossAccountsSearchOptions(searchMailboxId),
);
unifiedErrors = result.errors;
@@ -4117,12 +4131,13 @@ export const useEmailStore = create((set, get) => ({
// A refresh while a search is active must re-run it under the
// search's own folder scope, which is independent of selectedMailbox.
const { searchMailboxId } = get();
- if (searchMailboxId === '') {
- // "All folders": the same own + group account fan-out the search
- // ran, so a change in a group account reaches the list (#1082).
+ if (isAllFoldersSearchScope(searchMailboxId)) {
+ // Folder-less scope: the same own + group account fan-out the
+ // search ran, so a change in a group account reaches the list (#1082).
const built = buildTagViewAccountClients(client);
result = await advancedSearchAcrossAccounts(
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
+ acrossAccountsSearchOptions(searchMailboxId),
);
unifiedErrors = result.errors;
} else {