import { jmapClient } from './jmap-client'; import { assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError, requireMethodResult, ScheduleTooLateError, type RefusedRecipient, } from './jmap-result'; import { keywordPointer, mailboxPointer } from './patch-pointer'; import { deliveryStatusCall, destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy, refusedRecipients, submissionAccountIds, submissionCreateError, submissionDestroyCall, submissionQuotaError, SUBMISSION_ONLY_USING, SUBMISSION_SCAN_MAX_PAGES, } from './submissions'; import { CAPABILITIES } from './types'; import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; import { textFilter } from '../lib/search-utils'; import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; import { buildMdnMessage, type MdnOptions } from '../lib/mdn'; import { hasTruncatedDisplayedBody } from '../lib/email-body'; import { withInlineBodyParts } from '../lib/email-inline-parts'; import { stalwartStateWentBack } from './stalwart-state'; import { isTransientNetworkError } from '../lib/network-error'; export const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', ]; // 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', ]; // The Mailbox properties the folder list reads (RFC 8621 §2), the default // list on 0.16. Stalwart 1.0.0 adds JMAP Metadata's `metadata` and // `privateMetadata` to the default (the pre-release's handlers panicked on // them); the app reads neither, so an explicit list keeps them out. export const MAILBOX_PROPERTIES = [ 'id', 'name', 'parentId', 'role', 'sortOrder', 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads', 'myRights', 'isSubscribed', ]; 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 { return `${accountId}:${rawId}`; } /** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */ export function unprefixMailboxId(id: string, accountId?: string): string { if (!accountId) return id; const prefix = `${accountId}:`; return id.startsWith(prefix) ? id.slice(prefix.length) : id; } // Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw // ids; a shared account's are prefixed and remember their `originalId` so // server calls can still address them. function tagMailbox( mailbox: Mailbox, accountId: string, accountName: string | undefined, isShared: boolean, ): Mailbox { if (!isShared) { return { ...mailbox, accountId, accountName, isShared: false }; } return { ...mailbox, id: sharedMailboxId(accountId, mailbox.id), originalId: mailbox.id, parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId, accountId, accountName, isShared: true, }; } function maxInGet(): number { return typeof jmapClient.getMaxObjectsInGet === 'function' ? jmapClient.getMaxObjectsInGet() : 500; } function maxInSet(): number { return typeof jmapClient.getMaxObjectsInSet === 'function' ? jmapClient.getMaxObjectsInSet() : 500; } // A /changes or /queryChanges answer whose state went back below the one it // was asked from (see stalwartStateWentBack). The server then skipped every // change since, so the caller takes it like cannotCalculateChanges and reads // in full. Only Stalwart's states are read, on accounts that list its // extension. function stateWentBack(accountId: string, sinceState: string, newState: unknown): boolean { if (typeof jmapClient.hasAccountCapability !== 'function') return false; if (!jmapClient.hasAccountCapability('urn:stalwart:jmap', accountId)) return false; if (!stalwartStateWentBack(sinceState, newState)) return false; console.warn(`[email] ${accountId}: state went back from ${sinceState} to ${String(newState)}, reading in full`); return true; } /** * 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. Every slice is sent before * the first refusal is thrown: a refused message (Stalwart 1.0 refuses * keywords over 128 bytes and more than 100 keywords on one message) must not * leave the rest of a selection unchanged. */ 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(); let refusal: unknown = null; // Every batch's refusals, named on the error thrown: the rest was written. const failed: string[] = []; const check = (body: Record, ids: string[]) => { try { assertSetResult(body, ids, what); } catch (err) { refusal ??= err; failed.push(...(failedIdsOf(err) ?? [])); } }; 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'); check(body, slice.map(([id]) => id)); 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'); check(body, slice); out.destroyed.push(...((body.destroyed as string[] | undefined) ?? [])); } if (refusal) { if (refusal instanceof JMAPSetError) refusal.failedIds = failed; throw refusal; } return out; } export async function getMailboxes(accountId?: string): Promise { return (await getMailboxesWithState(accountId)).list; } // Variant that also returns the JMAP `state` token so callers can later issue // Mailbox/changes(sinceState=…) to fetch only what changed. export async function getMailboxesWithState( accountIdOverride?: string, ): Promise<{ list: Mailbox[]; state: string }> { const accountId = accountIdOverride ?? jmapClient.accountId; const isShared = accountId !== jmapClient.accountId; const res = await jmapClient.request( [['Mailbox/get', { accountId, properties: MAILBOX_PROPERTIES }, '0']], ); const body = requireMethodResult(res, '0', 'Mailbox/get'); const list = ((body.list as Mailbox[]) ?? []).map((m) => tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared), ); return { list, state: body.state as string }; } /** * Every folder of every shared/group account in this session, ready to merge * into the sidebar next to the user's own. One request carrying a Mailbox/get * per account, chunked to the server's maxCallsInRequest. An account that * errors (revoked access, server hiccup) is skipped rather than sinking the * whole list. */ export async function getSharedMailboxes(): Promise { const accounts = jmapClient.getSharedMailAccounts(); if (accounts.length === 0) return []; const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest()); const out: Mailbox[] = []; for (let i = 0; i < accounts.length; i += perRequest) { const chunk = accounts.slice(i, i + perRequest); const res = await jmapClient.request( chunk.map((acc, idx): JMAPMethodCall => [ 'Mailbox/get', { accountId: acc.id, properties: MAILBOX_PROPERTIES }, String(idx), ]), ); for (const [name, body, callId] of res.methodResponses) { if (name !== 'Mailbox/get') continue; const acc = chunk[Number(callId)]; if (!acc) continue; for (const m of (body.list as Mailbox[]) ?? []) { out.push(tagMailbox(m, acc.id, acc.name, true)); } } } return out; } export async function getMailboxesByIds( ids: string[], accountIdOverride?: string, ): Promise<{ list: Mailbox[]; state: string }> { if (ids.length === 0) return { list: [], state: '' }; const accountId = accountIdOverride ?? jmapClient.accountId; const isShared = accountId !== jmapClient.accountId; const list: Mailbox[] = []; let state = ''; for (const slice of batched(ids, maxInGet())) { const res = await jmapClient.request( [['Mailbox/get', { accountId, ids: slice, properties: MAILBOX_PROPERTIES }, '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 { oldState: string; newState: string; hasMoreChanges: boolean; created: string[]; updated: string[]; destroyed: string[]; } // Returns null when the server can't compute the diff (typically // `cannotCalculateChanges`, or a state that went back); callers should fall // back to a full Mailbox/get. export async function getMailboxChanges( sinceState: string, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['Mailbox/changes', { accountId, sinceState }, '0']], ); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; if (stateWentBack(accountId, sinceState, body.newState)) return null; return { oldState: body.oldState as string, newState: body.newState as string, hasMoreChanges: Boolean(body.hasMoreChanges), created: (body.created as string[]) ?? [], updated: (body.updated as string[]) ?? [], destroyed: (body.destroyed as string[]) ?? [], }; } export async function createMailbox( data: { name: string; parentId?: string | null; role?: string | null }, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const cid = 'new-mailbox'; // Subscribe explicitly: IMAP clients that list folders via LSUB // (Thunderbird) hide unsubscribed mailboxes, and the server default is // not guaranteed to be true. (#951) const create: Record = { name: data.name, parentId: data.parentId ?? null, isSubscribed: true, }; if (data.role !== undefined) create.role = data.role; const res = await jmapClient.request([ ['Mailbox/set', { accountId, create: { [cid]: create } }, '0'], ]); 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[] } | undefined; throw new Error( failure ? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}` : 'Mailbox create returned no id', ); } export async function updateMailbox( id: string, 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 body = requireMethodResult(res, '0', 'Mailbox/set'); const failure = body.notUpdated?.[id] as | { type?: string; description?: string } | undefined; if (failure) { throw new Error( `${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`, ); } } export async function deleteMailbox( id: string, accountIdOverride?: string, opts?: { onDestroyRemoveEmails?: boolean }, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; 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) { throw new Error( `${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`, ); } } /** * 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. Throws * the first refusal once the rest are marked: a refused message stays unread * (a shared folder without the right to set it, a message at Stalwart 1.0's * 100-keyword limit), and the folder would otherwise look done. */ export async function markMailboxAsRead(mailboxId: string, accountIdOverride?: string): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const pageSize = Math.min(500, maxInSet()); let total = 0; let refusal: unknown = null; 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; try { assertSetResult(body, ids, 'email'); } catch (err) { refusal ??= err; } // Refused messages come back in every page; stop once only they are left. if (marked === 0 || ids.length < pageSize) break; } if (refusal) throw refusal; return total; } /** * Mark every message in each folder read ("Mark folder as read" on a folder * and its subfolders). A folder the server refuses - a read-only share, a * message at the keyword limit - doesn't leave the folders after it unread: * the first refusal is thrown once every folder was tried. A lost connection * stops at once. Resolves with the number of messages marked. */ export async function markMailboxesAsRead(mailboxes: Array<{ id: string; accountId?: string }>): Promise { let total = 0; let refusal: unknown = null; for (const { id, accountId } of mailboxes) { try { total += await markMailboxAsRead(id, accountId); } catch (err) { if (isTransientNetworkError(err)) throw err; refusal ??= err; } } if (refusal) throw refusal; return total; } function buildMailboxQueryFilter( mailboxId: string | undefined, userFilter: Record | undefined, ): Record { // 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 mailboxId ? { operator: 'AND', conditions: [inMailbox, userFilter] } : userFilter; } return { ...inMailbox, ...userFilter }; } interface EmailQueryOptions { position?: number; limit?: number; 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; } function emailQueryArgs(mailboxId: string | undefined, options?: EmailQueryOptions): Record { const accountId = options?.accountId ?? jmapClient.accountId; const filter = buildMailboxQueryFilter(mailboxId, options?.filter); 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; return args; } export async function queryEmails( mailboxId: string | undefined, options?: EmailQueryOptions, ): Promise<{ ids: string[]; total: number; queryState?: string }> { const res = await jmapClient.request([['Email/query', emailQueryArgs(mailboxId, options), '0']]); const body = requireMethodResult(res, '0', 'Email/query'); return { ids: (body.ids as string[]) ?? [], total: (body.total as number) ?? 0, queryState: body.queryState as string | undefined, }; } /** * One page of a message list in a single request: Email/query, Email/get of * the ids it returns and, with `threads`, Thread/get of those messages' * threads (for the conversation-size badges), chained with result references * instead of three round trips. `state` is the Email state the rows were * read at. */ export async function queryEmailPage( mailboxId: string | undefined, options?: EmailQueryOptions & { threads?: boolean }, ): Promise<{ ids: string[]; total: number; queryState?: string; list: Email[]; state?: string; threads: Thread[]; }> { // The chained Email/get takes every id the query returns, so a page can't // be larger than the server lets one /get fetch. const args = emailQueryArgs(mailboxId, { ...options, limit: Math.min(options?.limit ?? 50, maxInGet()), }); const accountId = args.accountId; const calls: JMAPMethodCall[] = [ ['Email/query', args, '0'], ['Email/get', { accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: EMAIL_LIST_PROPERTIES, }, '1'], ]; if (options?.threads) { calls.push(['Thread/get', { accountId, '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' }, }, '2']); } const res = await jmapClient.request(calls); const query = requireMethodResult(res, '0', 'Email/query'); const got = requireMethodResult(res, '1', 'Email/get'); const ids = (query.ids as string[]) ?? []; let threads: Thread[] = []; if (options?.threads) { // Thread sizes only decorate the rows: a failed Thread/get keeps the page. try { threads = (requireMethodResult(res, '2', 'Thread/get').list as Thread[]) ?? []; } catch { /* the list falls back to the in-page count */ } } // Email/get may answer in any order; keep the query's. const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e])); return { ids, total: (query.total as number) ?? 0, queryState: query.queryState as string | undefined, list: ids.flatMap((id) => byId.get(id) ?? []), state: got.state as string | undefined, threads, }; } /** One account's part of a list that spans accounts. */ export interface AccountPageTarget { /** Shared/group JMAP account; undefined = the user's own. */ accountId?: string; position: number; sort: Array<{ property: string; isAscending: boolean; keyword?: string }>; /** * This account's own filter, in place of the shared `options.filter` * (the default search leaves each account's own Trash and Junk out). */ filter?: Record; } export type AccountPage = | { accountId?: string; ok: true; total: number; list: Email[]; threads: Thread[] } | { accountId?: string; ok: false; error: Error }; /** * A folder-less ("All folders") page from each of several accounts the * session reaches, in as few requests as `maxCallsInRequest` allows: per * account its Email/query, the Email/get of the ids and, with `threads`, the * Thread/get of their threads, chained with result references. Results come * back in `targets` order; an account whose calls fail carries its error * while the others still return their page (webmail `fanOutAccountQuery`). */ export async function queryEmailPagesAcrossAccounts( targets: AccountPageTarget[], options: { limit: number; filter?: Record; threads?: boolean }, ): Promise { const callsPerTarget = options.threads ? 3 : 2; const perRequest = Math.max(1, Math.floor(jmapClient.getMaxCallsInRequest() / callsPerTarget)); const limit = Math.min(options.limit, maxInGet()); const out: AccountPage[] = new Array(targets.length); const indexed = targets.map((target, index) => ({ target, index })); await Promise.all(batched(indexed, perRequest).map(async (chunk) => { const calls: JMAPMethodCall[] = []; for (const { target, index } of chunk) { const args = emailQueryArgs(undefined, { position: target.position, limit, sort: target.sort, filter: target.filter ?? options.filter, accountId: target.accountId, }); calls.push(['Email/query', args, `${index}:q`]); calls.push(['Email/get', { accountId: args.accountId, '#ids': { resultOf: `${index}:q`, name: 'Email/query', path: '/ids' }, properties: EMAIL_LIST_PROPERTIES, }, `${index}:g`]); if (options.threads) { calls.push(['Thread/get', { accountId: args.accountId, '#ids': { resultOf: `${index}:g`, name: 'Email/get', path: '/list/*/threadId' }, }, `${index}:t`]); } } let res: Awaited>; try { res = await jmapClient.request(calls); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); for (const { target, index } of chunk) out[index] = { accountId: target.accountId, ok: false, error }; return; } for (const { target, index } of chunk) { try { const query = requireMethodResult(res, `${index}:q`, 'Email/query'); const got = requireMethodResult(res, `${index}:g`, 'Email/get'); let threads: Thread[] = []; if (options.threads) { try { threads = (requireMethodResult(res, `${index}:t`, 'Thread/get').list as Thread[]) ?? []; } catch { /* thread sizes only decorate the rows */ } } const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e])); out[index] = { accountId: target.accountId, ok: true, total: (query.total as number) ?? 0, list: ((query.ids as string[]) ?? []).flatMap((id) => byId.get(id) ?? []), threads, }; } catch (err) { out[index] = { accountId: target.accountId, ok: false, error: err instanceof Error ? err : new Error(String(err)), }; } } })); return out; } export interface EmailQueryChangesResult { oldQueryState: string; newQueryState: string; total: number; removed: string[]; added: Array<{ id: string; index: number }>; } // Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox" // query. Returns null when the server replies with `cannotCalculateChanges` // (or any other error, or a query state that went back) — caller should fall // back to a fresh Email/query. export async function getEmailQueryChanges( mailboxId: string | undefined, sinceQueryState: string, options?: { sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>; filter?: Record; upToId?: string; maxChanges?: number; /** Owning JMAP account when the mailbox belongs to a shared account. */ accountId?: string; }, ): Promise { const accountId = options?.accountId ?? jmapClient.accountId; const filter = buildMailboxQueryFilter(mailboxId, options?.filter); const args: Record = { accountId, filter, sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }], sinceQueryState, calculateTotal: true, }; if (options?.upToId) args.upToId = options.upToId; if (options?.maxChanges) args.maxChanges = options.maxChanges; const res = await jmapClient.request([['Email/queryChanges', args, '0']]); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; // A query state is the Email change id the query was answered at. if (stateWentBack(accountId, sinceQueryState, body.newQueryState)) return null; return { oldQueryState: body.oldQueryState as string, newQueryState: body.newQueryState as string, total: (body.total as number) ?? 0, removed: (body.removed as string[]) ?? [], added: (body.added as Array<{ id: string; index: number }>) ?? [], }; } export interface EmailListDelta { /** Null when the server can't diff the query (the list is re-queried). */ queryChanges: EmailQueryChangesResult | null; /** Null when the server can't compute Email changes since the list's state. */ changes: EmailChangesResult | null; /** The messages the query added, with the list properties. */ added: Email[]; /** False when the chained Email/get failed (too many ids): fetch them apart. */ addedFetched: boolean; /** Threads of the added messages (with `threads`). */ threads: Thread[]; } function methodBody( responses: Array<[string, Record, string]>, callId: string, name: string, ): Record | null { const match = responses.find((r) => r[2] === callId); return match && match[0] === name ? match[1] : null; } /** * What changed in a folder list since it was read, in one request instead of * three or four in a row: Email/queryChanges from the list's queryState, * Email/changes from its Email state (rows that changed in place), and by * result reference Email/get of the messages the query added and, with * `threads`, Thread/get of their threads. Each part fails on its own: a query * the server can't diff comes back as `queryChanges: null` (the caller * re-queries), a state it can't diff as `changes: null`. */ export async function getEmailListDelta( mailboxId: string | undefined, sinceQueryState: string, sinceState: string, options?: { sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>; /** Owning JMAP account when the mailbox belongs to a shared account. */ accountId?: string; threads?: boolean; }, ): Promise { const accountId = options?.accountId ?? jmapClient.accountId; const calls: JMAPMethodCall[] = [ ['Email/queryChanges', { accountId, filter: buildMailboxQueryFilter(mailboxId, undefined), sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }], sinceQueryState, calculateTotal: true, }, 'qc'], ['Email/changes', { accountId, sinceState }, 'ch'], ['Email/get', { accountId, '#ids': { resultOf: 'qc', name: 'Email/queryChanges', path: '/added/*/id' }, properties: EMAIL_LIST_PROPERTIES, }, 'get'], ]; if (options?.threads) { calls.push(['Thread/get', { accountId, '#ids': { resultOf: 'get', name: 'Email/get', path: '/list/*/threadId' }, }, 'th']); } const res = await jmapClient.request(calls); const responses = res.methodResponses ?? []; // A state that went back counts as one the server can't diff. let qc = methodBody(responses, 'qc', 'Email/queryChanges'); if (qc && stateWentBack(accountId, sinceQueryState, qc.newQueryState)) qc = null; let ch = methodBody(responses, 'ch', 'Email/changes'); if (ch && stateWentBack(accountId, sinceState, ch.newState)) ch = null; const got = methodBody(responses, 'get', 'Email/get'); const th = methodBody(responses, 'th', 'Thread/get'); return { queryChanges: qc ? { oldQueryState: qc.oldQueryState as string, newQueryState: qc.newQueryState as string, total: (qc.total as number) ?? 0, removed: (qc.removed as string[]) ?? [], added: (qc.added as Array<{ id: string; index: number }>) ?? [], } : null, changes: ch ? { oldState: ch.oldState as string, newState: ch.newState as string, hasMoreChanges: Boolean(ch.hasMoreChanges), created: (ch.created as string[]) ?? [], updated: (ch.updated as string[]) ?? [], destroyed: (ch.destroyed as string[]) ?? [], } : null, added: (got?.list as Email[] | undefined) ?? [], addedFetched: !!got, threads: (th?.list as Thread[] | undefined) ?? [], }; } export async function getEmails(ids: string[], accountIdOverride?: string): Promise { 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. 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 res = await jmapClient.request([ ['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); return { list: [], 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 }; } // Body-part fields a list-row attachment chip needs (lib/list-attachments). const LIST_ATTACHMENT_BODY_PROPERTIES = ['blobId', 'size', 'name', 'type', 'cid', 'disposition']; /** * Attachment parts of the given emails, keyed by email id, for list-row * chips. Kept out of the list request on purpose: Stalwart reads and parses * each message's whole raw blob to answer `attachments`, so asking for it on * every list page made large folders slow (webmail #1089). Callers ask only * for rows on screen that have `hasAttachment`. Ids the server did not * return are absent from the map. */ export async function getEmailAttachments( ids: string[], accountIdOverride?: string, ): Promise> { const accountId = accountIdOverride ?? jmapClient.accountId; const found = new Map(); for (const slice of batched(ids, maxInGet())) { const res = await jmapClient.request([ ['Email/get', { accountId, ids: slice, properties: ['id', 'attachments'], bodyProperties: LIST_ATTACHMENT_BODY_PROPERTIES, }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); for (const email of (body.list as Pick[]) ?? []) { found.set(email.id, email.attachments ?? []); } } return found; } export interface EmailChangesResult { oldState: string; newState: string; hasMoreChanges: boolean; created: string[]; updated: string[]; destroyed: string[]; } // Returns null when the server replies with `cannotCalculateChanges` or any // other error response, or with a state that went back; caller should treat // that as "rebuild from scratch". export async function getEmailChanges( sinceState: string, maxChanges?: number, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const args: Record = { accountId, sinceState }; if (maxChanges) args.maxChanges = maxChanges; const res = await jmapClient.request([['Email/changes', args, '0']]); const [name, body] = res.methodResponses[0]; if (name === 'error') return null; if (stateWentBack(accountId, sinceState, body.newState)) return null; return { oldState: body.oldState as string, newState: body.newState as string, hasMoreChanges: Boolean(body.hasMoreChanges), created: (body.created as string[]) ?? [], updated: (body.updated as string[]) ?? [], destroyed: (body.destroyed as string[]) ?? [], }; } const FULL_BODY_ARGS = { properties: EMAIL_FULL_PROPERTIES, fetchHTMLBodyValues: true, fetchTextBodyValues: true, fetchAllBodyValues: true, maxBodyValueBytes: 512000, }; // A displayed part over that cap comes back cut off, often mid-tag, so a // large HTML report rendered as a heading and a stub (#884). Those parts are // fetched once more with this far larger, still bounded cap. const TRUNCATED_BODY_REFETCH_MAX_BYTES = 8_000_000; /** * Refetch, in place and batched across the list, the HTML and text body * values of messages whose displayed part came back truncated. Other parts' * values are kept. A failure leaves the truncated values in place (the viewer * says the message isn't shown in full) instead of failing the open. */ async function refetchTruncatedBodyValues(emails: Email[], accountId: string): Promise { const truncated = emails.filter(hasTruncatedDisplayedBody); if (truncated.length === 0) return; try { const refetched = new Map(); for (const slice of batched(truncated.map((e) => e.id), maxInGet())) { const res = await jmapClient.request([ ['Email/get', { accountId, ids: slice, properties: ['id', 'bodyValues'], fetchHTMLBodyValues: true, fetchTextBodyValues: true, maxBodyValueBytes: TRUNCATED_BODY_REFETCH_MAX_BYTES, }, '0'], ]); for (const e of (requireMethodResult(res, '0', 'Email/get').list as Email[] | undefined) ?? []) { refetched.set(e.id, e.bodyValues); } } for (const email of truncated) { const values = refetched.get(email.id); if (values) email.bodyValues = { ...email.bodyValues, ...values }; } } catch (err) { console.warn('[email] refetching truncated bodies failed', err); } } 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], ...FULL_BODY_ARGS }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); const fetched = (body.list as Email[] | undefined)?.[0]; if (!fetched) throw new Error(`Email ${id} not found`); // Stalwart 1.0 lists an inline image in mixed only in the body lists. const email = withInlineBodyParts(fetched); await refetchTruncatedBodyValues([email], accountId); return email; } // 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 { return (await getFullEmailsWithState(ids, accountIdOverride)).list; } /** An `Email/get` result: what came back, what the server does not know, and its Email state. */ export interface EmailGetResult { list: T[]; notFound: string[]; /** The account's Email state the result was read at (RFC 8620 §5.1). */ state?: string; } /** * {@link getFullEmails} with the Email state of the response and the ids the * server reported missing, for callers that cache what they read. */ export async function getFullEmailsWithState( ids: string[], accountIdOverride?: string, ): Promise> { if (ids.length === 0) return { list: [], notFound: [] }; const accountId = accountIdOverride ?? jmapClient.accountId; const out: EmailGetResult = { list: [], notFound: [] }; for (const slice of batched(ids, maxInGet())) { const res = await jmapClient.request([ ['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); out.list.push(...((body.list as Email[]) ?? []).map(withInlineBodyParts)); out.notFound.push(...((body.notFound as string[] | undefined) ?? [])); out.state = body.state as string | undefined; } await refetchTruncatedBodyValues(out.list, accountId); return out; } /** The two mutable properties of an Email (RFC 8621 §4.1); nothing else ever changes. */ export type EmailFlags = Pick; /** * Only the keywords and folders of messages, to bring a copy read earlier up * to date without downloading its body again. */ export async function getEmailFlags( ids: string[], accountIdOverride?: string, ): Promise> { if (ids.length === 0) return { list: [], notFound: [] }; const accountId = accountIdOverride ?? jmapClient.accountId; const out: EmailGetResult = { list: [], notFound: [] }; for (const slice of batched(ids, maxInGet())) { const res = await jmapClient.request([ ['Email/get', { accountId, ids: slice, properties: ['id', 'keywords', 'mailboxIds'] }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); out.list.push(...((body.list as EmailFlags[]) ?? [])); out.notFound.push(...((body.notFound as string[] | undefined) ?? [])); out.state = body.state as string | undefined; } return out; } // What a collapsed conversation card shows; `sentAt` is the date it prints. export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt']; /** A conversation's members, oldest first, without bodies. */ export interface ThreadHeaders { emailIds: string[]; list: Email[]; } /** * The messages of several threads without their bodies, oldest first, in one * back-referenced Thread/get → Email/get. The viewer lists conversations from * these and downloads bodies only for the cards it opens. Threads the server * does not know are in `notFound`. */ export async function getThreadsHeaders( threadIds: string[], accountIdOverride?: string, ): Promise<{ threads: Record; notFound: string[]; state?: string }> { const accountId = accountIdOverride ?? jmapClient.accountId; const ids = Array.from(new Set(threadIds)); const res = await jmapClient.request([ ['Thread/get', { accountId, ids }, '0'], ['Email/get', { accountId, '#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' }, properties: THREAD_HEADER_PROPERTIES, }, '1'], ]); const found = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined) ?? []; const body = requireMethodResult(res, '1', 'Email/get'); const byId = new Map(((body.list as Email[]) ?? []).map((e) => [e.id, e])); const threads: Record = {}; for (const thread of found) { // Members the server did not return (no access to them) are left out. const emailIds = thread.emailIds.filter((id) => byId.has(id)); threads[thread.id] = { emailIds, list: emailIds.map((id) => byId.get(id)!) }; } return { threads, notFound: ids.filter((id) => !threads[id]), state: body.state as string | undefined, }; } /** * Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox * via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's * `importRawEmail` import step. */ export async function importEmailBlob( blobId: string, mailboxId: string, keywords: Record = { $seen: true }, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/import', { accountId, emails: { 'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords }, }, }, '0'], ]); 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'); } const id = result?.created?.['import-0']?.id; if (!id) throw new Error('Email import succeeded but no id was returned'); return id; } export async function getThread(threadId: string, accountIdOverride?: string): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['Thread/get', { accountId, ids: [threadId] }, '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; } // One `keywords/` pointer per changed keyword. Never send the whole // `keywords` map on update: the server replaces it, erasing every keyword the // local copy didn't have (a star or tag set elsewhere, or all of them on a // message that was never loaded). function keywordPointers(patch: Record): Record { const pointers: Record = {}; for (const [keyword, value] of Object.entries(patch)) { pointers[keywordPointer(keyword)] = value === false ? null : value; } return pointers; } /** 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 = keywordPointers(patch); const update: Record> = {}; for (const id of ids) update[id] = { ...pointerPatch }; await emailSetBatched(accountId, { update }); } export async function moveEmail( emailId: string, fromMailboxId: string, toMailboxId: string, accountIdOverride?: string, ): Promise { await moveEmails([emailId], fromMailboxId, toMailboxId, accountIdOverride); } // 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, toMailboxId: string, accountIdOverride?: string, ): Promise { if (ids.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record> = {}; for (const id of ids) { update[id] = { [mailboxPointer(fromMailboxId)]: null, [mailboxPointer(toMailboxId)]: true, }; } 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. export async function deleteEmails( ids: string[], trashMailboxId: string, currentMailboxId: string, accountIdOverride?: string, ): Promise { if (ids.length === 0) return; if (currentMailboxId === trashMailboxId) { await destroyEmails(ids, accountIdOverride); } else { await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride); } } // Apply a different keyword patch to each of several emails in one // round-trip (chunked). Undo uses it to put back each message's own keywords. export async function patchKeywordsPerEmail( updates: Array<{ id: string; patch: Record }>, accountIdOverride?: string, ): Promise { if (updates.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record> = {}; for (const u of updates) update[u.id] = keywordPointers(u.patch); await emailSetBatched(accountId, { update }); } // Restore each email's mailboxIds to the snapshot supplied. Used by undo to // reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces // the entire map, so we don't need to compute a diff against the current state. export async function restoreEmailMailboxes( items: Array<{ id: string; mailboxIds: Record }>, accountIdOverride?: string, ): Promise { if (items.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record }> = {}; for (const item of items) { const onlyTrue: Record = {}; for (const [id, present] of Object.entries(item.mailboxIds)) { if (present) onlyTrue[id] = true; } update[item.id] = { mailboxIds: onlyTrue }; } await emailSetBatched(accountId, { update }); } // Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole // set, so this is idempotent — replaying it produces the same result no matter // the current server state. The offline outbox relies on that to coalesce and // safely retry move/archive/trash operations. export async function setEmailMailboxes( emailId: string, mailboxIds: Record, accountIdOverride?: string, ): Promise { await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride); } // Permanently destroy emails (no move-to-trash). Idempotent: destroying an // 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; // Every slice goes out before a refusal is thrown, and the error names the // ids that are still there (failedIds): the server destroyed the rest, and // the list must drop those rather than keep them until the next refresh // (webmail ac99c68ef). let refusal: JMAPSetError | null = null; const failed: string[] = []; for (const slice of batched(ids, maxInSet())) { const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]); try { assertEmailsDestroyed(requireMethodResult(res, '0', 'Email/set')); } catch (err) { if (!(err instanceof JMAPSetError)) throw err; refusal ??= err; failed.push(...(err.failedIds ?? [])); } } if (refusal) { refusal.failedIds = failed; throw refusal; } } /** Throw when an Email/set destroy left messages behind; `notFound` counts as gone. */ function assertEmailsDestroyed(body: Record): void { 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]; const error = new JMAPSetError( err.type || 'setError', `Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, ); error.failedIds = real.map(([failedId]) => failedId); throw error; } } // Archive one or more emails into the archive mailbox, optionally auto-sorting // into year or year/month subfolders. Mirrors the webmail implementation in // lib/jmap/client.ts so behavior stays in sync between platforms. export async function archiveEmails( emails: Array<{ id: string; receivedAt: string }>, archiveMailboxId: string, mode: 'single' | 'year' | 'month', existingMailboxes: Mailbox[], accountIdOverride?: string, ): Promise { if (emails.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; if (mode === 'single') { const updates = Object.fromEntries( emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]), ); await emailSetBatched(accountId, { update: updates }); return; } type Dest = { year: string; month?: string }; const destFor = new Map(); for (const e of emails) { const d = new Date(e.receivedAt); const year = d.getFullYear().toString(); const month = (d.getMonth() + 1).toString().padStart(2, '0'); destFor.set(e.id, mode === 'year' ? { year } : { year, month }); } // Resolve each destination folder to either an existing id or a creation-id reference ("#"). const yearIdFor = new Map(); const monthIdFor = new Map(); const createEntries: Record> = {}; const findExisting = (name: string, parentId: string) => existingMailboxes.find( (m) => m.name === name && m.parentId === parentId, ); for (const dest of destFor.values()) { if (!yearIdFor.has(dest.year)) { const existing = findExisting(dest.year, archiveMailboxId); if (existing) { yearIdFor.set(dest.year, existing.id); } else { const cid = `year-${dest.year}`; createEntries[cid] = { name: dest.year, parentId: archiveMailboxId }; yearIdFor.set(dest.year, `#${cid}`); } } if (mode === 'month' && dest.month) { const monthKey = `${dest.year}/${dest.month}`; if (!monthIdFor.has(monthKey)) { const yearRef = yearIdFor.get(dest.year)!; const existingMonth = yearRef.startsWith('#') ? undefined : findExisting(dest.month, yearRef); if (existingMonth) { monthIdFor.set(monthKey, existingMonth.id); } else { const cid = `month-${dest.year}-${dest.month}`; createEntries[cid] = { name: dest.month, parentId: yearRef }; monthIdFor.set(monthKey, `#${cid}`); } } } } const updates: Record }> = {}; for (const [emailId, dest] of destFor.entries()) { const destId = mode === 'month' && dest.month ? monthIdFor.get(`${dest.year}/${dest.month}`)! : yearIdFor.get(dest.year)!; updates[emailId] = { mailboxIds: { [destId]: true } }; } // 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; let createdIdFor: Record = {}; 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]; }); 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 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})` : ''}`, ); } } } export async function deleteEmail( emailId: string, trashMailboxId: string, currentMailboxId: string, accountIdOverride?: string, ): Promise { await deleteEmails([emailId], trashMailboxId, currentMailboxId, accountIdOverride); } export async function searchEmails( query: string, mailboxId?: string, limit = 30, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; // Quoted phrases and `word*` prefixes go out as conditions of their own (see // splitSearchText), and an operator can't carry `inMailbox` next to them. const filter = buildMailboxQueryFilter(mailboxId, textFilter(query)); const res = await jmapClient.request([ ['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit, }, '0'], ]); return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? []; } // Cross-mailbox query with an arbitrary JMAP filter (used by contact activity // to search "from OR to " without picking a specific mailbox). export async function queryEmailsByFilter( filter: Record, limit = 5, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit, }, '0'], ]); return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? []; } export interface OutgoingAttachment { blobId: string; type: string; name: string; size?: number; disposition?: 'attachment' | 'inline'; 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; /** * SMTP MAIL FROM when it must differ from the header From - a catch-all * alias on an owned domain is sent through the identity's envelope while * the visible From keeps the alias (webmail #246). */ envelopeMailFrom?: string; } export interface SendEmailResult { /** True when the message was deferred (HOLDFOR / FUTURERELEASE). */ scheduled: boolean; /** ISO timestamp the server resolved for a deferred send, when known. */ sendAt?: string; emailId?: string; emailSubmissionId?: string; /** Post-send filing/cleanup problem (message did go out). */ filingWarning?: string; /** * Recipients the server refused while it took the submission; the others * get the message. When it refused every one, the send throws * RecipientsRefusedError instead. */ refusedRecipients?: RefusedRecipient[]; } 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, to: cleanAddresses(email.to) ?? [], cc: cleanAddresses(email.cc), bcc: dedupeAddresses(cleanAddresses(email.bcc)), replyTo: cleanAddresses(email.replyTo), subject: email.subject, messageId: [email.messageId ? stripMessageIdBrackets(email.messageId) : generateMessageId(from[0]?.email ?? 'localhost')], }; const bodyValues: Record = {}; if (email.htmlBody) { emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }]; bodyValues.html = { value: email.htmlBody }; } if (email.textBody || !email.htmlBody) { emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }]; bodyValues.text = { value: email.textBody ?? '' }; } emailCreate.bodyValues = bodyValues; if (email.attachments?.length) { emailCreate.attachments = email.attachments.map((a) => { const part: Record = { blobId: a.blobId, type: a.type, name: a.name, disposition: a.disposition ?? 'attachment', }; if (a.size != null) part.size = a.size; if (a.cid) part.cid = a.cid; return part; }); } // 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; } /** * The error for a submission the server refused. A hold beyond the MTA's * limit becomes `ScheduleTooLateError`, and the limit it names is remembered * so the pickers stop offering such times (webmail parity). A full * submission quota (Stalwart 1.0, still full after making room) becomes * `SubmissionQuotaError`, which the UI explains instead of showing the * server's "please delete some". */ function submissionError(err: { description?: string; type?: string }, fallback: string, accountId: string): Error { if (err.type === 'overQuota') return submissionQuotaError(err.description, accountId); const limit = parseHoldLimit(err.description); if (limit === null) return new Error(err.description ?? err.type ?? fallback); jmapClient.learnHoldLimit(limit); return new ScheduleTooLateError(limit); } /** * The send request came back without an EmailSubmission and without an * error: nothing confirms the message left, and it may have (webmail * SendUnconfirmedError, #1019). The draft is kept. */ export class SendUnconfirmedError extends Error { constructor() { super('Send confirmation was not received. Check Sent before sending again. Your draft has been kept.'); this.name = 'SendUnconfirmedError'; } } 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). A refused submission * removes that copy again rather than leaving a stray draft. */ draftsMailboxId?: string; /** Previous draft version to destroy once the submission succeeded (#849). */ draftId?: string; /** * Account `draftId` lives in, when not the submitting one: a draft saved * in the own Drafts and sent from a group identity, or the other way * round. Email ids repeat across accounts. */ draftAccountId?: 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 }; // For a deferred send the envelope must be set explicitly so the HOLDFOR // mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the // server derive mailFrom from the Identity, dropping our parameter). An // explicit envelope sender (catch-all From override) needs it as well. const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0; if (holdFor > 0 || email.envelopeMailFrom) { const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] .map((r) => r.email.trim()) .filter(Boolean) .map((address) => ({ email: address })); const mailFrom: Record = { email: email.envelopeMailFrom || email.from[0]?.email, }; if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) }; submissionCreate.envelope = { mailFrom, rcptTo }; } const submissionArgs: Record = { accountId, create: { 'sub-1': submissionCreate }, }; if (viaDrafts) { submissionArgs.onSuccessUpdateEmail = { '#sub-1': { mailboxIds: { [sentMailboxId]: true }, [keywordPointer('$draft')]: null, }, }; } let res = await jmapClient.request( [ ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], ['EmailSubmission/set', submissionArgs, '1'], // Which recipients the server took. A refused one is lost without a // bounce while the create succeeds and files the message in Sent. deliveryStatusCall(accountId, 'sub-1', '2'), ], SUBMISSION_USING, ); // Stalwart 1.0 refuses a submission once the account holds `maxSubmissions` // records (overQuota). The message exists by then, so make room and submit // that copy once more instead of failing the send. const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0'); const copyId = (copyCreated?.[1] as { created?: Record } | undefined)?.created?.draft?.id; if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') { await makeRoomForSubmission(accountId); const retry = await jmapClient.request( [ ['EmailSubmission/set', { ...submissionArgs, create: { 'sub-1': { ...submissionCreate, emailId: copyId } }, }, '1'], deliveryStatusCall(accountId, 'sub-1', '2'), ], SUBMISSION_USING, ); res = { ...retry, methodResponses: [copyCreated, ...(retry.methodResponses ?? [])] }; } // The get goes on its own: it fails whenever the create did, and must not // count as a failed send or a filing problem either way. const deliveryAnswer = res.methodResponses.find(([, , callId]) => callId === '2'); res = { ...res, methodResponses: res.methodResponses.filter(([, , callId]) => callId !== '2') }; let emailId: string | undefined; let emailSubmissionId: string | undefined; let sendAt: string | undefined; let filingWarning: string | undefined; let failure: Error | undefined; for (const [methodName, result] of res.methodResponses) { if (methodName === 'error' || methodName.endsWith('/error')) { // Once the submission exists the message has left (or is held), so a // later error is the implicit `onSuccessUpdateEmail` Email/set that // Stalwart appends failing to file it. Failing the send here made the // user retry and send the message twice. if (emailSubmissionId) { const err = result as { description?: string; type?: string }; filingWarning = filingWarning ?? (err.description || err.type || 'post-send filing failed'); continue; } failure = new Error((result as { description?: string }).description ?? 'Send failed'); break; } if (methodName === 'Email/set') { 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}`); } // Stalwart answers a submission carrying `onSuccessUpdateEmail` with a // SECOND `Email/set` response (reusing the submission's call id) that // reports the filing update and carries no `created`. Only take an id // when one is actually there - assigning unconditionally overwrote the // real id with undefined, which silently disabled the undo-send window. const createdId = (result as { created?: Record }).created?.draft?.id; if (createdId) emailId = createdId; // 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']; if (notCreated) { failure = submissionError(notCreated, 'Failed to submit message', accountId); break; } const created = (result as { created?: Record }).created?.['sub-1']; emailSubmissionId = created?.id; sendAt = created?.sendAt; } } // No submission came back and nothing said why: an answer cut short, or a // server that skipped the call. Nothing confirms the message left, and it // may have. Reporting it as sent dropped the draft and closed the composer // (#1019); this keeps both, and the copy made for it too, since it may be // the one that went out. if (!failure && !emailSubmissionId) failure = new SendUnconfirmedError(); if (failure instanceof SendUnconfirmedError) throw failure; if (failure) { // Nothing went out. A message created before the submission was refused // must not stay behind: nobody tracked that copy, so a retry left a // second one next to it. The caller still holds the message, and a // previous draft version is untouched. if (emailId) { try { await destroyEmails([emailId], accountId); } catch (err) { console.warn('[email] failed to remove the unsent copy:', err); } } throw failure; } // Every recipient refused: nothing was queued, so nothing goes out, held or // not, and no bounce will tell. That is a failed send. The copy made for it // goes, as after any failed send - onSuccessUpdateEmail filed it in Sent, // where it passes for a sent message - while the previous draft version // stays and the composer keeps the text, so the address can be fixed and // the message sent again. The record can neither go out nor be cancelled. const delivery = emailSubmissionId ? refusedRecipients(deliveryAnswer) : { recipients: [], all: false }; if (delivery.all) { if (emailId) { try { await destroyEmails([emailId], accountId); } catch (err) { console.warn('[email] failed to remove the unsent copy:', err); } } if (isStalwartAccount(accountId)) await destroyEmailSubmissions([emailSubmissionId!], accountId); throw new RecipientsRefusedError(delivery.recipients); } // An immediate send's submission record has done its job once the request // that created it returned: the message is queued and filed, and with no // hold there is nothing to cancel. Stalwart 1.0 stops accepting submissions // once an account holds `maxSubmissions` records, so the record goes now; // Stalwart's destroy only removes the record, never the queued message. // Other servers are left to the prune. Decided by the request, not by the // undoStatus the create returned, which 0.16.10-0.16.16 report as final // even for a held send. const recordToDestroy = holdFor === 0 && emailSubmissionId && isStalwartAccount(accountId) ? emailSubmissionId : undefined; // 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). The submission record rides along, and // nothing about it may turn into a warning. if (opts?.draftId && emailSubmissionId) { const draftAccountId = opts.draftAccountId ?? accountId; try { if (recordToDestroy) { const cleanup = await jmapClient.request( [ ['Email/set', { accountId: draftAccountId, destroy: [opts.draftId] }, '0'], submissionDestroyCall([recordToDestroy], accountId, '1'), ], SUBMISSION_USING, ); readSubmissionDestroy(cleanup, '1'); assertEmailsDestroyed(requireMethodResult(cleanup, '0', 'Email/set')); } else { await destroyEmails([opts.draftId], draftAccountId); } } catch (err) { filingWarning = filingWarning ?? `old draft cleanup failed: ${err instanceof Error ? err.message : String(err)}`; } } else if (recordToDestroy) { await destroyEmailSubmissions([recordToDestroy], accountId); } return { scheduled: !!(holdForSeconds && holdForSeconds > 0), sendAt, emailId, emailSubmissionId, filingWarning, // Some recipients refused: the others get the message, the caller says // who doesn't. ...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}), }; } /** A saved draft version and the attachment parts the server built for it. */ export interface SavedDraftVersion { id: string; /** * Attachment and inline parts of the new version, with their blobIds. A * part's blobId only lives as long as this version: re-opened drafts must * send these (not the previous version's) on their next save or send. * Empty when the server did not answer the read-back. */ attachments: Attachment[]; } const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition']; /** * 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). The new * version's attachment parts are read back in the same request, so the * composer can re-point its draft-part blobIds before the old version (and * its part blobs) is destroyed. */ export async function saveDraftVersion( email: OutgoingEmail, draftsMailboxId: string, previousDraftId?: string, accountIdOverride?: string, /** * Account the previous version lives in, when not this one: the draft * moves between the own Drafts and a group's when its From does (#1090). */ previousDraftAccountId?: 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'], // `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not // resolve a result reference into an Email/set `created` map // (invalidResultReference), but it does resolve the creation id. ['Email/get', { accountId, ids: ['#draft'], // The body lists too: Stalwart 1.0 lists the inline images there only. properties: ['id', 'attachments', 'textBody', 'htmlBody'], bodyProperties: DRAFT_PART_BODY_PROPERTIES, }, '1'], ]); 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'); let attachments: Attachment[] = []; try { const got = requireMethodResult(res, '1', 'Email/get'); const saved = (got.list as Array> | undefined) ?.find((e) => e.id === id); attachments = (saved ? withInlineBodyParts(saved).attachments ?? [] : []).filter((a) => !!a.blobId); } catch (err) { console.warn('[email] could not read back the saved draft parts:', err); } const previousAccountId = previousDraftAccountId ?? accountId; if (previousDraftId && (previousDraftId !== id || previousAccountId !== accountId)) { try { await destroyEmails([previousDraftId], previousAccountId); } catch (err) { console.warn('[email] failed to destroy previous draft version:', err); } } return { id, attachments }; } /** {@link saveDraftVersion} for callers that only need the new draft id. */ export async function createDraft( email: OutgoingEmail, draftsMailboxId: string, previousDraftId?: string, accountIdOverride?: string, ): Promise { return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id; } export interface ScheduledEmail { /** Account holding the submission: a shared account for a send from its identity. */ accountId: string; emailSubmissionId: string; emailId: string; identityId: string; threadId?: string; sendAt: string; undoStatus?: string; subject?: string; to?: EmailAddress[]; from?: EmailAddress[]; preview?: string; } // List pending (not-yet-delivered, not cancelled) scheduled submissions whose // send time is still in the future, joined with a light Email/get so the UI // can show subject/recipients. Covers every account that can hold mail: a // send from a shared identity is held in the shared account, where a // primary-only lookup never found it, so it could not be cancelled (webmail // #874). Empty when no account supports FUTURERELEASE. export async function listScheduledEmails(): Promise { const accountIds = submissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id)); const out: ScheduledEmail[] = []; for (const accountId of accountIds) { try { out.push(...(await listScheduledEmailsIn(accountId))); } catch (err) { // A shared account that can't answer (revoked access, server error) // must not hide the user's own scheduled mail. if (accountId === jmapClient.accountId) throw err; console.warn(`[email] scheduled sends of account ${accountId} unavailable:`, err); } } return out.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()); } async function listScheduledEmailsIn(accountId: string): Promise { const now = Date.now(); // Every page of the query. It lists records oldest sendAt first, so a // single page of 200 lost the mail scheduled furthest ahead once the // three-day window held more records than that. `total` only comes back // with calculateTotal. const pageSize = Math.min(500, maxInGet()); const found: string[] = []; for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) { const queryRes = await jmapClient.request( [['EmailSubmission/query', { accountId, position: found.length, limit: pageSize, calculateTotal: true }, '0']], SUBMISSION_ONLY_USING, ); const [queryName, queryBody] = queryRes.methodResponses[0]; if (queryName === 'error' || queryName.endsWith('/error')) { if (page === 0) return []; break; } const pageIds = (queryBody.ids as string[]) ?? []; found.push(...pageIds); const done = typeof queryBody.total === 'number' ? found.length >= queryBody.total : pageIds.length < pageSize; if (pageIds.length === 0 || done) break; } // A record created between two pages shifts the later ones by one. const ids = Array.from(new Set(found)); if (ids.length === 0) return []; const submissions: Array<{ id: string; emailId: string; identityId: string; threadId?: string; sendAt?: string; undoStatus?: string; }> = []; 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 (pending.length === 0) return []; 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 pending .map((s): ScheduledEmail | null => { const e = emailById.get(s.emailId); if (!s.sendAt) return null; return { accountId, emailSubmissionId: s.id, emailId: s.emailId, identityId: s.identityId, threadId: s.threadId ?? e?.threadId, sendAt: s.sendAt, undoStatus: s.undoStatus, subject: e?.subject, to: e?.to, from: e?.from, preview: e?.preview, }; }) .filter((s): s is ScheduledEmail => s !== null); } // Cancel a pending scheduled send. The held message copy stays in Sent; only // delivery is stopped (matches the webmail behaviour), and the canceled // submission record is removed. `accountIdOverride` is the account holding // the submission (see `ScheduledEmail.accountId`). export async function cancelScheduledSend(emailSubmissionId: string, accountIdOverride?: string): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['EmailSubmission/set', { accountId, update: { [emailSubmissionId]: { undoStatus: 'canceled' } }, }, '0']], [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], ); const body = requireMethodResult(res, '0', 'EmailSubmission/set'); const failure = body.notUpdated?.[emailSubmissionId] as | { type?: string; description?: string } | undefined; if (failure) { throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send'); } // The canceled record is dead weight against Stalwart 1.0's submission // limit. It goes in a request of its own, and only once the cancel is // confirmed: had the two gone together and the cancel failed, the mail // would still be queued with no record left to cancel it through. if (body.updated && emailSubmissionId in body.updated) { await destroyEmailSubmissions([emailSubmissionId], accountId); } } /** * A reschedule or "Send now" whose replacement the server refused, after the * original had been cancelled: the original was put back at its old time * under a new submission id, which the caller now holds instead (webmail * RescheduleRestoredError). `reason` is why the replacement failed. */ export class RescheduleRestoredError extends Error { constructor( readonly reason: unknown, readonly restoredSubmissionId: string, readonly restoredSendAt?: string, ) { super(reason instanceof Error ? reason.message : String(reason)); this.name = 'RescheduleRestoredError'; } } /** * A reschedule or "Send now" that failed and could not put the original back * either: the message is no longer scheduled. It was moved back to Drafts * (`inDrafts`) so it does not sit in Sent looking sent. `reason` is why the * replacement failed. */ export class RescheduleLostError extends Error { constructor(readonly reason: unknown, readonly inDrafts: boolean) { super(`Reschedule failed and the message is no longer scheduled: ${reason instanceof Error ? reason.message : String(reason)}`); this.name = 'RescheduleLostError'; } } /** * Change when a scheduled message goes out, or send it now with * `holdForSeconds` = 0. The original is cancelled first and a replacement * for the same Email, with the held envelope and a fresh HOLDFOR, created * after it (#1308, webmail parity). The other order failed on servers that * allow one active submission per Email and refuse the second create, and on * servers that undo onSuccessUpdateEmail on cancel, which pulled the message * back to Drafts once the original was cancelled after the replacement * existed; the replacement now files it in Sent itself. * * A cancel that fails changes nothing: the original still goes out, or went * out already (NoLongerScheduledError). A replacement the server refuses, or * takes for no recipient, puts the original back at its old time under a new * id (RescheduleRestoredError); when that fails too the message goes back to * Drafts (RescheduleLostError). Returns the new submission id and send time, * and the recipients the server refused for the replacement when it took * the others. */ export async function rescheduleScheduledSend( scheduled: { emailSubmissionId: string; emailId: string; identityId: string; from?: EmailAddress[]; to?: EmailAddress[]; /** Account holding the submission; the replacement is created there too. */ accountId?: string; }, holdForSeconds: number, recipients?: EmailAddress[], ): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> { const accountId = scheduled.accountId ?? jmapClient.accountId; // "Send now" is a 1-second hold, as in the webmail. const holdFor = Math.max(1, Math.ceil(holdForSeconds)); // Reuse the held submission's envelope: it names every recipient, // Cc and Bcc included, and the envelope sender a catch-all From went // out through. Rebuilding it from the Email's To dropped Cc and Bcc. // The Email's To/Cc/Bcc are the fallback when the server has none. The // account's Drafts and Sent come along for filing the replacement and for // a message that ends up not scheduled at all. const lookup = await jmapClient.request( [ ['EmailSubmission/get', { accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'], }, '0'], ['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'], ['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'], ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], ); const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{ envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null; sendAt?: string; }> | undefined)?.[0]; const envelope = original?.envelope; const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined; const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])]; const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients); const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email); const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email; // Without an answer the replacement is made without filing, and a message // that can't be put back stays where it is. const [mailboxAnswer, mailboxBody] = lookup.methodResponses.find(([, , callId]) => callId === '2') ?? []; const roles = mailboxAnswer === 'Mailbox/get' ? ((mailboxBody?.list ?? []) as Array<{ id: string; role?: string | null }>) : []; const draftsId = roles.find((m) => m.role === 'drafts')?.id; const sentId = roles.find((m) => m.role === 'sent')?.id; type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } }; // `until`: when the submission goes out (ms); undefined, at once. const createSubmission = async (until: number | undefined): Promise => { // HOLDFOR counts from when the server takes the request, so a retry // after making room takes the time spent off it. const holdSeconds = () => (until === undefined ? undefined : Math.max(1, Math.ceil((until - Date.now()) / 1000))); const send = () => { const seconds = holdSeconds(); return jmapClient.request( [ ['EmailSubmission/set', { accountId, create: { replacement: { emailId: scheduled.emailId, identityId: scheduled.identityId, envelope: { mailFrom: seconds === undefined ? { email: mailFrom } : { email: mailFrom, parameters: { HOLDFOR: String(seconds) } }, rcptTo, }, }, }, ...(draftsId && sentId ? { onSuccessUpdateEmail: { '#replacement': { mailboxIds: { [sentId]: true }, [keywordPointer('$draft')]: null }, }, } : {}), }, '0'], deliveryStatusCall(accountId, 'replacement', '1'), ], SUBMISSION_USING, ); }; let res = await send(); // A full submission quota (Stalwart 1.0): make room and try once more. if (submissionCreateError(res, 'replacement')?.type === 'overQuota') { await makeRoomForSubmission(accountId); res = await send(); } // Only the submission call counts: the implied Email/set of // onSuccessUpdateEmail can fail while the submission was created. const body = requireMethodResult(res, '0', 'EmailSubmission/set'); const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined; if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId); const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined; if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission'); return { id: created.id, sendAt: created.sendAt, delivery: refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')), }; }; // A submission that took no recipient goes nowhere; its record only counts // against the limit. The recipients it refused, or null when it took some. const withdrawIfDead = async (created: Created): Promise => { if (!created.delivery.all) return null; if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId); return created.delivery.recipients; }; // HOLDFOR counts from when the server takes the request. const holdUntil = Date.now() + holdFor * 1000; // Cancel first. Should it fail, nothing has changed and the original still // goes out - unless it has gone out already ("Send now" of a message due // any moment), or was cancelled on another device. Either cancel also // removes the record it canceled. try { await cancelScheduledSend(scheduled.emailSubmissionId, accountId); } catch (err) { const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId); if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final'); throw err; } let replacement: Created; try { replacement = await createSubmission(holdUntil); // The recipients are checked again for the replacement, and a throttle // used up since the original was taken can refuse some or all of them. // When it took nobody the reschedule failed; when it took some, the // caller names the refused ones. const refused = await withdrawIfDead(replacement); if (refused) throw new RecipientsRefusedError(refused); } catch (err) { // Put the original back at its old time: HOLDFOR with the seconds left, // at once when that time has passed meanwhile. const originalTime = original?.sendAt ? Date.parse(original.sendAt) : NaN; let restored: Created | undefined; try { restored = await createSubmission(Number.isFinite(originalTime) && originalTime > Date.now() ? originalTime : undefined); } catch (restoreErr) { console.warn('[email] could not put the original scheduled send back:', restoreErr); } if (restored && !(await withdrawIfDead(restored).catch(() => null))) { throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt); } // Neither went through: the message is not scheduled any more. It goes // back to Drafts rather than sit in Sent looking sent. let inDrafts = false; if (draftsId) { try { await restoreEmailToDraft(scheduled.emailId, draftsId, sentId, accountId); inDrafts = true; } catch (draftErr) { console.warn('[email] could not move the unscheduled message back to Drafts:', draftErr); } } throw new RescheduleLostError(err, inDrafts); } return { emailSubmissionId: replacement.id, sendAt: replacement.sendAt, ...(replacement.delivery.recipients.length > 0 ? { refusedRecipients: replacement.delivery.recipients } : {}), }; } /** * A submission's `undoStatus`; undefined when the server no longer has the * record. Throws when it can't be read. */ async function readSubmissionUndoStatus(emailSubmissionId: string, accountId: string): Promise { const res = await jmapClient.request( [['EmailSubmission/get', { accountId, ids: [emailSubmissionId], properties: ['id', 'undoStatus'] }, '0']], [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION], ); const list = requireMethodResult(res, '0', 'EmailSubmission/get').list as Array<{ id?: string; undoStatus?: string }> | undefined; return list?.find((s) => s.id === emailSubmissionId)?.undoStatus; } /** A submission's `undoStatus`, or undefined when it can't be read. */ async function submissionUndoStatus(emailSubmissionId: string, accountId: string): Promise { try { return await readSubmissionUndoStatus(emailSubmissionId, accountId); } catch { return undefined; } } /** * Move a message back into Drafts (out of Sent) and re-flag it `$draft` so * it can be edited and re-sent - used after cancelling a scheduled send for * editing (webmail `restoreEmailToDraft`). */ export async function restoreEmailToDraft( emailId: string, draftsMailboxId: string, sentMailboxId?: string, accountIdOverride?: string, ): Promise { const accountId = accountIdOverride ?? jmapClient.accountId; const patch: Record = { [mailboxPointer(draftsMailboxId)]: true, [keywordPointer('$draft')]: true, [keywordPointer('$seen')]: true, }; if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null; await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft'); } export interface SendReadReceiptOptions extends MdnOptions { /** Identity the receipt is submitted through (its address is the MDN From). */ identityId: string; /** Where the sent receipt is filed. */ sentMailboxId: string; /** Submitting account (shared/group account); defaults to the primary. */ accountId?: string; } /** * Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the * multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob, * imported into Sent and submitted with an explicit envelope - mirrors the * webmail's `client.sendReadReceipt`. When the submission fails the imported * copy is destroyed again, so a refused receipt does not sit in Sent as if * it had gone out. The caller flags the original `$mdnsent` afterwards. */ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise { const accountId = opts.accountId ?? jmapClient.accountId; const raw = buildMdnMessage(opts); const bytes = new TextEncoder().encode(raw); // Lazy: blob.ts pulls in expo-file-system, which this module otherwise // never needs (and which the node test environment cannot load). const { uploadBytes } = await import('./blob'); const upload = await uploadBytes(bytes, 'message/rfc822', accountId); const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId); let submissionId: string | undefined; try { const submit = () => jmapClient.request( [ ['EmailSubmission/set', { accountId, create: { mdn: { emailId, identityId: opts.identityId, envelope: { mailFrom: { email: opts.fromEmail }, rcptTo: [{ email: opts.to }], }, }, }, }, '0'], ], SUBMISSION_USING, ); let res = await submit(); // A full submission quota (Stalwart 1.0): make room and try once more. if (submissionCreateError(res, 'mdn')?.type === 'overQuota') { await makeRoomForSubmission(accountId, { automatic: opts.automatic }); res = await submit(); } const body = requireMethodResult(res, '0', 'EmailSubmission/set'); const refused = body.notCreated?.mdn as { type?: string; description?: string } | undefined; if (refused?.type === 'overQuota') throw submissionQuotaError(refused.description, accountId); assertSetResult(body, ['mdn'], 'read receipt'); submissionId = body.created?.mdn?.id as string | undefined; } catch (err) { await destroyEmails([emailId], accountId).catch((cleanupErr) => { console.warn('[mdn] failed to remove the unsent receipt from Sent:', cleanupErr); }); throw err; } // A receipt goes out without a hold, so its record can go at once, as after // an immediate send (see sendEmail). if (submissionId && isStalwartAccount(accountId)) { await destroyEmailSubmissions([submissionId], accountId); } return emailId; } /** * Copy blobs into another account of the login (RFC 8620 §6.3): the blob of * an attachment in a shared or group mailbox, filed in the user's Files, or * a file attached to a message of another account. Resolves with the new * blobId for each. Stalwart answers Blob/copy only with the blob capability * in `using`. Same account: the ids as they are, nothing is sent. */ export async function copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise> { if (blobIds.length === 0 || fromAccountId === toAccountId) { return Object.fromEntries(blobIds.map((id) => [id, id])); } const res = await jmapClient.request( [['Blob/copy', { fromAccountId, accountId: toAccountId, blobIds }, '0']], [CAPABILITIES.CORE, CAPABILITIES.BLOB], ); const body = requireMethodResult(res, '0', 'Blob/copy'); const copied = (body.copied ?? {}) as Record; const missing = blobIds.find((id) => !copied[id]); if (missing) { const error = (body.notCopied ?? {})[missing] as { type?: string; description?: string } | undefined; throw new Error(error?.description || error?.type || 'Blob/copy did not copy the blob'); } return copied; }