mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { readFileSync, readdirSync } from 'fs';
|
|
import path from 'path';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
/**
|
|
* Stalwart 1.0 advertises JMAP Metadata (`urn:ietf:params:jmap:metadata`) by
|
|
* default. Pre-release 1.0 builds left every handler behind it `todo!()`:
|
|
* selecting `metadata` / `privateMetadata` (or a `metadata/...` pointer) in a
|
|
* get, or sending `ignoreMetadataOnlyChanges` to a /changes method, panics the
|
|
* request there. The v1.0.0 release implements it; 0.16 knows none of it. So
|
|
* ordinary requests never send any of it, and the gets whose server default
|
|
* could grow to include metadata ask for an explicit property list instead.
|
|
* Only the Bulwark metadata adapter (lib/jmap/bulwark-metadata.ts and the
|
|
* client's *BulwarkMetadata methods) reads and writes it, in requests of its
|
|
* own and only after the server has answered one (organisation features).
|
|
*/
|
|
|
|
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
|
|
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
type RequestBody = { using: string[]; methodCalls: MethodCall[] };
|
|
|
|
function makeSession(server: '0.16' | '1.0') {
|
|
const metadata = server === '1.0';
|
|
const info = { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: null };
|
|
return {
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:core': { maxObjectsInGet: 500, maxObjectsInSet: 500 },
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': {},
|
|
'urn:ietf:params:jmap:sieve': {},
|
|
...(metadata ? { [METADATA_CAPABILITY]: {} } : {}),
|
|
},
|
|
accounts: {
|
|
'acct-1': {
|
|
name: 'user@example.org',
|
|
isPersonal: true,
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': {},
|
|
'urn:ietf:params:jmap:sieve': { implementation: 'Stalwart v1.0.0' },
|
|
...(metadata
|
|
? { [METADATA_CAPABILITY]: { dataTypes: { Email: info, Mailbox: info, SieveScript: info } } }
|
|
: {}),
|
|
},
|
|
},
|
|
},
|
|
primaryAccounts: {
|
|
'urn:ietf:params:jmap:mail': 'acct-1',
|
|
'urn:ietf:params:jmap:submission': 'acct-1',
|
|
'urn:ietf:params:jmap:sieve': 'acct-1',
|
|
},
|
|
username: 'user@example.org',
|
|
apiUrl: 'https://mail.example.org/jmap/',
|
|
downloadUrl: 'https://mail.example.org/jmap/download/{accountId}/{blobId}/{name}',
|
|
uploadUrl: 'https://mail.example.org/jmap/upload/{accountId}/',
|
|
eventSourceUrl: '',
|
|
};
|
|
}
|
|
|
|
const MAILBOXES = [
|
|
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 1, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} },
|
|
{ id: 'drafts', name: 'Drafts', role: 'drafts', parentId: null, sortOrder: 2, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} },
|
|
{ id: 'sent', name: 'Sent', role: 'sent', parentId: null, sortOrder: 3, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} },
|
|
];
|
|
|
|
/** Answers every call the way both server versions do for these methods. */
|
|
function answer([name, args, callId]: MethodCall): MethodCall {
|
|
const created = () => Object.fromEntries(
|
|
Object.keys((args.create as Record<string, unknown>) ?? {}).map((key) => [key, { id: `new-${key}` }]),
|
|
);
|
|
switch (name) {
|
|
case 'Mailbox/get':
|
|
return [name, { accountId: args.accountId, state: 'm1', list: MAILBOXES, notFound: [] }, callId];
|
|
case 'SieveScript/get':
|
|
return [name, { accountId: args.accountId, state: 's1', list: [{ id: 'sc1', name: 'bulwark', blobId: 'b1', isActive: true }], notFound: [] }, callId];
|
|
case 'Identity/get':
|
|
return [name, { accountId: args.accountId, state: 'i1', list: [{ id: 'id1', name: 'User', email: 'user@example.org', mayDelete: false }] }, callId];
|
|
case 'Mailbox/changes':
|
|
case 'Email/changes':
|
|
return [name, { accountId: args.accountId, oldState: args.sinceState, newState: 'next', hasMoreChanges: false, created: [], updated: [], destroyed: [] }, callId];
|
|
case 'Email/set':
|
|
case 'EmailSubmission/set':
|
|
return [name, { accountId: args.accountId, created: created() }, callId];
|
|
default:
|
|
return [name, { accountId: args.accountId }, callId];
|
|
}
|
|
}
|
|
|
|
/** Every way a request could reach one of 1.0's unfinished Metadata handlers. */
|
|
function metadataUses(body: RequestBody): string[] {
|
|
const found: string[] = [];
|
|
if (body.using.includes(METADATA_CAPABILITY)) found.push(`using ${METADATA_CAPABILITY}`);
|
|
for (const [name, args] of body.methodCalls) {
|
|
if ('ignoreMetadataOnlyChanges' in args) found.push(`${name} ignoreMetadataOnlyChanges`);
|
|
for (const key of ['properties', 'bodyProperties']) {
|
|
const list = args[key];
|
|
if (!Array.isArray(list)) continue;
|
|
for (const property of list) {
|
|
if (/^(private)?[Mm]etadata(\/|$)/.test(String(property))) found.push(`${name} ${key} ${property}`);
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe('JMAP Metadata never reaches the server', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
let client: JMAPClient;
|
|
let requests: RequestBody[];
|
|
|
|
async function connect(server: '0.16' | '1.0') {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makeSession(server)), { status: 200 }));
|
|
client = new JMAPClient('https://mail.example.org', 'user@example.org', 'secret');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
requests = [];
|
|
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody;
|
|
requests.push(body);
|
|
return new Response(JSON.stringify({ methodResponses: body.methodCalls.map(answer) }), { status: 200 });
|
|
});
|
|
}
|
|
|
|
/** The folder, filter, delta-sync and send paths. */
|
|
async function sweep() {
|
|
await client.getMailboxes();
|
|
await client.getAllMailboxesWithState();
|
|
await client.getMailboxesByIds(['inbox']);
|
|
await client.getMailboxChanges('m0');
|
|
await client.getEmailChanges('e0');
|
|
await client.getSieveScripts();
|
|
await client.sendEmail(['friend@example.net'], 'Hello', 'Hi there', undefined, undefined, 'id1', 'user@example.org');
|
|
}
|
|
|
|
const callsOf = (method: string) => requests.flatMap((r) => r.methodCalls).filter(([name]) => name === method);
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
client?.disconnect();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it.each(['0.16', '1.0'] as const)('asks Mailbox/get and SieveScript/get for explicit RFC properties on Stalwart %s', async (server) => {
|
|
await connect(server);
|
|
await sweep();
|
|
|
|
const folderList = ['id', 'name', 'parentId', 'role', 'sortOrder', 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads', 'myRights', 'isSubscribed'];
|
|
const mailboxGets = callsOf('Mailbox/get');
|
|
// getMailboxes, getAllMailboxesWithState, getMailboxesByIds, then sendEmail.
|
|
expect(mailboxGets).toHaveLength(4);
|
|
expect(mailboxGets.slice(0, 3).map(([, args]) => args.properties)).toEqual([folderList, folderList, folderList]);
|
|
expect(mailboxGets[3][1].properties).toEqual(['id', 'role']);
|
|
|
|
const sieveGets = callsOf('SieveScript/get');
|
|
expect(sieveGets).toHaveLength(1);
|
|
expect(sieveGets[0][1].properties).toEqual(['id', 'name', 'blobId', 'isActive']);
|
|
});
|
|
|
|
it('keeps the folder list working with the explicit properties on Stalwart 1.0', async () => {
|
|
await connect('1.0');
|
|
const mailboxes = await client.getMailboxes();
|
|
expect(mailboxes.map((m) => [m.id, m.role, m.sortOrder])).toEqual([['inbox', 'inbox', 1], ['drafts', 'drafts', 2], ['sent', 'sent', 3]]);
|
|
expect((await client.getSieveScripts()).map((s) => s.id)).toEqual(['sc1']);
|
|
});
|
|
|
|
it('never selects metadata, sends ignoreMetadataOnlyChanges or uses the Metadata capability on Stalwart 1.0', async () => {
|
|
await connect('1.0');
|
|
await sweep();
|
|
|
|
expect(requests.length).toBeGreaterThan(0);
|
|
// The delta-sync paths build their /changes arguments explicitly.
|
|
expect(callsOf('Mailbox/changes').map(([, args]) => args)).toEqual([{ accountId: 'acct-1', sinceState: 'm0' }]);
|
|
expect(callsOf('Email/changes').map(([, args]) => Object.keys(args).sort())).toEqual([['accountId', 'sinceState']]);
|
|
expect(requests.flatMap(metadataUses)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The runtime sweep covers the paths above; this catches any other request a
|
|
* future change builds. Comments may name the Metadata properties, code may
|
|
* not. The dev JMAP mock (a server, not the client) is skipped.
|
|
*/
|
|
describe('JMAP Metadata in the request-building sources', () => {
|
|
const root = process.cwd();
|
|
const SOURCE_DIRS = ['app', 'components', 'hooks', 'lib', 'stores'];
|
|
|
|
function sourceFiles(dir: string): string[] {
|
|
const files: string[] = [];
|
|
for (const entry of readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
|
const rel = `${dir}/${entry.name}`;
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === '__tests__' || entry.name === 'node_modules' || rel === 'app/api/dev-jmap') continue;
|
|
files.push(...sourceFiles(rel));
|
|
} else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
|
|
files.push(rel);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Drops block comments and line comments that start a line or follow
|
|
// whitespace, so `https://` inside a string stays.
|
|
const withoutComments = (source: string) =>
|
|
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
|
|
const FORBIDDEN = [
|
|
/privateMetadata/,
|
|
/ignoreMetadataOnlyChanges/,
|
|
/urn:ietf:params:jmap:metadata/,
|
|
// A `metadata` property or a `metadata/...` pointer as a string literal.
|
|
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
|
];
|
|
|
|
// The metadata adapter is the one place allowed to name Metadata: its own
|
|
// module, and the bodies of the client's *BulwarkMetadata methods.
|
|
const ADAPTER_FILES = new Set(['lib/jmap/bulwark-metadata.ts']);
|
|
const ADAPTER_METHOD = /^\s+(?:async\s+)?\w*BulwarkMetadata\s*\(/;
|
|
|
|
function withoutAdapterMethods(source: string): string {
|
|
const lines = source.split('\n');
|
|
const kept: string[] = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (!ADAPTER_METHOD.test(lines[i])) {
|
|
kept.push(lines[i]);
|
|
continue;
|
|
}
|
|
// Skip to the line that closes the method's body.
|
|
let depth = 0;
|
|
let opened = false;
|
|
for (; i < lines.length; i++) {
|
|
for (const ch of lines[i]) {
|
|
if (ch === '{') { depth++; opened = true; }
|
|
if (ch === '}') depth--;
|
|
}
|
|
if (opened && depth <= 0) break;
|
|
}
|
|
}
|
|
return kept.join('\n');
|
|
}
|
|
|
|
it('never names a JMAP Metadata property, argument or capability in code outside the adapter', () => {
|
|
const files = SOURCE_DIRS.flatMap(sourceFiles).filter((file) => !ADAPTER_FILES.has(file));
|
|
expect(files).toContain('lib/jmap/client.ts');
|
|
const hits: string[] = [];
|
|
for (const file of files) {
|
|
let code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
|
|
if (file === 'lib/jmap/client.ts') code = withoutAdapterMethods(code);
|
|
for (const pattern of FORBIDDEN) {
|
|
const match = code.match(pattern);
|
|
if (match) hits.push(`${file}: ${match[0]}`);
|
|
}
|
|
}
|
|
expect(hits).toEqual([]);
|
|
});
|
|
});
|