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/__tests__/live-stalwart-mailbox-share.test.ts b/src/api/__tests__/live-stalwart-mailbox-share.test.ts
new file mode 100644
index 00000000..0d1c9c29
--- /dev/null
+++ b/src/api/__tests__/live-stalwart-mailbox-share.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Live checks of folder sharing (the folder menu's "Share…") against a real
+ * Stalwart: the first login makes a folder, shares it with the second one,
+ * changes the role, and takes the access away again.
+ *
+ * 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-mailbox-share.test.ts
+ *
+ * The folder and its share are removed afterwards, also after a failed check.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+
+import { JMAPClient } from '../jmap-client';
+import { CAPABILITIES } from '../types';
+import { supportsMailboxSharing } from '../delegation';
+import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing';
+import { createMailbox, deleteMailbox } from '../email';
+import { listPrincipals } from '../files';
+import { directoryNotice } from '../../lib/principal-directory';
+import { detectPreset, MAILBOX_PRESETS } from '../../lib/delegation/share-rights';
+
+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;
+
+let owner: JMAPClient;
+let delegate: JMAPClient;
+let folderId: string | null = null;
+
+async function useOwner(): Promise {
+ const mod = await import('../jmap-client');
+ (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
+}
+
+/** The owner's folders the delegate can see. */
+async function delegateSees(): Promise {
+ const session = await delegate.refreshSession();
+ if (!session.accounts?.[owner.accountId]) return [];
+ const res = await delegate.request(
+ [['Mailbox/get', { accountId: owner.accountId, ids: null, properties: ['id'] }, '0']],
+ [CAPABILITIES.CORE, CAPABILITIES.MAIL],
+ );
+ const [method, body] = (res.methodResponses ?? [])[0] as [string, { list?: { id: string }[] }, string];
+ return method === 'Mailbox/get' ? (body.list ?? []).map((m) => m.id) : [];
+}
+
+live('live Stalwart: folder sharing', () => {
+ beforeAll(async () => {
+ owner = new JMAPClient();
+ await owner.connect(SERVER!, USER, PASS);
+ delegate = new JMAPClient();
+ await delegate.connect(SERVER!, USER2!, PASS2);
+ await useOwner();
+ }, 60_000);
+
+ afterAll(async () => {
+ if (!owner || !folderId) return;
+ await useOwner();
+ await setMailboxShare(folderId, delegate.accountId, null).catch(() => undefined);
+ await deleteMailbox(folderId, undefined, { onDestroyRemoveEmails: true }).catch(() => undefined);
+ }, 60_000);
+
+ it('shares a folder read-only, upgrades it, and takes it back', async () => {
+ expect(supportsMailboxSharing()).toBe(true);
+ folderId = await createMailbox({ name: `Bulwark share check ${Date.now()}` });
+ const principalId = delegate.accountId;
+
+ // Nobody yet: Stalwart answers {} (or null).
+ expect(Object.keys((await getMailboxShareWith(folderId)) ?? {})).toEqual([]);
+
+ await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.read);
+ const afterRead = await getMailboxShareWith(folderId);
+ expect(afterRead?.[principalId]).toBeTruthy();
+ // 1.0 stores the keyword right with the seen right; still "Read only".
+ expect(detectPreset('mailbox', afterRead![principalId])).toBe('read');
+ expect(await delegateSees()).toContain(folderId);
+
+ await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.readWrite);
+ const afterWrite = await getMailboxShareWith(folderId);
+ expect(detectPreset('mailbox', afterWrite![principalId])).toBe('readWrite');
+
+ await setMailboxShare(folderId, principalId, null);
+ const afterRemove = await getMailboxShareWith(folderId);
+ expect(afterRemove?.[principalId]).toBeFalsy();
+ expect(await delegateSees()).not.toContain(folderId);
+ }, 60_000);
+
+ // Stalwart 1.0 hides the directory unless the admin turns directory
+ // queries on: the sheet then says why the list is short and names a
+ // sharee it can't resolve "Unknown user". 0.16 lists everyone.
+ it('either lists the person shared with or explains why the list is short', async () => {
+ const listing = await listPrincipals();
+ const listed = listing.principals.some((p) => p.id === delegate.accountId);
+ const notice = directoryNotice(listing);
+ expect(listed || notice !== null).toBe(true);
+ if (!listed) expect(notice).toBe('restricted');
+ }, 60_000);
+});
diff --git a/src/api/__tests__/mailbox-sharing.test.ts b/src/api/__tests__/mailbox-sharing.test.ts
new file mode 100644
index 00000000..022b84a5
--- /dev/null
+++ b/src/api/__tests__/mailbox-sharing.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../jmap-client', () => ({
+ jmapClient: {
+ accountId: 'own',
+ request: vi.fn(),
+ },
+}));
+
+import { jmapClient } from '../jmap-client';
+import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing';
+import { MAILBOX_PRESETS } from '../../lib/delegation/share-rights';
+import { canShareMailbox, mailboxShareRef } from '../../lib/mailbox-share';
+import type { Mailbox, MailboxRights } from '../types';
+
+const mockRequest = jmapClient.request as ReturnType;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('getMailboxShareWith', () => {
+ it("asks for the folder's shareWith with the mail:share capability", async () => {
+ mockRequest.mockResolvedValue({
+ methodResponses: [['Mailbox/get', { list: [{ id: 'mb1', shareWith: { p2: MAILBOX_PRESETS.read } }] }, '0']],
+ });
+ const shareWith = await getMailboxShareWith('mb1');
+ expect(shareWith).toEqual({ p2: MAILBOX_PRESETS.read });
+ const [[call], using] = mockRequest.mock.calls[0];
+ expect(call).toEqual(['Mailbox/get', { accountId: 'own', ids: ['mb1'], properties: ['id', 'shareWith'] }, '0']);
+ expect(using).toContain('urn:ietf:params:jmap:mail:share');
+ });
+
+ it('reads a folder of a shared account under its owner', async () => {
+ mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [{ id: 'x', shareWith: null }] }, '0']] });
+ expect(await getMailboxShareWith('x', 'team')).toBeNull();
+ expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('team');
+ });
+
+ it('fails when the folder is gone', async () => {
+ mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [], notFound: ['mb1'] }, '0']] });
+ await expect(getMailboxShareWith('mb1')).rejects.toThrow('Folder not found');
+ });
+});
+
+describe('setMailboxShare', () => {
+ it('patches one principal into shareWith, and null takes the access away', async () => {
+ mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: { mb1: null } }, '0']] });
+ await setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read);
+ await setMailboxShare('mb1', 'p2', null, 'team');
+ expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'own', update: { mb1: { 'shareWith/p2': MAILBOX_PRESETS.read } } });
+ expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'team', update: { mb1: { 'shareWith/p2': null } } });
+ });
+
+ it("reports the server's refusal", async () => {
+ mockRequest.mockResolvedValue({
+ methodResponses: [['Mailbox/set', { notUpdated: { mb1: { type: 'forbidden', description: 'Not allowed' } } }, '0']],
+ });
+ await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('Not allowed');
+ });
+
+ it('fails when the server does not confirm the change', async () => {
+ mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: {} }, '0']] });
+ await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('did not confirm');
+ });
+});
+
+describe('which folders offer Share', () => {
+ const rights = (extra: Record = {}) => ({ ...MAILBOX_PRESETS.read, ...extra }) as unknown as MailboxRights;
+
+ it("offers it on the user's own folders where the server shares folders", () => {
+ expect(canShareMailbox({ isShared: false, myRights: rights() }, true)).toBe(true);
+ expect(canShareMailbox({ isShared: false, myRights: rights() }, false)).toBe(false);
+ });
+
+ it("offers it on someone else's folder only with their permission to pass it on", () => {
+ expect(canShareMailbox({ isShared: true, myRights: rights() }, true)).toBe(false);
+ expect(canShareMailbox({ isShared: true, myRights: rights({ mayShare: true }) }, true)).toBe(true);
+ });
+
+ it("shares a shared account's folder under its owner, with the owner's id", () => {
+ const shared = { id: 'team:5', originalId: '5', isShared: true, accountId: 'team' } as Mailbox;
+ expect(mailboxShareRef(shared)).toEqual({ mailboxId: '5', accountId: 'team' });
+ expect(mailboxShareRef({ id: 'a', isShared: false, accountId: 'own' } as Mailbox)).toEqual({ mailboxId: 'a', accountId: undefined });
+ });
+});
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/api/mailbox-sharing.ts b/src/api/mailbox-sharing.ts
new file mode 100644
index 00000000..f53ec327
--- /dev/null
+++ b/src/api/mailbox-sharing.ts
@@ -0,0 +1,62 @@
+// Sharing a mail folder with other principals
+// (urn:ietf:params:jmap:mail:share), as the webmail's
+// JMAPClient.getMailboxShareWith / setMailboxShare do (lib/jmap/client.ts).
+
+import { jmapClient } from './jmap-client';
+import { CAPABILITIES } from './types';
+import { requireMethodResult } from './jmap-result';
+import { MAIL_SHARE_CAPABILITY } from './delegation';
+import type { ShareMailboxRights } from '../lib/delegation/share-rights';
+
+const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY];
+
+/**
+ * The current `shareWith` of one folder. Read on demand (the share sheet):
+ * Stalwart leaves shareWith out of Mailbox/get unless it is asked for.
+ * `accountId` is the owner's account for a folder someone shared with the
+ * user, with the owner's mailbox id.
+ */
+export async function getMailboxShareWith(
+ mailboxId: string,
+ accountId?: string,
+): Promise | null> {
+ const res = await jmapClient.request(
+ [['Mailbox/get', { accountId: accountId || jmapClient.accountId, ids: [mailboxId], properties: ['id', 'shareWith'] }, '0']],
+ USING,
+ );
+ const result = requireMethodResult<{ list?: Array<{ id: string; shareWith?: Record | null }> }>(
+ res, '0', 'Mailbox/get',
+ );
+ const mailbox = (result.list ?? []).find((mb) => mb.id === mailboxId);
+ if (!mailbox) throw new Error('Folder not found');
+ return mailbox.shareWith ?? null;
+}
+
+/**
+ * Grants (`rights`) or revokes (`null`) a principal's access to a folder with
+ * a `shareWith/{principalId}` patch, like the calendar and address book
+ * shares.
+ */
+export async function setMailboxShare(
+ mailboxId: string,
+ principalId: string,
+ rights: ShareMailboxRights | null,
+ accountId?: string,
+): Promise {
+ const res = await jmapClient.request(
+ [['Mailbox/set', {
+ accountId: accountId || jmapClient.accountId,
+ update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
+ }, '0']],
+ USING,
+ );
+ const result = requireMethodResult<{
+ updated?: Record | null;
+ notUpdated?: Record;
+ }>(res, '0', 'Mailbox/set');
+ const err = result.notUpdated?.[mailboxId];
+ if (err) throw new Error(err.description || err.type || 'Failed to update folder share');
+ if (!result.updated || !(mailboxId in result.updated)) {
+ throw new Error('Server did not confirm the share update');
+ }
+}
diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx
index 84db85dc..984092f6 100644
--- a/src/components/SidebarDrawer.tsx
+++ b/src/components/SidebarDrawer.tsx
@@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
- Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
+ Clock, Layers, Users, Tag, Mails, MoreHorizontal, Share2, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
@@ -34,6 +34,11 @@ import {
markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox,
} from '../api/email';
import { useTagCountsStore } from '../stores/tag-counts-store';
+import { sidebarTagRows } from '../lib/keyword-nesting';
+import { supportsMailboxSharing } from '../api/delegation';
+import { MailboxShareSheet } from './sharing/MailboxShareSheet';
+import { canShareMailbox } from '../lib/mailbox-share';
+import { tagIdFromKeyword } from '../lib/thread-utils';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
@@ -59,6 +64,7 @@ const STORAGE_KEYS = {
expanded: 'sidebar:expandedMailboxes',
foldersExpanded: 'sidebar:foldersExpanded',
tagsExpanded: 'sidebar:tagsExpanded',
+ collapsedTags: 'sidebar:collapsedTags',
unifiedExpanded: 'sidebar:unifiedExpanded',
};
@@ -360,18 +366,26 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
+ const nestedTags = useKeywordsStore((s) => s.nested);
const navigation = useNavigation>();
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
const [tagsExpanded, setTagsExpanded] = React.useState(true);
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set());
+ // Parent tags the user folded; every other parent shows its children, as
+ // the webmail opens all of them until the user folds one.
+ const [collapsedTags, setCollapsedTags] = React.useState>(() => new Set());
+ // "Show all (N)": the tags the visibility settings hide, for now.
+ const [showAllTags, setShowAllTags] = React.useState(false);
const [accountMenuOpen, setAccountMenuOpen] = React.useState(false);
const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null);
const [prompt, setPrompt] = React.useState<{
title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void;
} | null>(null);
const [busy, setBusy] = React.useState(false);
+ // The folder whose "Share" sheet is open (webmail MailboxShareDialog).
+ const [sharing, setSharing] = React.useState<{ mailbox: Mailbox; name: string } | null>(null);
const tagCounts = useTagCountsStore((s) => s.counts);
const tagCountsGeneration = useTagCountsStore((s) => s.generation);
const ensureTagCounts = useTagCountsStore((s) => s.ensure);
@@ -381,12 +395,19 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
React.useEffect(() => {
void (async () => {
try {
- const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([
+ const [rawExp, rawFld, rawTags, rawUnified, rawCollapsedTags] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.expanded),
AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded),
AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded),
AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded),
+ AsyncStorage.getItem(STORAGE_KEYS.collapsedTags),
]);
+ if (rawCollapsedTags) {
+ try {
+ const ids = JSON.parse(rawCollapsedTags) as string[];
+ if (Array.isArray(ids)) setCollapsedTags(new Set(ids));
+ } catch { /* ignore */ }
+ }
if (rawExp) {
try {
const ids = JSON.parse(rawExp) as string[];
@@ -459,6 +480,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose();
}, [selectMailbox, setFilters, onClose]);
+ const toggleTagExpand = React.useCallback((id: string) => {
+ setCollapsedTags((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ void AsyncStorage.setItem(STORAGE_KEYS.collapsedTags, JSON.stringify(Array.from(next))).catch(() => {});
+ return next;
+ });
+ }, []);
+
// Tag view (#175): every folder, messages carrying the tag.
const selectTag = React.useCallback((id: string) => {
setFilters({ keyword: keywordToken(id) });
@@ -479,6 +509,16 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded);
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]);
+ // The tag rows: nested under their parents with "Nested tags" on, and
+ // without the tags whose visibility hides them (webmail sidebar).
+ const selectedTagId = filters.keyword ? tagIdFromKeyword(filters.keyword) : null;
+ const tagRows = React.useMemo(
+ () => sidebarTagRows(keywordDefs, nestedTags, tagCounts, {
+ showAll: showAllTags, selectedId: selectedTagId, collapsed: collapsedTags,
+ }),
+ [keywordDefs, nestedTags, tagCounts, showAllTags, selectedTagId, collapsedTags],
+ );
+
// ── Folder actions (long-press) ───────────────────────────────────────
const refFor = (mb: Mailbox) => ({
id: mb.originalId ?? mb.id,
@@ -629,6 +669,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
}),
});
}
+ if (canShareMailbox(mb, supportsMailboxSharing())) {
+ actions.push({
+ key: 'share',
+ label: t('mailbox_context_menu.share', 'Share…'),
+ icon: Share2,
+ onPress: () => setSharing({ mailbox: mb, name }),
+ });
+ }
if (!mb.role && mb.myRights?.mayRename !== false) {
actions.push({
key: 'rename',
@@ -1221,27 +1269,44 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{t('sidebar.tags', 'Tags')}
- {tagsExpanded && keywordDefs.map((kw) => {
- const counts = tagCounts[kw.id];
- const isSelected = filters.keyword === keywordToken(kw.id);
- const dot = c.tags[kw.color]?.dot ?? c.textMuted;
+ {tagsExpanded && tagRows.rows.map(({ node, label, hasChildren, expanded }) => {
+ const counts = tagCounts[node.id];
+ const isSelected = filters.keyword === keywordToken(node.id);
+ const dot = c.tags[node.color]?.dot ?? c.textMuted;
return (
}
- label={kw.label}
- depth={0}
+ label={label}
+ depth={node.depth}
isSelected={isSelected}
unread={counts?.unread ?? 0}
total={counts?.total ?? 0}
showTotal={showFolderTotalCount}
- hasChildren={false}
- isExpanded={false}
- onPress={() => selectTag(kw.id)}
- onToggleExpand={() => {}}
+ hasChildren={hasChildren}
+ isExpanded={expanded}
+ onPress={() => selectTag(node.id)}
+ onToggleExpand={() => toggleTagExpand(node.id)}
/>
);
})}
+ {tagsExpanded && (tagRows.hiddenCount > 0 || showAllTags) && (
+ }
+ label={showAllTags
+ ? t('sidebar.show_fewer_tags', 'Show less')
+ : t('sidebar.show_all_tags', 'Show all ({count})', { count: tagRows.hiddenCount })}
+ depth={0}
+ isSelected={false}
+ unread={0}
+ total={0}
+ showTotal={false}
+ hasChildren={false}
+ isExpanded={false}
+ onPress={() => setShowAllTags((prev) => !prev)}
+ onToggleExpand={() => {}}
+ />
+ )}
>
)}
@@ -1264,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
+ setSharing(null)} />
);
}
@@ -1299,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
+ setSharing(null)} />
);
}
diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx
index 8e6426f3..8afddb13 100644
--- a/src/components/TagSheet.tsx
+++ b/src/components/TagSheet.tsx
@@ -6,7 +6,8 @@ import { X, Check, Tag } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
-import { keywordToken, type KeywordDef } from '../stores/keywords-store';
+import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
+import { buildKeywordTree, flattenKeywordTree, treeRowLabel } from '../lib/keyword-nesting';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
@@ -59,6 +60,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
[selectedEmails],
);
+ // With "Nested tags" on, a tree: children indented under their parent and
+ // named by their own label, as the webmail tag picker draws it.
+ const nested = useKeywordsStore((s) => s.nested);
+ const rows = React.useMemo(
+ () => flattenKeywordTree(buildKeywordTree(keywords, nested)),
+ [keywords, nested],
+ );
+
// Tags set on the selection that no local definition explains (set by the
// webmail or another device): listed so they can at least be removed.
const unknownIds = React.useMemo(() => {
@@ -103,7 +112,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
{keywords.length === 0 && unknownIds.length === 0 ? (
{t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')}
) : (
- keywords.map((kw) => {
+ rows.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = c.tags[kw.color]?.dot ?? c.primary;
@@ -111,10 +120,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
onToggle(token, !applied)}
- style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
+ style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]}
>
- {kw.label}
+ {treeRowLabel(kw, keywords, nested)}
{applied && }
);
@@ -143,6 +152,11 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
);
}
+/** Left padding of a tag row: one step per nesting level. */
+export function tagRowIndent(depth: number): { paddingLeft: number } {
+ return { paddingLeft: spacing.lg + depth * spacing.lg };
+}
+
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
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/__tests__/mailbox-share-wiring.test.ts b/src/components/__tests__/mailbox-share-wiring.test.ts
new file mode 100644
index 00000000..805c533f
--- /dev/null
+++ b/src/components/__tests__/mailbox-share-wiring.test.ts
@@ -0,0 +1,30 @@
+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('folder sharing in the sidebar', () => {
+ it('offers Share in the folder menu where the folder can be shared, and opens the share sheet', () => {
+ const source = read('components/SidebarDrawer.tsx');
+ expect(source).toMatch(/canShareMailbox\(mb, supportsMailboxSharing\(\)\)/);
+ expect(source).toMatch(/key: 'share'/);
+ expect(source).toMatch(/onPress: \(\) => setSharing\(\{ mailbox: mb, name \}\)/);
+ // Both the docked sidebar and the drawer render the sheet.
+ expect(source.match(/ {
+ const source = read('components/sharing/MailboxShareSheet.tsx');
+ expect(source).toMatch(/getMailboxShareWith\(mailboxId, accountId\)/);
+ expect(source).toMatch(/setMailboxShare\(mailboxId, principalId, rights, accountId\)/);
+ expect(source).toMatch(/detectPreset\('mailbox', rights\)/);
+ expect(source).not.toMatch(/freeBusy/);
+ });
+
+ it('lets the share sheet name a preset the server stored differently', () => {
+ const source = read('components/sharing/ShareCollectionSheet.tsx');
+ expect(source).toMatch(/detectPreset \? detectPreset\(rights\) : detectSharePreset\(rights, presets\)/);
+ });
+});
diff --git a/src/components/__tests__/nested-tags-wiring.test.ts b/src/components/__tests__/nested-tags-wiring.test.ts
new file mode 100644
index 00000000..02004be4
--- /dev/null
+++ b/src/components/__tests__/nested-tags-wiring.test.ts
@@ -0,0 +1,37 @@
+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. The rows
+// themselves come from lib/keyword-nesting (keyword-nesting.test.ts); these
+// check that the screens draw them.
+const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8');
+
+describe('nested tags and tag visibility on screen', () => {
+ it('draws the sidebar tags as a tree with the visibility settings applied', () => {
+ const source = read('components/SidebarDrawer.tsx');
+ expect(source).toMatch(/sidebarTagRows\(keywordDefs, nestedTags, tagCounts/);
+ expect(source).toMatch(/depth=\{node\.depth\}/);
+ expect(source).toMatch(/onToggleExpand=\{\(\) => toggleTagExpand\(node\.id\)\}/);
+ expect(source).toMatch(/sidebar\.show_all_tags/);
+ });
+
+ it('indents nested tags in the tag sheet', () => {
+ const source = read('components/TagSheet.tsx');
+ expect(source).toMatch(/flattenKeywordTree\(buildKeywordTree\(keywords, nested\)\)/);
+ expect(source).toMatch(/tagRowIndent\(kw\.depth\)/);
+ });
+
+ it('offers the nesting switch, a parent and the sidebar visibility in Settings > Tags', () => {
+ const source = read('components/settings/KeywordSettings.tsx');
+ expect(source).toMatch(/settings\.keywords\.nesting\.label/);
+ expect(source).toMatch(/onChange=\{setNested\}/);
+ expect(source).toMatch(/composeKeywordId\(parentId \|\| null, label\)/);
+ expect(source).toMatch(/updateKeyword\(kw\.id, \{ visibility: value as KeywordVisibility \}\)/);
+ });
+
+ it('names a nested tag by its path in the list and the reader', () => {
+ expect(read('screens/EmailListScreen.tsx')).toMatch(/formatKeyword\(id, keywordDefs, nestedTags\)/);
+ expect(read('components/email/MessageHeader.tsx')).toMatch(/formatKeyword\(kw\.id, keywordDefs, nestedTags\)/);
+ });
+});
diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx
index c2a7e085..e249218b 100644
--- a/src/components/calendar/CalendarSearchSheet.tsx
+++ b/src/components/calendar/CalendarSearchSheet.tsx
@@ -14,7 +14,7 @@ import { format } from 'date-fns';
import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDay } from '../../lib/calendar-system';
@@ -62,6 +62,7 @@ export function CalendarSearchSheet({
onClose,
}: CalendarSearchSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t, system } = useCalendarLocale();
const loaded = useCalendarStore((s) => s.events);
@@ -134,7 +135,7 @@ export function CalendarSearchSheet({
const start = getEventStartDate(event);
const end = getEventEndDate(event);
const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event));
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const inactive = isInactiveEvent(event, currentUserEmails);
const place = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const meta = [place, calendar?.name].filter(Boolean).join(' · ');
diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx
index f725a8f6..5900e2f1 100644
--- a/src/components/calendar/CalendarSidebarDrawer.tsx
+++ b/src/components/calendar/CalendarSidebarDrawer.tsx
@@ -16,7 +16,7 @@ import {
} from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
@@ -309,6 +309,7 @@ function Section({
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Neighbours a calendar can move past: the server calendars of this section.
@@ -352,8 +353,8 @@ function Section({
style={[
styles.swatch,
{
- backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
- borderColor: getCalendarColor(cal),
+ backgroundColor: visible ? paint(getCalendarColor(cal)) : 'transparent',
+ borderColor: paint(getCalendarColor(cal)),
},
]}
>
diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx
index fd6f9055..0ada84b6 100644
--- a/src/components/calendar/EventCard.tsx
+++ b/src/components/calendar/EventCard.tsx
@@ -4,7 +4,7 @@ import { Clock, MapPin, Users } from '../icons';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
@@ -38,9 +38,10 @@ function participantCount(event: CalendarEvent): number {
*/
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const range = eventTimeRange(event);
const { start, end } = range;
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx
index 3a52bcc1..975b5f48 100644
--- a/src/components/calendar/EventDetailSheet.tsx
+++ b/src/components/calendar/EventDetailSheet.tsx
@@ -39,7 +39,7 @@ import {
import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
@@ -140,6 +140,7 @@ export function EventDetailSheet({
onSaveNote,
}: EventDetailSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const { locale, t } = useCalendarLocale();
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const [noteOpen, setNoteOpen] = React.useState(false);
@@ -200,7 +201,7 @@ export function EventDetailSheet({
if (!event) return null;
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event, timeFormat, locale);
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index 7c45d440..9dd5175f 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -36,7 +36,7 @@ import type {
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
getCalendarColor,
getPrimaryCalendarId,
@@ -189,6 +189,7 @@ export function EventModal({
onClose,
}: EventModalProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
@@ -588,7 +589,7 @@ export function EventModal({
)}
@@ -614,7 +615,7 @@ export function EventModal({
{c.name}
diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx
index 9ae1b344..b112c323 100644
--- a/src/components/calendar/ICalImportSheet.tsx
+++ b/src/components/calendar/ICalImportSheet.tsx
@@ -10,7 +10,7 @@ import { uploadBlob } from '../../api/blob';
import { parseCalendarBlob } from '../../api/calendar';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { ImportRefusedError, type ImportResult } from '../../stores/calendar-store';
@@ -58,6 +58,7 @@ function eventDateLabel(event: Partial, locale: Locale): string |
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
@@ -308,7 +309,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
-
+
{cal.name}
))}
diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx
index 4350f39f..f05f87f9 100644
--- a/src/components/calendar/ICalSubscriptionSheet.tsx
+++ b/src/components/calendar/ICalSubscriptionSheet.tsx
@@ -7,7 +7,7 @@ import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -37,6 +37,7 @@ interface Props {
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const { locale, t } = useCalendarLocale();
@@ -257,7 +258,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const busy = !!syncing[sub.id];
return (
-
+
startEdit(sub)}>
{sub.name}
diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx
index 0d2868f1..f9bdda85 100644
--- a/src/components/calendar/MonthView.tsx
+++ b/src/components/calendar/MonthView.tsx
@@ -10,7 +10,7 @@ import {
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import {
buildEventDayIndex,
@@ -157,6 +157,7 @@ function MonthWeekRowInner({
const { width } = useWindowDimensions();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
+ const paint = useCvdColor();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
@@ -241,7 +242,7 @@ function MonthWeekRowInner({
{visible.map((event, idx) => (
))}
{overflow > 0 && (
@@ -255,7 +256,7 @@ function MonthWeekRowInner({
// Solid bars in the calendar colour; declined and
// cancelled events outlined (repos/branding/APP.md).
const inactive = isInactiveEvent(event, currentUserEmails);
- const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted);
+ const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted);
return (
s.calendarTintedEvents);
+ const paint = useCvdColor();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
@@ -371,7 +373,7 @@ function FullWeekRow({
{dayEvents.slice(0, shown).map((event, idx) => {
const inactive = isInactiveEvent(event, currentUserEmails);
- const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted);
+ const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted);
// No time on events that fill the whole day.
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx
index 046502ed..0b0b7be3 100644
--- a/src/components/calendar/TasksSheet.tsx
+++ b/src/components/calendar/TasksSheet.tsx
@@ -27,7 +27,7 @@ import {
import { format, isBefore, startOfDay } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { getCalendarColor, getTaskDueDisplayDate, timePattern, type TimeFormat } from '../../lib/calendar-utils';
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
import {
@@ -109,6 +109,7 @@ export function TasksSheet({
onDelete,
}: TasksSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
@@ -373,7 +374,7 @@ export function TasksSheet({
onPress={() => setEditor((e) => ({ ...e, calendarId: cal.id }))}
style={[styles.calChip, cal.id === editor.calendarId && styles.calChipActive]}
>
-
+
{cal.name}
))}
diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx
index a5c3b2e0..7c3f2a05 100644
--- a/src/components/calendar/TimeGridScrollView.tsx
+++ b/src/components/calendar/TimeGridScrollView.tsx
@@ -18,7 +18,7 @@ import {
import { differenceInCalendarDays, format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
@@ -125,6 +125,7 @@ function TimeGridScrollViewInner({
draftResetKey = 0,
}: TimeGridScrollViewProps) {
const c = useColors();
+ const paint = useCvdColor();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -455,7 +456,7 @@ function TimeGridScrollViewInner({
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
- getEventColor(segment.event, calendars),
+ paint(getEventColor(segment.event, calendars)),
isInactiveEvent(segment.event, currentUserEmails),
c,
tinted,
@@ -620,6 +621,7 @@ const DayColumn = React.memo(function DayColumn({
const { locale, t } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
+ const paint = useCvdColor();
const handleLongPress = React.useCallback(
(e: GestureResponderEvent) => {
onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
@@ -650,7 +652,7 @@ const DayColumn = React.memo(function DayColumn({
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
- getEventColor(event, calendars),
+ paint(getEventColor(event, calendars)),
isInactiveEvent(event, currentUserEmails),
colors,
tinted,
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index fb47be4b..2d47f51d 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -10,7 +10,7 @@ import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
@@ -68,6 +68,7 @@ function WeekViewInner({
currentUserEmails,
}: WeekViewProps) {
const c = useColors();
+ const paint = useCvdColor();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -213,7 +214,7 @@ function WeekViewInner({
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
- getEventColor(segment.event, calendars),
+ paint(getEventColor(segment.event, calendars)),
isInactiveEvent(segment.event, currentUserEmails),
c,
tinted,
@@ -289,7 +290,7 @@ function WeekViewInner({
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
- getEventColor(event, calendars),
+ paint(getEventColor(event, calendars)),
isInactiveEvent(event, currentUserEmails),
c,
tinted,
diff --git a/src/components/calendar/__tests__/calendar-cvd-colours.test.ts b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts
new file mode 100644
index 00000000..e3bf4a95
--- /dev/null
+++ b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { cvdColor } from '../../../lib/cvd-palette';
+
+// Checked on the source: the suite has no React Native renderer. Every place
+// that draws a calendar's colour runs it through useCvdColor() first, so the
+// colour-blind-safe setting recolours calendars as its description promises
+// (webmail cf4d02b49). The colour pickers keep the raw colours.
+const src = path.resolve(__dirname, '../../..');
+const DRAWERS = [
+ 'components/calendar/EventCard.tsx',
+ 'components/calendar/EventDetailSheet.tsx',
+ 'components/calendar/MonthView.tsx',
+ 'components/calendar/WeekView.tsx',
+ 'components/calendar/TimeGridScrollView.tsx',
+ 'components/calendar/CalendarSearchSheet.tsx',
+ 'components/calendar/CalendarSidebarDrawer.tsx',
+ 'components/calendar/EventModal.tsx',
+ 'components/calendar/TasksSheet.tsx',
+ 'components/calendar/ICalImportSheet.tsx',
+ 'components/contacts/ContactActivity.tsx',
+ 'components/email/CalendarInvitationBanner.tsx',
+ 'components/email/structured-mail/booking-calendar-actions.tsx',
+];
+
+function colourCalls(source: string): string[] {
+ return source
+ .split(/\r?\n/)
+ .filter((line) => !/^\s*import\b/.test(line) && /\bget(Event|Calendar)Color\(/.test(line))
+ .map((line) => line.trim());
+}
+
+describe('calendar colours in the colour-blind-safe palette', () => {
+ it.each(DRAWERS)('%s paints every calendar colour it draws', (file) => {
+ const source = fs.readFileSync(path.join(src, file), 'utf8');
+ expect(source).toMatch(/const paint = useCvdColor\(\);/);
+ const calls = colourCalls(source);
+ expect(calls.length).toBeGreaterThan(0);
+ for (const line of calls) {
+ expect(line).toMatch(/paint\(get(Event|Calendar)Color\(/);
+ }
+ });
+
+ it('keeps the raw colour in the calendar editor, which is a colour picker', () => {
+ const source = fs.readFileSync(path.join(src, 'components/calendar/CalendarEditSheet.tsx'), 'utf8');
+ expect(source).not.toMatch(/useCvdColor/);
+ });
+
+ it('paints a subscription swatch through the hook', () => {
+ const source = fs.readFileSync(path.join(src, 'components/calendar/ICalSubscriptionSheet.tsx'), 'utf8');
+ expect(source).toMatch(/paint\(sub\.color\)/);
+ });
+
+ it('maps a calendar green to the palette green only when the setting is on', () => {
+ expect(cvdColor('#22c55e', false)).toBe('#22c55e');
+ expect(cvdColor('#22c55e', true).toLowerCase()).not.toBe('#22c55e');
+ });
+});
diff --git a/src/components/contacts/ContactActivity.tsx b/src/components/contacts/ContactActivity.tsx
index 803dfdf5..b76e8219 100644
--- a/src/components/contacts/ContactActivity.tsx
+++ b/src/components/contacts/ContactActivity.tsx
@@ -17,7 +17,7 @@ import { singleLine } from '../../lib/single-line';
import { prefetchMessage } from '../../lib/email-detail-cache';
import SenderAvatar from '../SenderAvatar';
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
@@ -92,6 +92,7 @@ interface Props {
export function ContactActivity({ contact }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation