feat: leave spam and trash out of search by default

This commit is contained in:
Linus Rath
2026-09-29 19:52:39 +02:00
parent 0b3e47e3d2
commit 763732fdc9
38 changed files with 320 additions and 71 deletions
+11 -9
View File
@@ -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 = {}) {
</div>
{/* 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 && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
@@ -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"
>
<option value="">{t("advanced_search.all_folders")}</option>
<option value="">{t("advanced_search.all_folders_except_spam_trash")}</option>
<option value={SEARCH_SCOPE_ALL_FOLDERS}>{t("advanced_search.all_folders")}</option>
{searchScopeFolders.own.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.name}
@@ -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);
});
});
+14 -9
View File
@@ -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<string, unknown>): 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<string, unknown>, _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);
@@ -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');
+4 -22
View File
@@ -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<string, unknown>;
/** `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 = {
+12
View File
@@ -125,6 +125,18 @@ export function buildJMAPFilter(
};
}
type JmapFilter = Record<string, unknown>;
/** `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 &&
+16
View File
@@ -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;
+45 -4
View File
@@ -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<string, unknown> | 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<UnifiedFetchResult> {
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<string, unknown>,
limit: number,
position: FanOutPosition,
options: AcrossAccountsSearchOptions = {},
): Promise<UnifiedFetchResult> {
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,
);
}
+1
View File
@@ -3322,6 +3322,7 @@
"body_placeholder": "يحتوي النص على...",
"folder": "المجلد",
"all_folders": "جميع المجلدات",
"all_folders_except_spam_trash": "جميع المجلدات باستثناء البريد المزعج والمهملات",
"has_attachment": "المرفقات",
"date_after": "بعد",
"date_before": "قبل",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -3322,6 +3322,7 @@
"body_placeholder": "بدنه شامل...",
"folder": "پوشه",
"all_folders": "همه پوشه‌ها",
"all_folders_except_spam_trash": "همه پوشه‌ها به‌جز هرزنامه و زباله‌دان",
"has_attachment": "پیوست",
"date_after": "بعد از",
"date_before": "قبل از",
+1
View File
@@ -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",
+1
View File
@@ -3204,6 +3204,7 @@
"body_placeholder": "הגוף מכיל...",
"folder": "תיקיה",
"all_folders": "כל התיקיות",
"all_folders_except_spam_trash": "כל התיקיות מלבד ספאם ואשפה",
"has_attachment": "קבצים מצורפים",
"date_after": "אחרי",
"date_before": "לפני",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -3280,6 +3280,7 @@
"body_placeholder": "本文に含む...",
"folder": "フォルダー",
"all_folders": "すべてのフォルダー",
"all_folders_except_spam_trash": "迷惑メールとゴミ箱を除くすべてのフォルダー",
"has_attachment": "添付ファイル",
"date_after": "以降",
"date_before": "以前",
+1
View File
@@ -3280,6 +3280,7 @@
"body_placeholder": "내용에 포함된 단어...",
"folder": "폴더",
"all_folders": "모든 폴더",
"all_folders_except_spam_trash": "스팸함과 휴지통을 제외한 모든 폴더",
"has_attachment": "첨부파일 있음",
"date_after": "이후",
"date_before": "이전",
+1
View File
@@ -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",
+1
View File
@@ -3322,6 +3322,7 @@
"body_placeholder": "Биед агуулагдах...",
"folder": "Хавтас",
"all_folders": "Бүх хавтаснууд",
"all_folders_except_spam_trash": "Спам болон Хогийн савнаас бусад бүх хавтас",
"has_attachment": "Хавсралт",
"date_after": "Дараа",
"date_before": "Өмнө нь",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -3280,6 +3280,7 @@
"body_placeholder": "Текст содержит...",
"folder": "Папка",
"all_folders": "Все папки",
"all_folders_except_spam_trash": "Все папки, кроме спама и корзины",
"has_attachment": "Вложения",
"date_after": "После",
"date_before": "До",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -3280,6 +3280,7 @@
"body_placeholder": "Тіло містить...",
"folder": "Папка",
"all_folders": "Всі папки",
"all_folders_except_spam_trash": "Всі папки, крім спаму та кошика",
"has_attachment": "Додатки",
"date_after": "Після",
"date_before": "Раніше",
+1
View File
@@ -3322,6 +3322,7 @@
"body_placeholder": "內文包含…",
"folder": "信件匣",
"all_folders": "所有信件匣",
"all_folders_except_spam_trash": "除垃圾郵件和垃圾桶外的所有信件匣",
"has_attachment": "附件",
"date_after": "後",
"date_before": "前",
+1
View File
@@ -3280,6 +3280,7 @@
"body_placeholder": "正文包含...",
"folder": "文件夹",
"all_folders": "所有文件夹",
"all_folders_except_spam_trash": "除垃圾邮件和已删除外的所有文件夹",
"has_attachment": "附件",
"date_after": "后",
"date_before": "前",
@@ -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<typeof makeClient>;
const exclusionsSent = () => client.advancedSearchEmails.mock.calls.map(([filter, accountId]) => [
accountId,
((filter.conditions ?? []) as Record<string, unknown>[]).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('');
});
});
@@ -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();
});
+39 -24
View File
@@ -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<EmailStore, 'isUnifiedView' | 'searchMailboxId' | 'searchQuery' | 'searchFilters'>,
): 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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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 {