mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
# Conflicts: # CONTRIBUTING.md # LICENSE # README.md # app/api/calendar-agenda/route.ts # app/api/wopi/files/[fileId]/contents/route.ts # app/api/wopi/launch/route.ts # components/calendar/calendar-app.tsx # components/calendar/calendar-day-view.tsx # components/calendar/calendar-month-view.tsx # components/calendar/calendar-week-view.tsx # components/calendar/event-modal.tsx # components/contacts/contact-detail.tsx # components/email/email-composer.tsx # components/email/email-list.tsx # components/files/file-browser.tsx # components/files/files-app.tsx # components/files/folder-tree-sidebar.tsx # components/filters/filter-rule-modal.tsx # components/mail/mail-app.tsx # hooks/use-mailbox-drop.ts # integration/run-tests.sh # lib/__tests__/calendar-agenda-route.test.ts # lib/__tests__/jmap-draft-lifecycle.test.ts # lib/__tests__/web-push.test.ts # lib/account-state-manager.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/jmap/types.ts # lib/message-list-order.ts # lib/sieve/generator.ts # lib/sieve/parser.ts # lib/telemetry/payload.ts # lib/thread-utils.ts # lib/unified-mailbox.ts # lib/web-push.ts # lib/wopi/token.ts # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/tr/common.json # locales/uk/common.json # locales/zh-TW/common.json # locales/zh/common.json # scripts/lite/lib.mjs # stores/__tests__/calendar-store-synthetic-ids.test.ts # stores/__tests__/email-store-shared-folder-keywords.test.ts # stores/__tests__/email-store-unified-actions.test.ts # stores/__tests__/file-store.test.ts # stores/email-store.ts # stores/file-store.ts # stores/message-list-tabs-store.ts
313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
import type { SortLevel } from '../message-list-order';
|
|
|
|
// Configurable message-list ordering (#718) maps onto Email/query sort
|
|
// comparators. Stalwart 0.16.8 reads `isAscending` on hasKeyword the other
|
|
// way round from RFC 8621, so the client probes the server once per account
|
|
// and flips the keyword comparators accordingly - instead of showing users the
|
|
// inverse of what they picked.
|
|
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
|
|
const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }];
|
|
|
|
function makeSession(mailCaps: Record<string, unknown> = {}, accountCaps: Record<string, unknown> = {}) {
|
|
return {
|
|
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
|
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': mailCaps, ...accountCaps } } },
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
|
apiUrl: 'https://mail.example.com/jmap/api',
|
|
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
|
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
|
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
const seenEmail = { id: 'read-1', keywords: { $seen: true }, receivedAt: '2026-01-02T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
|
|
const unseenEmail = { id: 'unread-1', keywords: {}, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
|
|
|
|
/**
|
|
* Answers the polarity probe (call ids asc/desc/get) per `server`, and every
|
|
* folder query (call ids 0/1) with a fixed page, optionally refusing keyword
|
|
* comparators with unsupportedSort.
|
|
*/
|
|
function replyFor(server: 'rfc' | 'inverted' | 'homogeneous', opts: { refuseKeywordSort?: boolean } = {}) {
|
|
return (methodCalls: MethodCall[]) => {
|
|
if (methodCalls[0][2] === 'asc') {
|
|
const ascFirst = server === 'inverted' ? seenEmail : unseenEmail;
|
|
const descFirst = server === 'homogeneous' ? ascFirst : (server === 'inverted' ? unseenEmail : seenEmail);
|
|
return {
|
|
methodResponses: [
|
|
['Email/query', { ids: [ascFirst.id] }, 'asc'],
|
|
['Email/query', { ids: [descFirst.id] }, 'desc'],
|
|
['Email/get', { list: [{ id: ascFirst.id, keywords: ascFirst.keywords }] }, 'get'],
|
|
],
|
|
};
|
|
}
|
|
const sort = methodCalls[0][1].sort as Array<{ property: string }>;
|
|
if (opts.refuseKeywordSort && sort.some(s => s.property === 'hasKeyword')) {
|
|
return { methodResponses: [['error', { type: 'unsupportedSort' }, '0'], ['error', { type: 'invalidResultReference' }, '1']] };
|
|
}
|
|
return {
|
|
methodResponses: [
|
|
['Email/query', { ids: [seenEmail.id, unseenEmail.id], total: 2 }, '0'],
|
|
['Email/get', { list: [seenEmail, unseenEmail] }, '1'],
|
|
],
|
|
};
|
|
};
|
|
}
|
|
|
|
describe('JMAPClient list ordering', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function connectedClient(mailCaps?: Record<string, unknown>, accountCaps?: Record<string, unknown>): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(mailCaps, accountCaps)));
|
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
return client;
|
|
}
|
|
|
|
function recordRequests(reply: (methodCalls: MethodCall[]) => unknown) {
|
|
const sent: MethodCall[][] = [];
|
|
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
|
const body = JSON.parse(init.body as string);
|
|
sent.push(body.methodCalls);
|
|
return jsonResponse(reply(body.methodCalls));
|
|
}) as never);
|
|
return sent;
|
|
}
|
|
|
|
const folderQueries = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === '0').map(calls => calls[0][1].sort);
|
|
const probes = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === 'asc');
|
|
|
|
it('sends the RFC polarity to a server that follows RFC 8621', async () => {
|
|
const client = await connectedClient();
|
|
const sent = recordRequests(replyFor('rfc'));
|
|
|
|
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
|
|
|
expect(probes(sent)).toHaveLength(1);
|
|
expect(folderQueries(sent)).toEqual([[
|
|
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
|
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
]]);
|
|
});
|
|
|
|
it('flips the keyword comparators for a server with inverted polarity (Stalwart 0.16.8)', async () => {
|
|
const client = await connectedClient();
|
|
const sent = recordRequests(replyFor('inverted'));
|
|
|
|
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
|
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
|
|
|
|
// One probe per account and session, not per page.
|
|
expect(probes(sent)).toHaveLength(1);
|
|
expect(folderQueries(sent)).toEqual([
|
|
[
|
|
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
|
|
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
],
|
|
[
|
|
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
|
|
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
],
|
|
]);
|
|
// The within-page safety sort mirrors the requested order.
|
|
expect(emails.map(e => e.id)).toEqual(['unread-1', 'read-1']);
|
|
});
|
|
|
|
it('falls back to the RFC polarity when every message is in the same read state', async () => {
|
|
const client = await connectedClient();
|
|
const sent = recordRequests(replyFor('homogeneous'));
|
|
|
|
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
|
|
|
expect(folderQueries(sent)[0]).toEqual([
|
|
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
|
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
]);
|
|
});
|
|
|
|
it('skips the probe and the comparators when the order needs no keyword sort', async () => {
|
|
const client = await connectedClient();
|
|
const sent = recordRequests(replyFor('rfc'));
|
|
|
|
await client.getEmails('inbox', undefined, 50, 0, undefined, false, undefined, [{ criterion: 'size', direction: 'desc' }]);
|
|
|
|
expect(probes(sent)).toHaveLength(0);
|
|
expect(folderQueries(sent)).toEqual([[
|
|
{ property: 'size', isAscending: false },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
]]);
|
|
});
|
|
|
|
it('keeps the server order when sorting by date sent (#1219)', async () => {
|
|
const client = await connectedClient();
|
|
// Sent first, delivered last: received order and sent order disagree.
|
|
const delayed = { id: 'delayed', keywords: {}, sentAt: '2026-01-01T00:00:00Z', receivedAt: '2026-01-05T00:00:00Z', mailboxIds: {}, threadId: 't1', size: 1, hasAttachment: false };
|
|
const prompt = { id: 'prompt', keywords: {}, sentAt: '2026-01-03T00:00:00Z', receivedAt: '2026-01-03T00:00:00Z', mailboxIds: {}, threadId: 't2', size: 1, hasAttachment: false };
|
|
const sent = recordRequests((methodCalls) => {
|
|
// Like a real server, answer only the properties the client asked for.
|
|
const properties = methodCalls[1][1].properties as string[];
|
|
const pick = (email: Record<string, unknown>) =>
|
|
Object.fromEntries(Object.entries(email).filter(([key]) => properties.includes(key)));
|
|
return {
|
|
methodResponses: [
|
|
['Email/query', { ids: [prompt.id, delayed.id], total: 2 }, '0'],
|
|
['Email/get', { list: [pick(prompt), pick(delayed)] }, '1'],
|
|
],
|
|
};
|
|
});
|
|
|
|
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, false, undefined, [{ criterion: 'sentAt', direction: 'desc' }]);
|
|
|
|
expect(folderQueries(sent)).toEqual([[
|
|
{ property: 'sentAt', isAscending: false },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
]]);
|
|
expect(emails.map(e => e.id)).toEqual(['prompt', 'delayed']);
|
|
});
|
|
|
|
it('omits keyword comparators when the server advertises sort options without hasKeyword', async () => {
|
|
const client = await connectedClient({ emailQuerySortOptions: ['receivedAt', 'size'] });
|
|
const sent = recordRequests(replyFor('rfc'));
|
|
|
|
expect(client.getEmailQuerySortOptions()).toEqual(['receivedAt', 'size']);
|
|
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
|
|
|
expect(probes(sent)).toHaveLength(0);
|
|
expect(folderQueries(sent)).toEqual([[{ property: 'receivedAt', isAscending: false }]]);
|
|
expect(emails).toHaveLength(2);
|
|
});
|
|
|
|
it('retries without keyword comparators after an unsupportedSort refusal and remembers it', async () => {
|
|
const client = await connectedClient();
|
|
const sent = recordRequests(replyFor('rfc', { refuseKeywordSort: true }));
|
|
|
|
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
|
|
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
|
|
|
|
expect(emails).toHaveLength(2);
|
|
expect(folderQueries(sent)).toEqual([
|
|
[
|
|
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
|
|
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
],
|
|
[{ property: 'receivedAt', isAscending: false }],
|
|
[{ property: 'receivedAt', isAscending: false }],
|
|
]);
|
|
});
|
|
|
|
describe('sender and subject orders', () => {
|
|
// Every Stalwart 1.0 account lists principals:owner in its
|
|
// accountCapabilities (api/session.rs jmap_account); 0.16 lists it nowhere.
|
|
const STALWART_1_0 = {
|
|
'urn:ietf:params:jmap:principals:owner': { accountIdForPrincipal: 'acct-1', principalId: 'acct-1' },
|
|
};
|
|
const message = (id: string, name: string, address: string, subject: string, receivedAt = '2026-01-01T00:00:00Z') => ({
|
|
id, threadId: `t-${id}`, keywords: { $seen: true }, receivedAt, mailboxIds: {}, size: 1, hasAttachment: false,
|
|
from: address ? [{ name, email: address }] : [], subject,
|
|
});
|
|
type Message = ReturnType<typeof message>;
|
|
const zoe = message('zoe', 'Zoe', 'zoe@example.org', 'Zebra report', '2026-01-01T00:00:00Z');
|
|
const emile = message('emile', 'Émile', 'emile@example.org', 'Éclair', '2026-01-02T00:00:00Z');
|
|
const nobody = message('nobody', '', '', '', '2026-01-03T00:00:00Z');
|
|
// The senders and subjects of a live check against both servers.
|
|
const adam = message('adam', 'Adam', 'adam@x.test', 'Banana split');
|
|
const bea = message('bea', 'bea', 'bea@x.test', 'aardvark');
|
|
const carla = message('carla', 'Carla', 'carla@x.test', 'Mango');
|
|
const dave = message('dave', 'dave', 'dave@x.test', 'apple pie');
|
|
const eve = message('eve', 'Eve', 'eve@x.test', 'Re: cherry tart');
|
|
const zed = message('zed', 'zed', 'zed@x.test', 'Zebra report');
|
|
|
|
/** A folder query answered in `queryOrder`, with Email/get listing the objects in `getOrder`. */
|
|
function replyInOrder(queryOrder: Message[], getOrder: Message[] = [...queryOrder].reverse()) {
|
|
return (methodCalls: MethodCall[]) => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: queryOrder.map((e) => e.id), total: queryOrder.length }, methodCalls[0][2]],
|
|
['Email/get', { list: getOrder }, methodCalls[1][2]],
|
|
],
|
|
});
|
|
}
|
|
const listed = async (client: JMAPClient, order: SortLevel[]) =>
|
|
(await client.getEmails('inbox', undefined, 50, 0, undefined, false, undefined, order)).emails.map((e) => e.id);
|
|
|
|
it('keeps the sender order of Stalwart 1.0: lowercased byte order, the empty sender last', async () => {
|
|
// 1.0 ranks lowercased "name address" keys by bytes, so "zoe ..." sorts
|
|
// before "émile ...". A client re-sort (localeCompare) would swap them.
|
|
const client = await connectedClient({}, STALWART_1_0);
|
|
const sent = recordRequests(replyInOrder([zoe, emile, nobody], [nobody, emile, zoe]));
|
|
|
|
expect(await listed(client, [{ criterion: 'from', direction: 'asc' }])).toEqual(['zoe', 'emile', 'nobody']);
|
|
expect(folderQueries(sent)).toEqual([[
|
|
{ property: 'from', isAscending: true },
|
|
{ property: 'receivedAt', isAscending: false },
|
|
]]);
|
|
});
|
|
|
|
it('keeps the subject order of Stalwart 1.0 behind another level', async () => {
|
|
const client = await connectedClient({}, STALWART_1_0);
|
|
recordRequests(replyInOrder([zed, emile, nobody]));
|
|
|
|
expect(await listed(client, [
|
|
{ criterion: 'size', direction: 'desc' },
|
|
{ criterion: 'subject', direction: 'asc' },
|
|
])).toEqual(['zed', 'emile', 'nobody']);
|
|
});
|
|
|
|
it('re-sorts a sender page of Stalwart 0.16 case-insensitively, the empty sender last', async () => {
|
|
// 0.16 compares the raw bytes: capitals first, "Eve" before "bea".
|
|
const client = await connectedClient();
|
|
recordRequests(replyInOrder([adam, carla, eve, bea, dave, zed, nobody]));
|
|
|
|
expect(await listed(client, [{ criterion: 'from', direction: 'asc' }]))
|
|
.toEqual(['adam', 'bea', 'carla', 'dave', 'eve', 'zed', 'nobody']);
|
|
});
|
|
|
|
it('re-sorts a subject page of Stalwart 0.16 on the thread name, the empty subject last', async () => {
|
|
// 0.16 sorts the thread name ("cherry tart" for "Re: cherry tart") by raw
|
|
// bytes; both versions put a message without a subject last, descending too.
|
|
const client = await connectedClient();
|
|
recordRequests(replyInOrder([adam, carla, zed, bea, dave, eve, nobody]));
|
|
|
|
expect(await listed(client, [{ criterion: 'subject', direction: 'asc' }]))
|
|
.toEqual(['bea', 'dave', 'adam', 'eve', 'carla', 'zed', 'nobody']);
|
|
|
|
recordRequests(replyInOrder([eve, dave, bea, zed, carla, adam, nobody]));
|
|
expect(await listed(client, [{ criterion: 'subject', direction: 'desc' }]))
|
|
.toEqual(['zed', 'carla', 'eve', 'adam', 'dave', 'bea', 'nobody']);
|
|
});
|
|
|
|
it('still re-sorts a page ordered without text levels, on 0.16 and 1.0', async () => {
|
|
// The safety net for servers that ignore the sort stays for everything else.
|
|
for (const accountCaps of [{}, STALWART_1_0]) {
|
|
const client = await connectedClient({}, accountCaps);
|
|
recordRequests(replyInOrder([zoe, emile, nobody], [zoe, emile, nobody]));
|
|
|
|
expect(await listed(client, [{ criterion: 'receivedAt', direction: 'desc' }])).toEqual(['nobody', 'emile', 'zoe']);
|
|
}
|
|
});
|
|
});
|
|
});
|