import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types"; export const CAP = { core: "urn:ietf:params:jmap:core", mail: "urn:ietf:params:jmap:mail", submission: "urn:ietf:params:jmap:submission", vacation: "urn:ietf:params:jmap:vacationresponse", sieve: "urn:ietf:params:jmap:sieve", contacts: "urn:ietf:params:jmap:contacts", contactsParse: "urn:ietf:params:jmap:contacts:parse", calendars: "urn:ietf:params:jmap:calendars", calendarsParse: "urn:ietf:params:jmap:calendars:parse", principals: "urn:ietf:params:jmap:principals", availability: "urn:ietf:params:jmap:principals:availability", quota: "urn:ietf:params:jmap:quota", blob: "urn:ietf:params:jmap:blob", filenode: "urn:ietf:params:jmap:filenode", websocket: "urn:ietf:params:jmap:websocket", } as const; export class JmapMethodError extends Error { constructor( public readonly method: string, public readonly error: MethodError, ) { super(`${method}: ${error.type}${error.description ? ` - ${error.description}` : ""}`); this.name = "JmapMethodError"; } get type() { return this.error.type; } } export class ApiError extends Error { constructor( public readonly status: number, public readonly code: string, message?: string, ) { super(message ?? `${code} (${status})`); this.name = "ApiError"; } } export interface ApiErrorBody { error?: string; message?: string; type?: string; detail?: string; title?: string; } interface Pending { method: string; args: Record; using: Set; resolve: (v: unknown) => void; reject: (e: unknown) => void; } export type ResultRef = { resultOf: string; name: string; path: string }; const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" }; /** Generic fetch against our same-origin API with CSRF header + auth handling. */ export async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(path, { ...init, headers: { ...HEADERS, ...(init.headers as Record | undefined) }, credentials: "same-origin", }); if (res.status === 401 && !path.startsWith("/api/auth/login")) { client.handleUnauthenticated(); throw new ApiError(401, "unauthenticated", "Your session has expired. Please sign in again."); } if (!res.ok) { let body: ApiErrorBody = {}; try { body = (await res.json()) as ApiErrorBody; } catch { /* ignore */ } throw new ApiError(res.status, body.error ?? body.type ?? "error", body.message ?? body.detail ?? body.title ?? res.statusText); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } export class JmapClient { session: JmapSession | null = null; private pending: Pending[] = []; private flushScheduled = false; private callCounter = 0; private unauthHandlers = new Set<() => void>(); private stateHandlers = new Set<(sessionState: string) => void>(); get maxCallsInRequest(): number { const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined; return core?.maxCallsInRequest ?? 16; } get maxObjectsInGet(): number { const core = this.session?.capabilities[CAP.core] as { maxObjectsInGet?: number } | undefined; return core?.maxObjectsInGet ?? 500; } get maxSizeUpload(): number { const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined; return core?.maxSizeUpload ?? 50_000_000; } hasCapability(cap: string): boolean { return Boolean(this.session?.capabilities && cap in this.session.capabilities); } accountHasCapability(accountId: Id, cap: string): boolean { const acc = this.session?.accounts[accountId]; return Boolean(acc && cap in acc.accountCapabilities); } primaryAccount(cap: string): Id | null { return this.session?.primaryAccounts[cap] ?? null; } onUnauthenticated(fn: () => void): () => void { this.unauthHandlers.add(fn); return () => this.unauthHandlers.delete(fn); } onSessionState(fn: (s: string) => void): () => void { this.stateHandlers.add(fn); return () => this.stateHandlers.delete(fn); } handleUnauthenticated(): void { for (const fn of this.unauthHandlers) fn(); } /** * Queue a single method call; calls made within the same tick are batched * into one HTTP request (up to maxCallsInRequest). */ call>(method: string, args: Record, using: string[] = []): Promise { return new Promise((resolve, reject) => { this.pending.push({ method, args, using: new Set([CAP.core, ...usingFor(method), ...using]), resolve: resolve as (v: unknown) => void, reject, }); if (!this.flushScheduled) { this.flushScheduled = true; queueMicrotask(() => void this.flush()); } }); } private async flush(): Promise { this.flushScheduled = false; const batch = this.pending; this.pending = []; const max = this.maxCallsInRequest; for (let i = 0; i < batch.length; i += max) { void this.sendBatch(batch.slice(i, i + max)); } } private async sendBatch(batch: Pending[]): Promise { const using = new Set(); const calls: Invocation[] = batch.map((p, idx) => { for (const u of p.using) using.add(u); return [p.method, p.args, `c${this.callCounter++}_${idx}`]; }); try { const res = await this.request(calls, [...using]); const byId = new Map(); for (const inv of res.methodResponses) { const arr = byId.get(inv[2]) ?? []; arr.push(inv); byId.set(inv[2], arr); } batch.forEach((p, idx) => { const responses = byId.get(calls[idx]![2]); const first = responses?.[0]; if (!first) { p.reject(new JmapMethodError(p.method, { type: "serverFail", description: "No response for call" })); return; } if (first[0] === "error") p.reject(new JmapMethodError(p.method, first[1] as MethodError)); else p.resolve(first[1]); }); } catch (err) { for (const p of batch) p.reject(err); } } /** * Drop capabilities this session never advertised. * * A server MUST reject the whole request with `unknownCapability` when * `using` names something it does not implement (RFC 8620), which would take * down every call in the batch — not just the one that wanted the capability. * Core always stays: it is the one urn every server has. */ private supportedUsing(using: string[]): string[] { if (!this.session?.capabilities) return using; return using.filter((u) => u === CAP.core || this.hasCapability(u)); } /** Low-level request: send invocations verbatim, return raw response. */ async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record): Promise { const body: Record = { using: this.supportedUsing(using), methodCalls }; if (createdIds) body.createdIds = createdIds; const res = await apiFetch("/api/jmap", { method: "POST", body: JSON.stringify(body) }); if (res.sessionState && this.session && res.sessionState !== this.session.state) { for (const fn of this.stateHandlers) fn(res.sessionState); } return res; } /** * Run a chain of invocations (which may use result references) and return * responses keyed by call id. Throws if any call errored, unless `allowErrors`. */ async chain( calls: Array<[method: string, args: Record, id: string]>, opts: { using?: string[]; allowErrors?: boolean } = {}, ): Promise[]>> { const using = new Set([CAP.core]); for (const [m] of calls) for (const u of usingFor(m)) using.add(u); for (const u of opts.using ?? []) using.add(u); const res = await this.request(calls, [...using]); const out = new Map[]>(); for (const [name, args, id] of res.methodResponses) { if (name === "error" && !opts.allowErrors) { const method = calls.find((c) => c[2] === id)?.[0] ?? id; throw new JmapMethodError(method, args as MethodError); } const arr = out.get(id) ?? []; arr.push(name === "error" ? { __error: args } : args); out.set(id, arr); } return out; } uploadUrl(accountId: Id): string { return `/api/upload/${encodeURIComponent(accountId)}`; } downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string { const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_"); const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`; return inline ? `${u}&inline=1` : u; } /** Upload a blob with progress reporting (XHR because fetch lacks upload progress). */ upload( accountId: Id, data: Blob, opts: { type?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal } = {}, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", this.uploadUrl(accountId)); xhr.setRequestHeader("content-type", opts.type || data.type || "application/octet-stream"); xhr.setRequestHeader("x-requested-with", "ihasmail"); xhr.responseType = "json"; xhr.upload.onprogress = (e) => { if (e.lengthComputable) opts.onProgress?.(e.loaded, e.total); }; xhr.onload = () => { if (xhr.status === 401) { this.handleUnauthenticated(); reject(new ApiError(401, "unauthenticated")); return; } if (xhr.status >= 200 && xhr.status < 300 && xhr.response) resolve(xhr.response as UploadResponse); else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed")); }; xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload")); xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled")); opts.signal?.addEventListener("abort", () => xhr.abort()); xhr.send(data); }); } /** Fetch a blob's content as text (via the download proxy). */ async fetchBlobText(accountId: Id, blobId: Id, type = "text/plain"): Promise { const res = await fetch(this.downloadUrl(accountId, blobId, "blob.txt", type), { credentials: "same-origin" }); if (res.status === 401) { this.handleUnauthenticated(); throw new ApiError(401, "unauthenticated"); } if (!res.ok) throw new ApiError(res.status, "download_failed"); return await res.text(); } async fetchBlob(accountId: Id, blobId: Id, type = "application/octet-stream"): Promise { const res = await fetch(this.downloadUrl(accountId, blobId, "blob", type), { credentials: "same-origin" }); if (res.status === 401) { this.handleUnauthenticated(); throw new ApiError(401, "unauthenticated"); } if (!res.ok) throw new ApiError(res.status, "download_failed"); return await res.blob(); } } /** Map method name prefix → required capability URNs. */ function usingFor(method: string): string[] { const type = method.split("/")[0] ?? ""; switch (type) { case "Mailbox": case "Thread": case "Email": case "SearchSnippet": return [CAP.mail]; // Identity belongs to the submission capability (RFC 8621), not mail: // Stalwart >= 0.16 rejects Identity/get and Identity/set outright when // "using" names only mail. Keep mail as well, so the filter in // supportedUsing() still leaves a usable urn on servers that predate // advertising submission. case "Identity": case "EmailSubmission": return [CAP.mail, CAP.submission]; case "VacationResponse": return [CAP.mail, CAP.vacation]; case "SieveScript": return [CAP.sieve]; case "AddressBook": case "ContactCard": return [CAP.contacts, CAP.contactsParse]; case "Calendar": case "CalendarEvent": case "ParticipantIdentity": case "CalendarEventNotification": return [CAP.calendars, CAP.calendarsParse]; case "Principal": return [CAP.principals, CAP.availability]; case "Quota": return [CAP.quota]; case "Blob": return [CAP.blob]; case "FileNode": return [CAP.filenode]; case "PushSubscription": return []; default: return []; } } export const client = new JmapClient(); /** Build a JMAP result reference argument ("#ids": {...}). */ export function ref(resultOf: string, name: string, path: string): ResultRef { return { resultOf, name, path }; } /** Chunk ids for /get calls to respect maxObjectsInGet. */ export function chunk(arr: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } /** * A readable message for a JMAP SetError. * * Servers name the offending field in `properties`, which is usually the whole * answer to "why was this rejected" — Stalwart's description alone is often * just "Invalid property or value." Keep both. */ export function setErrorMessage(err: { type: string; description?: string; properties?: string[] } | null | undefined): string { if (!err) return "Unknown error"; const base = err.description ?? err.type; const props = err.properties?.length ? ` (${err.properties.join(", ")})` : ""; return `${base}${props}`; }