mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: leave spam and trash out of search by default
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3322,6 +3322,7 @@
|
||||
"body_placeholder": "يحتوي النص على...",
|
||||
"folder": "المجلد",
|
||||
"all_folders": "جميع المجلدات",
|
||||
"all_folders_except_spam_trash": "جميع المجلدات باستثناء البريد المزعج والمهملات",
|
||||
"has_attachment": "المرفقات",
|
||||
"date_after": "بعد",
|
||||
"date_before": "قبل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3322,6 +3322,7 @@
|
||||
"body_placeholder": "بدنه شامل...",
|
||||
"folder": "پوشه",
|
||||
"all_folders": "همه پوشهها",
|
||||
"all_folders_except_spam_trash": "همه پوشهها بهجز هرزنامه و زبالهدان",
|
||||
"has_attachment": "پیوست",
|
||||
"date_after": "بعد از",
|
||||
"date_before": "قبل از",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3204,6 +3204,7 @@
|
||||
"body_placeholder": "הגוף מכיל...",
|
||||
"folder": "תיקיה",
|
||||
"all_folders": "כל התיקיות",
|
||||
"all_folders_except_spam_trash": "כל התיקיות מלבד ספאם ואשפה",
|
||||
"has_attachment": "קבצים מצורפים",
|
||||
"date_after": "אחרי",
|
||||
"date_before": "לפני",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3280,6 +3280,7 @@
|
||||
"body_placeholder": "本文に含む...",
|
||||
"folder": "フォルダー",
|
||||
"all_folders": "すべてのフォルダー",
|
||||
"all_folders_except_spam_trash": "迷惑メールとゴミ箱を除くすべてのフォルダー",
|
||||
"has_attachment": "添付ファイル",
|
||||
"date_after": "以降",
|
||||
"date_before": "以前",
|
||||
|
||||
@@ -3280,6 +3280,7 @@
|
||||
"body_placeholder": "내용에 포함된 단어...",
|
||||
"folder": "폴더",
|
||||
"all_folders": "모든 폴더",
|
||||
"all_folders_except_spam_trash": "스팸함과 휴지통을 제외한 모든 폴더",
|
||||
"has_attachment": "첨부파일 있음",
|
||||
"date_after": "이후",
|
||||
"date_before": "이전",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3322,6 +3322,7 @@
|
||||
"body_placeholder": "Биед агуулагдах...",
|
||||
"folder": "Хавтас",
|
||||
"all_folders": "Бүх хавтаснууд",
|
||||
"all_folders_except_spam_trash": "Спам болон Хогийн савнаас бусад бүх хавтас",
|
||||
"has_attachment": "Хавсралт",
|
||||
"date_after": "Дараа",
|
||||
"date_before": "Өмнө нь",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3280,6 +3280,7 @@
|
||||
"body_placeholder": "Текст содержит...",
|
||||
"folder": "Папка",
|
||||
"all_folders": "Все папки",
|
||||
"all_folders_except_spam_trash": "Все папки, кроме спама и корзины",
|
||||
"has_attachment": "Вложения",
|
||||
"date_after": "После",
|
||||
"date_before": "До",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3280,6 +3280,7 @@
|
||||
"body_placeholder": "Тіло містить...",
|
||||
"folder": "Папка",
|
||||
"all_folders": "Всі папки",
|
||||
"all_folders_except_spam_trash": "Всі папки, крім спаму та кошика",
|
||||
"has_attachment": "Додатки",
|
||||
"date_after": "Після",
|
||||
"date_before": "Раніше",
|
||||
|
||||
@@ -3322,6 +3322,7 @@
|
||||
"body_placeholder": "內文包含…",
|
||||
"folder": "信件匣",
|
||||
"all_folders": "所有信件匣",
|
||||
"all_folders_except_spam_trash": "除垃圾郵件和垃圾桶外的所有信件匣",
|
||||
"has_attachment": "附件",
|
||||
"date_after": "後",
|
||||
"date_before": "前",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user