// The replica's JMAP calls. Reuses `lib/mail-index/jmap.ts`'s session fetch, // origin pinning and request plumbing rather than duplicating them (that file // already handles Stalwart's 307 on /.well-known/jmap and refuses to send // credentials off-origin), and adds the delta-sync methods the index never // needed: `Mailbox/changes`, `Email/changes`, the ascending coverage query, and // the two-tier `Email/get`. // // THIS FILE IS THE ONLY PLACE ALLOWED TO MINT A BRANDED STATE TOKEN. That is what // makes cursor provenance checkable by grep: `asChangesState` appears only in the // `/changes` parser, `asSnapshotState` only in the `Foo/get {ids: []}` parser. import type { Email, Mailbox } from '@/lib/jmap/types'; import { CAP_CORE, CAP_MAIL, jmapRequest, type JmapSessionInfo } from '@/lib/mail-index/jmap'; import type { ChangesPage } from './apply'; import { ReplicaSyncError, classify } from './errors'; import { asChangesState, asSnapshotState, type SnapshotState } from './states'; /** The envelope tier. Mirrors `lib/jmap/client.ts`'s EMAIL_LIST_PROPERTIES exactly. */ export const ENVELOPE_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', 'blobId', ] as const; /** * The body tier - everything `lib/jmap/client.ts`'s `getEmail()` asks for beyond * the envelope tier, so a replica-served message is field-for-field what the * online read path produces. `components/email/email-viewer.tsx` reads * `bodyValues` keyed by the SAME partIds as `htmlBody`/`textBody`, so all three * must travel together or the viewer sits on its loading skeleton forever. */ export const BODY_PROPERTIES = [ 'id', 'sentAt', 'bcc', 'replyTo', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'messageId', 'inReplyTo', 'references', 'headers', 'bodyStructure', ] as const; /** The three MUTABLE properties. */ export const MUTABLE_PROPERTIES = ['id', 'keywords', 'mailboxIds'] as const; export const MAX_BODY_VALUE_BYTES = 512_000; interface MethodError { type?: string; description?: string; } function asMethodError(args: Record): MethodError { return { type: typeof args.type === 'string' ? args.type : undefined, description: typeof args.description === 'string' ? args.description : undefined, }; } /** * Runs one JMAP request and classifies any failure. Wraps the shared transport so * a transport-level failure becomes `Transport` (cursor untouched) rather than an * opaque throw the caller has to guess about. */ async function call( session: JmapSessionInfo, authHeader: string, methodCalls: ReadonlyArray<[string, Record, string]>, ): Promise, string]>> { try { return await jmapRequest(session, authHeader, [CAP_CORE, CAP_MAIL], methodCalls); } catch (error) { const status = (error as { status?: number } | null)?.status; const message = error instanceof Error ? error.message : String(error); throw new ReplicaSyncError(classify({ httpStatus: status, message }), message); } } function findResponse( responses: Array<[string, Record, string]>, callId: string, ): { name: string; args: Record } | null { for (const [name, args, id] of responses) { if (id === callId) return { name, args }; } return null; } /** Turns a method-level `error` response into a classified throw. */ function raiseMethodError(args: Record, context: string): never { const { type, description } = asMethodError(args); throw new ReplicaSyncError( classify({ jmapErrorType: type, message: description }), `${context} failed: ${type ?? 'unknown'}${description ? ` (${description})` : ''}`, ); } function strArray(v: unknown): string[] { return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } // ── snapshot states, for bootstrap / reconcile ─────────────────────────────── /** * Captures both cursors in ONE request, before touching any data. * * `Foo/get {ids: []}` returns the account's current state token with no records, * which RFC 8620 s5.1 defines as a valid `sinceState` for `Foo/changes`. This is * step 1 of the mandatory bootstrap order and the single thing most likely to be * "optimised" into a permanent data hole: the cursor must be captured BEFORE the * enumeration, so it is deliberately OLDER than the data and the first delta cycle * re-delivers a few changes we already have. The cheaper opposite order - enumerate, * then capture - silently loses every change that arrived during the scan, which on * a large mailbox is minutes. */ export async function captureSnapshotStates( session: JmapSessionInfo, authHeader: string, accountId: string, ): Promise<{ mailbox: SnapshotState; email: SnapshotState }> { const responses = await call(session, authHeader, [ ['Mailbox/get', { accountId, ids: [] }, 'm'], ['Email/get', { accountId, ids: [] }, 'e'], ]); const mailbox = findResponse(responses, 'm'); const email = findResponse(responses, 'e'); if (!mailbox || mailbox.name === 'error') { raiseMethodError(mailbox?.args ?? {}, 'Mailbox/get (state capture)'); } if (!email || email.name === 'error') { raiseMethodError(email?.args ?? {}, 'Email/get (state capture)'); } return { mailbox: asSnapshotState(mailbox.args.state), email: asSnapshotState(email.args.state), }; } // ── /changes ───────────────────────────────────────────────────────────────── function parseChangesPage(args: Record): ChangesPage { return { oldState: asChangesState(args.oldState), newState: asChangesState(args.newState), hasMoreChanges: args.hasMoreChanges === true, created: strArray(args.created), updated: strArray(args.updated), destroyed: strArray(args.destroyed), updatedProperties: args.updatedProperties === null ? null : Array.isArray(args.updatedProperties) ? strArray(args.updatedProperties) : undefined, }; } export async function getMailboxChanges( session: JmapSessionInfo, authHeader: string, accountId: string, sinceState: string, maxChanges: number, ): Promise { const responses = await call(session, authHeader, [ ['Mailbox/changes', { accountId, sinceState, maxChanges }, 'c'], ]); const res = findResponse(responses, 'c'); if (!res || res.name === 'error') raiseMethodError(res?.args ?? {}, 'Mailbox/changes'); return parseChangesPage(res.args); } export async function getEmailChanges( session: JmapSessionInfo, authHeader: string, accountId: string, sinceState: string, maxChanges: number, ): Promise { const responses = await call(session, authHeader, [ ['Email/changes', { accountId, sinceState, maxChanges }, 'c'], ]); const res = findResponse(responses, 'c'); if (!res || res.name === 'error') raiseMethodError(res?.args ?? {}, 'Email/changes'); // `Email/changes` has no `updatedProperties` - RFC 8621 s4.3 is a plain // /changes - which is why the 3-property `Email/get` is unavoidable there. return parseChangesPage(res.args); } // ── gets ───────────────────────────────────────────────────────────────────── export async function getMailboxes( session: JmapSessionInfo, authHeader: string, accountId: string, ids: readonly string[] | null, properties?: readonly string[], ): Promise { const args: Record = { accountId, ids: ids === null ? null : [...ids] }; if (properties) args.properties = [...properties, 'id']; const responses = await call(session, authHeader, [['Mailbox/get', args, 'g']]); const res = findResponse(responses, 'g'); if (!res || res.name === 'error') raiseMethodError(res?.args ?? {}, 'Mailbox/get'); return Array.isArray(res.args.list) ? (res.args.list as Mailbox[]) : []; } export interface EmailGetResult { list: Email[]; /** Normal, not an error: the record was destroyed between /changes and /get. */ notFound: string[]; } export async function getEmails( session: JmapSessionInfo, authHeader: string, accountId: string, ids: readonly string[], tier: 'envelope' | 'mutable' | 'body', ): Promise { if (ids.length === 0) return { list: [], notFound: [] }; const args: Record = { accountId, ids: [...ids] }; if (tier === 'envelope') { args.properties = [...ENVELOPE_PROPERTIES]; } else if (tier === 'mutable') { args.properties = [...MUTABLE_PROPERTIES]; } else { args.properties = [...BODY_PROPERTIES]; // Without these the bodyValues map comes back EMPTY and every stored body // would be an empty object that renders as a blank message offline. args.fetchTextBodyValues = true; args.fetchHTMLBodyValues = true; args.fetchAllBodyValues = true; args.maxBodyValueBytes = MAX_BODY_VALUE_BYTES; } const responses = await call(session, authHeader, [['Email/get', args, 'g']]); const res = findResponse(responses, 'g'); if (!res || res.name === 'error') raiseMethodError(res?.args ?? {}, 'Email/get'); return { list: Array.isArray(res.args.list) ? (res.args.list as Email[]) : [], notFound: strArray(res.args.notFound), }; } // ── coverage enumeration ───────────────────────────────────────────────────── export interface CoveragePage { ids: string[]; /** Echoed back so the caller can detect the tie-cluster case. */ requestedAfter: string; } /** * The ascending keyset walk. * * ASCENDING is not a style choice. New mail arrives at the TAIL, so insertions * never shift rows the scan has already passed. With a DESCENDING sort and * position-based paging, one delivery between page 1 and page 2 pushes a message * from page 1's boundary into page 2's start and one message out of the scan's * reach entirely - and that message is pre-existing relative to our cursor, so * `Email/changes` will never report it. A permanent hole with no signal it exists. * * `calculateTotal: false` because the total is unstable and unused. */ export async function queryAscending( session: JmapSessionInfo, authHeader: string, accountId: string, after: string, limit: number, anchor?: { anchor: string; anchorOffset: number }, ): Promise { const args: Record = { accountId, filter: { after }, sort: [{ property: 'receivedAt', isAscending: true }], limit, calculateTotal: false, }; if (anchor) { args.anchor = anchor.anchor; args.anchorOffset = anchor.anchorOffset; } const responses = await call(session, authHeader, [['Email/query', args, 'q']]); const res = findResponse(responses, 'q'); if (!res || res.name === 'error') { const { type } = asMethodError(res?.args ?? {}); if (type === 'anchorNotFound') { // Not a failure - the caller falls back to the last-resort rung. throw new AnchorNotFoundError(); } raiseMethodError(res?.args ?? {}, 'Email/query'); } return { ids: strArray(res.args.ids), requestedAfter: after }; } export class AnchorNotFoundError extends Error { constructor() { super('Email/query rejected the anchor'); this.name = 'AnchorNotFoundError'; } } /** `maxObjectsInGet`, so the maxChanges ladder can be clamped to what the server allows. */ export function maxObjectsInGet(session: JmapSessionInfo): number | undefined { const core = session.capabilities[CAP_CORE]; if (!core || typeof core !== 'object') return undefined; const value = (core as Record).maxObjectsInGet; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; }