mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
fix: list and count tagged mail from every account in the tag view #1038
A tag sits on messages in the user's own account and in the shared (group) accounts alike, but the tag view only asked the account of the open folder, and the sidebar badge only counted the own account: with 2 own and 2 team messages tagged Red, the Red view showed 2, and which 2 depended on the folder it was opened from. The tag view now fans out like the "All folders" search (#1082), which it is: every folder of the own account and of each shared account with folders in the sidebar, in one request, with rows stamped with their account so opening, untagging and every other action reach the right one. A search inside the tag view narrows it in every account. The tag badges sum the counts of the same accounts; the query pairs for all of them go out packed into as few requests as `maxCallsInRequest` allows, and an account that can't be reached counts nothing instead of failing the badges.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'c',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { fetchTagCounts } from '../tag-counts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Answers every Email/query with `totals[accountId][callId prefix]`, or an
|
||||
// error for an account listed in `failing`.
|
||||
function answer(totals: Record<string, { total: number; unread: number }>, failing: string[] = []) {
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
|
||||
methodResponses: calls.map(([, args, callId]) => {
|
||||
const accountId = args.accountId as string;
|
||||
if (failing.includes(accountId)) return ['error', { type: 'forbidden' }, callId];
|
||||
const unread = 'operator' in (args.filter as object);
|
||||
return ['Email/query', { ids: [], total: unread ? totals[accountId].unread : totals[accountId].total }, callId];
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMaxCalls.mockReturnValue(16);
|
||||
});
|
||||
|
||||
describe('fetchTagCounts', () => {
|
||||
it('counts the own account alone by default', async () => {
|
||||
answer({ c: { total: 2, unread: 1 } });
|
||||
|
||||
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls).toEqual([
|
||||
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 0, calculateTotal: true }, 't0:red'],
|
||||
['Email/query', {
|
||||
accountId: 'c',
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, 'u0:red'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sums a tag over the own and the team account in one request (#1038)', async () => {
|
||||
answer({ c: { total: 2, unread: 1 }, team: { total: 2, unread: 2 } });
|
||||
|
||||
const counts = await fetchTagCounts(['red', 'blue'], [undefined, 'team']);
|
||||
|
||||
expect(counts).toEqual([
|
||||
{ id: 'red', total: 4, unread: 3 },
|
||||
{ id: 'blue', total: 4, unread: 3 },
|
||||
]);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.mock.calls[0][0].map((c: unknown[]) => (c[1] as { accountId: string }).accountId))
|
||||
.toEqual(['c', 'c', 'c', 'c', 'team', 'team', 'team', 'team']);
|
||||
});
|
||||
|
||||
it('packs the calls into requests of maxCallsInRequest', async () => {
|
||||
mockMaxCalls.mockReturnValue(4);
|
||||
answer({ c: { total: 1, unread: 0 }, team: { total: 1, unread: 1 } });
|
||||
|
||||
await fetchTagCounts(['red', 'blue', 'green'], [undefined, 'team']);
|
||||
|
||||
expect(mockRequest.mock.calls.map((c) => c[0].length)).toEqual([4, 4, 4]);
|
||||
});
|
||||
|
||||
it('lets an account that fails count nothing', async () => {
|
||||
answer({ c: { total: 2, unread: 1 } }, ['team']);
|
||||
|
||||
expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||
});
|
||||
});
|
||||
+36
-30
@@ -9,50 +9,56 @@ export interface TagCount {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread/total counts per tag across every folder of one account — two
|
||||
* `Email/query` calls (`calculateTotal`, `limit: 0`) per tag, batched to the
|
||||
* server's maxCallsInRequest. Port of the webmail's `fetchTagCounts`
|
||||
* (stores/email-store.ts). A tag whose queries fail is reported as 0/0
|
||||
* rather than sinking the whole batch.
|
||||
* Unread/total counts per tag across every folder of the given JMAP accounts
|
||||
* (undefined = the user's own), summed: a tag sits on messages in the own and
|
||||
* in the shared (group) accounts alike, and its view lists all of them
|
||||
* (#1038). Two `Email/query` calls (`calculateTotal`, `limit: 0`) per tag and
|
||||
* account, packed into as few requests as the server's maxCallsInRequest
|
||||
* allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A
|
||||
* call that fails, such as for an account that can't be reached, counts 0
|
||||
* rather than sinking the rest.
|
||||
*/
|
||||
export async function fetchTagCounts(tagIds: string[], accountIdOverride?: string): Promise<TagCount[]> {
|
||||
export async function fetchTagCounts(
|
||||
tagIds: string[],
|
||||
accountIds: Array<string | undefined> = [undefined],
|
||||
): Promise<TagCount[]> {
|
||||
if (tagIds.length === 0) return [];
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
||||
|
||||
const calls: Array<{ id: string; kind: 'total' | 'unread'; call: JMAPMethodCall }> = [];
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'total',
|
||||
call: ['Email/query', { accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true }, `t:${id}`],
|
||||
});
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'unread',
|
||||
call: ['Email/query', {
|
||||
const calls: JMAPMethodCall[] = [];
|
||||
const targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
|
||||
accountIds.forEach((override, index) => {
|
||||
const accountId = override ?? jmapClient.accountId;
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
targets.set(`t${index}:${id}`, { id, kind: 'total' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `t${index}:${id}`]);
|
||||
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `u:${id}`],
|
||||
});
|
||||
}
|
||||
}, `u${index}:${id}`]);
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < calls.length; i += perRequest) {
|
||||
const chunk = calls.slice(i, i + perRequest);
|
||||
const res = await jmapClient.request(chunk.map((c) => c.call));
|
||||
const res = await jmapClient.request(calls.slice(i, i + perRequest));
|
||||
for (const [name, body, callId] of res.methodResponses) {
|
||||
if (name !== 'Email/query' || typeof callId !== 'string') continue;
|
||||
const [kind, ...rest] = callId.split(':');
|
||||
const id = rest.join(':');
|
||||
const entry = counts.get(id);
|
||||
if (!entry) continue;
|
||||
const target = targets.get(callId);
|
||||
const entry = target ? counts.get(target.id) : undefined;
|
||||
if (!target || !entry) continue;
|
||||
const total = typeof body.total === 'number' ? body.total : 0;
|
||||
if (kind === 't') entry.total = total;
|
||||
else if (kind === 'u') entry.unread = total;
|
||||
if (target.kind === 'total') entry.total += total;
|
||||
else entry.unread += total;
|
||||
}
|
||||
}
|
||||
return tagIds.map((id) => counts.get(id)!);
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@ export interface Email {
|
||||
bodyStructure?: BodyPart;
|
||||
/**
|
||||
* Client-side stamp, never sent by the server: the JMAP account a row of a
|
||||
* list that spans accounts (an "All folders" search) was fetched from, the
|
||||
* user's own account included. Rows of a single folder carry none; they
|
||||
* list that spans accounts (an "All folders" search, a tag view) was
|
||||
* fetched from, the user's own account included. Rows of a single folder carry none; they
|
||||
* belong to the folder's account.
|
||||
*/
|
||||
jmapAccountId?: string;
|
||||
|
||||
Reference in New Issue
Block a user