feat: answer every mail read from the local store, including header forms, identities and quotas, and keep attachments

This commit is contained in:
Linus Rath
2026-11-03 05:30:09 +01:00
parent bad156eaed
commit 51ccac02ca
12 changed files with 1256 additions and 42 deletions
@@ -0,0 +1,173 @@
/**
* FakeJmap with what the mail answers and the outbox talk to: identities,
* quotas, Email/set and submissions. Follows what was measured on Stalwart
* (stalwart-changes-prf.md section 1): `header` and `text` filters find no
* Message-ID, a second create or submission of the same message is accepted,
* a canceled submission still shows in EmailSubmission/query.
*/
import { FakeJmap, type FakeEmail } from './fake-jmap';
export interface FakeSubmission {
id: string;
emailId: string;
identityId?: string;
undoStatus: 'pending' | 'final' | 'canceled';
}
type Filter = Record<string, unknown>;
export class FakeJmapMail extends FakeJmap {
readonly identities = new Map<string, Record<string, unknown> & { id: string }>();
readonly quotas: Array<Record<string, unknown> & { id: string }> = [];
readonly submissions = new Map<string, FakeSubmission>();
/** Email/set update or destroy of these ids is refused with this error. */
readonly refuse = new Map<string, { type: string; description?: string }>();
/** A method answered with a per-call error once. */
readonly failNext = new Map<string, string>();
identityState = 0;
nextSubmission = 1;
private matches(email: FakeEmail, filter: Filter | undefined): boolean {
if (!filter) return true;
if (filter.operator === 'AND') return (filter.conditions as Filter[]).every((c) => this.matches(email, c));
if (filter.operator === 'OR') return (filter.conditions as Filter[]).some((c) => this.matches(email, c));
if (filter.operator === 'NOT') return !(filter.conditions as Filter[]).some((c) => this.matches(email, c));
for (const [key, value] of Object.entries(filter)) {
switch (key) {
case 'inMailbox':
if (!(email.mailboxIds as Record<string, boolean>)[value as string]) return false;
break;
case 'after':
if (Date.parse(String(email.receivedAt)) < Date.parse(String(value))) return false;
break;
// The filters that find nothing on Stalwart for a Message-ID.
case 'header':
case 'text':
return false;
default:
throw new Error(`FakeJmapMail: filter ${key} isn't modelled`);
}
}
return true;
}
private applyPatch(email: FakeEmail, patch: Record<string, unknown>): void {
const keywords = { ...(email.keywords as Record<string, boolean>) };
const boxes = { ...(email.mailboxIds as Record<string, boolean>) };
let wholeKeywords = keywords;
let wholeBoxes = boxes;
for (const [path, value] of Object.entries(patch)) {
if (path === 'keywords') wholeKeywords = { ...(value as Record<string, boolean>) };
else if (path === 'mailboxIds') wholeBoxes = { ...(value as Record<string, boolean>) };
else if (path.startsWith('keywords/')) {
const name = path.slice('keywords/'.length).replace(/~1/g, '/').replace(/~0/g, '~');
if (value) wholeKeywords[name] = true;
else delete wholeKeywords[name];
} else if (path.startsWith('mailboxIds/')) {
const name = path.slice('mailboxIds/'.length);
if (value) wholeBoxes[name] = true;
else delete wholeBoxes[name];
}
}
this.updateEmail(email.id, { keywords: wholeKeywords, mailboxIds: wholeBoxes });
}
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
const failure = this.failNext.get(name);
if (failure) {
this.failNext.delete(name);
throw Object.assign(new Error(failure), { jmapError: failure });
}
switch (name) {
case 'Identity/get': {
const ids = args.ids as string[] | null;
const all = [...this.identities.values()];
return { accountId: args.accountId, state: `s${this.identityState}`, list: ids ? all.filter((i) => ids.includes(i.id)) : all, notFound: [] };
}
case 'Identity/changes':
return {
accountId: args.accountId,
oldState: args.sinceState,
newState: `s${this.identityState}`,
hasMoreChanges: false,
created: args.sinceState === `s${this.identityState}` ? [] : [...this.identities.keys()],
updated: [],
destroyed: [],
};
case 'Quota/get':
return { accountId: args.accountId, list: this.quotas, notFound: [] };
case 'Email/query': {
const matched = [...this.emails.values()]
.filter((email) => this.matches(email, args.filter as Filter | undefined))
.sort((a, b) => Date.parse(String(b.receivedAt)) - Date.parse(String(a.receivedAt)));
const position = Number(args.position ?? 0);
const limit = Math.min(Number(args.limit ?? 5000) || 5000, 5000);
return {
accountId: args.accountId,
queryState: this.coll.Email.state(),
canCalculateChanges: true,
position,
ids: matched.slice(position, position + limit).map((email) => email.id),
...(args.calculateTotal ? { total: matched.length } : {}),
};
}
case 'Email/set': {
const out: Record<string, unknown> = { accountId: args.accountId };
if (args.update) {
const updated: Record<string, null> = {};
const notUpdated: Record<string, unknown> = {};
for (const [id, patch] of Object.entries(args.update as Record<string, Record<string, unknown>>)) {
const email = this.emails.get(id);
if (this.refuse.has(id)) notUpdated[id] = this.refuse.get(id);
else if (!email) notUpdated[id] = { type: 'notFound' };
else {
this.applyPatch(email, patch);
updated[id] = null;
}
}
out.updated = updated;
if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated;
}
if (args.destroy) {
const destroyed: string[] = [];
const notDestroyed: Record<string, unknown> = {};
for (const id of args.destroy as string[]) {
if (this.refuse.has(id)) notDestroyed[id] = this.refuse.get(id);
else if (!this.emails.has(id)) notDestroyed[id] = { type: 'notFound' };
else {
this.destroyEmail(id);
destroyed.push(id);
}
}
out.destroyed = destroyed;
if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed;
}
return out;
}
case 'EmailSubmission/query': {
const emailIds = ((args.filter as Filter | undefined)?.emailIds ?? []) as string[];
const ids = [...this.submissions.values()].filter((s) => emailIds.includes(s.emailId)).map((s) => s.id);
return { accountId: args.accountId, queryState: 'q', canCalculateChanges: false, position: 0, ids };
}
case 'EmailSubmission/get': {
const ids = (args.ids ?? []) as string[];
return {
accountId: args.accountId,
state: 'q',
list: ids.flatMap((id) => (this.submissions.has(id) ? [this.submissions.get(id)] : [])),
notFound: ids.filter((id) => !this.submissions.has(id)),
};
}
default:
return super.handle(name, args);
}
}
/** A submission record, as if a send got that far. */
addSubmission(emailId: string, undoStatus: FakeSubmission['undoStatus'] = 'pending'): FakeSubmission {
const submission: FakeSubmission = { id: `sub${this.nextSubmission++}`, emailId, undoStatus };
this.submissions.set(submission.id, submission);
return submission;
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ export class FakeJmap implements JmapTransport {
return list.map((e) => e.id);
}
private handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
protected handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
switch (name) {
case 'Mailbox/get': {
const ids = args.ids as string[] | null;
+474
View File
@@ -0,0 +1,474 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { DEFAULT_SCOPE } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { TransportError, type MethodCall, type StoreSession } from '../engine/transport';
import { mailArea } from '../mail';
import { headerValue, parseHeaderProperty } from '../mail/headers';
import { FAKE_SESSION, JMAP_ACCOUNT } from './helpers/fake-jmap';
import { FakeJmapMail } from './helpers/fake-jmap-mail';
import { openMemoryStore } from './helpers/node-adapter';
const EVERYTHING = { ...DEFAULT_SCOPE, days: null };
const FULL_SESSION: StoreSession = {
...FAKE_SESSION,
accounts: {
[JMAP_ACCOUNT]: {
name: 'a@example.org',
isPersonal: true,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:quota': {},
},
},
},
};
function engineFor(server: FakeJmapMail, scope = EVERYTHING, session = FULL_SESSION) {
const db = openMemoryStore();
const engine = new AccountEngine({
account: 'a@fake.test',
db,
session,
transport: server,
areas: [mailArea],
scope,
now: () => Date.UTC(2026, 10, 3),
sleep: async () => {},
});
return { engine, db };
}
async function one(engine: AccountEngine, call: MethodCall) {
const response = await engine.answer([call]);
return response?.methodResponses[0];
}
const HEADERS = [
{ name: 'List-Id', value: ' Old list <old.example.org>' },
{ name: 'List-Id', value: ' =?UTF-8?B?VGVzdA==?= list\r\n <list.example.org>' },
{ name: 'From', value: ' "Doe, Jane" <jane@example.org>, Bob <bob@example.org>' },
{ name: 'To', value: ' Team: a@example.org, b@example.org;, c@example.org' },
{ name: 'Message-ID', value: ' <abc@example.org>' },
{ name: 'Date', value: ' Tue, 03 Nov 2026 10:00:00 +0100' },
{ name: 'List-Unsubscribe', value: ' <https://example.org/u>, <mailto:u@example.org>' },
];
describe('Email/get property forms', () => {
async function seeded() {
const server = new FakeJmapMail();
server.addEmail({ id: 'm', headers: HEADERS, cc: [{ name: 'Carol', email: 'carol@example.org' }] });
const { engine } = engineFor(server);
await engine.requestSync();
return engine;
}
it('answers every header form from the raw headers', async () => {
const engine = await seeded();
const [, result] = (await one(engine, ['Email/get', {
ids: ['m'],
properties: [
'header:List-Id',
'header:List-Id:asText',
'header:list-id:asText:all',
'header:List-Id:all',
'header:From:asAddresses',
'header:To:asGroupedAddresses',
'header:Message-ID:asMessageIds',
'header:Date:asDate',
'header:List-Unsubscribe:asURLs',
'header:Nope:asText',
'header:Nope:all',
],
}, 'g']))!;
const got = result.list[0];
expect(got['header:List-Id']).toBe(' =?UTF-8?B?VGVzdA==?= list\r\n <list.example.org>');
expect(got['header:List-Id:asText']).toBe('Test list <list.example.org>');
expect(got['header:list-id:asText:all']).toEqual(['Old list <old.example.org>', 'Test list <list.example.org>']);
expect(got['header:List-Id:all']).toHaveLength(2);
expect(got['header:From:asAddresses']).toEqual([{ name: 'Doe, Jane', email: 'jane@example.org' }, { name: 'Bob', email: 'bob@example.org' }]);
expect(got['header:To:asGroupedAddresses']).toEqual([
{ name: 'Team', addresses: [{ name: null, email: 'a@example.org' }, { name: null, email: 'b@example.org' }] },
{ name: null, addresses: [{ name: null, email: 'c@example.org' }] },
]);
expect(got['header:Message-ID:asMessageIds']).toEqual(['abc@example.org']);
expect(got['header:Date:asDate']).toBe('2026-11-03T09:00:00.000Z');
expect(got['header:List-Unsubscribe:asURLs']).toEqual(['https://example.org/u', 'mailto:u@example.org']);
expect(got['header:Nope:asText']).toBeNull();
expect(got['header:Nope:all']).toEqual([]);
});
it('prefers a form the list already stored, and has no header values for a message without a body', async () => {
const server = new FakeJmapMail();
server.addEmail({ id: 'm', headers: HEADERS, 'header:Authentication-Results:all': ['dkim=pass'] });
const { engine, db } = engineFor(server);
await engine.requestSync();
db.run('DELETE FROM email_body');
const [, result] = (await one(engine, ['Email/get', {
ids: ['m'],
properties: ['id', 'header:Authentication-Results:all', 'header:List-Id:asText'],
}, 'g']))!;
expect(result.list).toEqual([{ id: 'm', 'header:Authentication-Results:all': ['dkim=pass'] }]);
});
it('cuts body parts to bodyProperties, in the structure too', async () => {
const server = new FakeJmapMail();
server.addEmail({
id: 'm',
attachments: [{ partId: '2', blobId: 'b', name: 'a.pdf', type: 'application/pdf', size: 3, language: ['de'] }],
bodyStructure: { type: 'multipart/mixed', subParts: [{ partId: '1', type: 'text/plain', language: ['de'] }] },
});
const { engine } = engineFor(server, { ...EVERYTHING, attachments: false });
await engine.requestSync();
const [, result] = (await one(engine, ['Email/get', {
ids: ['m'],
properties: ['attachments', 'bodyStructure', 'textBody'],
bodyProperties: ['partId', 'blobId', 'size', 'name', 'type'],
}, 'g']))!;
expect(result.list[0].attachments).toEqual([{ partId: '2', blobId: 'b', name: 'a.pdf', type: 'application/pdf', size: 3 }]);
expect(result.list[0].bodyStructure).toEqual({ type: 'multipart/mixed', subParts: [{ partId: '1', type: 'text/plain' }] });
});
it('gives the whole text with no truncation mark, whatever maxBodyValueBytes says', async () => {
const long = 'x'.repeat(5000);
const server = new FakeJmapMail();
server.addEmail({ id: 'm', bodyValues: { 1: { value: long, isTruncated: false, isEncodingProblem: false } } });
const { engine } = engineFor(server);
await engine.requestSync();
const [, result] = (await one(engine, ['Email/get', { ids: ['m'], properties: ['bodyValues'], fetchAllBodyValues: true, maxBodyValueBytes: 100 }, 'g']))!;
expect(result.list[0].bodyValues['1'].value).toBe(long);
expect(result.list[0].bodyValues['1'].isTruncated).toBe(false);
});
it('returns only the body values asked for', async () => {
const server = new FakeJmapMail();
server.addEmail({
id: 'm',
textBody: [{ partId: 't', type: 'text/plain' }],
htmlBody: [{ partId: 'h', type: 'text/html' }],
bodyValues: { t: { value: 'text' }, h: { value: '<p>html</p>' } },
});
const { engine } = engineFor(server);
await engine.requestSync();
const values = async (flags: Record<string, unknown>) => (await one(engine, ['Email/get', { ids: ['m'], properties: ['bodyValues'], ...flags }, 'g']))![1].list[0].bodyValues;
expect(Object.keys(await values({ fetchTextBodyValues: true }))).toEqual(['t']);
expect(Object.keys(await values({ fetchHTMLBodyValues: true }))).toEqual(['h']);
expect(Object.keys(await values({ fetchAllBodyValues: true })).sort()).toEqual(['h', 't']);
expect(await values({})).toEqual({});
});
it('keeps refusing ids: null, because the server gives only the first 500 there', async () => {
const engine = await seeded();
const response = await engine.answer([['Email/get', { ids: null }, 'g'], ['Core/echo', {}, 'e']]);
expect(response?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'g']);
});
});
describe('header property parsing', () => {
it('leaves out what is not a header form', () => {
expect(parseHeaderProperty('subject')).toBeNull();
expect(parseHeaderProperty('header:')).toBeNull();
expect(parseHeaderProperty('header:X:asWhat')).toBeNull();
expect(parseHeaderProperty('header:X-Label:asText')).toEqual({ name: 'X-Label', form: 'asText', all: false });
expect(headerValue({ name: 'x', form: 'asRaw', all: false }, [{ name: 'X', value: ' 1' }, { name: 'x', value: ' 2' }])).toBe(' 2');
});
});
describe('Email/query filters and sorts', () => {
async function seeded() {
const server = new FakeJmapMail({
mailboxes: [
{ id: 'inbox', name: 'Inbox', role: 'inbox' },
{ id: 'trash', name: 'Trash', role: 'trash' },
{ id: 'work', name: 'Work' },
],
});
server.addEmail({
id: 'a', subject: 'Rechnung', from: [{ name: 'Anna', email: 'anna@example.org' }], to: [{ email: 'x@example.org' }],
cc: [{ name: 'Carol', email: 'carol@example.org' }], keywords: { $seen: true, '$label:work': true }, size: 500,
receivedAt: '2026-10-01T00:00:00Z', hasAttachment: true, headers: [{ name: 'X-Tag', value: ' blue' }], bcc: [{ email: 'hidden@example.org' }],
});
server.addEmail({
id: 'b', subject: 'Hello', from: [{ name: 'Bert', email: 'bert@example.org' }], to: [{ email: 'y@example.org' }],
keywords: { $flagged: true }, size: 100, receivedAt: '2026-10-02T00:00:00Z', mailboxIds: { inbox: true, work: true },
});
server.addEmail({
id: 'c', subject: 'Trash mail', from: [{ name: 'Cora', email: 'cora@example.org' }], to: [{ email: 'y@example.org' }],
keywords: {}, size: 2000, receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { trash: true },
});
const { engine } = engineFor(server);
await engine.requestSync();
return engine;
}
const ids = async (engine: AccountEngine, args: Record<string, unknown>) => (await one(engine, ['Email/query', args, 'q']))![1].ids;
it('answers every shape the UI sends', async () => {
const engine = await seeded();
const newest = [{ property: 'receivedAt', isAscending: false }];
const cases: Array<[Record<string, unknown>, string[]]> = [
[{ inMailbox: 'inbox' }, ['b', 'a']],
[{ hasKeyword: '$seen' }, ['a']],
[{ notKeyword: '$seen' }, ['c', 'b']],
[{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { notKeyword: '$seen' }] }, ['b']],
[{ operator: 'NOT', conditions: [{ hasKeyword: '$label:work' }] }, ['c', 'b']],
[{ operator: 'OR', conditions: [{ inMailbox: 'trash' }, { inMailbox: 'work' }] }, ['c', 'b']],
[{ inMailboxOtherThan: ['trash'] }, ['b', 'a']],
[{ text: 'rechnung' }, ['a']],
[{ subject: 'hello' }, ['b']],
[{ from: 'anna' }, ['a']],
[{ to: 'y@example.org' }, ['c', 'b']],
[{ cc: 'carol' }, ['a']],
[{ bcc: 'hidden' }, ['a']],
[{ header: ['X-Tag'] }, ['a']],
[{ header: ['X-Tag', 'blue'] }, ['a']],
[{ header: ['X-Tag', 'red'] }, []],
[{ hasAttachment: true }, ['a']],
[{ after: '2026-10-02T00:00:00Z' }, ['c', 'b']],
[{ before: '2026-10-02T00:00:00Z' }, ['a']],
[{ minSize: 500 }, ['c', 'a']],
[{ maxSize: 500 }, ['b']],
[{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { operator: 'OR', conditions: [{ to: 'x@example.org' }, { cc: 'nobody' }] }] }, ['a']],
];
for (const [filter, want] of cases) expect([filter, await ids(engine, { filter, sort: newest })]).toEqual([filter, want]);
expect(await ids(engine, { sort: newest })).toEqual(['c', 'b', 'a']);
});
it('sorts by every level the list offers', async () => {
const engine = await seeded();
const sorted = (sort: unknown[]) => ids(engine, { sort });
expect(await sorted([{ property: 'size', isAscending: true }])).toEqual(['b', 'a', 'c']);
expect(await sorted([{ property: 'subject', isAscending: true }])).toEqual(['b', 'a', 'c']);
expect(await sorted([{ property: 'from', isAscending: false }])).toEqual(['c', 'b', 'a']);
expect(await sorted([{ property: 'sentAt', isAscending: true }])).toHaveLength(3);
expect(await sorted([{ property: 'hasKeyword', keyword: '$flagged', isAscending: false }, { property: 'receivedAt', isAscending: false }])).toEqual(['b', 'c', 'a']);
});
it('gives a per-call error for a filter it can\'t answer, and no made-up state', async () => {
const engine = await seeded();
const response = await engine.answer([['Email/query', { filter: { allInThreadHaveKeyword: '$seen' } }, 'q'], ['Core/echo', {}, 'e']]);
expect(response?.methodResponses[0][0]).toBe('error');
expect(response?.methodResponses[0][1].type).toBe('unsupportedFilter');
expect(await engine.answer([['Email/query', { filter: { allInThreadHaveKeyword: '$seen' } }, 'q']])).toBeNull();
});
it('says total only when asked, and pages by position', async () => {
const engine = await seeded();
const [, plain] = (await one(engine, ['Email/query', {}, 'q']))!;
expect(plain).not.toHaveProperty('total');
const [, page] = (await one(engine, ['Email/query', { position: 1, limit: 1, calculateTotal: true, sort: [{ property: 'receivedAt', isAscending: false }] }, 'q']))!;
expect(page).toMatchObject({ ids: ['b'], total: 3, position: 1 });
});
});
describe('the rest of the mail methods', () => {
it('answers Core/echo with what it was given', async () => {
const { engine } = engineFor(new FakeJmapMail());
expect(await one(engine, ['Core/echo', { ping: 'pong' }, 'e'])).toEqual(['Core/echo', { ping: 'pong' }, 'e']);
});
it('answers /changes with an error, so the client does a full fetch', async () => {
const server = new FakeJmapMail();
server.addEmail();
const { engine } = engineFor(server);
await engine.requestSync();
const response = await engine.answer([
['Email/changes', { sinceState: 's1' }, 'e'],
['Mailbox/changes', { sinceState: 's1' }, 'm'],
['Core/echo', {}, 'x'],
]);
expect(response?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'cannotCalculateChanges' }), 'e']);
expect(response?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'cannotCalculateChanges' }), 'm']);
});
it('gives a per-call error for SearchSnippet/get and keeps the search', async () => {
const server = new FakeJmapMail();
server.addEmail({ subject: 'Findme' });
const { engine } = engineFor(server);
await engine.requestSync();
const response = await engine.answer([
['Email/query', { filter: { text: 'findme' } }, '0'],
['SearchSnippet/get', { filter: { text: 'findme' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'],
]);
expect(response?.methodResponses[0][1].ids).toHaveLength(1);
expect(response?.methodResponses[1][0]).toBe('error');
});
it('counts keywords for Keyword/get', async () => {
const server = new FakeJmapMail();
server.addEmail({ keywords: { '$label:a': true, $seen: true } });
server.addEmail({ keywords: { '$label:a': true } });
const { engine } = engineFor(server);
await engine.requestSync();
const [, wanted] = (await one(engine, ['Keyword/get', { ids: ['$label:a', '$label:none'] }, 'k']))!;
expect(wanted.list).toEqual([{ id: '$label:a', total: 2, unread: 1 }, { id: '$label:none', total: 0, unread: 0 }]);
const [, all] = (await one(engine, ['Keyword/get', {}, 'k']))!;
expect(all.totalEmails).toBe(2);
expect(all.list.map((k: { id: string }) => k.id).sort()).toEqual(['$label:a', '$seen']);
});
it('keeps identities through a sync and answers them, with the real state', async () => {
const server = new FakeJmapMail();
server.identities.set('i1', { id: 'i1', email: 'a@example.org', name: 'A' });
const { engine } = engineFor(server);
expect((await one(engine, ['Identity/get', {}, 'i']))).toBeUndefined();
await engine.requestSync();
const [, result] = (await one(engine, ['Identity/get', {}, 'i']))!;
expect(result.list).toEqual([{ id: 'i1', email: 'a@example.org', name: 'A' }]);
expect(result.state).toBe('s0');
server.identities.set('i2', { id: 'i2', email: 'b@example.org', name: 'B' });
server.identityState = 1;
await engine.requestSync();
expect((await one(engine, ['Identity/get', { ids: ['i2'] }, 'i']))![1].list).toEqual([{ id: 'i2', email: 'b@example.org', name: 'B' }]);
});
it('keeps the last quota', async () => {
const server = new FakeJmapMail();
server.quotas.push({ id: 'q', used: 10, hardLimit: 100 });
const { engine } = engineFor(server);
await engine.requestSync();
server.offline = true;
expect((await one(engine, ['Quota/get', {}, 'q']))![1].list).toEqual([{ id: 'q', used: 10, hardLimit: 100 }]);
});
it('keeps going when one account has no submission capability', async () => {
const server = new FakeJmapMail();
server.addEmail();
const { engine, db } = engineFor(server, EVERYTHING, FAKE_SESSION);
await engine.requestSync();
expect(db.get('SELECT 1 AS x FROM email')).toBeTruthy();
expect(server.calls.some(([name]) => name === 'Identity/get')).toBe(false);
});
it('removes a folder deleted on the server, and answers what is left', async () => {
const server = new FakeJmapMail({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'old', name: 'Old' }] });
const { engine } = engineFor(server);
await engine.requestSync();
server.mailboxes.delete('old');
server.coll.Mailbox.push('destroyed', 'old');
await engine.requestSync();
const [, result] = (await one(engine, ['Mailbox/get', {}, 'm']))!;
expect(result.list.map((box: { id: string }) => box.id)).toEqual(['inbox']);
const [, queried] = (await one(engine, ['Mailbox/query', { filter: { role: 'inbox' } }, 'q']))!;
expect(queried).toMatchObject({ ids: ['inbox'], queryState: server.coll.Mailbox.state() });
});
});
describe('attachments', () => {
class Files extends FakeJmapMail {
downloads: string[] = [];
missing = new Set<string>();
override async download(_jmap?: string, blobId?: string): Promise<Uint8Array> {
this.downloads.push(String(blobId));
if (this.offline) throw new TransportError('network', 'Failed to fetch');
if (this.missing.has(String(blobId))) throw new TransportError('http', 'Gone', 404);
return new Uint8Array([1, 2, 3]);
}
}
const withFile = (server: Files, id: string, blobId: string, size = 3) => server.addEmail({
id,
attachments: [{ partId: '2', blobId, name: `${blobId}.pdf`, type: 'application/pdf', size }],
});
it('keeps the files of kept messages within the chosen size, and deletes them with the message', async () => {
const server = new Files();
withFile(server, 'small', 'b-small');
withFile(server, 'big', 'b-big', 10_000);
const { engine, db } = engineFor(server, { ...EVERYTHING, maxAttachmentBytes: 1000 });
await engine.requestSync();
expect(server.downloads).toEqual(['b-small']);
const stored = await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-small' }) as { type: string; data: Uint8Array };
expect(stored.type).toBe('application/pdf');
expect([...stored.data]).toEqual([1, 2, 3]);
server.destroyEmail('small');
await engine.requestSync();
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-small' })).toBeNull();
expect(db.get('SELECT 1 AS x FROM email_part WHERE blob_id = ?', ['b-small'])).toBeUndefined();
});
it('keeps a shared file until the last message using it goes', async () => {
const server = new Files();
withFile(server, 'one', 'b-same');
withFile(server, 'two', 'b-same');
const { engine } = engineFor(server);
await engine.requestSync();
server.destroyEmail('one');
await engine.requestSync();
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-same' })).not.toBeNull();
server.destroyEmail('two');
await engine.requestSync();
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-same' })).toBeNull();
});
it('keeps none when the user chose so, stops at the total, and doesn\'t ask again for a file the server refused', async () => {
const off = new Files();
withFile(off, 'm', 'b-1');
await engineFor(off, { ...EVERYTHING, attachments: false }).engine.requestSync();
expect(off.downloads).toEqual([]);
const capped = new Files();
withFile(capped, 'old', 'b-old', 3);
withFile(capped, 'new', 'b-new', 3);
await engineFor(capped, { ...EVERYTHING, maxTotalBytes: 4 }).engine.requestSync();
expect(capped.downloads).toEqual(['b-new']);
const refused = new Files();
refused.missing.add('b-gone');
withFile(refused, 'm', 'b-gone');
const { engine } = engineFor(refused);
await engine.requestSync();
await engine.requestSync();
expect(refused.downloads).toEqual(['b-gone']);
});
it('files a blob the user opened only under a kept message: never a stray file', async () => {
const server = new Files();
server.addEmail({ id: 'm', attachments: [{ blobId: 'b-kept', size: 99_999_999, type: 'image/png' }] });
const { engine } = engineFor(server);
await engine.requestSync();
expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'export-of-a-message', type: null, data: new Uint8Array([1]) })).toBe(false);
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'export-of-a-message' })).toBeNull();
expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-kept', type: 'image/png', data: new Uint8Array([7]) })).toBe(true);
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-kept' })).toMatchObject({ type: 'image/png' });
});
});
describe('what the user opened online', () => {
it('keeps a message from outside the window with its text, and leaves kept ones alone', async () => {
const server = new FakeJmapMail();
server.addEmail({ id: 'kept', subject: 'Kept' });
const { engine } = engineFor(server);
await engine.requestSync();
const opened = {
id: 'old', threadId: 'told', mailboxIds: { inbox: true }, keywords: {}, receivedAt: '2019-01-01T00:00:00Z', subject: 'Old',
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [], bodyValues: { 1: { value: 'Old text' } }, headers: [{ name: 'X', value: ' y' }],
};
await engine.areaCall('mail.recordOpened', {
jmap: JMAP_ACCOUNT,
emails: [
opened,
{ id: 'kept', subject: 'Changed?', bodyValues: { 1: { value: 'other' } }, mailboxIds: {}, receivedAt: '2019-01-01T00:00:00Z' },
{ id: 'cut', mailboxIds: { inbox: true }, receivedAt: '2019-01-01T00:00:00Z', bodyValues: { 1: { value: 'par', isTruncated: true } } },
{ id: 'nobody', mailboxIds: { inbox: true }, receivedAt: '2019-01-01T00:00:00Z' },
],
});
const [, result] = (await one(engine, ['Email/get', { ids: ['old', 'kept', 'cut', 'nobody'], properties: ['subject', 'bodyValues', 'header:X:asText'], fetchAllBodyValues: true }, 'g']))!;
expect(result.notFound).toEqual(['cut', 'nobody']);
expect(result.list[0]).toMatchObject({ id: 'old', subject: 'Old', 'header:X:asText': 'y' });
expect(result.list[0].bodyValues['1'].value).toBe('Old text');
expect(result.list[1].subject).toBe('Kept');
});
});
describe('usage and session', () => {
it('reports the bytes of the file and the kept session', async () => {
const { engine } = engineFor(new FakeJmapMail());
expect((await engine.areaCall('mail.usage', {}) as { bytes: number }).bytes).toBeGreaterThan(0);
expect(await engine.areaCall('mail.session', {})).toEqual(FULL_SESSION);
});
});
+11
View File
@@ -52,8 +52,18 @@ export interface StoreContext {
scope: StoreScope;
now(): number;
emit(event: StoreEvent): void;
/** Records of one type as this device shows them: see RecordOverlay. */
overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[];
}
/**
* What an area changes about records another area stores: the outbox lays
* changes that wait for the server over what the server sent. Runs inside the
* caller's transaction. Returns the records to use; a record left out is one
* this device already treats as gone.
*/
export type RecordOverlay = (ctx: Pick<StoreContext, 'db' | 'now'>, jmap: string, type: string, records: Array<{ id: string }>) => Array<{ id: string }>;
export interface SyncContext extends StoreContext {
transport: JmapTransport;
/** True once the engine is closing or lost the network: stop at the next batch. */
@@ -106,4 +116,5 @@ export interface StoreArea {
methods: Readonly<Record<string, MethodHandler>>;
syncers: readonly Syncer[];
calls?: Readonly<Record<string, AreaCall>>;
overlay?: RecordOverlay;
}
+15 -2
View File
@@ -4,7 +4,7 @@
* the UI. Nothing here touches the DOM, so tests run it on node:sqlite.
*/
import { MethodError, type AreaCall, type MethodHandler, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area';
import { MethodError, type AreaCall, type MethodHandler, type RecordOverlay, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area';
import { migrate, type StorageAdapter } from '../storage';
import { answerRequest, buildMethodTable } from './responder';
import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport';
@@ -36,6 +36,7 @@ export class AccountEngine {
private readonly syncers: readonly Syncer[];
private readonly methods: ReadonlyMap<string, MethodHandler>;
private readonly calls = new Map<string, AreaCall>();
private readonly overlays: readonly RecordOverlay[];
private readonly emitter: (event: StoreEvent) => void;
private readonly now: () => number;
private readonly sleep: (ms: number) => Promise<void>;
@@ -53,6 +54,7 @@ export class AccountEngine {
this.transport = options.transport;
this.syncers = options.areas.flatMap((area) => area.syncers);
this.methods = buildMethodTable(options.areas);
this.overlays = options.areas.flatMap((area) => (area.overlay ? [area.overlay] : []));
for (const area of options.areas) {
for (const [name, call] of Object.entries(area.calls ?? {})) {
if (this.calls.has(name)) throw new Error(`Two store areas define ${name}`);
@@ -62,9 +64,11 @@ export class AccountEngine {
this.emitter = options.emit ?? (() => {});
this.now = options.now ?? Date.now;
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.invertedKeywordSort = options.invertedKeywordSort ?? false;
migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]);
metaSet(this.db, 'session', JSON.stringify(options.session));
// An offline start has no way to probe the server, so the last answer stays.
this.invertedKeywordSort = options.invertedKeywordSort ?? metaGet(this.db, 'inverted_keyword_sort') === '1';
if (options.invertedKeywordSort !== undefined) metaSet(this.db, 'inverted_keyword_sort', options.invertedKeywordSort ? '1' : '0');
}
/** The session kept from the last time, for a start without a network. */
@@ -86,6 +90,13 @@ export class AccountEngine {
setInvertedKeywordSort(inverted: boolean): void {
this.invertedKeywordSort = inverted;
metaSet(this.db, 'inverted_keyword_sort', inverted ? '1' : '0');
}
private overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[] {
let out: Array<{ id: string }> = records;
for (const overlay of this.overlays) out = overlay({ db: this.db, now: this.now }, jmap, type, out);
return out as T[];
}
setOnline(online: boolean): void {
@@ -199,6 +210,7 @@ export class AccountEngine {
stopped: () => this.closed || !this.online,
syncState: (jmap, type) => this.syncState(jmap, type),
setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row),
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
};
}
@@ -210,6 +222,7 @@ export class AccountEngine {
scope: this.scope,
now: this.now,
emit: this.emitter,
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
};
}
+62
View File
@@ -0,0 +1,62 @@
/** What the page asks the mail part of the store outside JMAP. */
import type { AreaCall, StoreContext } from '../area';
import { putBlob, storeBody, upsertEmail, type EmailObject } from './records';
/**
* A message the user opened online: kept with its text if the store doesn't
* have that yet, for instance because it's older than the window the user
* chose. Parts cut short for a list view aren't kept.
*/
function recordOpened(ctx: StoreContext, params: { jmap: string; emails: EmailObject[] }): void {
ctx.db.transaction(() => {
for (const email of ctx.overlay(params.jmap, 'Email', params.emails)) {
const values = (email.bodyValues ?? {}) as Record<string, { isTruncated?: boolean }>;
if (Object.keys(values).length === 0 || Object.values(values).some((value) => value?.isTruncated)) continue;
const kept = ctx.db.get<{ rowid: number; json: string; body_state: number }>(
'SELECT rowid, json, body_state FROM email WHERE jmap = ? AND id = ?',
[params.jmap, email.id],
);
if (kept && Number(kept.body_state) === 1) continue;
if (kept) {
storeBody(ctx.db, Number(kept.rowid), String(kept.json), email);
continue;
}
// A row needs at least what lists sort and filter on.
if (typeof email.receivedAt !== 'string' || !email.mailboxIds) continue;
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [params.jmap])?.gen ?? 0);
const { bodyValues, textBody, htmlBody, attachments, bodyStructure, headers, bcc, replyTo, messageId, inReplyTo, references, sender, ...list } = email;
const rowid = upsertEmail(ctx.db, params.jmap, list as EmailObject, gen);
storeBody(ctx.db, rowid, JSON.stringify(list), { bodyValues, textBody, htmlBody, attachments, bodyStructure, headers, bcc, replyTo, messageId, inReplyTo, references, sender });
}
});
}
/** A file of a kept message the user opened. Nothing else is kept: not an export, not a stray file. */
function recordBlob(ctx: StoreContext, params: { jmap: string; blobId: string; type: string | null; data: Uint8Array }): boolean {
const owner = ctx.db.get<{ received_at: number }>('SELECT received_at FROM email_part WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]);
if (!owner) return false;
putBlob(ctx.db, params.jmap, params.blobId, params.type, params.data, Number(owner.received_at));
return true;
}
function getBlob(ctx: StoreContext, params: { jmap: string; blobId: string }): { type: string | null; data: Uint8Array } | null {
const row = ctx.db.get<{ type: string | null; data: Uint8Array }>('SELECT type, data FROM blob WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]);
return row ? { type: row.type, data: row.data } : null;
}
/** How much of the disk this account's file takes. */
function usage(ctx: StoreContext): { bytes: number } {
const pages = Number(ctx.db.get<{ page_count: number }>('PRAGMA page_count')?.page_count ?? 0);
const size = Number(ctx.db.get<{ page_size: number }>('PRAGMA page_size')?.page_size ?? 0);
return { bytes: pages * size };
}
export const MAIL_CALLS: Readonly<Record<string, AreaCall>> = {
'mail.recordOpened': recordOpened,
'mail.recordBlob': recordBlob,
'mail.getBlob': getBlob,
'mail.usage': usage,
/** The session kept with the account, for a start from the local copy. */
'mail.session': (ctx) => ctx.session,
};
+181
View File
@@ -0,0 +1,181 @@
/**
* The `header:<Name>[:<form>][:all]` properties of Email/get (RFC 8621
* section 4.1.2), worked out from the raw `headers` a kept message stores.
* Callers read these for mailing list ids, classification labels and rules.
*/
export interface RawHeader {
name: string;
value: string;
}
export interface HeaderForm {
name: string;
form: 'asRaw' | 'asText' | 'asAddresses' | 'asGroupedAddresses' | 'asMessageIds' | 'asDate' | 'asURLs';
all: boolean;
}
const FORMS = new Set<HeaderForm['form']>(['asRaw', 'asText', 'asAddresses', 'asGroupedAddresses', 'asMessageIds', 'asDate', 'asURLs']);
export function parseHeaderProperty(property: string): HeaderForm | null {
if (!property.startsWith('header:')) return null;
const parts = property.slice('header:'.length).split(':');
const name = parts.shift();
if (!name) return null;
let all = false;
if (parts[parts.length - 1] === 'all') {
all = true;
parts.pop();
}
if (parts.length > 1) return null;
const form = (parts[0] ?? 'asRaw') as HeaderForm['form'];
return FORMS.has(form) ? { name, form, all } : null;
}
function unfold(value: string): string {
return value.replace(/\r?\n(?=[ \t])/g, '').trim();
}
function decodeBytes(charset: string, bytes: Uint8Array): string {
try {
return new TextDecoder(charset).decode(bytes);
} catch {
return new TextDecoder().decode(bytes);
}
}
/** RFC 2047 encoded words. Whitespace between two of them goes. */
function decodeWords(value: string): string {
return value
.replace(/(=\?[^?\s]+\?[bBqQ]\?[^?\s]*\?=)\s+(?==\?[^?\s]+\?[bBqQ]\?)/g, '$1')
.replace(/=\?([^?\s]+)\?([bBqQ])\?([^?\s]*)\?=/g, (whole, charset: string, encoding: string, text: string) => {
try {
if (encoding.toUpperCase() === 'B') {
return decodeBytes(charset, Uint8Array.from(atob(text), (c) => c.charCodeAt(0)));
}
const raw = text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
return decodeBytes(charset, Uint8Array.from(raw, (c) => c.charCodeAt(0)));
} catch {
return whole;
}
});
}
interface Address { name: string | null; email: string }
interface Group { name: string | null; addresses: Address[] }
/** Splits on a separator outside quotes, comments and angle brackets. */
function splitOutside(value: string, separators: string): string[] {
const parts: string[] = [];
let current = '';
let quoted = false;
let depth = 0;
let angle = false;
for (let i = 0; i < value.length; i += 1) {
const c = value[i];
if (quoted) {
current += c;
if (c === '\\') {
current += value[i + 1] ?? '';
i += 1;
} else if (c === '"') quoted = false;
continue;
}
if (c === '"') quoted = true;
else if (c === '(') depth += 1;
else if (c === ')') depth = Math.max(0, depth - 1);
else if (c === '<') angle = true;
else if (c === '>') angle = false;
if (depth === 0 && !angle && separators.includes(c)) {
parts.push(current);
if (c !== ',') parts.push(c);
current = '';
continue;
}
current += c;
}
parts.push(current);
return parts;
}
function parseAddress(text: string): Address | null {
const trimmed = text.replace(/\((?:[^()\\]|\\.)*\)/g, ' ').trim();
if (!trimmed) return null;
const angle = trimmed.match(/^([\s\S]*)<([^<>]*)>\s*$/);
const email = (angle ? angle[2] : trimmed).trim();
if (!angle && !email.includes('@')) return null;
const rawName = angle ? angle[1].trim() : '';
const name = rawName.startsWith('"') && rawName.endsWith('"') && rawName.length >= 2
? rawName.slice(1, -1).replace(/\\(.)/g, '$1')
: rawName;
return { name: name ? decodeWords(name) : null, email };
}
function parseGroups(value: string): Group[] {
const groups: Group[] = [];
let current: Group | null = null;
let loose: Group | null = null;
const tokens = splitOutside(unfold(value), ',:;');
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (tokens[i + 1] === ':') {
current = { name: decodeWords(token.trim().replace(/^"|"$/g, '')) || null, addresses: [] };
groups.push(current);
loose = null;
i += 1;
continue;
}
if (token === ';') {
current = null;
continue;
}
if (token === ':') continue;
const address = parseAddress(token);
if (!address) continue;
if (current) current.addresses.push(address);
else {
if (!loose) {
loose = { name: null, addresses: [] };
groups.push(loose);
}
loose.addresses.push(address);
}
}
return groups;
}
function parseMessageIds(value: string): string[] | null {
const ids = [...unfold(value).matchAll(/<([^<>\s]+)>/g)].map((match) => match[1]);
return ids.length > 0 ? ids : null;
}
function parseDate(value: string): string | null {
const ms = Date.parse(unfold(value).replace(/\((?:[^()\\]|\\.)*\)/g, ' '));
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
}
function parseUrls(value: string): string[] | null {
const urls = [...unfold(value).matchAll(/<([^<>\s]+)>/g)].map((match) => match[1]);
return urls.length > 0 ? urls : null;
}
function convert(value: string, form: HeaderForm['form']): unknown {
switch (form) {
case 'asRaw': return value;
case 'asText': return decodeWords(unfold(value));
case 'asAddresses': return parseGroups(value).flatMap((group) => group.addresses);
case 'asGroupedAddresses': return parseGroups(value);
case 'asMessageIds': return parseMessageIds(value);
case 'asDate': return parseDate(value);
case 'asURLs': return parseUrls(value);
}
}
/** The value of one header property. Without `:all`, the last header of that name, or null. */
export function headerValue(form: HeaderForm, headers: readonly RawHeader[]): unknown {
const wanted = form.name.toLowerCase();
const found = headers.filter((header) => header.name.toLowerCase() === wanted);
if (form.all) return found.map((header) => convert(header.value, form.form));
const last = found[found.length - 1];
return last ? convert(last.value, form.form) : null;
}
+4 -2
View File
@@ -1,11 +1,13 @@
import type { StoreArea } from '../area';
import { MAIL_CALLS } from './calls';
import { MAIL_METHODS } from './methods';
import { MAIL_MIGRATIONS } from './schema';
import { mailSyncer } from './sync';
import { identitySyncer, mailSyncer, quotaSyncer } from './sync';
export const mailArea: StoreArea = {
name: 'mail',
migrations: MAIL_MIGRATIONS,
methods: MAIL_METHODS,
syncers: [mailSyncer],
syncers: [mailSyncer, identitySyncer, quotaSyncer],
calls: MAIL_CALLS,
};
+164 -34
View File
@@ -1,8 +1,13 @@
/** Mail reads answered from the store. */
/**
* Mail reads answered from the store. What an answer must look like is in
* local-data/desktop-offline-facts-parts/offline-jmap.md sections 1 and 4: it
* follows what the calling method in lib/jmap/client.ts reads.
*/
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
import { listObjects } from '../engine/core-schema';
import type { SqlValue } from '../storage';
import { headerValue, parseHeaderProperty, type RawHeader } from './headers';
import { textCondition, type TextField } from './search';
type Filter = Record<string, unknown>;
@@ -15,6 +20,14 @@ function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
return (args.accountId as string | undefined) ?? ctx.defaultJmap;
}
function lastState(ctx: MethodContext, jmap: string, type: string): string | null {
return ctx.db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? null;
}
function likeTerm(value: unknown): string {
return `%${String(value).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
}
function filterSql(filter: Filter, jmap: string): Sql {
if (typeof filter.operator === 'string') {
const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : [];
@@ -71,6 +84,24 @@ function filterSql(filter: Filter, jmap: string): Sql {
conditions.push(`e.id IN (${(value as string[]).map(() => '?').join(',') || "''"})`);
params.push(...(value as string[]).map(String));
break;
case 'cc':
conditions.push("EXISTS (SELECT 1 FROM json_each(e.json, '$.cc') a WHERE a.value LIKE ? ESCAPE '\\')");
params.push(likeTerm(value));
break;
case 'bcc':
conditions.push("EXISTS (SELECT 1 FROM email_body b, json_each(b.json, '$.bcc') a WHERE b.email_rowid = e.rowid AND a.value LIKE ? ESCAPE '\\')");
params.push(likeTerm(value));
break;
case 'header': {
// Stalwart finds nothing for this on either version, plugins may still
// ask. Answered from the raw headers of the messages kept with a body.
const [name, wanted] = value as [string, string | undefined];
conditions.push(`EXISTS (SELECT 1 FROM email_body b, json_each(b.json, '$.headers') h
WHERE b.email_rowid = e.rowid AND lower(json_extract(h.value, '$.name')) = ?${wanted === undefined ? '' : " AND json_extract(h.value, '$.value') LIKE ? ESCAPE '\\'"})`);
params.push(String(name).toLowerCase());
if (wanted !== undefined) params.push(likeTerm(wanted));
break;
}
default:
if ((TEXT_FIELDS as readonly string[]).includes(key)) {
const text = textCondition(key as TextField, String(value));
@@ -78,7 +109,7 @@ function filterSql(filter: Filter, jmap: string): Sql {
params.push(...text.params);
break;
}
// cc, bcc, header and the thread keyword filters: the server's job.
// The thread keyword filters: nothing in the app sends them.
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
}
}
@@ -152,11 +183,12 @@ const emailQuery: MethodHandler = (ctx, args) => {
`SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`,
[...base.params, ...where.params, ...order.params, limit, position],
).map((row) => String(row.id));
// Stalwart's queryState is the Email state. Absent until the first sync, as allowed.
const queryState = lastState(ctx, jmap, 'Email');
const result: MethodResult = {
accountId: jmap,
canCalculateChanges: false,
// Local answers aren't a position in the server's log, so this is a token only.
queryState: 'local',
...(queryState ? { queryState } : {}),
position,
ids,
};
@@ -170,12 +202,60 @@ const emailQuery: MethodHandler = (ctx, args) => {
return result;
};
const BODY_PROPERTIES = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references']);
/** What only the stored body has, past what the list keeps. */
const BODY_PROPERTIES = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references', 'sender']);
function pick(email: Record<string, unknown>, properties: string[] | undefined): Record<string, unknown> {
if (!properties) return email;
const out: Record<string, unknown> = { id: email.id };
for (const property of properties) if (property in email) out[property] = email[property];
interface BodyPart { partId?: string | null; subParts?: BodyPart[]; [key: string]: unknown }
function partWithProperties(part: BodyPart, wanted: readonly string[]): BodyPart {
const out: BodyPart = {};
for (const name of wanted) if (name in part) out[name] = part[name];
if (part.subParts) out.subParts = part.subParts.map((sub) => partWithProperties(sub, wanted));
return out;
}
/** The body values the call asked for. The whole kept text: never cut to maxBodyValueBytes. */
function chosenBodyValues(body: Record<string, unknown>, args: Record<string, unknown>): Record<string, unknown> {
const values = (body.bodyValues ?? {}) as Record<string, unknown>;
if (args.fetchAllBodyValues) return values;
const parts = [
...(args.fetchTextBodyValues ? (body.textBody ?? []) as BodyPart[] : []),
...(args.fetchHTMLBodyValues ? (body.htmlBody ?? []) as BodyPart[] : []),
];
const out: Record<string, unknown> = {};
for (const part of parts) {
const id = part.partId ?? '';
if (id in values) out[id] = values[id];
}
return out;
}
function shapeEmail(
list: Record<string, unknown>,
body: Record<string, unknown> | null,
args: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...(body ?? {}), ...list };
if (body) merged.bodyValues = chosenBodyValues(body, args);
const bodyProperties = args.bodyProperties as string[] | undefined;
if (bodyProperties) {
for (const key of ['textBody', 'htmlBody', 'attachments']) {
if (Array.isArray(merged[key])) merged[key] = (merged[key] as BodyPart[]).map((part) => partWithProperties(part, bodyProperties));
}
if (merged.bodyStructure) merged.bodyStructure = partWithProperties(merged.bodyStructure as BodyPart, bodyProperties);
}
const properties = args.properties as string[] | undefined;
if (!properties) return merged;
const out: Record<string, unknown> = { id: list.id };
for (const property of properties) {
if (property in merged) {
out[property] = merged[property];
continue;
}
// Only a message kept with its text has the raw headers these come from.
const form = parseHeaderProperty(property);
if (form && body && Array.isArray(body.headers)) out[property] = headerValue(form, body.headers as RawHeader[]);
}
return out;
}
@@ -184,7 +264,7 @@ const emailGet: MethodHandler = (ctx, args) => {
if (!Array.isArray(args.ids)) throw new MethodError('requestTooLarge', 'ids: null needs the server');
const properties = args.properties as string[] | undefined;
const wantsValues = Boolean(args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues) || Boolean(properties?.includes('bodyValues'));
const wantsBody = wantsValues || !properties || properties.some((p) => BODY_PROPERTIES.has(p));
const wantsBody = wantsValues || !properties || properties.some((p) => BODY_PROPERTIES.has(p) || p.startsWith('header:'));
const list: Record<string, unknown>[] = [];
const notFound: string[] = [];
for (const id of args.ids as string[]) {
@@ -198,7 +278,7 @@ const emailGet: MethodHandler = (ctx, args) => {
}
const email = JSON.parse(row.json) as Record<string, unknown>;
if (!wantsBody) {
list.push(pick(email, properties));
list.push(shapeEmail(email, null, args));
continue;
}
const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]);
@@ -207,56 +287,73 @@ const emailGet: MethodHandler = (ctx, args) => {
notFound.push(id);
continue;
}
list.push(pick({ ...(body ? JSON.parse(body.json) as object : {}), ...email }, properties));
list.push(shapeEmail(email, body ? JSON.parse(body.json) as Record<string, unknown> : null, args));
}
const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Email'", [jmap])?.state;
const state = lastState(ctx, jmap, 'Email');
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
};
const threadGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const ids = Array.isArray(args.ids) ? [...new Set(args.ids as string[])] : [];
const list: Array<{ id: string; emailIds: string[] }> = [];
const notFound: string[] = [];
const found: Array<{ id: string; emailIds: string[] }> = [];
for (const id of ids) {
const stored = ctx.db.get<{ email_ids: string }>('SELECT email_ids FROM thread WHERE jmap = ? AND id = ?', [jmap, id]);
if (stored) {
list.push({ id, emailIds: JSON.parse(stored.email_ids) as string[] });
found.push({ id, emailIds: JSON.parse(stored.email_ids) as string[] });
continue;
}
const rows = ctx.db.all<{ id: string }>('SELECT id FROM email WHERE jmap = ? AND thread_id = ? ORDER BY received_at ASC', [jmap, id]);
if (rows.length > 0) list.push({ id, emailIds: rows.map((row) => String(row.id)) });
else notFound.push(id);
if (rows.length > 0) found.push({ id, emailIds: rows.map((row) => String(row.id)) });
}
return { accountId: jmap, list, notFound };
};
const mailboxGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const boxes = listObjects<Record<string, unknown>>(ctx.db, jmap, 'Mailbox');
if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet');
const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null;
const chosen = ids ? boxes.filter((box) => ids.has(box.id as string)) : boxes;
const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Mailbox'", [jmap])?.state;
const list = ctx.overlay(jmap, 'Thread', found).filter((thread) => thread.emailIds.length > 0);
const state = lastState(ctx, jmap, 'Thread');
return {
accountId: jmap,
...(state ? { state } : {}),
list: chosen.map((box) => pick(box, args.properties as string[] | undefined)),
notFound: ids ? [...ids].filter((id) => !boxes.some((box) => box.id === id)) : [],
list,
notFound: ids.filter((id) => !list.some((thread) => thread.id === id)),
};
};
function pick(item: Record<string, unknown>, properties: string[] | undefined): Record<string, unknown> {
if (!properties) return item;
const out: Record<string, unknown> = { id: item.id };
for (const property of properties) if (property in item) out[property] = item[property];
return out;
}
/** The answer of a /get over objects the syncers keep. An error until they've run once. */
function objectGet(ctx: MethodContext, args: Record<string, unknown>, type: string, label: string): MethodResult {
const jmap = jmapOf(ctx, args);
const state = lastState(ctx, jmap, type);
const all = ctx.overlay(jmap, type, listObjects<Record<string, unknown> & { id: string }>(ctx.db, jmap, type));
if (all.length === 0 && !state) throw new MethodError('serverUnavailable', `${label} not kept yet`);
const ids = Array.isArray(args.ids) ? args.ids as string[] : null;
const chosen = ids ? all.filter((item) => ids.includes(item.id)) : all;
return {
accountId: jmap,
...(state ? { state } : {}),
list: chosen.map((item) => pick(item, args.properties as string[] | undefined)),
notFound: ids ? ids.filter((id) => !all.some((item) => item.id === id)) : [],
};
}
const mailboxQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const boxes = listObjects<Record<string, unknown>>(ctx.db, jmap, 'Mailbox');
if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet');
const filter = (args.filter ?? {}) as { role?: string };
const state = lastState(ctx, jmap, 'Mailbox');
if (boxes.length === 0 || !state) throw new MethodError('serverUnavailable', 'Folders not kept yet');
const filter = (args.filter ?? {}) as Record<string, unknown>;
for (const key of Object.keys(filter)) {
if (key !== 'role') throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
}
const chosen = filter.role ? boxes.filter((box) => box.role === filter.role) : boxes;
const position = typeof args.position === 'number' ? args.position : 0;
const limit = typeof args.limit === 'number' ? args.limit : chosen.length;
return {
accountId: jmap,
queryState: 'local',
queryState: state,
canCalculateChanges: false,
position,
ids: chosen.slice(position, position + limit).map((box) => box.id),
@@ -264,10 +361,43 @@ const mailboxQuery: MethodHandler = (ctx, args) => {
};
};
/** The client treats this as "do a full fetch": what it reads next comes from the store anyway. */
const noChanges: MethodHandler = () => {
throw new MethodError('cannotCalculateChanges', 'Changes need the server');
};
const keywordGet: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
const rows = ctx.db.all<{ keyword: string; total: number; unread: number }>(
`SELECT k.keyword AS keyword, COUNT(*) AS total,
SUM(NOT EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = k.email_rowid AND s.keyword = '$seen')) AS unread
FROM email_keyword k JOIN email e ON e.rowid = k.email_rowid
WHERE e.jmap = ? GROUP BY k.keyword`,
[jmap],
);
const counts = new Map(rows.map((row) => [String(row.keyword), { id: String(row.keyword), total: Number(row.total), unread: Number(row.unread) }]));
const wanted = Array.isArray(args.ids) ? args.ids as string[] : null;
const list = wanted
? wanted.map((id) => ({ ...(counts.get(id.toLowerCase()) ?? { total: 0, unread: 0 }), id }))
: [...counts.values()];
return {
accountId: jmap,
list,
notFound: [],
totalEmails: Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email WHERE jmap = ?', [jmap])?.n ?? 0),
};
};
export const MAIL_METHODS: Readonly<Record<string, MethodHandler>> = {
'Core/echo': (_ctx, args) => args,
'Email/query': emailQuery,
'Email/get': emailGet,
'Email/changes': noChanges,
'Thread/get': threadGet,
'Mailbox/get': mailboxGet,
'Mailbox/get': (ctx, args) => objectGet(ctx, args, 'Mailbox', 'Folders'),
'Mailbox/query': mailboxQuery,
'Mailbox/changes': noChanges,
'Identity/get': (ctx, args) => objectGet(ctx, args, 'Identity', 'Identities'),
'Quota/get': (ctx, args) => objectGet(ctx, args, 'Quota', 'Quotas'),
'Keyword/get': keywordGet,
};
+35
View File
@@ -85,6 +85,14 @@ export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject
return rowid;
}
export function putBlob(db: StorageAdapter, jmap: string, blobId: string, type: string | null, data: Uint8Array, at: number): void {
db.run(
`INSERT INTO blob (jmap, blob_id, type, size, at, data) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (jmap, blob_id) DO UPDATE SET type = excluded.type, size = excluded.size, at = excluded.at, data = excluded.data`,
[jmap, blobId, type, data.byteLength, at, data],
);
}
export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void {
const row = db.get<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND id = ?', [jmap, id]);
if (!row) return;
@@ -94,6 +102,7 @@ export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void
export function deleteEmailRow(db: StorageAdapter, rowid: number): void {
db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]);
db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]);
forgetParts(db, rowid);
// Explicit rather than ON DELETE CASCADE: foreign keys stay off for bulk speed.
db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]);
db.run('DELETE FROM email_keyword WHERE email_rowid = ?', [rowid]);
@@ -135,10 +144,36 @@ export function indexEmail(db: StorageAdapter, rowid: number, email: Record<stri
}
}
/** A message's files go with it, unless another kept message has the same one. */
function forgetParts(db: StorageAdapter, rowid: number): void {
const parts = db.all<{ jmap: string; blob_id: string }>('SELECT jmap, blob_id FROM email_part WHERE email_rowid = ?', [rowid]);
db.run('DELETE FROM email_part WHERE email_rowid = ?', [rowid]);
for (const part of parts) {
db.run(
'DELETE FROM blob WHERE jmap = ? AND blob_id = ? AND NOT EXISTS (SELECT 1 FROM email_part p WHERE p.jmap = ? AND p.blob_id = ?)',
[part.jmap, part.blob_id, part.jmap, part.blob_id],
);
}
}
function rememberParts(db: StorageAdapter, rowid: number, attachments: unknown): void {
const row = db.get<{ jmap: string; received_at: number }>('SELECT jmap, received_at FROM email WHERE rowid = ?', [rowid]);
if (!row || !Array.isArray(attachments)) return;
for (const part of attachments as Array<{ blobId?: unknown; size?: unknown; type?: unknown; name?: unknown }>) {
if (typeof part.blobId !== 'string') continue;
db.run(
`INSERT INTO email_part (jmap, blob_id, email_rowid, received_at, size, type, name) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (jmap, blob_id, email_rowid) DO NOTHING`,
[row.jmap, part.blobId, rowid, row.received_at, typeof part.size === 'number' ? part.size : 0, typeof part.type === 'string' ? part.type : null, typeof part.name === 'string' ? part.name : null],
);
}
}
export function storeBody(db: StorageAdapter, rowid: number, listJson: string, full: Record<string, unknown>): void {
const list = JSON.parse(listJson) as Record<string, unknown>;
const body: Record<string, unknown> = {};
for (const [key, value] of Object.entries(full)) if (!(key in list)) body[key] = value;
rememberParts(db, rowid, body.attachments);
db.run('INSERT INTO email_body (email_rowid, json) VALUES (?, ?) ON CONFLICT (email_rowid) DO UPDATE SET json = excluded.json', [rowid, JSON.stringify(body)]);
db.run('UPDATE email SET body_state = 1 WHERE rowid = ?', [rowid]);
db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]);
+30
View File
@@ -62,4 +62,34 @@ export const MAIL_MIGRATIONS: readonly Migration[] = [
);
`,
},
{
id: 101,
name: 'mail attachments',
sql: `
-- The attachments and inline images of kept messages.
CREATE TABLE email_part (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
email_rowid INTEGER NOT NULL,
received_at INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
type TEXT,
name TEXT,
-- 1 when the server wouldn't give it: not asked for again
refused INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (jmap, blob_id, email_rowid)
) WITHOUT ROWID;
CREATE INDEX email_part_message ON email_part (email_rowid);
CREATE INDEX email_part_newest ON email_part (jmap, received_at DESC);
CREATE TABLE blob (
jmap TEXT NOT NULL,
blob_id TEXT NOT NULL,
type TEXT,
size INTEGER NOT NULL,
at INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (jmap, blob_id)
) WITHOUT ROWID;
`,
},
];
+106 -3
View File
@@ -10,13 +10,14 @@
import type { SyncContext, Syncer } from '../area';
import { deleteObject, putObject } from '../engine/core-schema';
import { runChangesLoop } from '../engine/changes';
import { responseFor, type MethodCall } from '../engine/transport';
import { responseFor, TransportError, type MethodCall } from '../engine/transport';
import {
deleteEmail,
deleteEmailRow,
EMAIL_BODY_PROPERTIES,
EMAIL_LIST_PROPERTIES,
MAIL_USING,
putBlob,
storeBody,
upsertEmail,
type EmailObject,
@@ -130,7 +131,7 @@ async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): P
}
cursor.position += got;
ctx.db.transaction(() => {
for (const list of lists) for (const email of list) upsertEmail(ctx.db, jmap, email, cursor.gen);
for (const list of lists) for (const email of ctx.overlay(jmap, 'Email', list)) upsertEmail(ctx.db, jmap, email, cursor.gen);
if (done) {
// Whatever this pass didn't see is gone from the server or the window.
const stale = ctx.db.all<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]);
@@ -171,7 +172,7 @@ async function syncEmailDelta(ctx: SyncContext, jmap: string, since: string, tar
const created = new Set(page.created);
const newIds: string[] = [];
ctx.db.transaction(() => {
for (const email of fetched) {
for (const email of ctx.overlay(jmap, 'Email', fetched)) {
const known = ctx.db.get('SELECT 1 AS x FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]);
// An old message changing doesn't pull it into the window.
if (!known && !inWindow(ctx, email)) continue;
@@ -291,6 +292,106 @@ async function backfillBodies(ctx: SyncContext, jmap: string): Promise<void> {
}
}
// ---- Attachments ----
/**
* The files of kept messages, newest first, within what the user chose: one
* at a time, each in its own transaction, so a reload loses at most one.
*/
async function fetchAttachments(ctx: SyncContext, jmap: string): Promise<void> {
if (!ctx.scope.attachments) return;
let used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0);
for (;;) {
if (ctx.stopped()) return;
const parts = ctx.db.all<{ blob_id: string; size: number; type: string | null; name: string | null; received_at: number }>(
`SELECT p.blob_id, p.size, p.type, p.name, p.received_at FROM email_part p
WHERE p.jmap = ? AND p.refused = 0 AND p.size <= ?
AND NOT EXISTS (SELECT 1 FROM blob b WHERE b.jmap = p.jmap AND b.blob_id = p.blob_id)
ORDER BY p.received_at DESC LIMIT 20`,
[jmap, ctx.scope.maxAttachmentBytes],
);
if (parts.length === 0) return;
for (const part of parts) {
if (ctx.stopped()) return;
const blobId = String(part.blob_id);
if (used + Number(part.size) > ctx.scope.maxTotalBytes) return;
let data: Uint8Array;
try {
data = await ctx.transport.download(jmap, blobId, part.type ?? 'application/octet-stream', part.name ?? 'attachment');
} catch (error) {
if (error instanceof TransportError && error.kind === 'http') {
ctx.db.run('UPDATE email_part SET refused = 1 WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
continue;
}
throw error;
}
putBlob(ctx.db, jmap, blobId, part.type, data, Number(part.received_at));
used += data.byteLength;
}
}
}
// ---- Identities and quotas ----
const IDENTITY_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'];
const QUOTA_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:quota'];
async function fullIdentities(ctx: SyncContext, jmap: string): Promise<void> {
const response = await ctx.transport.request([['Identity/get', { accountId: jmap, ids: null }, 'i']], IDENTITY_USING);
const [, result] = responseFor(response, 'i');
ctx.db.transaction(() => {
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Identity'", [jmap]);
for (const identity of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'Identity', identity.id, identity);
ctx.setSyncState(jmap, 'Identity', { state: String(result.state), phase: null, cursor: null });
});
}
export const identitySyncer: Syncer = {
name: 'identities',
capability: 'urn:ietf:params:jmap:submission',
async run(ctx, jmap) {
const { state, target } = ctx.syncState(jmap, 'Identity');
if (!state) return fullIdentities(ctx, jmap);
const outcome = await runChangesLoop({
transport: ctx.transport,
type: 'Identity',
jmap,
using: IDENTITY_USING,
since: state,
target,
stopped: ctx.stopped,
apply: async (page) => {
const ids = [...page.created, ...page.updated];
const response = ids.length > 0
? await ctx.transport.request([['Identity/get', { accountId: jmap, ids }, 'i']], IDENTITY_USING)
: null;
const list = response ? responseFor(response, 'i')[1].list as Array<{ id: string }> : [];
ctx.db.transaction(() => {
for (const identity of list) putObject(ctx.db, jmap, 'Identity', identity.id, identity);
for (const id of page.destroyed) deleteObject(ctx.db, jmap, 'Identity', id);
ctx.setSyncState(jmap, 'Identity', { state: page.newState });
});
},
});
if (outcome.outcome === 'resync') await fullIdentities(ctx, jmap);
},
};
/** Only the last value: Quota/changes always refuses on Stalwart 1.0. */
export const quotaSyncer: Syncer = {
name: 'quota',
capability: 'urn:ietf:params:jmap:quota',
async run(ctx, jmap) {
const response = await ctx.transport.request([['Quota/get', { accountId: jmap, ids: null }, 'q']], QUOTA_USING);
const [, result] = responseFor(response, 'q');
ctx.db.transaction(() => {
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Quota'", [jmap]);
for (const quota of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'Quota', quota.id, quota);
ctx.setSyncState(jmap, 'Quota', { state: typeof result.state === 'string' ? result.state : null, phase: null, cursor: null });
});
},
};
// ---- The whole pass ----
export const mailSyncer: Syncer = {
@@ -319,5 +420,7 @@ export const mailSyncer: Syncer = {
await syncThreads(ctx, jmap);
if (ctx.stopped()) return;
await backfillBodies(ctx, jmap);
if (ctx.stopped()) return;
await fetchAttachments(ctx, jmap);
},
};