From f15b7bb3cf7367a27cfe6fa3124c6ca4d8549641 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:50:02 +0100
Subject: [PATCH] feat: search mail, contacts, events and files of every
account at once
---
.../__tests__/global-search-providers.test.ts | 103 ++++
.../live-stalwart-global-search.test.ts | 172 ++++++
src/api/global-search.ts | 516 ++++++++++++++++++
.../__tests__/global-search-wiring.test.ts | 25 +
src/components/search/GlobalSearchSheet.tsx | 340 ++++++++++++
.../__tests__/global-search-open-hit.test.ts | 77 +++
src/lib/__tests__/global-search.test.ts | 226 ++++++++
src/lib/global-search/filters.ts | 181 ++++++
src/lib/global-search/open-hit.ts | 74 +++
src/lib/global-search/query-parser.ts | 205 +++++++
src/lib/global-search/rank.ts | 77 +++
src/lib/global-search/run.ts | 132 +++++
src/lib/global-search/types.ts | 113 ++++
src/lib/global-search/use-global-search.ts | 64 +++
src/screens/EmailListScreen.tsx | 27 +-
15 files changed, 2330 insertions(+), 2 deletions(-)
create mode 100644 src/api/__tests__/global-search-providers.test.ts
create mode 100644 src/api/__tests__/live-stalwart-global-search.test.ts
create mode 100644 src/api/global-search.ts
create mode 100644 src/components/__tests__/global-search-wiring.test.ts
create mode 100644 src/components/search/GlobalSearchSheet.tsx
create mode 100644 src/lib/__tests__/global-search-open-hit.test.ts
create mode 100644 src/lib/__tests__/global-search.test.ts
create mode 100644 src/lib/global-search/filters.ts
create mode 100644 src/lib/global-search/open-hit.ts
create mode 100644 src/lib/global-search/query-parser.ts
create mode 100644 src/lib/global-search/rank.ts
create mode 100644 src/lib/global-search/run.ts
create mode 100644 src/lib/global-search/types.ts
create mode 100644 src/lib/global-search/use-global-search.ts
diff --git a/src/api/__tests__/global-search-providers.test.ts b/src/api/__tests__/global-search-providers.test.ts
new file mode 100644
index 00000000..113916f2
--- /dev/null
+++ b/src/api/__tests__/global-search-providers.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { client } = vi.hoisted(() => ({
+ client: {
+ isConnected: true,
+ username: 'me@example.org',
+ serverUrl: 'https://mail.example.org',
+ accountId: 'own',
+ currentSession: null as unknown,
+ request: vi.fn(),
+ getStoredCredentials: vi.fn(),
+ },
+}));
+vi.mock('../jmap-client', () => ({
+ jmapClient: client,
+ rewriteSessionUrl: (url: string) => url,
+ extractOrigin: () => null,
+}));
+
+vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: null }) } }));
+vi.mock('../../stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } }));
+vi.mock('../../stores/email-store', () => ({ useEmailStore: { getState: () => ({ emails: [], mailboxes: [] }) } }));
+vi.mock('../../stores/contacts-store', () => ({ useContactsStore: { getState: () => ({ contacts: [], addressBooks: [] }) } }));
+vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ events: [], calendars: [] }) } }));
+
+import { accountsFor, mailProvider, contactsProvider, resetGlobalSearchCache } from '../global-search';
+import { parseSearchQuery } from '../../lib/global-search/query-parser';
+import { generateAccountId } from '../../lib/account-utils';
+import type { JMAPSession } from '../types';
+
+const MAIL = 'urn:ietf:params:jmap:mail';
+const CONTACTS = 'urn:ietf:params:jmap:contacts';
+
+const session = {
+ capabilities: { 'urn:ietf:params:jmap:core': {}, [MAIL]: {}, [CONTACTS]: {} },
+ primaryAccounts: { [MAIL]: 'own', [CONTACTS]: 'own' },
+ accounts: {
+ own: { name: 'me@example.org', isPersonal: true, accountCapabilities: { [MAIL]: {}, [CONTACTS]: {} } },
+ team: { name: 'Support', isPersonal: false, accountCapabilities: { [MAIL]: {} } },
+ },
+} as unknown as JMAPSession;
+
+const live = {
+ localAccountId: generateAccountId('me@example.org', 'https://mail.example.org'),
+ label: 'Me', email: 'me@example.org', active: true,
+};
+
+beforeEach(() => {
+ resetGlobalSearchCache();
+ client.request.mockReset();
+ client.currentSession = session;
+});
+
+describe('the JMAP accounts a login searches', () => {
+ it('lists the own account first, then the shared ones that hold the kind', () => {
+ expect(accountsFor(session, MAIL).map((a) => [a.id, a.primary])).toEqual([['own', true], ['team', false]]);
+ expect(accountsFor(session, CONTACTS).map((a) => a.id)).toEqual(['own']);
+ });
+});
+
+describe('mail search of the signed-in login', () => {
+ it('searches every folder but Trash and Junk of each account, with snippets, in one request per account', async () => {
+ client.request.mockImplementation(async (calls: unknown[][]) => {
+ const [method, args] = calls[0] as [string, { accountId: string }];
+ if (method === 'Mailbox/get') {
+ return { methodResponses: [['Mailbox/get', { list: [{ id: `${args.accountId}-in`, role: 'inbox', name: 'Inbox' }, { id: `${args.accountId}-tr`, role: 'trash', name: 'Trash' }] }, '0']] };
+ }
+ return {
+ methodResponses: [
+ ['Email/query', { ids: [`${args.accountId}-m1`] }, 'q'],
+ ['Email/get', { list: [{ id: `${args.accountId}-m1`, threadId: 't', subject: 'Invoice', mailboxIds: { [`${args.accountId}-in`]: true } }] }, 'g'],
+ ['SearchSnippet/get', { list: [{ emailId: `${args.accountId}-m1`, subject: 'Invoice', preview: null }] }, 's'],
+ ],
+ };
+ });
+ const { hits } = await mailProvider.remote(parseSearchQuery('invoice'), live, { limit: 10, signal: new AbortController().signal });
+
+ const query = client.request.mock.calls.find(([calls]) => calls[0][0] === 'Email/query')![0];
+ expect(query[0][1].filter).toEqual({ operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['own-tr'] }] });
+ expect(query.map((c: unknown[]) => c[0])).toEqual(['Email/query', 'Email/get', 'SearchSnippet/get']);
+ expect(hits.map((h) => [h.id, h.jmapAccountId, h.shared, h.subtitle])).toEqual([
+ ['own-m1', 'own', false, 'Inbox'],
+ ['team-m1', 'team', true, 'Support · Inbox'],
+ ]);
+ expect(hits[0].kind === 'mail' && hits[0].snippet?.subject).toBe('Invoice');
+ });
+});
+
+describe('contact search of the signed-in login', () => {
+ it('asks ContactCard/query with the text and names the card by the id the contacts list uses', async () => {
+ client.request.mockResolvedValue({
+ methodResponses: [
+ ['ContactCard/query', { ids: ['c1'] }, 'q'],
+ ['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { b1: true }, name: { full: 'Anna Berg' }, emails: { e: { address: 'anna@x.example' } } }] }, 'g'],
+ ['AddressBook/get', { list: [{ id: 'b1', name: 'Personal' }] }, 'b'],
+ ],
+ });
+ const { hits } = await contactsProvider.remote(parseSearchQuery('anna'), live, { limit: 10, signal: new AbortController().signal });
+ expect(client.request.mock.calls[0][0][0]).toEqual(['ContactCard/query', { accountId: 'own', filter: { text: 'anna' }, limit: 11 }, 'q']);
+ expect(hits).toHaveLength(1);
+ expect(hits[0]).toMatchObject({ kind: 'contacts', id: 'c1', storeId: 'c1', subtitle: 'Personal · anna@x.example' });
+ });
+});
diff --git a/src/api/__tests__/live-stalwart-global-search.test.ts b/src/api/__tests__/live-stalwart-global-search.test.ts
new file mode 100644
index 00000000..9fe1698f
--- /dev/null
+++ b/src/api/__tests__/live-stalwart-global-search.test.ts
@@ -0,0 +1,172 @@
+/**
+ * Live checks of "Search everything" against a real Stalwart: one word finds
+ * a message, a contact, an event and a folder of the signed-in login, and a
+ * contact of a second login that is searched "detached", with its stored
+ * credentials, while the first stays connected.
+ *
+ * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`,
+ * `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2`
+ * defaults to the first password).
+ *
+ * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
+ * BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
+ * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
+ * npx vitest run src/api/__tests__/live-stalwart-global-search.test.ts
+ *
+ * Everything the run makes is removed again, also after a failed check.
+ */
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+
+const { secrets } = vi.hoisted(() => ({ secrets: new Map() }));
+vi.mock('expo-secure-store', () => ({
+ setItemAsync: vi.fn(async (key: string, value: string) => { secrets.set(key, value); }),
+ getItemAsync: vi.fn(async (key: string) => secrets.get(key) ?? null),
+ deleteItemAsync: vi.fn(async (key: string) => { secrets.delete(key); }),
+}));
+
+import { JMAPClient, credentialsKey } from '../jmap-client';
+import { CAPABILITIES } from '../types';
+import { GLOBAL_SEARCH_PROVIDERS, resetGlobalSearchCache } from '../global-search';
+import { runGlobalSearch } from '../../lib/global-search/run';
+import { parseSearchQuery } from '../../lib/global-search/query-parser';
+import type { SearchAccount, SearchOutcome } from '../../lib/global-search/types';
+import { generateAccountId } from '../../lib/account-utils';
+import { createFolder, deleteFileNodes } from '../files';
+
+const SERVER = process.env.BULWARK_LIVE_JMAP;
+const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
+const PASS = process.env.BULWARK_LIVE_PASS ?? '';
+const USER2 = process.env.BULWARK_LIVE_USER2;
+const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS;
+const live = SERVER && PASS && USER2 ? describe : describe.skip;
+
+// One word nobody else has, so the full-text index finds only this run.
+const TOKEN = `qz${Date.now().toString(36)}`;
+
+type Responses = Array<[string, Record, string]>;
+
+let owner: JMAPClient;
+let second: JMAPClient;
+const made: Array<{ client: () => JMAPClient; method: string; accountId: string; id: string; using: string[] }> = [];
+
+async function call(client: JMAPClient, calls: unknown[], using: string[]): Promise {
+ return (await client.request(calls as never, using)).methodResponses as Responses;
+}
+
+async function createOne(client: JMAPClient, method: string, accountId: string, object: Record, using: string[]): Promise {
+ const [[name, body]] = await call(client, [[method, { accountId, create: { x: object } }, '0']], using);
+ expect(name, JSON.stringify(body)).toBe(method);
+ const id = body.created?.x?.id as string | undefined;
+ expect(id, JSON.stringify(body.notCreated)).toBeTruthy();
+ made.push({ client: () => client, method, accountId, id: id!, using });
+ return id!;
+}
+
+async function seedContact(client: JMAPClient): Promise {
+ const accountId = client.currentSession!.primaryAccounts[CAPABILITIES.CONTACTS];
+ const using = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
+ const [[, books]] = await call(client, [['AddressBook/get', { accountId, properties: ['id', 'isDefault'] }, '0']], using);
+ const book = (books.list as { id: string; isDefault?: boolean }[]).find((b) => b.isDefault) ?? books.list[0];
+ return createOne(client, 'ContactCard/set', accountId, {
+ '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book.id]: true },
+ uid: `urn:uuid:${TOKEN}-${client.username}`,
+ // 0.16 finds a card by the words of its name components, not of `full`.
+ name: { components: [{ kind: 'given', value: 'Findme' }, { kind: 'surname', value: TOKEN }], isOrdered: true, full: `Findme ${TOKEN}` },
+ }, using);
+}
+
+const accountFor = (user: string, active: boolean): SearchAccount => ({
+ localAccountId: generateAccountId(user, SERVER!), label: user, email: user, serverUrl: SERVER, active,
+});
+
+async function search(accounts: SearchAccount[]): Promise {
+ return runGlobalSearch({
+ parsed: parseSearchQuery(TOKEN), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
+ localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
+ });
+}
+
+live('live Stalwart: search everything', () => {
+ let ids: { mail: string; contact: string; event: string; folder: string | null; secondContact: string };
+
+ beforeAll(async () => {
+ const mod = await import('../jmap-client');
+ owner = new JMAPClient();
+ await owner.connect(SERVER!, USER, PASS);
+ (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
+ second = new JMAPClient();
+ await second.connect(SERVER!, USER2!, PASS2);
+ // The second login is searched with what the app keeps in secure storage.
+ secrets.set(credentialsKey(generateAccountId(USER2!, SERVER!)), JSON.stringify({ serverUrl: SERVER, username: USER2, password: PASS2 }));
+ resetGlobalSearchCache();
+
+ const mailAccount = owner.accountId;
+ const mailUsing = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
+ const [[, drafts]] = await call(owner, [['Mailbox/query', { accountId: mailAccount, filter: { role: 'drafts' } }, '0']], mailUsing);
+ const mail = await createOne(owner, 'Email/set', mailAccount, {
+ mailboxIds: { [drafts.ids[0]]: true }, keywords: { $draft: true, $seen: true },
+ from: [{ email: USER }], subject: `Findme ${TOKEN}`,
+ bodyValues: { b: { value: 'search check' } }, textBody: [{ partId: 'b', type: 'text/plain' }],
+ }, mailUsing);
+ const contact = await seedContact(owner);
+
+ const calAccount = owner.currentSession!.primaryAccounts[CAPABILITIES.CALENDARS];
+ const calUsing = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
+ const [[, calendars]] = await call(owner, [['Calendar/get', { accountId: calAccount, properties: ['id', 'isDefault'] }, '0']], calUsing);
+ const calendar = (calendars.list as { id: string; isDefault?: boolean }[]).find((c) => c.isDefault) ?? calendars.list[0];
+ const event = await createOne(owner, 'CalendarEvent/set', calAccount, {
+ '@type': 'Event', calendarIds: { [calendar.id]: true }, title: `Findme ${TOKEN}`,
+ start: '2027-06-01T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
+ }, calUsing);
+
+ let folder: string | null = null;
+ if (owner.currentSession!.capabilities[CAPABILITIES.FILES]) {
+ // The app's own call: 1.0 wants the draft's @type, 0.16 refuses it.
+ folder = (await createFolder(`findme-${TOKEN}`, null)).id;
+ }
+ const secondContact = await seedContact(second);
+ ids = { mail, contact, event, folder, secondContact };
+ }, 90_000);
+
+ afterAll(async () => {
+ if (ids?.folder) await deleteFileNodes([ids.folder]).catch(() => undefined);
+ for (const item of made.reverse()) {
+ await call(item.client(), [[item.method, { accountId: item.accountId, destroy: [item.id] }, '0']], item.using).catch(() => undefined);
+ }
+ }, 60_000);
+
+ it('finds the message, contact, event and folder of the signed-in login and the contact of the other one', async () => {
+ const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
+ // The full-text index lags a little behind a write.
+ let outcome = await search(accounts);
+ for (let i = 0; i < 15; i++) {
+ const found = outcome.hits.mail.some((h) => h.id === ids.mail)
+ && outcome.hits.calendar.some((h) => h.id === ids.event)
+ && outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact).length === 2;
+ if (found) break;
+ await new Promise((r) => setTimeout(r, 2000));
+ outcome = await search(accounts);
+ }
+ for (const kind of ['mail', 'contacts', 'calendar', 'files'] as const) expect(outcome.status[kind].errors, kind).toEqual([]);
+
+ const mail = outcome.hits.mail.find((h) => h.id === ids.mail);
+ expect(mail?.localAccountId).toBe(accounts[0].localAccountId);
+ expect(outcome.hits.calendar.find((h) => h.id === ids.event)?.title).toBe(`Findme ${TOKEN}`);
+ const contacts = outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact);
+ expect(contacts.map((h) => h.localAccountId).sort()).toEqual(accounts.map((a) => a.localAccountId).sort());
+ if (ids.folder) {
+ const folder = outcome.hits.files.find((h) => h.id === ids.folder);
+ expect(folder).toMatchObject({ isFolder: true, folderPath: '/' });
+ }
+ }, 120_000);
+
+ it('keeps to the account picked with account: and the kind picked with in:', async () => {
+ const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
+ const outcome = await runGlobalSearch({
+ parsed: parseSearchQuery(`in:contacts account:${USER2} ${TOKEN}`), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
+ localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
+ });
+ expect(outcome.hits.mail).toEqual([]);
+ expect(outcome.hits.contacts.map((h) => h.localAccountId)).toEqual([accounts[1].localAccountId]);
+ }, 60_000);
+});
diff --git a/src/api/global-search.ts b/src/api/global-search.ts
new file mode 100644
index 00000000..4141df50
--- /dev/null
+++ b/src/api/global-search.ts
@@ -0,0 +1,516 @@
+// "Search everything": the search providers (webmail
+// lib/global-search/providers/*.ts and accounts.ts).
+//
+// The JMAP client is one live session, so the signed-in login goes through
+// it, and every other login is asked "detached": with its stored
+// credentials, posting to its own API URL (as the unified inbox does), so the
+// live session is never disturbed. Shared and group accounts inside a login
+// are searched too.
+
+import { jmapClient, rewriteSessionUrl, extractOrigin, type StoredCredentials } from './jmap-client';
+import { CAPABILITIES, type CalendarEvent, type ContactCard, type Email, type FileNode, type JMAPMethodCall, type JMAPSession } from './types';
+import { fetchWithDeadline } from './unified-inbox';
+import { getAllFileNodesAcrossAccounts, fileNodeKind } from './files';
+import { namespaceId } from './contacts';
+import { freshStoredCredentials } from '../lib/stored-token-refresh';
+import { generateAccountId } from '../lib/account-utils';
+import { accountSupports } from '../lib/account-capability';
+import { contactFromWire } from '../lib/contact-wire';
+import { getContactDisplayName, getContactPrimaryEmail } from '../lib/contact-utils';
+import { textFilter } from '../lib/search-utils';
+import { snippetFilterFor, snippetHighlight, type SearchSnippetResult } from '../lib/search-snippet';
+import { matchesTerms, type ParsedQuery } from '../lib/global-search/query-parser';
+import {
+ calendarFilterFor, contactFields, emailFields, emailMatchesFilters, eventFields, isRecurringEvent,
+ mailFilterFor, pathOfNode, type MailboxRef,
+} from '../lib/global-search/filters';
+import type {
+ CalendarHit, ContactHit, FileHit, GlobalSearchHit, MailHit, SearchAccount, SearchProvider,
+} from '../lib/global-search/types';
+import { useAccountStore } from '../stores/account-store';
+import { useAuthStore } from '../stores/auth-store';
+import { useEmailStore } from '../stores/email-store';
+import { useContactsStore } from '../stores/contacts-store';
+import { useCalendarStore } from '../stores/calendar-store';
+
+type MethodResponses = Array<[string, Record, string]>;
+
+const SESSION_TTL_MS = 10 * 60_000;
+const MAILBOX_TTL_MS = 60_000;
+/** Files have no usable server search: the listing is filtered, cached a minute. */
+export const FILE_LISTING_TTL_MS = 60_000;
+
+const MAIL_PROPERTIES = [
+ 'id', 'threadId', 'mailboxIds', 'keywords', 'receivedAt', 'from', 'to', 'cc',
+ 'subject', 'preview', 'hasAttachment', 'size',
+];
+const EVENT_PROPERTIES = [
+ 'id', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone',
+ 'showWithoutTime', 'locations', 'participants', 'recurrenceRule', 'recurrenceId',
+];
+const DETACHED_FILE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'nodeType'];
+
+// ── Logins ───────────────────────────────────────────────────────────────
+
+/** Every login the search fans out to (webmail getSearchAccounts). */
+export function getSearchAccounts(): SearchAccount[] {
+ const { accounts } = useAccountStore.getState();
+ const activeId = useAuthStore.getState().activeAccountId;
+ return accounts
+ .filter((a) => !a.needsSignIn)
+ .map((a) => ({
+ localAccountId: a.id,
+ label: a.displayName || a.email || a.username,
+ email: a.email || a.username,
+ serverUrl: a.serverUrl,
+ active: a.id === activeId,
+ }));
+}
+
+interface LoginSession {
+ session: JMAPSession;
+ post: (calls: JMAPMethodCall[], using: string[]) => Promise;
+ live: boolean;
+}
+
+const sessions = new Map }>();
+
+/** Forget the detached sessions (sign-out, tests). */
+export function resetGlobalSearchCache(): void {
+ sessions.clear();
+ mailboxCache.clear();
+ fileListings.clear();
+}
+
+function isLiveLogin(localAccountId: string): boolean {
+ if (!jmapClient.isConnected || !jmapClient.currentSession) return false;
+ const { username, serverUrl } = jmapClient;
+ return !!username && !!serverUrl && generateAccountId(username, serverUrl) === localAccountId;
+}
+
+function authHeader(creds: StoredCredentials): string {
+ if (creds.accessToken) return `Bearer ${creds.accessToken}`;
+ return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
+}
+
+async function openDetached(localAccountId: string): Promise {
+ let creds = await jmapClient.getStoredCredentials(localAccountId);
+ if (!creds) throw new Error('No stored credentials');
+ creds = await freshStoredCredentials(localAccountId, creds);
+ const base = creds.serverUrl.replace(/\/+$/, '');
+ const auth = authHeader(creds);
+ const sessionRes = await fetchWithDeadline(`${base}/.well-known/jmap`, {
+ headers: { Authorization: auth, Accept: 'application/json' },
+ });
+ const session = (await sessionRes.json()) as JMAPSession;
+ const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(base)) ?? session.apiUrl;
+ return {
+ session,
+ live: false,
+ post: async (calls, using) => {
+ try {
+ const res = await fetchWithDeadline(apiUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: auth },
+ body: JSON.stringify({ using, methodCalls: calls }),
+ });
+ const body = await res.json();
+ return (body.methodResponses ?? []) as MethodResponses;
+ } catch (err) {
+ // A dead session must not be reused for the next search.
+ if (err instanceof Error && err.message === 'Session expired') sessions.delete(localAccountId);
+ throw err;
+ }
+ },
+ };
+}
+
+async function loginSession(account: SearchAccount): Promise {
+ if (isLiveLogin(account.localAccountId)) {
+ return {
+ session: jmapClient.currentSession!,
+ live: true,
+ post: async (calls, using) => (await jmapClient.request(calls, using)).methodResponses as MethodResponses,
+ };
+ }
+ const cached = sessions.get(account.localAccountId);
+ if (cached && Date.now() - cached.at < SESSION_TTL_MS) return cached.value;
+ const value = openDetached(account.localAccountId);
+ sessions.set(account.localAccountId, { at: Date.now(), value });
+ value.catch(() => sessions.delete(account.localAccountId));
+ return value;
+}
+
+/**
+ * The JMAP accounts of a login that hold a kind: its primary one first, then
+ * the shared and group accounts the user may use it with.
+ */
+export function accountsFor(session: JMAPSession, capability: string): { id: string; name: string; primary: boolean }[] {
+ const primaryId = session.primaryAccounts?.[capability] ?? session.primaryAccounts?.[CAPABILITIES.MAIL];
+ const out: { id: string; name: string; primary: boolean }[] = [];
+ if (primaryId && session.accounts?.[primaryId]) out.push({ id: primaryId, name: session.accounts[primaryId].name || primaryId, primary: true });
+ for (const [id, info] of Object.entries(session.accounts ?? {})) {
+ if (id === primaryId) continue;
+ if (!accountSupports(info, session.capabilities, capability)) continue;
+ out.push({ id, name: info.name || id, primary: false });
+ }
+ return out;
+}
+
+function responseOf(responses: MethodResponses, callId: string, method: string): Record {
+ const match = responses.find((r) => r[2] === callId);
+ if (!match) throw new Error(`Missing response to ${method}`);
+ if (match[0] === 'error') throw new Error((match[1] as { description?: string; type?: string }).description || (match[1] as { type?: string }).type || `${method} failed`);
+ return match[1];
+}
+
+function abortIfNeeded(signal: AbortSignal): void {
+ if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
+}
+
+// ── Mail ─────────────────────────────────────────────────────────────────
+
+const mailboxCache = new Map();
+
+async function mailboxesOf(login: LoginSession, localAccountId: string, accountId: string): Promise {
+ const key = `${localAccountId}|${accountId}`;
+ const cached = mailboxCache.get(key);
+ if (cached && Date.now() - cached.at < MAILBOX_TTL_MS) return cached.list;
+ const res = await login.post([['Mailbox/get', { accountId, properties: ['id', 'name', 'role'] }, '0']], [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
+ const list = (responseOf(res, '0', 'Mailbox/get').list ?? []) as MailboxRef[];
+ mailboxCache.set(key, { at: Date.now(), list });
+ return list;
+}
+
+function folderName(email: Pick, mailboxes: MailboxRef[]): string {
+ for (const id of Object.keys(email.mailboxIds ?? {})) {
+ const mailbox = mailboxes.find((m) => m.id === id);
+ if (mailbox) return mailbox.name;
+ }
+ return '';
+}
+
+function mailHit(
+ email: Email, account: SearchAccount, jmapAccountId: string, shared: boolean, folder: string, source: 'local' | 'remote',
+): MailHit {
+ return {
+ kind: 'mail', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
+ id: email.id, accountLabel: account.label, title: email.subject ?? '', subtitle: folder,
+ date: email.receivedAt ?? null, source, email, snippet: email.searchSnippet ?? null,
+ };
+}
+
+/**
+ * One account's mail search: Email/query with the parsed filter (all
+ * folders but Trash and Junk), its messages, and the search snippets in the
+ * same request; a server without snippets still lists the messages.
+ */
+async function searchMailAccount(
+ login: LoginSession, parsed: ParsedQuery, accountId: string, mailboxes: MailboxRef[], limit: number,
+): Promise<{ emails: Email[]; hasMore: boolean }> {
+ const filter = mailFilterFor(parsed, mailboxes);
+ const calls: JMAPMethodCall[] = [
+ ['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: limit + 1, collapseThreads: false }, 'q'],
+ ['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: MAIL_PROPERTIES }, 'g'],
+ ['SearchSnippet/get', { accountId, filter: snippetFilterFor(filter), '#emailIds': { resultOf: 'q', name: 'Email/query', path: '/ids' } }, 's'],
+ ];
+ const res = await login.post(calls, [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
+ const ids = (responseOf(res, 'q', 'Email/query').ids ?? []) as string[];
+ const list = (responseOf(res, 'g', 'Email/get').list ?? []) as Email[];
+ let snippets: SearchSnippetResult[] = [];
+ try {
+ snippets = (responseOf(res, 's', 'SearchSnippet/get').list ?? []) as SearchSnippetResult[];
+ } catch {
+ // Snippets only decorate the rows.
+ }
+ const byId = new Map(list.map((e) => [e.id, e]));
+ const emails = ids.slice(0, limit).map((id) => byId.get(id)).filter((e): e is Email => !!e).map((email) => {
+ const snippet = snippets.find((s) => s.emailId === email.id);
+ const highlight = snippet ? snippetHighlight(snippet) : null;
+ return highlight ? { ...email, searchSnippet: highlight } : email;
+ });
+ return { emails, hasMore: ids.length > limit };
+}
+
+export const mailProvider: SearchProvider = {
+ kind: 'mail',
+ local: (parsed, accounts, limit) => {
+ const account = accounts.find((a) => a.active);
+ if (!account) return [];
+ const { emails, mailboxes } = useEmailStore.getState();
+ let primary = '';
+ try { primary = jmapClient.accountId; } catch { /* no session */ }
+ const hits: MailHit[] = [];
+ for (const email of emails) {
+ if (!matchesTerms(parsed.terms, emailFields(email))) continue;
+ if (!emailMatchesFilters(email, parsed)) continue;
+ const mailbox = mailboxes.find((mb) => email.mailboxIds?.[mb.originalId ?? mb.id] || email.mailboxIds?.[mb.id]);
+ const jmapAccountId = email.jmapAccountId ?? (mailbox?.isShared && mailbox.accountId ? mailbox.accountId : primary);
+ hits.push(mailHit(email, account, jmapAccountId, jmapAccountId !== primary, mailbox?.name ?? '', 'local'));
+ if (hits.length >= limit) break;
+ }
+ return hits;
+ },
+ remote: async (parsed, account, { limit, signal }) => {
+ const login = await loginSession(account);
+ const hits: MailHit[] = [];
+ let hasMore = false;
+ // One account after the other: the run already asks the logins in parallel.
+ for (const target of accountsFor(login.session, CAPABILITIES.MAIL)) {
+ abortIfNeeded(signal);
+ const mailboxes = await mailboxesOf(login, account.localAccountId, target.id);
+ const result = await searchMailAccount(login, parsed, target.id, mailboxes, limit);
+ if (result.hasMore) hasMore = true;
+ for (const email of result.emails) {
+ const label = target.primary ? folderName(email, mailboxes) : [target.name, folderName(email, mailboxes)].filter(Boolean).join(' · ');
+ hits.push(mailHit(email, account, target.id, !target.primary, label, 'remote'));
+ }
+ }
+ return { hits, hasMore };
+ },
+};
+
+// ── Contacts ─────────────────────────────────────────────────────────────
+
+function contactHit(
+ contact: ContactCard, account: SearchAccount, jmapAccountId: string, shared: boolean, storeId: string,
+ bookName: string, source: 'local' | 'remote',
+): ContactHit {
+ const name = getContactDisplayName(contact);
+ const email = getContactPrimaryEmail(contact);
+ const title = name || email || contact.originalId || contact.id;
+ return {
+ kind: 'contacts', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
+ id: contact.originalId ?? contact.id, accountLabel: account.label, title,
+ subtitle: [bookName, email && email !== title ? email : ''].filter(Boolean).join(' · '),
+ date: contact.updated ?? null, source, contact, storeId,
+ };
+}
+
+export const contactsProvider: SearchProvider = {
+ kind: 'contacts',
+ local: (parsed, accounts, limit) => {
+ const account = accounts.find((a) => a.active);
+ if (!account) return [];
+ const { contacts, addressBooks } = useContactsStore.getState();
+ const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] ?? '';
+ const hits: ContactHit[] = [];
+ for (const contact of contacts) {
+ if (!matchesTerms(parsed.terms, contactFields(contact, getContactDisplayName(contact)))) continue;
+ const bookId = Object.keys(contact.addressBookIds ?? {})[0];
+ const book = addressBooks.find((b) => b.id === bookId || b.originalId === bookId);
+ const jmapAccountId = contact.accountId ?? primary;
+ hits.push(contactHit(contact, account, jmapAccountId, !!contact.isShared, contact.id, book?.name ?? '', 'local'));
+ if (hits.length >= limit) break;
+ }
+ return hits;
+ },
+ remote: async (parsed, account, { limit, signal }) => {
+ const login = await loginSession(account);
+ const hits: ContactHit[] = [];
+ let hasMore = false;
+ for (const target of accountsFor(login.session, CAPABILITIES.CONTACTS)) {
+ abortIfNeeded(signal);
+ if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CONTACTS)) continue;
+ const res = await login.post([
+ ['ContactCard/query', { accountId: target.id, filter: textFilter(parsed.text), limit: limit + 1 }, 'q'],
+ ['ContactCard/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
+ ['AddressBook/get', { accountId: target.id, properties: ['id', 'name'] }, 'b'],
+ ], [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]);
+ const ids = (responseOf(res, 'q', 'ContactCard/query').ids ?? []) as string[];
+ const list = ((responseOf(res, 'g', 'ContactCard/get').list ?? []) as ContactCard[]).map((c) => contactFromWire(c));
+ let books: { id: string; name: string }[] = [];
+ try { books = responseOf(res, 'b', 'AddressBook/get').list ?? []; } catch { /* names only */ }
+ if (ids.length > limit) hasMore = true;
+ for (const contact of list.slice(0, limit)) {
+ const bookId = Object.keys(contact.addressBookIds ?? {})[0];
+ const bookName = books.find((b) => b.id === bookId)?.name ?? '';
+ const storeId = target.primary ? contact.id : namespaceId(target.id, contact.id);
+ const shown: ContactCard = target.primary ? contact : { ...contact, id: storeId, originalId: contact.id, accountId: target.id, isShared: true };
+ hits.push(contactHit(shown, account, target.id, !target.primary, storeId,
+ target.primary ? bookName : [target.name, bookName].filter(Boolean).join(' · '), 'remote'));
+ }
+ }
+ return { hits, hasMore };
+ },
+};
+
+// ── Calendar ─────────────────────────────────────────────────────────────
+
+function eventHit(
+ event: CalendarEvent, account: SearchAccount, jmapAccountId: string, shared: boolean, calendarName: string, source: 'local' | 'remote',
+): CalendarHit {
+ const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name ?? '';
+ return {
+ kind: 'calendar', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
+ id: event.originalId ?? event.id, accountLabel: account.label, title: event.title || '',
+ subtitle: [calendarName, location].filter(Boolean).join(' · '),
+ date: event.start ?? null, source, event, isRecurring: isRecurringEvent(event),
+ };
+}
+
+/** Stalwart names it recurrenceRule (singular); the app's type has the plural. */
+function eventFromWire(raw: Record): CalendarEvent {
+ const rules = raw.recurrenceRules ?? (raw.recurrenceRule ? [raw.recurrenceRule] : null);
+ return { ...raw, recurrenceRules: rules } as CalendarEvent;
+}
+
+export const calendarProvider: SearchProvider = {
+ kind: 'calendar',
+ local: (parsed, accounts, limit) => {
+ const account = accounts.find((a) => a.active);
+ if (!account) return [];
+ const { events, calendars } = useCalendarStore.getState();
+ const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] ?? '';
+ const hits: CalendarHit[] = [];
+ // The store holds occurrences; a matching series shows once.
+ const seen = new Set();
+ for (const event of events) {
+ if (!matchesTerms(parsed.terms, eventFields(event))) continue;
+ const day = event.start?.slice(0, 10);
+ if (parsed.after && day && day < parsed.after) continue;
+ if (parsed.before && day && day > parsed.before) continue;
+ if (event.uid) {
+ if (seen.has(event.uid)) continue;
+ seen.add(event.uid);
+ }
+ const calendarId = Object.keys(event.calendarIds ?? {})[0];
+ const calendar = calendars.find((c) => c.id === calendarId);
+ const jmapAccountId = event.accountId ?? primary;
+ hits.push(eventHit(event, account, jmapAccountId, !!event.isShared, calendar?.name ?? '', 'local'));
+ if (hits.length >= limit) break;
+ }
+ return hits;
+ },
+ remote: async (parsed, account, { limit, signal }) => {
+ const login = await loginSession(account);
+ const hits: CalendarHit[] = [];
+ let hasMore = false;
+ for (const target of accountsFor(login.session, CAPABILITIES.CALENDARS)) {
+ abortIfNeeded(signal);
+ if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CALENDARS)) continue;
+ const res = await login.post([
+ ['CalendarEvent/query', { accountId: target.id, filter: calendarFilterFor(parsed), sort: [{ property: 'start', isAscending: false }], limit }, 'q'],
+ ['CalendarEvent/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: EVENT_PROPERTIES }, 'g'],
+ ['Calendar/get', { accountId: target.id, properties: ['id', 'name'] }, 'c'],
+ ], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
+ const ids = (responseOf(res, 'q', 'CalendarEvent/query').ids ?? []) as string[];
+ const list = ((responseOf(res, 'g', 'CalendarEvent/get').list ?? []) as Record[]).map(eventFromWire);
+ let calendars: { id: string; name: string }[] = [];
+ try { calendars = responseOf(res, 'c', 'Calendar/get').list ?? []; } catch { /* names only */ }
+ if (ids.length >= limit) hasMore = true;
+ for (const event of list) {
+ const calendarId = Object.keys(event.calendarIds ?? {})[0];
+ const name = calendars.find((c) => c.id === calendarId)?.name ?? '';
+ hits.push(eventHit(event, account, target.id, !target.primary,
+ target.primary ? name : [target.name, name].filter(Boolean).join(' · '), 'remote'));
+ }
+ }
+ return { hits, hasMore };
+ },
+};
+
+// ── Files ────────────────────────────────────────────────────────────────
+
+interface FileListing {
+ at: number;
+ nodes: FileNode[] | null;
+ promise: Promise;
+}
+
+const fileListings = new Map();
+
+/** Raw server id of a node (shared ones are listed as `:`). */
+export function rawFileNodeId(node: FileNode): string {
+ if (node.isShared && node.accountId && node.id.startsWith(`${node.accountId}:`)) return node.id.slice(node.accountId.length + 1);
+ return node.id;
+}
+
+async function listDetachedFiles(login: LoginSession): Promise {
+ const all: FileNode[] = [];
+ for (const target of accountsFor(login.session, CAPABILITIES.FILES)) {
+ if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.FILES)) continue;
+ try {
+ const res = await login.post([['FileNode/get', { accountId: target.id, ids: null, properties: DETACHED_FILE_PROPERTIES }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
+ for (const node of (responseOf(res, '0', 'FileNode/get').list ?? []) as FileNode[]) {
+ all.push(target.primary ? node : {
+ ...node,
+ id: `${target.id}:${node.id}`,
+ parentId: node.parentId == null ? null : `${target.id}:${node.parentId}`,
+ accountId: target.id, accountName: target.name, isShared: true,
+ });
+ }
+ } catch (err) {
+ if (target.primary) throw err;
+ }
+ }
+ return all;
+}
+
+function fileListing(account: SearchAccount, login: LoginSession): FileListing {
+ const cached = fileListings.get(account.localAccountId);
+ if (cached && Date.now() - cached.at < FILE_LISTING_TTL_MS) return cached;
+ const entry: FileListing = {
+ at: Date.now(),
+ nodes: null,
+ promise: (login.live ? getAllFileNodesAcrossAccounts() : listDetachedFiles(login)).then((nodes) => {
+ entry.nodes = nodes;
+ return nodes;
+ }, (err) => {
+ // A failed listing must not be served as "no files" for a minute.
+ if (fileListings.get(account.localAccountId) === entry) fileListings.delete(account.localAccountId);
+ throw err;
+ }),
+ };
+ fileListings.set(account.localAccountId, entry);
+ return entry;
+}
+
+function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote', primary: string): FileHit[] {
+ const hits: FileHit[] = [];
+ for (const node of nodes) {
+ if (node.discoverableOnly) continue;
+ const kind = fileNodeKind(node);
+ if (kind === 'symlink') continue;
+ if (!matchesTerms(parsed.terms, [node.name, kind === 'directory' ? 'folder' : node.type])) continue;
+ const folderPath = pathOfNode(nodes, node);
+ hits.push({
+ kind: 'files', serverUrl: account.serverUrl, localAccountId: account.localAccountId,
+ jmapAccountId: node.accountId ?? primary, shared: !!node.isShared,
+ id: rawFileNodeId(node), accountLabel: account.label, title: node.name,
+ subtitle: [node.isShared && node.accountName ? node.accountName : '', folderPath].filter(Boolean).join(' · '),
+ date: node.modified || node.created || null, source, node, folderPath, isFolder: kind === 'directory',
+ });
+ if (hits.length >= limit) break;
+ }
+ return hits;
+}
+
+export const filesProvider: SearchProvider = {
+ kind: 'files',
+ local: (parsed, accounts, limit) => {
+ const hits: FileHit[] = [];
+ for (const account of accounts) {
+ const nodes = fileListings.get(account.localAccountId)?.nodes;
+ if (!nodes) continue;
+ hits.push(...filterNodes(parsed, nodes, account, limit - hits.length, 'local', ''));
+ if (hits.length >= limit) break;
+ }
+ return hits;
+ },
+ remote: async (parsed, account, { limit, signal }) => {
+ const login = await loginSession(account);
+ if (accountsFor(login.session, CAPABILITIES.FILES).length === 0 || !login.session.capabilities?.[CAPABILITIES.FILES]) {
+ return { hits: [], hasMore: false };
+ }
+ const nodes = await fileListing(account, login).promise;
+ abortIfNeeded(signal);
+ const primary = login.session.primaryAccounts?.[CAPABILITIES.FILES] ?? '';
+ const hits = filterNodes(parsed, nodes, account, limit + 1, 'remote', primary);
+ return { hits: hits.slice(0, limit), hasMore: hits.length > limit };
+ },
+};
+
+export const GLOBAL_SEARCH_PROVIDERS: readonly SearchProvider[] = [mailProvider, contactsProvider, calendarProvider, filesProvider];
+
+export type { GlobalSearchHit };
diff --git a/src/components/__tests__/global-search-wiring.test.ts b/src/components/__tests__/global-search-wiring.test.ts
new file mode 100644
index 00000000..769de184
--- /dev/null
+++ b/src/components/__tests__/global-search-wiring.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// Checked on the source: the suite has no React Native renderer.
+const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8');
+
+describe('Search everything', () => {
+ it('opens from the mail search field with what was typed', () => {
+ const source = read('screens/EmailListScreen.tsx');
+ expect(source).toMatch(/testID="search-everything"/);
+ expect(source).toMatch(/setGlobalSearchOpen\(true\)/);
+ expect(source).toMatch(/ {
+ const source = read('components/search/GlobalSearchSheet.tsx');
+ expect(source).toMatch(/providers: GLOBAL_SEARCH_PROVIDERS/);
+ expect(source).toMatch(/accounts: getSearchAccounts/);
+ expect(source).toMatch(/openHit\(hit, navigation, allHits\)/);
+ // Scope and account chips, as in the webmail palette.
+ expect(source).toMatch(/global_search\.scope_files/);
+ expect(source).toMatch(/global_search\.all_accounts/);
+ });
+});
diff --git a/src/components/search/GlobalSearchSheet.tsx b/src/components/search/GlobalSearchSheet.tsx
new file mode 100644
index 00000000..9dee0145
--- /dev/null
+++ b/src/components/search/GlobalSearchSheet.tsx
@@ -0,0 +1,340 @@
+import React from 'react';
+import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { AlertTriangle, ArrowLeft, CalendarDays, Clock, FileText, Folder, Repeat, Search, X } from '../icons';
+import SenderAvatar from '../SenderAvatar';
+import { SafeAreaModal } from '../SafeAreaModal';
+import { SearchSnippetText } from '../email/SearchSnippetText';
+import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+import { useColors } from '../../theme/colors';
+import { useLocaleStore } from '../../stores/locale-store';
+import { useAccountStore } from '../../stores/account-store';
+import { useSearchHistoryStore } from '../../stores/search-history-store';
+import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { GLOBAL_SEARCH_PROVIDERS, getSearchAccounts } from '../../api/global-search';
+import { SEARCH_KINDS, type SearchKind, type SearchScope } from '../../lib/global-search/query-parser';
+import { useGlobalSearch } from '../../lib/global-search/use-global-search';
+import { openHit, type HitNavigation } from '../../lib/global-search/open-hit';
+import type { GlobalSearchHit } from '../../lib/global-search/types';
+import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
+
+export const SHEET_LOCAL_LIMIT = 10;
+export const SHEET_REMOTE_LIMIT = 25;
+/** Rows per kind before "Show all results" (the webmail palette shows five). */
+export const ROWS_PER_KIND = 5;
+
+/** The rows a kind shows: the first five, or all once its "Show all" was tapped. */
+export function visibleHits(hits: GlobalSearchHit[], expanded: boolean): GlobalSearchHit[] {
+ return expanded ? hits : hits.slice(0, ROWS_PER_KIND);
+}
+
+interface GlobalSearchSheetProps {
+ visible: boolean;
+ /** What the search starts with (the mail search field's text). */
+ initialQuery?: string;
+ onClose: () => void;
+ navigation: HitNavigation;
+}
+
+function formatHitDate(iso: string, locale: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+ const sameYear = date.getFullYear() === new Date().getFullYear();
+ try {
+ return new Intl.DateTimeFormat(locale, sameYear ? { month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
+ } catch {
+ return date.toDateString();
+ }
+}
+
+/**
+ * "Search everything" (webmail components/global-search/global-search-palette.tsx):
+ * one query across mail, contacts, calendar events and files of every
+ * signed-in account. What is already loaded shows while typing; the
+ * servers' answers merge in per kind as each account answers. A tap opens
+ * the hit in its own account.
+ */
+export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigation }: GlobalSearchSheetProps) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
+ const locale = useLocaleStore((s) => s.locale);
+ const accounts = useAccountStore((s) => s.accounts);
+ const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
+ const addRecentSearch = useSearchHistoryStore((s) => s.addRecentSearch);
+
+ const [query, setQuery] = React.useState(initialQuery);
+ const [scope, setScope] = React.useState('all');
+ const [accountId, setAccountId] = React.useState(null);
+ const [expanded, setExpanded] = React.useState>(() => new Set());
+ const pending = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!visible) return;
+ setQuery(initialQuery);
+ setExpanded(new Set());
+ }, [visible, initialQuery]);
+ React.useEffect(() => { setExpanded(new Set()); }, [query, scope, accountId]);
+
+ const { parsed, outcome, isSearching } = useGlobalSearch({
+ query, scope, accountId,
+ localLimit: SHEET_LOCAL_LIMIT,
+ remoteLimit: SHEET_REMOTE_LIMIT,
+ enabled: visible,
+ providers: GLOBAL_SEARCH_PROVIDERS,
+ accounts: getSearchAccounts,
+ });
+
+ const trimmed = query.trim();
+ const kinds = SEARCH_KINDS.filter((kind) => parsed.scope === 'all' || parsed.scope === kind);
+ const hasAnyHit = kinds.some((kind) => outcome.hits[kind].length > 0);
+ const allHits = kinds.flatMap((kind) => outcome.hits[kind]);
+ const errors = trimmed ? kinds.flatMap((kind) => outcome.status[kind].errors) : [];
+ const showRecent = !trimmed && recentSearches.length > 0;
+
+ const open = (hit: GlobalSearchHit) => {
+ if (trimmed) addRecentSearch(trimmed);
+ // iOS presents the next screen only once this modal is gone.
+ if (Platform.OS === 'ios') {
+ pending.current = hit;
+ onClose();
+ return;
+ }
+ onClose();
+ void openHit(hit, navigation, allHits);
+ };
+
+ const scopes: { key: SearchScope; label: string }[] = [
+ { key: 'all', label: t('global_search.scope_all', 'All') },
+ { key: 'mail', label: t('global_search.scope_mail', 'Mail') },
+ { key: 'contacts', label: t('global_search.scope_contacts', 'Contacts') },
+ { key: 'calendar', label: t('global_search.scope_calendar', 'Calendar') },
+ { key: 'files', label: t('global_search.scope_files', 'Files') },
+ ];
+ const kindLabel = (kind: SearchKind) => scopes.find((s) => s.key === kind)?.label ?? kind;
+
+ const renderLeading = (hit: GlobalSearchHit) => {
+ if (hit.kind === 'mail') {
+ const from = hit.email.from?.[0];
+ return ;
+ }
+ if (hit.kind === 'contacts') {
+ return ;
+ }
+ const Icon = hit.kind === 'calendar' ? CalendarDays : hit.isFolder ? Folder : FileText;
+ return (
+
+
+
+ );
+ };
+
+ const renderRow = (hit: GlobalSearchHit) => {
+ const title = hit.title || (hit.kind === 'mail' ? t('global_search.no_subject', '(no subject)') : hit.id);
+ const subtitle = [hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ');
+ const snippet = hit.kind === 'mail' && hit.snippet?.preview ? hit.snippet.preview : null;
+ return (
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={() => open(hit)}
+ accessibilityRole="button"
+ accessibilityLabel={[title, subtitle].filter(Boolean).join(', ')}
+ >
+ {renderLeading(hit)}
+
+
+
+ {hit.kind === 'mail' && hit.snippet?.subject ? : title}
+
+ {hit.kind === 'calendar' && hit.isRecurring ? (
+
+
+
+ ) : null}
+ {hit.date ? {formatHitDate(hit.date, locale)} : null}
+
+ {subtitle ? {subtitle} : null}
+ {snippet ? : null}
+
+
+ );
+ };
+
+ const renderGroup = (kind: SearchKind) => {
+ const hits = outcome.hits[kind];
+ const status = outcome.status[kind];
+ if (hits.length === 0 && status.status !== 'loading') return null;
+ const isExpanded = expanded.has(kind);
+ return (
+
+
+ {kindLabel(kind)}
+ {hits.length}
+ {status.status === 'loading' ? : null}
+
+ {visibleHits(hits, isExpanded).map(renderRow)}
+ {!isExpanded && hits.length > ROWS_PER_KIND ? (
+ [styles.showAll, pressed && styles.rowPressed]}
+ onPress={() => setExpanded((prev) => new Set(prev).add(kind))}
+ accessibilityRole="button"
+ >
+ {t('global_search.show_all', 'Show all results')}
+
+ ) : null}
+
+ );
+ };
+
+ const chip = (key: string, label: string, selected: boolean, onPress: () => void) => (
+
+ {label}
+
+ );
+
+ return (
+ {
+ const hit = pending.current;
+ pending.current = null;
+ if (hit) void openHit(hit, navigation, allHits);
+ }}
+ >
+
+
+
+
+
+
+
+ { if (trimmed) addRecentSearch(trimmed); }}
+ accessibilityLabel={t('global_search.title', 'Search everything')}
+ />
+ {isSearching ? : null}
+ {query ? (
+ setQuery('')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.clear_search', 'Clear search')}>
+
+
+ ) : null}
+
+
+
+
+ {scopes.map((s) => chip(s.key, s.label, scope === s.key, () => setScope(s.key)))}
+ {accounts.length > 1 ? (
+ <>
+
+ {chip('all-accounts', t('global_search.all_accounts', 'All accounts'), accountId === null, () => setAccountId(null))}
+ {accounts.map((a) => chip(a.id, a.displayName || a.email || a.username, accountId === a.id, () => setAccountId(a.id)))}
+ >
+ ) : null}
+
+
+
+ {showRecent ? (
+
+
+ {t('global_search.recent_searches', 'Recent searches')}
+
+ {recentSearches.map((term) => (
+ [styles.recentRow, pressed && styles.rowPressed]} onPress={() => setQuery(term)} accessibilityRole="button">
+
+ {term}
+
+ ))}
+
+ ) : null}
+ {trimmed ? kinds.map(renderGroup) : null}
+ {errors.map((error) => (
+
+
+ {t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })}
+
+ ))}
+ {!trimmed && !showRecent ? (
+
+ {t('global_search.empty_hint', 'Search mail, contacts, events and files across all accounts. Try from:, subject:, has:attachment, after:2026-01-01.')}
+
+ ) : null}
+ {trimmed && !hasAnyHit && !isSearching ? (
+
+ {t('global_search.no_results', 'No results for “{query}”', { query: trimmed })}
+ {t('global_search.server_hint', 'Complete words search the server; partial words match already-loaded items')}
+
+ ) : null}
+
+
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ container: { flex: 1, backgroundColor: c.background },
+ header: {
+ flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
+ paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
+ borderBottomWidth: 1, borderBottomColor: c.border,
+ },
+ headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
+ field: {
+ flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
+ paddingHorizontal: spacing.md, borderRadius: radius.md, backgroundColor: c.surface,
+ borderWidth: 1, borderColor: c.border,
+ },
+ input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm },
+ chips: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
+ chip: {
+ paddingHorizontal: spacing.md, paddingVertical: 6, borderRadius: radius.full,
+ borderWidth: 1, borderColor: c.border, backgroundColor: c.surface,
+ },
+ chipActive: { backgroundColor: c.primary, borderColor: c.primary },
+ chipText: { ...typography.caption, color: c.text },
+ chipTextActive: { color: c.primaryForeground },
+ chipDivider: { width: 1, height: 20, backgroundColor: c.border, marginHorizontal: spacing.xs },
+ results: { flex: 1 },
+ group: { paddingVertical: spacing.xs },
+ groupHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.xs },
+ groupTitle: { ...typography.captionMedium, color: c.textSecondary },
+ countPill: { minWidth: 18, height: 18, borderRadius: 9, paddingHorizontal: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
+ countText: { fontSize: 10, fontWeight: '600', color: c.textSecondary },
+ row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 48 },
+ rowPressed: { backgroundColor: c.surfaceHover },
+ rowText: { flex: 1, minWidth: 0 },
+ rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
+ rowTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
+ rowDate: { ...typography.caption, color: c.textMuted },
+ rowSub: { ...typography.caption, color: c.textMuted, marginTop: 2 },
+ iconChip: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
+ showAll: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
+ showAllText: { ...typography.caption, color: c.primary },
+ recentRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 44 },
+ recentText: { ...typography.body, color: c.text, flex: 1 },
+ errorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.xs },
+ hint: { ...typography.caption, color: c.textMuted, textAlign: 'center', padding: spacing.lg },
+ empty: { paddingVertical: spacing.lg },
+ emptyTitle: { ...typography.body, color: c.text, textAlign: 'center' },
+ });
+}
diff --git a/src/lib/__tests__/global-search-open-hit.test.ts b/src/lib/__tests__/global-search-open-hit.test.ts
new file mode 100644
index 00000000..39388bf2
--- /dev/null
+++ b/src/lib/__tests__/global-search-open-hit.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { auth } = vi.hoisted(() => ({
+ auth: { activeAccountId: 'login-a' as string | null, switchAccount: vi.fn() },
+}));
+vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => auth } }));
+
+import { openHit, filesLinkFor } from '../global-search/open-hit';
+import { usePendingCalendarOpen } from '../../navigation/pending-calendar-open';
+import { usePendingFilesLink } from '../../navigation/pending-files-link';
+import type { GlobalSearchHit } from '../global-search/types';
+
+const base = { localAccountId: 'login-a', jmapAccountId: 'own', shared: false, accountLabel: 'Me', subtitle: '', date: null, source: 'remote' as const };
+
+beforeEach(() => {
+ auth.activeAccountId = 'login-a';
+ auth.switchAccount.mockReset();
+ usePendingCalendarOpen.setState({ target: null });
+ usePendingFilesLink.setState({ link: null });
+});
+
+describe('opening a search hit', () => {
+ it("opens a group account's message under that account, paging over the same account's hits", async () => {
+ const navigate = vi.fn();
+ const mail = (id: string, jmapAccountId: string, shared: boolean): GlobalSearchHit => ({
+ ...base, kind: 'mail', id, jmapAccountId, shared, title: id, snippet: null,
+ email: { id, threadId: `t-${id}`, subject: id } as never,
+ });
+ const target = mail('5', 'team', true);
+ await openHit(target, { navigate }, [mail('4', 'team', true), target, mail('5', 'own', false)]);
+ expect(navigate).toHaveBeenCalledWith('EmailThread', expect.objectContaining({
+ emailId: '5', threadId: 't-5', jmapAccountId: 'team', emailIds: ['4', '5'],
+ }));
+ });
+
+ it("switches to the hit's login first, and opens nothing when the switch fails", async () => {
+ const navigate = vi.fn();
+ const contact: GlobalSearchHit = {
+ ...base, localAccountId: 'login-b', kind: 'contacts', id: 'c1', title: 'Anna', storeId: 'c1', contact: { id: 'c1' } as never,
+ };
+ auth.switchAccount.mockImplementation(async (id: string) => { auth.activeAccountId = id; });
+ await openHit(contact, { navigate });
+ expect(auth.switchAccount).toHaveBeenCalledWith('login-b');
+ expect(navigate).toHaveBeenCalledWith('ContactDetail', { contactId: 'c1' });
+
+ navigate.mockReset();
+ auth.activeAccountId = 'login-a';
+ auth.switchAccount.mockImplementation(async () => undefined);
+ expect(await openHit(contact, { navigate })).toBe(false);
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it('hands a shared calendar event to the Calendar tab by its server id and account', async () => {
+ const navigate = vi.fn();
+ await openHit({
+ ...base, kind: 'calendar', id: 'ev', jmapAccountId: 'boss', shared: true, title: 'Sync', isRecurring: false,
+ event: { id: 'ev', start: '2026-03-02T10:00:00', calendarIds: {} } as never,
+ }, { navigate });
+ expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'boss:ev', serverId: 'ev', accountId: 'boss' });
+ expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' });
+ });
+
+ it('opens a file in its folder with a preview, a folder by its path, and a shared node by its listed id', async () => {
+ const file = (over: Record): Extract => ({
+ ...base, kind: 'files', id: 'n', title: 'x', folderPath: '/Docs', isFolder: false,
+ node: { id: 'n', name: 'report.pdf' } as never, ...over,
+ } as never);
+ expect(filesLinkFor(file({}))).toEqual({ path: '/Docs', preview: 'report.pdf' });
+ expect(filesLinkFor(file({ isFolder: true, folderPath: '/', node: { id: 'n', name: 'Tax' } }))).toEqual({ path: '/Tax' });
+ expect(filesLinkFor(file({ node: { id: 'boss:n', name: 'a', isShared: true } }))).toEqual({ path: '/', shared: 'boss:n' });
+
+ const navigate = vi.fn();
+ await openHit(file({}), { navigate });
+ expect(usePendingFilesLink.getState().link).toEqual({ path: '/Docs', preview: 'report.pdf' });
+ expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' });
+ });
+});
diff --git a/src/lib/__tests__/global-search.test.ts b/src/lib/__tests__/global-search.test.ts
new file mode 100644
index 00000000..af15b991
--- /dev/null
+++ b/src/lib/__tests__/global-search.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect, vi } from 'vitest';
+import { accountMatches, hasRemoteQuery, matchesTerms, parseSearchQuery } from '../global-search/query-parser';
+import { mergeHits, rankHits } from '../global-search/rank';
+import { localOutcome, runGlobalSearch } from '../global-search/run';
+import { calendarFilterFor, mailFilterFor, pathOfNode } from '../global-search/filters';
+import { scopedQuery } from '../global-search/use-global-search';
+import type { GlobalSearchHit, SearchAccount, SearchOutcome, SearchProvider } from '../global-search/types';
+import type { FileNode } from '../../api/types';
+
+// Ported from the webmail's lib/global-search/__tests__ (query-parser,
+// rank, run-global-search), on the app's types.
+
+const ACCOUNTS: SearchAccount[] = [
+ { localAccountId: 'a', label: 'Work', email: 'me@work.example', active: true },
+ { localAccountId: 'b', label: 'Home', email: 'me@home.example', active: false },
+];
+
+function hit(partial: Partial & { id: string }): GlobalSearchHit {
+ return {
+ kind: 'mail', localAccountId: 'a', jmapAccountId: 'j', shared: false, accountLabel: 'Work',
+ title: partial.id, subtitle: '', date: null, source: 'local',
+ email: { id: partial.id, threadId: 't', subject: partial.id } as never, snippet: null,
+ ...partial,
+ } as GlobalSearchHit;
+}
+
+describe('parseSearchQuery', () => {
+ it('keeps words and quoted phrases as text, lower-cased terms for local matching', () => {
+ const parsed = parseSearchQuery('Annual "Q3 Report"');
+ expect(parsed.text).toBe('Annual "Q3 Report"');
+ expect(parsed.terms).toEqual(['annual', 'q3 report']);
+ expect(parsed.scope).toBe('all');
+ });
+
+ it('reads scope, account and the mail operators', () => {
+ const parsed = parseSearchQuery('in:events account:Work from:anna subject:plan has:attachment is:unread after:2026-01-01 before:2026-02-01 budget');
+ expect(parsed.scope).toBe('calendar');
+ expect(parsed.account).toBe('work');
+ expect(parsed.mail).toMatchObject({ from: 'anna', subject: 'plan', hasAttachment: true, isUnread: true, dateAfter: '2026-01-01', dateBefore: '2026-02-01' });
+ expect(parsed.after).toBe('2026-01-01');
+ expect(parsed.text).toBe('budget');
+ });
+
+ it('makes in:trash a mail search of that folder, and is:anything keeps Trash and Junk in', () => {
+ expect(parseSearchQuery('in:trash x')).toMatchObject({ scope: 'mail', mailboxRole: 'trash' });
+ expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
+ });
+
+ it('keeps malformed dates and unknown operators as free text', () => {
+ const parsed = parseSearchQuery('after:2026-02-30 foo:bar ticket');
+ expect(parsed.after).toBeNull();
+ expect(parsed.text).toBe('after:2026-02-30 foo:bar ticket');
+ });
+
+ it('lets an explicit in: win over the scope chip', () => {
+ expect(scopedQuery('x', 'files').scope).toBe('files');
+ expect(scopedQuery('in:contacts x', 'files').scope).toBe('contacts');
+ });
+
+ it('asks the server for mail on operators alone, for the other kinds only with text', () => {
+ const parsed = parseSearchQuery('is:unread');
+ expect(hasRemoteQuery(parsed, 'mail')).toBe(true);
+ expect(hasRemoteQuery(parsed, 'contacts')).toBe(false);
+ });
+});
+
+describe('matching helpers', () => {
+ it('needs every term in some field', () => {
+ expect(matchesTerms(['ann', 'plan'], ['Anna Smith', 'Project plan'])).toBe(true);
+ expect(matchesTerms(['ann', 'zzz'], ['Anna Smith'])).toBe(false);
+ expect(matchesTerms([], ['x'])).toBe(false);
+ });
+
+ it('matches a login by label, address prefix or id', () => {
+ expect(accountMatches('wor', { ...ACCOUNTS[0] })).toBe(true);
+ expect(accountMatches('me@home', { ...ACCOUNTS[1] })).toBe(true);
+ expect(accountMatches('home', { ...ACCOUNTS[0] })).toBe(false);
+ });
+});
+
+describe('server filters', () => {
+ const mailboxes = [
+ { id: 'in', role: 'inbox', name: 'Inbox' },
+ { id: 'tr', role: 'trash', name: 'Trash' },
+ { id: 'sp', role: 'junk', name: 'Junk' },
+ ];
+
+ it('searches every folder but Trash and Junk by default', () => {
+ expect(mailFilterFor(parseSearchQuery('invoice'), mailboxes)).toEqual({
+ operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['tr', 'sp'] }],
+ });
+ });
+
+ it('searches Trash alone with in:trash, and everything with is:anything', () => {
+ expect(mailFilterFor(parseSearchQuery('in:trash invoice'), mailboxes)).toEqual({
+ operator: 'AND', conditions: [{ text: 'invoice' }, { inMailbox: 'tr' }],
+ });
+ expect(mailFilterFor(parseSearchQuery('is:anything invoice'), mailboxes)).toEqual({ text: 'invoice' });
+ });
+
+ it('turns the mail operators into conditions', () => {
+ const filter = mailFilterFor(parseSearchQuery('from:anna is:starred has:attachment'), []);
+ expect(filter).toEqual({
+ operator: 'AND',
+ conditions: [{ from: 'anna' }, { hasAttachment: true }, { hasKeyword: '$flagged' }],
+ });
+ });
+
+ it('bounds the calendar search by whole days, one AND group for several parts', () => {
+ expect(calendarFilterFor(parseSearchQuery('standup after:2026-03-01'))).toEqual({ text: 'standup', after: '2026-03-01T00:00:00Z' });
+ expect(calendarFilterFor(parseSearchQuery('"team sync" weekly before:2026-03-31'))).toEqual({
+ operator: 'AND',
+ conditions: [{ before: '2026-03-31T23:59:59Z' }, { text: '"team sync"' }, { text: 'weekly' }],
+ });
+ });
+
+ it('finds the folder path of a file', () => {
+ const nodes = [
+ { id: '1', name: 'Docs', parentId: null },
+ { id: '2', name: 'Tax', parentId: '1' },
+ { id: '3', name: 'return.pdf', parentId: '2' },
+ ] as FileNode[];
+ expect(pathOfNode(nodes, nodes[2])).toBe('/Docs/Tax');
+ expect(pathOfNode(nodes, nodes[0])).toBe('/');
+ });
+});
+
+describe('rank and merge', () => {
+ it('puts exact and prefix title matches first, then newer hits', () => {
+ const parsed = parseSearchQuery('plan');
+ const ranked = rankHits([
+ hit({ id: 'old', title: 'about the plan', date: '2026-01-01T00:00:00Z' }),
+ hit({ id: 'new', title: 'about the plan', date: '2026-02-01T00:00:00Z' }),
+ hit({ id: 'exact', title: 'Plan' }),
+ hit({ id: 'prefix', title: 'Planning day' }),
+ ], parsed);
+ expect(ranked.map((h) => h.id)).toEqual(['exact', 'prefix', 'new', 'old']);
+ });
+
+ it('lets a server hit replace the loaded one, and keeps the same id of two accounts apart', () => {
+ const merged = mergeHits(
+ [hit({ id: '1', source: 'local' }), hit({ id: '1', jmapAccountId: 'other', source: 'local' })],
+ [hit({ id: '1', source: 'remote', title: 'from server' })],
+ );
+ expect(merged).toHaveLength(2);
+ expect(merged.find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
+ // A loaded hit never replaces a server one.
+ expect(mergeHits(merged, [hit({ id: '1', source: 'local' })]).find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
+ });
+});
+
+describe('runGlobalSearch', () => {
+ const provider = (over: Partial): SearchProvider => ({
+ kind: 'mail',
+ local: () => [],
+ remote: async () => ({ hits: [], hasMore: false }),
+ ...over,
+ });
+
+ it('shows the loaded hits first, then merges each account answer, and reports a failed account', async () => {
+ const updates: SearchOutcome[] = [];
+ const mail = provider({
+ local: () => [hit({ id: 'cached', title: 'plan cached' })],
+ remote: async (_p, account) => {
+ if (account.localAccountId === 'b') throw new Error('boom');
+ return { hits: [hit({ id: 'server', title: 'plan server', source: 'remote' })], hasMore: true };
+ },
+ });
+ const result = await runGlobalSearch({
+ parsed: parseSearchQuery('plan'), accounts: ACCOUNTS, providers: [mail],
+ localLimit: 10, remoteLimit: 10, signal: new AbortController().signal,
+ onUpdate: (o) => updates.push(o),
+ });
+ expect(updates[0].hits.mail.map((h) => h.id)).toEqual(['cached']);
+ expect(updates[0].status.mail.status).toBe('loading');
+ expect(result.hits.mail.map((h) => h.id).sort()).toEqual(['cached', 'server']);
+ expect(result.status.mail).toMatchObject({ status: 'done', hasMore: true });
+ expect(result.status.mail.errors).toEqual([{ localAccountId: 'b', accountLabel: 'Home', message: 'boom' }]);
+ });
+
+ it('asks only the account named by account:, and only the kind of the scope', async () => {
+ const asked: string[] = [];
+ const mail = provider({ remote: async (_p, a) => { asked.push(`mail:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
+ const files = provider({ kind: 'files', remote: async (_p, a) => { asked.push(`files:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
+ await runGlobalSearch({
+ parsed: parseSearchQuery('in:mail account:home plan'), accounts: ACCOUNTS, providers: [mail, files],
+ localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined,
+ });
+ expect(asked).toEqual(['mail:b']);
+ });
+
+ it('gives up on a slow account after the time limit', async () => {
+ vi.useFakeTimers();
+ try {
+ const slow = provider({ remote: () => new Promise(() => undefined) });
+ const done = runGlobalSearch({
+ parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [slow],
+ localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 1000,
+ });
+ await vi.advanceTimersByTimeAsync(1001);
+ const result = await done;
+ expect(result.status.mail.errors[0].message).toBe('timeout');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('publishes nothing after the search was replaced', async () => {
+ const controller = new AbortController();
+ const onUpdate = vi.fn();
+ const mail = provider({ remote: async () => { controller.abort(); return { hits: [hit({ id: 'late' })], hasMore: false }; } });
+ await runGlobalSearch({
+ parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [mail],
+ localLimit: 10, remoteLimit: 10, signal: controller.signal, onUpdate,
+ });
+ const last = onUpdate.mock.calls.at(-1)?.[0] as SearchOutcome;
+ expect(last.hits.mail.map((h) => h.id)).not.toContain('late');
+ });
+
+ it('runs no local pass for a query of operators only', () => {
+ const local = vi.fn(() => [hit({ id: 'x' })]);
+ const outcome = localOutcome(parseSearchQuery('is:unread'), ACCOUNTS, [provider({ local })], 10);
+ expect(local).not.toHaveBeenCalled();
+ expect(outcome.status.mail.status).toBe('loading');
+ });
+});
diff --git a/src/lib/global-search/filters.ts b/src/lib/global-search/filters.ts
new file mode 100644
index 00000000..d12e399c
--- /dev/null
+++ b/src/lib/global-search/filters.ts
@@ -0,0 +1,181 @@
+import type { CalendarEvent, ContactCard, Email, EmailAddress, FileNode } from '../../api/types';
+import { textConditions } from '../search-utils';
+import type { MailFilters, ParsedQuery } from './query-parser';
+
+// What each kind of item is searched by, and the server filters a parsed
+// query turns into (webmail lib/global-search/providers/*.ts and
+// buildJMAPFilter in lib/jmap/search-utils.ts).
+
+type JmapFilter = Record;
+
+/** AND of two filters, flattening an AND on the left. */
+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] };
+}
+
+/** The Email/query filter for free text and the mail operators (webmail buildJMAPFilter). */
+export function buildMailFilter(text: string, filters: MailFilters): JmapFilter {
+ const conditions: JmapFilter[] = [];
+ if (text.trim()) conditions.push(...textConditions(text));
+ if (filters.from) conditions.push(...textConditions(filters.from, 'from'));
+ if (filters.to) conditions.push(...textConditions(filters.to, 'to'));
+ if (filters.subject) conditions.push(...textConditions(filters.subject, 'subject'));
+ if (filters.body) conditions.push(...textConditions(filters.body, 'body'));
+ if (filters.hasAttachment !== null) conditions.push({ hasAttachment: filters.hasAttachment });
+ if (filters.dateAfter) {
+ const date = new Date(filters.dateAfter);
+ if (!Number.isNaN(date.getTime())) conditions.push({ after: date.toISOString() });
+ }
+ if (filters.dateBefore) {
+ const end = new Date(filters.dateBefore);
+ if (!Number.isNaN(end.getTime())) {
+ end.setHours(23, 59, 59, 999);
+ conditions.push({ before: end.toISOString() });
+ }
+ }
+ if (filters.isUnread === true) conditions.push({ notKeyword: '$seen' });
+ else if (filters.isUnread === false) conditions.push({ hasKeyword: '$seen' });
+ if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
+ else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' });
+ if (conditions.length === 0) return {};
+ if (conditions.length === 1) return conditions[0];
+ return { operator: 'AND', conditions };
+}
+
+/** The folders of one mail account the filter needs: id and role. */
+export interface MailboxRef {
+ id: string;
+ role?: string | null;
+ name: string;
+}
+
+function findRole(mailboxes: MailboxRef[], role: 'trash' | 'junk'): MailboxRef | undefined {
+ return mailboxes.find((m) => m.role === role || (role === 'junk' && m.role === 'spam'));
+}
+
+/**
+ * The default scope is everything but Trash and Junk; `in:trash` and
+ * `in:junk` search that folder alone and `is:anything` lifts the exclusion
+ * (webmail mailFilterFor). Without those folders, everything.
+ */
+export function mailFilterFor(parsed: ParsedQuery, mailboxes: MailboxRef[]): JmapFilter {
+ const base = buildMailFilter(parsed.text, parsed.mail);
+ if (parsed.mailboxRole) {
+ const mailbox = findRole(mailboxes, parsed.mailboxRole);
+ return mailbox ? andFilters(base, { inMailbox: mailbox.id }) : base;
+ }
+ if (parsed.includeTrashAndJunk) return base;
+ const excluded = (['trash', 'junk'] as const).map((role) => findRole(mailboxes, role)?.id).filter((id): id is string => !!id);
+ return excluded.length > 0 ? andFilters(base, { inMailboxOtherThan: excluded }) : base;
+}
+
+/**
+ * `after:`/`before:` are calendar days, compared against instants. Several
+ * text parts go out as an AND of single-key conditions: 1.0 takes one AND
+ * group when it expands recurrences (webmail calendarFilterFor).
+ */
+export function calendarFilterFor(parsed: ParsedQuery): JmapFilter {
+ const after = parsed.after ? `${parsed.after}T00:00:00Z` : undefined;
+ const before = parsed.before ? `${parsed.before}T23:59:59Z` : undefined;
+ const texts = textConditions(parsed.text).map(({ text }): JmapFilter => ({ text }));
+ if (texts.length === 1) {
+ const filter = texts[0];
+ if (after) filter.after = after;
+ if (before) filter.before = before;
+ return filter;
+ }
+ const bounds: JmapFilter[] = [];
+ if (after) bounds.push({ after });
+ if (before) bounds.push({ before });
+ return { operator: 'AND', conditions: [...bounds, ...texts] };
+}
+
+function addressFields(addresses: EmailAddress[] | null | undefined): string[] {
+ const out: string[] = [];
+ for (const address of addresses ?? []) {
+ if (address.name) out.push(address.name);
+ if (address.email) out.push(address.email);
+ }
+ return out;
+}
+
+/** The text a loaded message is found by. */
+export function emailFields(email: Email): string[] {
+ return [email.subject ?? '', email.preview ?? '', ...addressFields(email.from), ...addressFields(email.to)];
+}
+
+function includesCi(haystack: string[], needle: string): boolean {
+ const n = needle.toLowerCase();
+ return haystack.some((h) => h.toLowerCase().includes(n));
+}
+
+/** The mail operators, applied to a loaded message. */
+export function emailMatchesFilters(email: Email, parsed: ParsedQuery): boolean {
+ const { mail } = parsed;
+ if (mail.from && !includesCi(addressFields(email.from), mail.from)) return false;
+ if (mail.to && !includesCi([...addressFields(email.to), ...addressFields(email.cc)], mail.to)) return false;
+ if (mail.subject && !includesCi([email.subject ?? ''], mail.subject)) return false;
+ if (mail.hasAttachment !== null && Boolean(email.hasAttachment) !== mail.hasAttachment) return false;
+ if (mail.isUnread !== null && Boolean(email.keywords?.$seen) === mail.isUnread) return false;
+ if (mail.isStarred !== null && Boolean(email.keywords?.$flagged) !== mail.isStarred) return false;
+ const day = email.receivedAt?.slice(0, 10) ?? '';
+ if (mail.dateAfter && day < mail.dateAfter) return false;
+ if (mail.dateBefore && day > mail.dateBefore) return false;
+ return true;
+}
+
+/** The text a contact is found by: name, addresses, organisations, phones, nicknames. */
+export function contactFields(contact: ContactCard, displayName: string): string[] {
+ const fields: string[] = [displayName];
+ for (const email of Object.values(contact.emails ?? {})) if (email?.address) fields.push(email.address);
+ for (const org of Object.values(contact.organizations ?? {})) {
+ const name = (org as { name?: string }).name;
+ if (name) fields.push(name);
+ }
+ for (const phone of Object.values(contact.phones ?? {})) {
+ const number = (phone as { number?: string }).number;
+ if (number) fields.push(number);
+ }
+ for (const nick of Object.values(contact.nicknames ?? {})) {
+ const name = (nick as { name?: string }).name;
+ if (name) fields.push(name);
+ }
+ return fields;
+}
+
+/** The text an event is found by: title, description, places and participants. */
+export function eventFields(event: CalendarEvent): string[] {
+ const fields: string[] = [event.title ?? '', event.description ?? ''];
+ for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
+ for (const participant of Object.values(event.participants ?? {})) {
+ const p = participant as { name?: string | null; email?: string | null };
+ if (p.name) fields.push(p.name);
+ if (p.email) fields.push(p.email);
+ }
+ return fields;
+}
+
+export function isRecurringEvent(event: Pick): boolean {
+ return Boolean(event.recurrenceId) || (Array.isArray(event.recurrenceRules) && event.recurrenceRules.length > 0);
+}
+
+/** The folder path a file node lives in (`/` for the root). */
+export function pathOfNode(nodes: FileNode[], node: FileNode): string {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const segments: string[] = [];
+ const seen = new Set();
+ let parentId = node.parentId;
+ while (parentId != null && !seen.has(parentId)) {
+ seen.add(parentId);
+ const parent = byId.get(parentId);
+ if (!parent) break;
+ segments.unshift(parent.name);
+ parentId = parent.parentId;
+ }
+ return `/${segments.join('/')}`;
+}
diff --git a/src/lib/global-search/open-hit.ts b/src/lib/global-search/open-hit.ts
new file mode 100644
index 00000000..e226867f
--- /dev/null
+++ b/src/lib/global-search/open-hit.ts
@@ -0,0 +1,74 @@
+import { useAuthStore } from '../../stores/auth-store';
+import { setPendingCalendarOpen } from '../../navigation/pending-calendar-open';
+import { setPendingFilesLink } from '../../navigation/pending-files-link';
+import type { GlobalSearchHit } from './types';
+
+// Opening a search hit where it lives, in its own account (webmail
+// lib/global-search/open-hit.ts, the standard layout's path): switch to the
+// hit's login when it isn't the active one, then go to its screen. Every hit
+// names its login, owning account and raw id (#847).
+
+/** The navigation calls opening a hit needs. */
+export interface HitNavigation {
+ navigate: (...args: any[]) => void;
+}
+
+/** The Files link of a file hit: own files by path, a shared drive's by its listed id. */
+export function filesLinkFor(hit: Extract): { path: string; preview?: string; shared?: string } {
+ if (hit.node.isShared) return { path: '/', shared: hit.node.id };
+ if (hit.isFolder) {
+ const base = hit.folderPath === '/' ? '' : hit.folderPath;
+ return { path: `${base}/${hit.node.name}` };
+ }
+ return { path: hit.folderPath, preview: hit.node.name };
+}
+
+/**
+ * Opens a hit. `peers` are the other hits of the list, so the reader can
+ * page over the messages of the same account. Resolves false when the
+ * switch to the hit's login did not happen.
+ */
+export async function openHit(hit: GlobalSearchHit, navigation: HitNavigation, peers: GlobalSearchHit[] = []): Promise {
+ const auth = useAuthStore.getState();
+ if (auth.activeAccountId !== hit.localAccountId) {
+ await auth.switchAccount(hit.localAccountId);
+ if (useAuthStore.getState().activeAccountId !== hit.localAccountId) return false;
+ }
+ switch (hit.kind) {
+ case 'mail': {
+ const pages = peers
+ .filter((p) => p.kind === 'mail' && p.localAccountId === hit.localAccountId && p.jmapAccountId === hit.jmapAccountId)
+ .map((p) => p.id);
+ navigation.navigate('EmailThread', {
+ emailId: hit.id,
+ threadId: hit.email.threadId,
+ subject: hit.email.subject,
+ // A group or shared account's message names its account: ids repeat.
+ jmapAccountId: hit.shared ? hit.jmapAccountId : undefined,
+ emailIds: pages.includes(hit.id) ? pages : [hit.id],
+ });
+ return true;
+ }
+ case 'contacts':
+ navigation.navigate('ContactDetail', { contactId: hit.storeId });
+ return true;
+ case 'calendar': {
+ // Like a tapped reminder: the Calendar tab finds the event by its
+ // server id when it isn't in the loaded window.
+ const startMs = hit.event.start ? Date.parse(hit.event.start) : NaN;
+ setPendingCalendarOpen({
+ kind: 'event',
+ eventId: hit.source === 'local' ? hit.event.id : hit.shared ? `${hit.jmapAccountId}:${hit.id}` : hit.id,
+ serverId: hit.id,
+ accountId: hit.shared ? hit.jmapAccountId : undefined,
+ ...(Number.isNaN(startMs) ? {} : { startMs }),
+ });
+ navigation.navigate('MainTabs', { screen: 'Calendar' });
+ return true;
+ }
+ case 'files':
+ setPendingFilesLink(filesLinkFor(hit));
+ navigation.navigate('MainTabs', { screen: 'Files' });
+ return true;
+ }
+}
diff --git a/src/lib/global-search/query-parser.ts b/src/lib/global-search/query-parser.ts
new file mode 100644
index 00000000..f2c49e4a
--- /dev/null
+++ b/src/lib/global-search/query-parser.ts
@@ -0,0 +1,205 @@
+// Query syntax of "Search everything" (webmail lib/global-search/query-parser.ts):
+//
+// in:mail|contacts|calendar|files scope (also in:trash / in:junk for mail)
+// account: