fix: batch the default search across accounts into shared requests again

This commit is contained in:
Linus Rath
2026-10-29 04:32:20 +01:00
parent 440a2e7ac7
commit 2b5257bf6b
5 changed files with 45 additions and 16 deletions
+21
View File
@@ -311,6 +311,27 @@ describe('email operations', () => {
mockRequest.mockReset(); mockRequest.mockReset();
}); });
it("sends each account's own filter in the shared request", async () => {
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValueOnce(16);
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, 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 () => { it('leaves Thread/get out unless asked, and targets a shared account', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [ methodResponses: [
+6 -1
View File
@@ -543,6 +543,11 @@ export interface AccountPageTarget {
accountId?: string; accountId?: string;
position: number; position: number;
sort: Array<{ property: string; isAscending: boolean; keyword?: string }>; 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<string, unknown>;
} }
export type AccountPage = export type AccountPage =
@@ -573,7 +578,7 @@ export async function queryEmailPagesAcrossAccounts(
position: target.position, position: target.position,
limit, limit,
sort: target.sort, sort: target.sort,
filter: options.filter, filter: target.filter ?? options.filter,
accountId: target.accountId, accountId: target.accountId,
}); });
calls.push(['Email/query', args, `${index}:q`]); calls.push(['Email/query', args, `${index}:q`]);
@@ -193,10 +193,13 @@ beforeEach(() => {
// Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless // Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless
// "All folders" is picked, and one started inside them searches them. // "All folders" is picked, and one started inside them searches them.
describe('the default search scope', () => { 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'); await searchDefault('zephyr');
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']); expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
expect(server.current!.requests).toHaveLength(1);
const queries = server.current!.callsOf('Email/query'); const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([ expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }], ['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
+2 -1
View File
@@ -189,11 +189,12 @@ beforeEach(() => {
return { ...query, list: got.list, state: got.state, threads: [] }; return { ...query, list: got.list, state: got.state, threads: [] };
}); });
mockQueryAcross.mockImplementation(async ( mockQueryAcross.mockImplementation(async (
targets: Array<{ accountId?: string; position: number; sort: unknown }>, targets: Array<{ accountId?: string; position: number; sort: unknown; filter?: Record<string, unknown> }>,
opts: Record<string, unknown>, opts: Record<string, unknown>,
) => Promise.all(targets.map(async (target) => { ) => Promise.all(targets.map(async (target) => {
const page = await emailApi.queryEmailPage(undefined, { const page = await emailApi.queryEmailPage(undefined, {
...opts, position: target.position, sort: target.sort as never, accountId: target.accountId, ...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 }; return { accountId: target.accountId, ok: true, total: page.total, list: page.list, threads: page.threads };
}))); })));
+12 -13
View File
@@ -742,20 +742,19 @@ async function fetchSpanningPage(
const threads = !useSettingsStore.getState().disableThreading; const threads = !useSettingsStore.getState().disableThreading;
const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state)); const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state));
const run = async (accounts: Array<string | undefined>) => { const run = async (accounts: Array<string | undefined>) => {
const targets = await Promise.all(accounts.map(async (accountId) => ({ const targets = await Promise.all(accounts.map(async (accountId) => {
accountId, // Each account leaves its own Trash and Junk out, so each gets its own
position: loaded[accountId ?? primary] ?? 0, // filter; they still share requests (one request per account went
sort: await resolveSort(state, accountId), // past the servers' maxConcurrentRequests with a few shared accounts).
}))); const ids = skipTrashAndJunk ? trashAndJunkIds(state.mailboxes, accountId) : [];
if (!skipTrashAndJunk) return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads }); return {
// Each account leaves its own Trash and Junk out, so each gets its own accountId,
// filter (and request). position: loaded[accountId ?? primary] ?? 0,
const pages = await Promise.all(targets.map((target) => { sort: await resolveSort(state, accountId),
const ids = trashAndJunkIds(state.mailboxes, target.accountId); filter: ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter,
const scoped = ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter; };
return queryEmailPagesAcrossAccounts([target], { limit, filter: scoped, threads });
})); }));
return pages.map((p) => p[0]); return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads });
}; };
let pages = await run(spannedAccounts(state.mailboxes)); let pages = await run(spannedAccounts(state.mailboxes));
const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error)); const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error));