Files
ihasmail/web/src/jmap/client.ts
T
jcoffey-dev a3fd236c36 Ask for the submission capability when using identities
Identity is defined by RFC 8621 under urn:ietf:params:jmap:submission, not
under mail. ihasmail asked for mail alone, so Stalwart 0.16 rejected both
Identity/get and Identity/set with unknownMethod: no identities were ever
listed, none could be created, and sending then failed with "No sending
identity available". Older Stalwart builds accepted the calls anyway, which
is why this went unnoticed.

Also filter `using` down to the capabilities the session actually advertises.
A server must reject the entire request with unknownCapability when `using`
names something it does not implement, so one over-eager urn would take down
every call sharing the batch — including, on a server predating the submission
capability, the mailbox and message loads batched alongside an identity fetch.

Fixes #12
2026-08-24 06:31:37 -07:00

381 lines
14 KiB
TypeScript

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<string, unknown>;
using: Set<string>;
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<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | 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<T = Record<string, unknown>>(method: string, args: Record<string, unknown>, using: string[] = []): Promise<T> {
return new Promise<T>((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<void> {
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<void> {
const using = new Set<string>();
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<string, Invocation[]>();
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<string, Id>): Promise<JmapResponse> {
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/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<string, unknown>, id: string]>,
opts: { using?: string[]; allowErrors?: boolean } = {},
): Promise<Map<string, Record<string, unknown>[]>> {
const using = new Set<string>([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<string, Record<string, unknown>[]>();
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<UploadResponse> {
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<string> {
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<Blob> {
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<T>(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}`;
}