From ccbe67c1bcd3f76eb86e417dadcc3f966d729524 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:49:23 +0200 Subject: [PATCH] fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850) --- src/api/__tests__/email.test.ts | 12 +- src/api/__tests__/identity.test.ts | 12 +- src/api/email.ts | 866 +++++++++++++++++++++-------- src/api/identity.ts | 53 +- src/api/jmap-client.ts | 85 +-- src/api/jmap-result.ts | 91 +++ src/api/patch-pointer.ts | 35 ++ src/api/types.ts | 37 ++ 8 files changed, 859 insertions(+), 332 deletions(-) create mode 100644 src/api/jmap-result.ts create mode 100644 src/api/patch-pointer.ts diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 684b544f..5100df45 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -8,6 +8,8 @@ vi.mock('../jmap-client', () => ({ getAccountName: vi.fn(() => 'me@example.com'), getSharedMailAccounts: vi.fn(() => []), getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), }, })); @@ -394,16 +396,18 @@ describe('email operations', () => { to: [{ email: 'you@example.com' }], subject: 'Re: Hello', textBody: 'replying', - inReplyTo: '', - references: ' ', + inReplyTo: [''], + references: ['', ''], }, 'identity-1', 'sent-mb', ); const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft; - expect(emailCreate['header:In-Reply-To:asText']).toBe(''); - expect(emailCreate['header:References:asText']).toBe(' '); + // RFC 8621 §4.1.2.3: JMAP arrays of bare msg-ids, brackets stripped. + expect(emailCreate.inReplyTo).toEqual(['msg-1@example.com']); + expect(emailCreate.references).toEqual(['msg-0@example.com', 'msg-1@example.com']); + expect(emailCreate['header:In-Reply-To:asText']).toBeUndefined(); }); }); }); diff --git a/src/api/__tests__/identity.test.ts b/src/api/__tests__/identity.test.ts index 64adee1b..7e01403c 100644 --- a/src/api/__tests__/identity.test.ts +++ b/src/api/__tests__/identity.test.ts @@ -36,9 +36,15 @@ describe('identity operations', () => { describe('createIdentity', () => { it('should create a new identity', async () => { const created = { id: 'id-new', name: 'Work', email: 'work@example.com' }; - mockRequest.mockResolvedValue({ - methodResponses: [['Identity/set', { created: { 'new-identity': created } }, '0']], - }); + // The create echo carries only server-set properties; the API re-reads + // the full identity afterwards. + mockRequest + .mockResolvedValueOnce({ + methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'id-new' } } }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['Identity/get', { list: [created] }, '0']], + }); const result = await createIdentity({ name: 'Work', email: 'work@example.com' }); expect(result).toEqual(created); diff --git a/src/api/email.ts b/src/api/email.ts index 4333d9ff..11e7470a 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1,19 +1,29 @@ import { jmapClient } from './jmap-client'; +import { assertSetResult, batched, requireMethodResult } from './jmap-result'; +import { keywordPointer, mailboxPointer } from './patch-pointer'; import { CAPABILITIES } from './types'; import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; import { toWildcardQuery } from '../lib/search-utils'; +import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; +import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; -const EMAIL_LIST_PROPERTIES = [ +export const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', ]; -const EMAIL_FULL_PROPERTIES = [ +// The viewer derives reply threading (In-Reply-To/References), Reply-To +// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests +// from these; without `messageId`/`headers` none of that can work. +export const EMAIL_FULL_PROPERTIES = [ ...EMAIL_LIST_PROPERTIES, 'bodyStructure', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt', + 'messageId', 'inReplyTo', 'references', 'headers', ]; +const SUBMISSION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION]; + // Prefix a shared account's folder ids so they can't collide with the user's // own. Matches the webmail's `${accountId}:${mailboxId}` scheme. function sharedMailboxId(accountId: string, rawId: string): string { @@ -50,6 +60,45 @@ function tagMailbox( }; } +function maxInGet(): number { + return typeof jmapClient.getMaxObjectsInGet === 'function' ? jmapClient.getMaxObjectsInGet() : 500; +} + +function maxInSet(): number { + return typeof jmapClient.getMaxObjectsInSet === 'function' ? jmapClient.getMaxObjectsInSet() : 500; +} + +/** + * Run one `Email/set` per `maxObjectsInSet`-sized slice of `update` / + * `destroy`, checking every slice for `notUpdated` / `notDestroyed`. Over-limit + * requests fail whole (RFC 8620 §3.6.1), so a long multi-select or a big + * offline replay must be split before it is sent. + */ +async function emailSetBatched( + accountId: string, + args: { update?: Record>; destroy?: string[] }, + what = 'email', +): Promise<{ updated: string[]; destroyed: string[] }> { + const out = { updated: [] as string[], destroyed: [] as string[] }; + const size = maxInSet(); + const updateEntries = Object.entries(args.update ?? {}); + for (const slice of batched(updateEntries, size)) { + const res = await jmapClient.request([ + ['Email/set', { accountId, update: Object.fromEntries(slice) }, '0'], + ]); + const body = requireMethodResult(res, '0', 'Email/set'); + assertSetResult(body, slice.map(([id]) => id), what); + out.updated.push(...Object.keys(body.updated ?? {})); + } + for (const slice of batched(args.destroy ?? [], size)) { + const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]); + const body = requireMethodResult(res, '0', 'Email/set'); + assertSetResult(body, slice, what); + out.destroyed.push(...((body.destroyed as string[] | undefined) ?? [])); + } + return out; +} + export async function getMailboxes(accountId?: string): Promise { return (await getMailboxesWithState(accountId)).list; } @@ -64,7 +113,7 @@ export async function getMailboxesWithState( const res = await jmapClient.request( [['Mailbox/get', { accountId }, '0']], ); - const body = res.methodResponses[0][1]; + const body = requireMethodResult(res, '0', 'Mailbox/get'); const list = ((body.list as Mailbox[]) ?? []).map((m) => tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared), ); @@ -112,14 +161,19 @@ export async function getMailboxesByIds( if (ids.length === 0) return { list: [], state: '' }; const accountId = accountIdOverride ?? jmapClient.accountId; const isShared = accountId !== jmapClient.accountId; - const res = await jmapClient.request( - [['Mailbox/get', { accountId, ids }, '0']], - ); - const body = res.methodResponses[0][1]; - const list = ((body.list as Mailbox[]) ?? []).map((m) => - tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared), - ); - return { list, state: body.state as string }; + const list: Mailbox[] = []; + let state = ''; + for (const slice of batched(ids, maxInGet())) { + const res = await jmapClient.request( + [['Mailbox/get', { accountId, ids: slice }, '0']], + ); + const body = requireMethodResult(res, '0', 'Mailbox/get'); + for (const m of (body.list as Mailbox[]) ?? []) { + list.push(tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared)); + } + state = body.state as string; + } + return { list, state }; } export interface MailboxChangesResult { @@ -154,23 +208,20 @@ export async function getMailboxChanges( } export async function createMailbox( - data: { name: string; parentId?: string | null }, + data: { name: string; parentId?: string | null; role?: string | null }, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const cid = 'new-mailbox'; + const create: Record = { + name: data.name, + parentId: data.parentId ?? null, + }; + if (data.role !== undefined) create.role = data.role; const res = await jmapClient.request([ - ['Mailbox/set', { - accountId, - create: { - [cid]: { - name: data.name, - parentId: data.parentId ?? null, - }, - }, - }, '0'], + ['Mailbox/set', { accountId, create: { [cid]: create } }, '0'], ]); - const result = res.methodResponses[0][1]; + const result = requireMethodResult(res, '0', 'Mailbox/set'); if (result.created?.[cid]?.id) return result.created[cid].id as string; const failure = result.notCreated?.[cid] as | { type?: string; description?: string; properties?: string[] } @@ -184,14 +235,15 @@ export async function createMailbox( export async function updateMailbox( id: string, - changes: { name?: string; parentId?: string | null }, + changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Mailbox/set', { accountId, update: { [id]: changes } }, '0'], ]); - const failure = res.methodResponses[0][1].notUpdated?.[id] as + const body = requireMethodResult(res, '0', 'Mailbox/set'); + const failure = body.notUpdated?.[id] as | { type?: string; description?: string } | undefined; if (failure) { @@ -201,12 +253,17 @@ export async function updateMailbox( } } -export async function deleteMailbox(id: string, accountIdOverride?: string): Promise { +export async function deleteMailbox( + id: string, + accountIdOverride?: string, + opts?: { onDestroyRemoveEmails?: boolean }, +): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; - const res = await jmapClient.request([ - ['Mailbox/set', { accountId, destroy: [id] }, '0'], - ]); - const failure = res.methodResponses[0][1].notDestroyed?.[id] as + const args: Record = { accountId, destroy: [id] }; + if (opts?.onDestroyRemoveEmails) args.onDestroyRemoveEmails = true; + const res = await jmapClient.request([['Mailbox/set', args, '0']]); + const body = requireMethodResult(res, '0', 'Mailbox/set'); + const failure = body.notDestroyed?.[id] as | { type?: string; description?: string } | undefined; if (failure) { @@ -216,49 +273,104 @@ export async function deleteMailbox(id: string, accountIdOverride?: string): Pro } } +/** + * Destroy every message in a folder (Empty Trash / Empty Junk), in batches of + * at most `maxObjectsInSet`. Never gates on `Email/query.total`: it is only + * guaranteed with `calculateTotal`, and Stalwart omits it otherwise, which + * used to stop after the first batch (#711). Returns the number destroyed. + */ +export async function emptyMailbox(mailboxId: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const batchSize = Math.min(500, maxInSet()); + let totalDestroyed = 0; + for (;;) { + const res = await jmapClient.request([ + ['Email/query', { accountId, filter: { inMailbox: mailboxId }, limit: batchSize }, '0'], + ['Email/set', { accountId, '#destroy': { resultOf: '0', name: 'Email/query', path: '/ids' } }, '1'], + ]); + const query = requireMethodResult(res, '0', 'Email/query'); + const set = requireMethodResult(res, '1', 'Email/set'); + const found: string[] = (query.ids as string[]) ?? []; + const destroyed = ((set.destroyed as string[] | undefined) ?? []).length; + totalDestroyed += destroyed; + // Nothing left, or the server refused everything in this batch (missing + // permission, immutable mail) - stop instead of looping on the same ids. + if (found.length === 0 || destroyed === 0) break; + if (found.length < batchSize) break; + } + return totalDestroyed; +} + +/** Set `$seen` on every unread message in a folder. Returns the count. */ +export async function markMailboxAsRead(mailboxId: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const pageSize = Math.min(500, maxInSet()); + let total = 0; + for (;;) { + const res = await jmapClient.request([ + ['Email/query', { + accountId, + filter: { operator: 'AND', conditions: [{ inMailbox: mailboxId }, { notKeyword: '$seen' }] }, + limit: pageSize, + }, '0'], + ]); + const ids = (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? []; + if (ids.length === 0) break; + const update = Object.fromEntries(ids.map((id) => [id, { [keywordPointer('$seen')]: true }])); + const setRes = await jmapClient.request([['Email/set', { accountId, update }, '0']]); + const body = requireMethodResult(setRes, '0', 'Email/set'); + const marked = Object.keys(body.updated ?? {}).length; + total += marked; + if (marked === 0 || ids.length < pageSize) break; + } + return total; +} + function buildMailboxQueryFilter( - mailboxId: string, + mailboxId: string | undefined, userFilter: Record | undefined, ): Record { - const inMailbox = { inMailbox: mailboxId }; + // An undefined mailbox means "search every folder" (#788). + const inMailbox = mailboxId ? { inMailbox: mailboxId } : {}; // JMAP filters are either a FilterCondition or a FilterOperator (operator + // conditions) — never both. Spreading a FilterOperator next to `inMailbox` // produces a hybrid object that servers reduce to the FilterCondition, // silently dropping the operator's conditions (e.g. the "unread" toggle). if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox; if ('operator' in userFilter) { - return { operator: 'AND', conditions: [inMailbox, userFilter] }; + return mailboxId ? { operator: 'AND', conditions: [inMailbox, userFilter] } : userFilter; } return { ...inMailbox, ...userFilter }; } export async function queryEmails( - mailboxId: string, + mailboxId: string | undefined, options?: { position?: number; limit?: number; - sort?: Array<{ property: string; isAscending: boolean }>; + sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>; filter?: Record; /** Owning JMAP account when the mailbox belongs to a shared account. */ accountId?: string; + collapseThreads?: boolean; }, ): Promise<{ ids: string[]; total: number; queryState?: string }> { const accountId = options?.accountId ?? jmapClient.accountId; const filter = buildMailboxQueryFilter(mailboxId, options?.filter); - const res = await jmapClient.request([ - ['Email/query', { - accountId, - filter, - sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }], - position: options?.position ?? 0, - limit: options?.limit ?? 50, - calculateTotal: true, - }, '0'], - ]); - const body = res.methodResponses[0][1]; + const args: Record = { + accountId, + filter, + sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }], + position: options?.position ?? 0, + limit: options?.limit ?? 50, + calculateTotal: true, + }; + if (options?.collapseThreads) args.collapseThreads = true; + const res = await jmapClient.request([['Email/query', args, '0']]); + const body = requireMethodResult(res, '0', 'Email/query'); return { - ids: body.ids, - total: body.total, + ids: (body.ids as string[]) ?? [], + total: (body.total as number) ?? 0, queryState: body.queryState as string | undefined, }; } @@ -275,10 +387,10 @@ export interface EmailQueryChangesResult { // query. Returns null when the server replies with `cannotCalculateChanges` // (or any other error) — caller should fall back to a fresh Email/query. export async function getEmailQueryChanges( - mailboxId: string, + mailboxId: string | undefined, sinceQueryState: string, options?: { - sort?: Array<{ property: string; isAscending: boolean }>; + sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>; filter?: Record; upToId?: string; maxChanges?: number; @@ -311,36 +423,38 @@ export async function getEmailQueryChanges( } export async function getEmails(ids: string[], accountIdOverride?: string): Promise { - const accountId = accountIdOverride ?? jmapClient.accountId; - const res = await jmapClient.request([ - ['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'], - ]); - return res.methodResponses[0][1].list; + return (await getEmailsWithState(ids, accountIdOverride)).list; } // Returns the Email/get response with the JMAP `state` token. Used by the // store so we can later issue Email/changes(sinceState=…) for incremental -// updates instead of re-fetching the full list. +// updates instead of re-fetching the full list. Splits the id list to the +// server's maxObjectsInGet. export async function getEmailsWithState( ids: string[], accountIdOverride?: string, ): Promise<{ list: Email[]; state: string }> { + const accountId = accountIdOverride ?? jmapClient.accountId; if (ids.length === 0) { // Email/get with an empty id list still returns a state token; useful for // priming the store after an empty mailbox query. - const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'], ]); - const body = res.methodResponses[0][1]; + const body = requireMethodResult(res, '0', 'Email/get'); return { list: [], state: body.state as string }; } - const accountId = accountIdOverride ?? jmapClient.accountId; - const res = await jmapClient.request([ - ['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'], - ]); - const body = res.methodResponses[0][1]; - return { list: body.list as Email[], state: body.state as string }; + const list: Email[] = []; + let state = ''; + for (const slice of batched(ids, maxInGet())) { + const res = await jmapClient.request([ + ['Email/get', { accountId, ids: slice, properties: EMAIL_LIST_PROPERTIES }, '0'], + ]); + const body = requireMethodResult(res, '0', 'Email/get'); + list.push(...((body.list as Email[]) ?? [])); + state = body.state as string; + } + return { list, state }; } export interface EmailChangesResult { @@ -375,44 +489,40 @@ export async function getEmailChanges( }; } +const FULL_BODY_ARGS = { + properties: EMAIL_FULL_PROPERTIES, + fetchHTMLBodyValues: true, + fetchTextBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: 512000, +}; + export async function getFullEmail(id: string, accountIdOverride?: string): Promise { // `accountIdOverride` lets the unified inbox open a message that lives under // a group/shared account in the same session instead of the user's own. const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ - ['Email/get', { - accountId, - ids: [id], - properties: EMAIL_FULL_PROPERTIES, - fetchHTMLBodyValues: true, - fetchTextBodyValues: true, - fetchAllBodyValues: true, - maxBodyValueBytes: 512000, - }, '0'], + ['Email/get', { accountId, ids: [id], ...FULL_BODY_ARGS }, '0'], ]); - const email = res.methodResponses[0][1].list[0]; + const body = requireMethodResult(res, '0', 'Email/get'); + const email = (body.list as Email[] | undefined)?.[0]; if (!email) throw new Error(`Email ${id} not found`); return email; } -// Batch variant for offline sync. JMAP servers cap how many objects can be -// returned in a single Email/get; the caller should chunk to that ceiling -// (the client exposes maxObjectsInGet via getMaxObjectsInGet()). +// Batch variant for offline sync and the thread view. Splits to the server's +// maxObjectsInGet ceiling itself. export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise { if (ids.length === 0) return []; const accountId = accountIdOverride ?? jmapClient.accountId; - const res = await jmapClient.request([ - ['Email/get', { - accountId, - ids, - properties: EMAIL_FULL_PROPERTIES, - fetchHTMLBodyValues: true, - fetchTextBodyValues: true, - fetchAllBodyValues: true, - maxBodyValueBytes: 512000, - }, '0'], - ]); - return res.methodResponses[0][1].list as Email[]; + const out: Email[] = []; + for (const slice of batched(ids, maxInGet())) { + const res = await jmapClient.request([ + ['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'], + ]); + out.push(...((requireMethodResult(res, '0', 'Email/get').list as Email[]) ?? [])); + } + return out; } /** @@ -435,7 +545,7 @@ export async function importEmailBlob( }, }, '0'], ]); - const result = res.methodResponses[0][1]; + const result = requireMethodResult(res, '0', 'Email/import'); const notCreated = result?.notCreated?.['import-0']; if (notCreated) { throw new Error(notCreated.description || notCreated.type || 'Failed to import email'); @@ -450,7 +560,42 @@ export async function getThread(threadId: string, accountIdOverride?: string): P const res = await jmapClient.request( [['Thread/get', { accountId, ids: [threadId] }, '0']], ); - return res.methodResponses[0][1].list[0]; + const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0]; + if (!thread) throw new Error(`Thread ${threadId} not found`); + return thread; +} + +/** Thread/get for many ids at once (chunked). Missing threads are skipped. */ +export async function getThreads(threadIds: string[], accountIdOverride?: string): Promise { + if (threadIds.length === 0) return []; + const accountId = accountIdOverride ?? jmapClient.accountId; + const out: Thread[] = []; + for (const slice of batched(Array.from(new Set(threadIds)), maxInGet())) { + const res = await jmapClient.request([['Thread/get', { accountId, ids: slice }, '0']]); + out.push(...((requireMethodResult(res, '0', 'Thread/get').list as Thread[]) ?? [])); + } + return out; +} + +/** + * Every message of a thread with full bodies, oldest first, via a + * back-referenced Thread/get → Email/get. Powers the conversation view. + */ +export async function getThreadEmails(threadId: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const res = await jmapClient.request([ + ['Thread/get', { accountId, ids: [threadId] }, '0'], + ['Email/get', { + accountId, + '#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' }, + ...FULL_BODY_ARGS, + }, '1'], + ]); + const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0]; + const emails = (requireMethodResult(res, '1', 'Email/get').list as Email[]) ?? []; + if (!thread) return emails; + const order = new Map(thread.emailIds.map((id, i) => [id, i])); + return emails.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); } export async function setEmailKeywords( @@ -459,9 +604,24 @@ export async function setEmailKeywords( accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; - await jmapClient.request([ - ['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'], - ]); + await emailSetBatched(accountId, { update: { [emailId]: { keywords } } }); +} + +/** Patch individual keywords (`{ $seen: true, $junk: null }`) on many messages. */ +export async function patchKeywordsForEmails( + ids: string[], + patch: Record, + accountIdOverride?: string, +): Promise { + if (ids.length === 0) return; + const accountId = accountIdOverride ?? jmapClient.accountId; + const pointerPatch: Record = {}; + for (const [keyword, value] of Object.entries(patch)) { + pointerPatch[keywordPointer(keyword)] = value === false ? null : value; + } + const update: Record> = {}; + for (const id of ids) update[id] = { ...pointerPatch }; + await emailSetBatched(accountId, { update }); } export async function moveEmail( @@ -470,21 +630,11 @@ export async function moveEmail( toMailboxId: string, accountIdOverride?: string, ): Promise { - const accountId = accountIdOverride ?? jmapClient.accountId; - await jmapClient.request([ - ['Email/set', { - accountId, - update: { - [emailId]: { - [`mailboxIds/${fromMailboxId}`]: null, - [`mailboxIds/${toMailboxId}`]: true, - }, - }, - }, '0'], - ]); + await moveEmails([emailId], fromMailboxId, toMailboxId, accountIdOverride); } -// Move several emails from one mailbox to another in a single Email/set. +// Move several emails from one mailbox to another. Pointer keys are JSON +// Pointer escaped so a mailbox id containing `/` or `~` cannot break the patch. export async function moveEmails( ids: string[], fromMailboxId: string, @@ -496,11 +646,55 @@ export async function moveEmails( const update: Record> = {}; for (const id of ids) { update[id] = { - [`mailboxIds/${fromMailboxId}`]: null, - [`mailboxIds/${toMailboxId}`]: true, + [mailboxPointer(fromMailboxId)]: null, + [mailboxPointer(toMailboxId)]: true, }; } - await jmapClient.request([['Email/set', { accountId, update }, '0']]); + await emailSetBatched(accountId, { update }); +} + +/** + * File messages into Junk and flip `$junk`/`$notjunk` so the server's + * classifier and other clients learn (#850). Optionally also marks them read + * (the "trash-and-read" delete action). + */ +export async function markAsSpam( + ids: string[], + junkMailboxId: string, + accountIdOverride?: string, + opts?: { markRead?: boolean }, +): Promise { + if (ids.length === 0) return; + const accountId = accountIdOverride ?? jmapClient.accountId; + const update: Record> = {}; + for (const id of ids) { + update[id] = { + mailboxIds: { [junkMailboxId]: true }, + [keywordPointer('$junk')]: true, + [keywordPointer('$notjunk')]: null, + ...(opts?.markRead ? { [keywordPointer('$seen')]: true } : {}), + }; + } + await emailSetBatched(accountId, { update }); +} + +/** Inverse of {@link markAsSpam}: restore into `targetMailboxId` with `$notjunk`. */ +export async function undoSpam( + ids: string[], + targetMailboxId: string, + accountIdOverride?: string, +): Promise { + if (ids.length === 0) return; + const accountId = accountIdOverride ?? jmapClient.accountId; + const update: Record> = {}; + for (const id of ids) { + update[id] = { + mailboxIds: { [targetMailboxId]: true }, + [keywordPointer('$junk')]: null, + [keywordPointer('$notjunk')]: true, + }; + } + await emailSetBatched(accountId, { update }); } // Batch delete: destroy outright when already in trash, otherwise move to trash. @@ -512,14 +706,13 @@ export async function deleteEmails( ): Promise { if (ids.length === 0) return; if (currentMailboxId === trashMailboxId) { - const accountId = accountIdOverride ?? jmapClient.accountId; - await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]); + await destroyEmails(ids, accountIdOverride); } else { await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride); } } -// Apply keyword maps to several emails in one round-trip. +// Apply keyword maps to several emails in one round-trip (chunked). export async function setKeywordsForEmails( updates: Array<{ id: string; keywords: Record }>, accountIdOverride?: string, @@ -528,7 +721,7 @@ export async function setKeywordsForEmails( const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record }> = {}; for (const u of updates) update[u.id] = { keywords: u.keywords }; - await jmapClient.request([['Email/set', { accountId, update }, '0']]); + await emailSetBatched(accountId, { update }); } // Restore each email's mailboxIds to the snapshot supplied. Used by undo to @@ -548,9 +741,7 @@ export async function restoreEmailMailboxes( } update[item.id] = { mailboxIds: onlyTrue }; } - await jmapClient.request([ - ['Email/set', { accountId, update }, '0'], - ]); + await emailSetBatched(accountId, { update }); } // Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole @@ -566,11 +757,22 @@ export async function setEmailMailboxes( } // Permanently destroy emails (no move-to-trash). Idempotent: destroying an -// already-gone id is a no-op on replay. +// already-gone id is a no-op on replay (a `notFound` is tolerated). export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise { if (ids.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; - await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]); + for (const slice of batched(ids, maxInSet())) { + const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]); + const body = requireMethodResult(res, '0', 'Email/set'); + const notDestroyed = (body.notDestroyed ?? {}) as Record; + const real = Object.entries(notDestroyed).filter(([, err]) => err?.type !== 'notFound'); + if (real.length > 0) { + const [id, err] = real[0]; + throw new Error( + `Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, + ); + } + } } // Archive one or more emails into the archive mailbox, optionally auto-sorting @@ -590,9 +792,7 @@ export async function archiveEmails( const updates = Object.fromEntries( emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]), ); - await jmapClient.request([ - ['Email/set', { accountId, update: updates }, '0'], - ]); + await emailSetBatched(accountId, { update: updates }); return; } @@ -653,41 +853,64 @@ export async function archiveEmails( updates[emailId] = { mailboxIds: { [destId]: true } }; } - const methodCalls: JMAPMethodCall[] = []; + // Creation ids are scoped to the request that introduced them (RFC 8620 + // §3.3), so "#" only resolves in the request carrying the Mailbox/set: + // the folders are created alongside the first batch of messages, and the + // ids they were assigned are substituted into every later batch. + const updateBatches = batched(Object.entries(updates), maxInSet()); const hasCreates = Object.keys(createEntries).length > 0; - if (hasCreates) { - methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']); - } - methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]); + let createdIdFor: Record = {}; - const response = await jmapClient.request(methodCalls); + for (let i = 0; i < updateBatches.length; i++) { + const batch: Array<[string, { mailboxIds: Record }]> = i === 0 + ? updateBatches[i] + : updateBatches[i].map(([emailId, patch]) => { + const [destId] = Object.keys(patch.mailboxIds); + const resolved = createdIdFor[destId]; + return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record } : patch]; + }); - if (hasCreates) { - const mailboxResult = response.methodResponses?.[0]?.[1]; - const notCreated = mailboxResult?.notCreated as - | Record - | undefined; - const failures = notCreated ? Object.entries(notCreated) : []; - if (failures.length > 0) { - const [cid, err] = failures[0]; - const parts = [err.type || 'unknown']; - if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); - if (err.description) parts.push(err.description); - throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); + const methodCalls: JMAPMethodCall[] = []; + const withCreates = hasCreates && i === 0; + if (withCreates) { + methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']); } - } + methodCalls.push(['Email/set', { accountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]); - const emailIdx = hasCreates ? 1 : 0; - const emailResult = response.methodResponses?.[emailIdx]?.[1]; - const notUpdated = emailResult?.notUpdated as - | Record - | undefined; - const emailFailures = notUpdated ? Object.entries(notUpdated) : []; - if (emailFailures.length > 0) { - const [id, err] = emailFailures[0]; - throw new Error( - `Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, - ); + const response = await jmapClient.request(methodCalls); + + if (withCreates) { + const mailboxResult = requireMethodResult(response, '0', 'Mailbox/set'); + const notCreated = mailboxResult?.notCreated as + | Record + | undefined; + const failures = notCreated ? Object.entries(notCreated) : []; + if (failures.length > 0) { + const [cid, err] = failures[0]; + const parts = [err.type || 'unknown']; + if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); + if (err.description) parts.push(err.description); + throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); + } + const created = (mailboxResult?.created || {}) as Record; + createdIdFor = Object.fromEntries( + Object.entries(created) + .filter(([, mailbox]) => !!mailbox?.id) + .map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]), + ); + } + + const emailResult = requireMethodResult(response, String(withCreates ? 1 : 0), 'Email/set'); + const notUpdated = emailResult?.notUpdated as + | Record + | undefined; + const emailFailures = notUpdated ? Object.entries(notUpdated) : []; + if (emailFailures.length > 0) { + const [id, err] = emailFailures[0]; + throw new Error( + `Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, + ); + } } } @@ -697,14 +920,7 @@ export async function deleteEmail( currentMailboxId: string, accountIdOverride?: string, ): Promise { - if (currentMailboxId === trashMailboxId) { - const accountId = accountIdOverride ?? jmapClient.accountId; - await jmapClient.request([ - ['Email/set', { accountId, destroy: [emailId] }, '0'], - ]); - } else { - await moveEmail(emailId, currentMailboxId, trashMailboxId, accountIdOverride); - } + await deleteEmails([emailId], trashMailboxId, currentMailboxId, accountIdOverride); } export async function searchEmails( @@ -725,7 +941,7 @@ export async function searchEmails( limit, }, '0'], ]); - return res.methodResponses[0][1].ids; + return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? []; } // Cross-mailbox query with an arbitrary JMAP filter (used by contact activity @@ -733,8 +949,9 @@ export async function searchEmails( export async function queryEmailsByFilter( filter: Record, limit = 5, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/query', { accountId, @@ -743,7 +960,7 @@ export async function queryEmailsByFilter( limit, }, '0'], ]); - return res.methodResponses[0][1].ids; + return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? []; } export interface OutgoingAttachment { @@ -755,6 +972,25 @@ export interface OutgoingAttachment { cid?: string; } +export interface OutgoingEmail { + from: EmailAddress[]; + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + replyTo?: EmailAddress[]; + subject: string; + htmlBody?: string; + textBody?: string; + attachments?: OutgoingAttachment[]; + // RFC 5322 threading: bare msg-ids (angle brackets are stripped). A legacy + // whitespace-separated string is accepted and split. + inReplyTo?: string[] | string; + references?: string[] | string; + // Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent. + messageId?: string; + requestReadReceipt?: boolean; +} + export interface SendEmailResult { /** True when the message was deferred (HOLDFOR / FUTURERELEASE). */ scheduled: boolean; @@ -762,37 +998,55 @@ export interface SendEmailResult { sendAt?: string; emailId?: string; emailSubmissionId?: string; + /** Post-send filing/cleanup problem (message did go out). */ + filingWarning?: string; } -export async function sendEmail( - email: { - from: EmailAddress[]; - to: EmailAddress[]; - cc?: EmailAddress[]; - bcc?: EmailAddress[]; - subject: string; - htmlBody?: string; - textBody?: string; - attachments?: OutgoingAttachment[]; - inReplyTo?: string; - references?: string; - }, - identityId: string, - sentMailboxId: string, - // When > 0 the message is held for this many seconds before delivery via the - // SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later" - // scheduling and the global send-delay (undo-send) window. - holdForSeconds?: number, -): Promise { - const accountId = jmapClient.accountId; +function toMessageIdList(value: string[] | string | undefined): string[] | undefined { + if (!value) return undefined; + const raw = Array.isArray(value) ? value : value.split(/[\s,]+/); + const ids = raw.map(stripMessageIdBrackets).filter(Boolean); + return ids.length ? ids : undefined; +} + +function cleanAddresses(list: EmailAddress[] | undefined): EmailAddress[] | undefined { + if (!list) return undefined; + const out = list + .map((a) => { + const email = a.email?.trim(); + if (!email) return null; + const name = sanitizeDisplayName(a.name); + return name ? { name, email } : { email }; + }) + .filter((a): a is EmailAddress => a !== null); + // RFC 5322 §3.6.3: an empty address-list header is malformed; omit instead. + return out.length ? out : undefined; +} + +function dedupeAddresses(list: EmailAddress[] | undefined): EmailAddress[] | undefined { + if (!list) return undefined; + const seen = new Set(); + const out: EmailAddress[] = []; + for (const a of list) { + const key = a.email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(a); + } + return out.length ? out : undefined; +} + +/** The JMAP `Email/set` create object for an outgoing message or draft. */ +function buildEmailCreate(email: OutgoingEmail): Record { + const from = cleanAddresses(email.from) ?? email.from; const emailCreate: Record = { - from: email.from, - to: email.to, - cc: email.cc, - bcc: email.bcc, + from, + to: cleanAddresses(email.to) ?? [], + cc: cleanAddresses(email.cc), + bcc: dedupeAddresses(cleanAddresses(email.bcc)), + replyTo: cleanAddresses(email.replyTo), subject: email.subject, - mailboxIds: { [sentMailboxId]: true }, - keywords: { $seen: true }, + messageId: [email.messageId ? stripMessageIdBrackets(email.messageId) : generateMessageId(from[0]?.email ?? 'localhost')], }; const bodyValues: Record = {}; @@ -820,9 +1074,55 @@ export async function sendEmail( }); } - if (email.inReplyTo) { - emailCreate['header:In-Reply-To:asText'] = email.inReplyTo; - emailCreate['header:References:asText'] = email.references ?? email.inReplyTo; + // Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids. + const inReplyTo = toMessageIdList(email.inReplyTo); + const references = toMessageIdList(email.references); + if (inReplyTo) emailCreate.inReplyTo = inReplyTo; + if (references) emailCreate.references = references; + else if (inReplyTo) emailCreate.references = inReplyTo; + + if (email.requestReadReceipt && from[0]?.email) { + // RFC 8098: ask the recipient's client to return a Message Disposition + // Notification to our address. + emailCreate['header:Disposition-Notification-To:asText'] = from[0].email; + } + + return emailCreate; +} + +export interface SendEmailOptions { + /** + * Drafts folder id. When given, the message is created there with `$draft` + * and moved to Sent by `onSuccessUpdateEmail` on the submission, so the + * SMTP send happens before it lands in Sent (#188) and a failed submission + * leaves it in Drafts instead of faking a sent copy. + */ + draftsMailboxId?: string; + /** Previous draft version to destroy once the submission succeeded (#849). */ + draftId?: string; + /** Submitting account (shared/group account); defaults to the primary. */ + accountId?: string; +} + +export async function sendEmail( + email: OutgoingEmail, + identityId: string, + sentMailboxId: string, + // When > 0 the message is held for this many seconds before delivery via the + // SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later" + // scheduling and the global send-delay (undo-send) window. + holdForSeconds?: number, + opts?: SendEmailOptions, +): Promise { + const accountId = opts?.accountId ?? jmapClient.accountId; + const emailCreate = buildEmailCreate(email); + const viaDrafts = !!opts?.draftsMailboxId; + if (viaDrafts) { + emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true }; + emailCreate.keywords = { $seen: true, $draft: true }; + } else { + emailCreate.mailboxIds = { [sentMailboxId]: true }; + emailCreate.keywords = { $seen: true }; } const submissionCreate: Record = { emailId: '#draft', identityId }; @@ -843,28 +1143,49 @@ export async function sendEmail( }; } + const submissionArgs: Record = { + accountId, + create: { 'sub-1': submissionCreate }, + }; + if (viaDrafts) { + submissionArgs.onSuccessUpdateEmail = { + '#sub-1': { + mailboxIds: { [sentMailboxId]: true }, + [keywordPointer('$draft')]: null, + }, + }; + } + const res = await jmapClient.request( [ ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], - ['EmailSubmission/set', { - accountId, - create: { 'sub-1': submissionCreate }, - }, '1'], + ['EmailSubmission/set', submissionArgs, '1'], ], - [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], + SUBMISSION_USING, ); let emailId: string | undefined; let emailSubmissionId: string | undefined; let sendAt: string | undefined; + let filingWarning: string | undefined; for (const [methodName, result] of res.methodResponses) { - if (methodName.endsWith('/error')) { + if (methodName === 'error' || methodName.endsWith('/error')) { throw new Error((result as { description?: string }).description ?? 'Send failed'); } if (methodName === 'Email/set') { - const notCreated = (result as { notCreated?: Record }).notCreated?.draft; - if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message'); + const notCreated = (result as { notCreated?: Record }).notCreated?.draft; + if (notCreated) { + const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : ''; + throw new Error(`${notCreated.description ?? notCreated.type ?? 'Failed to create message'}${props}`); + } emailId = (result as { created?: Record }).created?.draft?.id; + // Filing problems from onSuccessUpdateEmail come back on the implicit + // Email/set; the message already left, so warn rather than fail. + const notUpdated = (result as { notUpdated?: Record }).notUpdated; + if (notUpdated && Object.keys(notUpdated).length) { + const first = Object.values(notUpdated)[0]; + filingWarning = filingWarning ?? (first?.description || first?.type || 'post-send filing failed'); + } } if (methodName === 'EmailSubmission/set') { const notCreated = (result as { notCreated?: Record }).notCreated?.['sub-1']; @@ -875,14 +1196,61 @@ export async function sendEmail( } } + // The message is out (or scheduled) - now it is safe to drop the old draft. + // A failure here leaves an orphan in Drafts, which is a filing warning + // rather than a failed send (#849). + if (opts?.draftId && emailSubmissionId) { + try { + await destroyEmails([opts.draftId], accountId); + } catch (err) { + filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`; + } + } + return { scheduled: !!(holdForSeconds && holdForSeconds > 0), sendAt, emailId, emailSubmissionId, + filingWarning, }; } +/** + * Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId` + * is given it is destroyed only AFTER the replacement was created (#849): a + * combined create+destroy would delete the last good copy when the create + * fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the + * new draft id. + */ +export async function createDraft( + email: OutgoingEmail, + draftsMailboxId: string, + previousDraftId?: string, + accountIdOverride?: string, +): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const emailCreate = buildEmailCreate(email); + emailCreate.mailboxIds = { [draftsMailboxId]: true }; + emailCreate.keywords = { $seen: true, $draft: true }; + const res = await jmapClient.request([ + ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], + ]); + const body = requireMethodResult(res, '0', 'Email/set'); + const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined; + if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft'); + const id = body.created?.draft?.id as string | undefined; + if (!id) throw new Error('Draft save returned no id'); + if (previousDraftId && previousDraftId !== id) { + try { + await destroyEmails([previousDraftId], accountId); + } catch (err) { + console.warn('[email] failed to destroy previous draft version:', err); + } + } + return id; +} + export interface ScheduledEmail { emailSubmissionId: string; emailId: string; @@ -909,45 +1277,52 @@ export async function listScheduledEmails(): Promise { [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], ); const [queryName, queryBody] = queryRes.methodResponses[0]; - if (queryName.endsWith('/error')) return []; + if (queryName === 'error' || queryName.endsWith('/error')) return []; const ids = (queryBody.ids as string[]) ?? []; if (ids.length === 0) return []; - const subRes = await jmapClient.request( - [['EmailSubmission/get', { - accountId, - ids, - properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'], - }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], - ); - const submissions = ((subRes.methodResponses[0][1].list as Array<{ + const submissions: Array<{ id: string; emailId: string; identityId: string; threadId?: string; sendAt?: string; undoStatus?: string; - }>) ?? []).filter((s) => { + }> = []; + for (const slice of batched(ids, maxInGet())) { + const subRes = await jmapClient.request( + [['EmailSubmission/get', { + accountId, + ids: slice, + properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'], + }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + submissions.push(...((requireMethodResult(subRes, '0', 'EmailSubmission/get').list as typeof submissions) ?? [])); + } + const pending = submissions.filter((s) => { if (s.undoStatus !== 'pending' || !s.sendAt) return false; const t = new Date(s.sendAt).getTime(); return Number.isFinite(t) && t > now; }); - if (submissions.length === 0) return []; + if (pending.length === 0) return []; - const emailIds = Array.from(new Set(submissions.map((s) => s.emailId))); - const emailRes = await jmapClient.request([ - ['Email/get', { - accountId, - ids: emailIds, - properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'], - }, '0'], - ]); - const emailById = new Map( - ((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]), - ); + const emailIds = Array.from(new Set(pending.map((s) => s.emailId))); + const emailById = new Map(); + for (const slice of batched(emailIds, maxInGet())) { + const emailRes = await jmapClient.request([ + ['Email/get', { + accountId, + ids: slice, + properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'], + }, '0'], + ]); + for (const e of (requireMethodResult(emailRes, '0', 'Email/get').list as Email[]) ?? []) { + emailById.set(e.id, e); + } + } - return submissions + return pending .map((s): ScheduledEmail | null => { const e = emailById.get(s.emailId); if (!s.sendAt) return null; @@ -979,10 +1354,51 @@ export async function cancelScheduledSend(emailSubmissionId: string): Promise { + const accountId = jmapClient.accountId; + const create: Record = { emailId: scheduled.emailId, identityId: scheduled.identityId }; + if (holdForSeconds > 0) { + const rcpt = (recipients ?? scheduled.to ?? []).map((r) => ({ email: r.email.trim() })).filter((r) => r.email); + create.envelope = { + mailFrom: { + email: scheduled.from?.[0]?.email, + parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) }, + }, + rcptTo: rcpt, + }; + } + const res = await jmapClient.request( + [['EmailSubmission/set', { + accountId, + update: { [scheduled.emailSubmissionId]: { undoStatus: 'canceled' } }, + create: { replacement: create }, + }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + ); + const body = requireMethodResult(res, '0', 'EmailSubmission/set'); + const notUpdated = body.notUpdated?.[scheduled.emailSubmissionId] as { description?: string; type?: string } | undefined; + if (notUpdated) throw new Error(notUpdated.description ?? notUpdated.type ?? 'Failed to cancel the previous schedule'); + const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined; + if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to reschedule'); + const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined; + return { emailSubmissionId: created?.id, sendAt: created?.sendAt }; +} diff --git a/src/api/identity.ts b/src/api/identity.ts index 65ada534..fb7f1f2a 100644 --- a/src/api/identity.ts +++ b/src/api/identity.ts @@ -1,28 +1,43 @@ import { jmapClient } from './jmap-client'; +import { assertSetResult, requireMethodResult } from './jmap-result'; import { CAPABILITIES } from './types'; import type { Identity } from './types'; +import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; -export async function getIdentities(): Promise { - const accountId = jmapClient.accountId; - const res = await jmapClient.request( - [['Identity/get', { accountId }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], - ); - return res.methodResponses[0][1].list; +const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION]; + +// A display name that carries an address or a line break produces a malformed +// `From:` on every message sent with the identity (#672); reduce it to the +// bare name before it reaches the server. +function sanitizeIdentityPatch(identity: Partial): Partial { + if (typeof identity.name !== 'string') return identity; + return { ...identity, name: sanitizeDisplayName(identity.name) }; +} + +export async function getIdentities(accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const res = await jmapClient.request([['Identity/get', { accountId }, '0']], USING); + const body = requireMethodResult<{ list?: Identity[] }>(res, '0', 'Identity/get'); + return body.list ?? []; } export async function createIdentity( identity: Partial, ): Promise { const accountId = jmapClient.accountId; + const cid = 'new-identity'; const res = await jmapClient.request( - [['Identity/set', { - accountId, - create: { 'new-identity': identity }, - }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + [['Identity/set', { accountId, create: { [cid]: sanitizeIdentityPatch(identity) } }, '0']], + USING, ); - return res.methodResponses[0][1].created['new-identity']; + const body = requireMethodResult(res, '0', 'Identity/set'); + assertSetResult(body, [cid], 'identity'); + const created = body.created?.[cid] as Partial | undefined; + if (!created?.id) throw new Error('Identity/set returned no id'); + // The create echo carries only server-set properties; re-read the full + // object so the caller gets name/email/signature back. + const all = await getIdentities(); + return all.find((i) => i.id === created.id) ?? ({ ...identity, ...created } as Identity); } export async function updateIdentity( @@ -30,16 +45,18 @@ export async function updateIdentity( changes: Partial, ): Promise { const accountId = jmapClient.accountId; - await jmapClient.request( - [['Identity/set', { accountId, update: { [id]: changes } }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + const res = await jmapClient.request( + [['Identity/set', { accountId, update: { [id]: sanitizeIdentityPatch(changes) } }, '0']], + USING, ); + assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity'); } export async function deleteIdentity(id: string): Promise { const accountId = jmapClient.accountId; - await jmapClient.request( + const res = await jmapClient.request( [['Identity/set', { accountId, destroy: [id] }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], + USING, ); + assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity'); } diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index b854b9af..6ae9080c 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -110,88 +110,9 @@ function fetchSessionDescribes(session: unknown): session is JMAPSession { return typeof s.apiUrl === 'string'; } -// ── Shared response helpers ────────────────────────────── -// Most RN api functions used to index `methodResponses[0][1]` blindly, so a -// method-level `['error', {...}]` reply produced a TypeError on reads and was -// silently treated as success on writes. Route every response through these. - -type MethodResponse = [string, Record, string]; - -export class JMAPMethodError extends Error { - type: string; - constructor(type: string, description?: string) { - super(description || type || 'JMAP method error'); - this.name = 'JMAPMethodError'; - this.type = type; - } -} - -/** - * Pull the response body for `callId` (or the first response when omitted) - * and throw a `JMAPMethodError` when the server answered with the error - * envelope. Also verifies the method name when `expectMethod` is given. - */ -export function requireMethodResult>( - res: JMAPResponseBody, - callId?: string, - expectMethod?: string, -): T { - const responses = (res.methodResponses ?? []) as MethodResponse[]; - const match = callId === undefined - ? responses[0] - : responses.find((r) => r[2] === callId); - if (!match) { - throw new JMAPMethodError('missingResponse', `Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`); - } - if (match[0] === 'error') { - const err = match[1] as { type?: string; description?: string }; - throw new JMAPMethodError(err.type ?? 'serverFail', err.description); - } - if (expectMethod && match[0] !== expectMethod) { - throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`); - } - return match[1] as T; -} - -/** - * Throw when a /set response reports the given ids (or any id when `ids` is - * omitted) under `notCreated` / `notUpdated` / `notDestroyed`. - */ -export function assertSetResult( - body: Record, - ids?: string[], - what = 'object', -): void { - const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const; - for (const bucket of buckets) { - const failures = body?.[bucket] as - | Record - | undefined; - if (!failures) continue; - const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id)); - if (entries.length === 0) continue; - const [id, err] = entries[0]; - const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy'; - const detail = [err?.type ?? 'unknown']; - if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`); - if (err?.description) detail.push(err.description); - const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : ''; - throw new JMAPMethodError( - err?.type ?? 'setError', - `Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`, - ); - } -} - -/** Split `items` into consecutive batches of at most `size` entries. */ -export function batched(items: T[], size: number): T[][] { - const step = Math.max(1, Math.floor(size)); - const result: T[][] = []; - for (let i = 0; i < items.length; i += step) { - result.push(items.slice(i, i + step)); - } - return result; -} +// Shared response helpers live in ./jmap-result (dependency-free) and are +// re-exported here for convenience. +export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result'; export class JMAPClient { private session: JMAPSession | null = null; diff --git a/src/api/jmap-result.ts b/src/api/jmap-result.ts new file mode 100644 index 00000000..d860b859 --- /dev/null +++ b/src/api/jmap-result.ts @@ -0,0 +1,91 @@ +// Shared JMAP response helpers. Kept dependency-free (no client import) so +// every `src/api/*.ts` module - and their tests, which mock `./jmap-client` +// wholesale - can use them. +// +// Most RN api functions used to index `methodResponses[0][1]` blindly, so a +// method-level `['error', {...}]` reply produced a TypeError on reads and was +// silently treated as success on writes (the outbox then dropped the op). +// Route every response through these instead. + +import type { JMAPResponseBody } from './types'; + +type MethodResponse = [string, Record, string]; + +export class JMAPMethodError extends Error { + type: string; + constructor(type: string, description?: string) { + super(description || type || 'JMAP method error'); + this.name = 'JMAPMethodError'; + this.type = type; + } +} + +/** + * Pull the response body for `callId` (or the first response when omitted) + * and throw a `JMAPMethodError` when the server answered with the error + * envelope. Also verifies the method name when `expectMethod` is given. + */ +export function requireMethodResult>( + res: JMAPResponseBody, + callId?: string, + expectMethod?: string, +): T { + const responses = (res?.methodResponses ?? []) as MethodResponse[]; + const match = callId === undefined + ? responses[0] + : responses.find((r) => r[2] === callId); + if (!match) { + throw new JMAPMethodError( + 'missingResponse', + `Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`, + ); + } + if (match[0] === 'error') { + const err = match[1] as { type?: string; description?: string }; + throw new JMAPMethodError(err.type ?? 'serverFail', err.description); + } + if (expectMethod && match[0] !== expectMethod) { + throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`); + } + return match[1] as T; +} + +/** + * Throw when a /set response reports the given ids (or any id when `ids` is + * omitted) under `notCreated` / `notUpdated` / `notDestroyed`. + */ +export function assertSetResult( + body: Record, + ids?: string[], + what = 'object', +): void { + const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const; + for (const bucket of buckets) { + const failures = body?.[bucket] as + | Record + | undefined; + if (!failures) continue; + const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id)); + if (entries.length === 0) continue; + const [id, err] = entries[0]; + const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy'; + const detail = [err?.type ?? 'unknown']; + if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`); + if (err?.description) detail.push(err.description); + const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : ''; + throw new JMAPMethodError( + err?.type ?? 'setError', + `Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`, + ); + } +} + +/** Split `items` into consecutive batches of at most `size` entries. */ +export function batched(items: T[], size: number): T[][] { + const step = Math.max(1, Math.floor(size)); + const result: T[][] = []; + for (let i = 0; i < items.length; i += step) { + result.push(items.slice(i, i + step)); + } + return result; +} diff --git a/src/api/patch-pointer.ts b/src/api/patch-pointer.ts new file mode 100644 index 00000000..49240107 --- /dev/null +++ b/src/api/patch-pointer.ts @@ -0,0 +1,35 @@ +/** + * Naming one member of an object in a JMAP PatchObject. Port of the webmail's + * lib/jmap/patch-pointer.ts. + * + * A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash in the + * member's own name starts a new path segment unless it is escaped. That bites + * keywords: a nested tag is stored as `$label:work/clients`, and the unescaped + * pointer `keywords/$label:work/clients` asks for the `clients` member of the + * `$label:work` keyword - not a boolean, so the patch is rejected or ignored + * and the tag never lands. + */ + +/** One JSON Pointer reference token: `~` -> `~0`, `/` -> `~1` (RFC 6901). */ +export function pointerToken(value: string): string { + return value.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** + * The member name a reference token stands for: the inverse of + * `pointerToken`. `~1` is read before `~0`, per RFC 6901 - the other order + * would turn the escaped `~1` of `~01` back into a slash. + */ +export function pointerTokenValue(token: string): string { + return token.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +/** The PatchObject key that sets or clears the keyword `keyword`. */ +export function keywordPointer(keyword: string): string { + return `keywords/${pointerToken(keyword)}`; +} + +/** The PatchObject key that adds or removes membership of `mailboxId`. */ +export function mailboxPointer(mailboxId: string): string { + return `mailboxIds/${pointerToken(mailboxId)}`; +} diff --git a/src/api/types.ts b/src/api/types.ts index 134bffa4..2a0ff413 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -78,6 +78,14 @@ export interface Email { subject?: string; preview?: string; hasAttachment: boolean; + // RFC 8621 §4.1.2.3 - bare msg-ids without angle brackets. Needed for + // reply threading (In-Reply-To / References) and read-receipt dedupe. + messageId?: string[] | null; + inReplyTo?: string[] | null; + references?: string[] | null; + // Raw headers (RFC 8621 §4.1.3) when fetched with `headers`; the viewer + // derives SPF/DKIM/DMARC chips, List-Unsubscribe, MDN requests from them. + headers?: Array<{ name: string; value: string }>; textBody?: BodyPart[]; htmlBody?: BodyPart[]; bodyValues?: Record; @@ -357,6 +365,12 @@ export interface ContactCard { prodId?: string; created?: string; updated?: string; + // Client-only metadata for cards that live in a shared / group account. + // Never sent to the server (api/contacts.ts strips them). `id` is + // namespaced as `:` for shared cards. + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface AddressBookRights { @@ -374,6 +388,15 @@ export interface AddressBook { isSubscribed?: boolean; sortOrder?: number; myRights?: AddressBookRights; + // RFC 9610 share map: principal id → rights. + shareWith?: Record | null; + // Client-only metadata for books that live in a shared / group account. + // `id` is namespaced as `:` for shared books so it + // cannot collide with the user's own "default" book. + originalId?: string; + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface AddressBookWithCount extends AddressBook { @@ -393,6 +416,8 @@ export interface Participant { roles?: Record; participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string; participationComment?: string; + // 'server' asks Stalwart to deliver the iTIP messages for this participant. + scheduleAgent?: 'server' | 'client' | 'none' | string; scheduleStatus?: string[]; expectReply?: boolean; description?: string; @@ -602,6 +627,17 @@ export interface PushSubscription { verificationCode?: string | null; expires?: string | null; types?: string[] | null; + emailPush?: Record | null; +} + +// draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter` +// against every newly delivered message and only pushes when it matches, so a +// client can keep spam (Junk) out of its push channel server-side. Keyed by +// account id on PushSubscription.emailPush. +export interface EmailPushConfig { + filter: Record | null; + properties: string[]; + urgency?: 'very-low' | 'low' | 'normal' | 'high'; } // ─── Capability URNs ──────────────────────────────────── @@ -618,4 +654,5 @@ export const CAPABILITIES = { FILES: 'urn:ietf:params:jmap:filenode', PRINCIPALS: 'urn:ietf:params:jmap:principals', PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner', + EMAIL_PUSH: 'urn:ietf:params:jmap:emailpush', } as const;