diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index be14a65f..213962d9 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -311,6 +311,27 @@ describe('email operations', () => { mockRequest.mockReset(); }); + it("sends each account's own filter in the shared request", async () => { + (jmapClient.getMaxCallsInRequest as ReturnType).mockReturnValueOnce(16); + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => ({ + methodResponses: calls.map(([name, , id]) => (name === 'Email/query' + ? [name, { ids: [], total: 0 }, id] + : [name, { list: [] }, id])), + })); + const sort = [{ property: 'receivedAt', isAscending: false }]; + const own = { operator: 'AND', conditions: [{ text: 'x' }, { inMailboxOtherThan: ['trash'] }] }; + + await queryEmailPagesAcrossAccounts( + [{ position: 0, sort, filter: own }, { accountId: 'g1', position: 0, sort }], + { limit: 10, filter: { text: 'x' } }, + ); + + expect(mockRequest).toHaveBeenCalledTimes(1); + const queries = mockRequest.mock.calls[0][0].filter((c: unknown[]) => c[0] === 'Email/query'); + expect(queries.map((c: unknown[]) => (c[1] as { filter: unknown }).filter)).toEqual([own, { text: 'x' }]); + mockRequest.mockReset(); + }); + it('leaves Thread/get out unless asked, and targets a shared account', async () => { mockRequest.mockResolvedValue({ methodResponses: [ diff --git a/src/api/email.ts b/src/api/email.ts index 75aaabc4..82e82917 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -543,6 +543,11 @@ export interface AccountPageTarget { accountId?: string; position: number; sort: Array<{ property: string; isAscending: boolean; keyword?: string }>; + /** + * This account's own filter, in place of the shared `options.filter` + * (the default search leaves each account's own Trash and Junk out). + */ + filter?: Record; } export type AccountPage = @@ -573,7 +578,7 @@ export async function queryEmailPagesAcrossAccounts( position: target.position, limit, sort: target.sort, - filter: options.filter, + filter: target.filter ?? options.filter, accountId: target.accountId, }); calls.push(['Email/query', args, `${index}:q`]); diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index c65d1a61..5a4d2fc3 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -193,10 +193,13 @@ beforeEach(() => { // Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless // "All folders" is picked, and one started inside them searches them. describe('the default search scope', () => { - it("leaves every account's Trash and Junk out", async () => { + // Each account gets its own filter, still in one request: one request per + // account went past the servers' maxConcurrentRequests (4). + it("leaves every account's Trash and Junk out, in one request", async () => { await searchDefault('zephyr'); expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']); + expect(server.current!.requests).toHaveLength(1); const queries = server.current!.callsOf('Email/query'); expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([ ['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }], diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 3a524be0..39c9f696 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -189,11 +189,12 @@ beforeEach(() => { return { ...query, list: got.list, state: got.state, threads: [] }; }); mockQueryAcross.mockImplementation(async ( - targets: Array<{ accountId?: string; position: number; sort: unknown }>, + targets: Array<{ accountId?: string; position: number; sort: unknown; filter?: Record }>, opts: Record, ) => Promise.all(targets.map(async (target) => { const page = await emailApi.queryEmailPage(undefined, { ...opts, position: target.position, sort: target.sort as never, accountId: target.accountId, + ...(target.filter ? { filter: target.filter } : {}), }); return { accountId: target.accountId, ok: true, total: page.total, list: page.list, threads: page.threads }; }))); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 12e0dd43..a989779e 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -742,20 +742,19 @@ async function fetchSpanningPage( const threads = !useSettingsStore.getState().disableThreading; const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state)); const run = async (accounts: Array) => { - const targets = await Promise.all(accounts.map(async (accountId) => ({ - accountId, - position: loaded[accountId ?? primary] ?? 0, - sort: await resolveSort(state, accountId), - }))); - if (!skipTrashAndJunk) return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads }); - // Each account leaves its own Trash and Junk out, so each gets its own - // filter (and request). - const pages = await Promise.all(targets.map((target) => { - const ids = trashAndJunkIds(state.mailboxes, target.accountId); - const scoped = ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter; - return queryEmailPagesAcrossAccounts([target], { limit, filter: scoped, threads }); + const targets = await Promise.all(accounts.map(async (accountId) => { + // Each account leaves its own Trash and Junk out, so each gets its own + // filter; they still share requests (one request per account went + // past the servers' maxConcurrentRequests with a few shared accounts). + const ids = skipTrashAndJunk ? trashAndJunkIds(state.mailboxes, accountId) : []; + return { + accountId, + position: loaded[accountId ?? primary] ?? 0, + sort: await resolveSort(state, accountId), + filter: ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter, + }; })); - return pages.map((p) => p[0]); + return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads }); }; let pages = await run(spannedAccounts(state.mailboxes)); const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error));