diff --git a/lib/store/__tests__/helpers/fake-jmap-mail.ts b/lib/store/__tests__/helpers/fake-jmap-mail.ts new file mode 100644 index 000000000..5d2081927 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-jmap-mail.ts @@ -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; + +export class FakeJmapMail extends FakeJmap { + readonly identities = new Map & { id: string }>(); + readonly quotas: Array & { id: string }> = []; + readonly submissions = new Map(); + /** Email/set update or destroy of these ids is refused with this error. */ + readonly refuse = new Map(); + /** A method answered with a per-call error once. */ + readonly failNext = new Map(); + 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)[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): void { + const keywords = { ...(email.keywords as Record) }; + const boxes = { ...(email.mailboxIds as Record) }; + let wholeKeywords = keywords; + let wholeBoxes = boxes; + for (const [path, value] of Object.entries(patch)) { + if (path === 'keywords') wholeKeywords = { ...(value as Record) }; + else if (path === 'mailboxIds') wholeBoxes = { ...(value as Record) }; + 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): Record { + 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 = { accountId: args.accountId }; + if (args.update) { + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries(args.update as Record>)) { + 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 = {}; + 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; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 9aa917be5..31148f561 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -169,7 +169,7 @@ export class FakeJmap implements JmapTransport { return list.map((e) => e.id); } - private handle(name: string, args: Record): Record { + protected handle(name: string, args: Record): Record { switch (name) { case 'Mailbox/get': { const ids = args.ids as string[] | null; diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts new file mode 100644 index 000000000..94346f231 --- /dev/null +++ b/lib/store/__tests__/mail-answers.test.ts @@ -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 ' }, + { name: 'List-Id', value: ' =?UTF-8?B?VGVzdA==?= list\r\n ' }, + { name: 'From', value: ' "Doe, Jane" , Bob ' }, + { name: 'To', value: ' Team: a@example.org, b@example.org;, c@example.org' }, + { name: 'Message-ID', value: ' ' }, + { name: 'Date', value: ' Tue, 03 Nov 2026 10:00:00 +0100' }, + { name: 'List-Unsubscribe', value: ' , ' }, +]; + +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 '); + expect(got['header:List-Id:asText']).toBe('Test list '); + expect(got['header:list-id:asText:all']).toEqual(['Old list ', 'Test list ']); + 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: '

html

' } }, + }); + const { engine } = engineFor(server); + await engine.requestSync(); + const values = async (flags: Record) => (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) => (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[]]> = [ + [{ 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(); + override async download(_jmap?: string, blobId?: string): Promise { + 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); + }); +}); diff --git a/lib/store/area.ts b/lib/store/area.ts index fceed110e..d40743b05 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -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(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, 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>; syncers: readonly Syncer[]; calls?: Readonly>; + overlay?: RecordOverlay; } diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 6281afc71..bbe0ab51a 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -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; private readonly calls = new Map(); + private readonly overlays: readonly RecordOverlay[]; private readonly emitter: (event: StoreEvent) => void; private readonly now: () => number; private readonly sleep: (ms: number) => Promise; @@ -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(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), }; } diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts new file mode 100644 index 000000000..429b55dd5 --- /dev/null +++ b/lib/store/mail/calls.ts @@ -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; + 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> = { + '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, +}; diff --git a/lib/store/mail/headers.ts b/lib/store/mail/headers.ts new file mode 100644 index 000000000..a35211cc5 --- /dev/null +++ b/lib/store/mail/headers.ts @@ -0,0 +1,181 @@ +/** + * The `header:[:
][: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(['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; +} diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts index e76d61d28..13dae2381 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -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, }; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index e91bdd45c..535a6689c 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -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; @@ -15,6 +20,14 @@ function jmapOf(ctx: MethodContext, args: Record): 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, properties: string[] | undefined): Record { - if (!properties) return email; - const out: Record = { 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, args: Record): Record { + const values = (body.bodyValues ?? {}) as Record; + if (args.fetchAllBodyValues) return values; + const parts = [ + ...(args.fetchTextBodyValues ? (body.textBody ?? []) as BodyPart[] : []), + ...(args.fetchHTMLBodyValues ? (body.htmlBody ?? []) as BodyPart[] : []), + ]; + const out: Record = {}; + for (const part of parts) { + const id = part.partId ?? ''; + if (id in values) out[id] = values[id]; + } + return out; +} + +function shapeEmail( + list: Record, + body: Record | null, + args: Record, +): Record { + const merged: Record = { ...(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 = { 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[] = []; 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; 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 : 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>(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, properties: string[] | undefined): Record { + if (!properties) return item; + const out: Record = { 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, type: string, label: string): MethodResult { + const jmap = jmapOf(ctx, args); + const state = lastState(ctx, jmap, type); + const all = ctx.overlay(jmap, type, listObjects & { 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>(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; + 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> = { + '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, }; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index b0e292718..d3528efa8 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -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('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): void { const list = JSON.parse(listJson) as Record; const body: Record = {}; 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]); diff --git a/lib/store/mail/schema.ts b/lib/store/mail/schema.ts index 52ef16dde..9a5b8b122 100644 --- a/lib/store/mail/schema.ts +++ b/lib/store/mail/schema.ts @@ -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; + `, + }, ]; diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index 21ad9bb0d..332ea4146 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -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 { } } +// ---- 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 { + 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 { + 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); }, };