mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: answer every mail read from the local store, including header forms, identities and quotas, and keep attachments
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user