A pushed mail change took three round trips: Email/changes beside a Mailbox/get, then Email/get for what changed, then the list, the open thread and a second Mailbox/get. Each page of changes now carries its own Email/get calls by back-reference, and the one Mailbox/get goes out with it, so a push settles in two. New mail fetched this way is not asked for again by the notice. A reply's sessionState that differs from the session is announced once rather than on every reply, and session refreshes in flight are shared. The mock's session state now matches the sessionState on its replies, as Stalwart's does; tying it to the data counter made every reply trigger a session refresh in development.
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
|
import { withBase } from "@/lib/basePath";
|
|
|
|
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;
|
|
|
|
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
|
export const STALWART_CAP = "urn:stalwart:jmap";
|
|
|
|
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.
|
|
*
|
|
* `path` is written root-absolute at every call site -- `/api/jmap` -- and the
|
|
* mount prefix is added here rather than there. One place to get it right, and
|
|
* the `startsWith` below keeps working on the path as written rather than on
|
|
* whatever the deployment happens to be called.
|
|
*/
|
|
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(withBase(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>();
|
|
/** The last session state announced, so a burst of replies announces it once. */
|
|
private announcedState: string | null = null;
|
|
|
|
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 maxObjectsInSet(): number {
|
|
const core = this.session?.capabilities[CAP.core] as { maxObjectsInSet?: number } | undefined;
|
|
return core?.maxObjectsInSet ?? 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);
|
|
}
|
|
|
|
/**
|
|
* Whether the server carries a capability at all, wherever it chose to
|
|
* advertise it.
|
|
*
|
|
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
|
|
* in the session-level `capabilities`, so `hasCapability` alone reports every
|
|
* real 0.16 server as though it were older. Look in all three places.
|
|
*/
|
|
hasCapabilityAnywhere(cap: string): boolean {
|
|
if (this.hasCapability(cap)) return true;
|
|
if (this.session?.primaryAccounts && cap in this.session.primaryAccounts) return true;
|
|
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.accountCapabilities ?? {}));
|
|
}
|
|
|
|
/**
|
|
* The capability object itself, for the capabilities that carry limits.
|
|
* Stalwart puts the interesting half of `urn:ietf:params:jmap:submission`
|
|
* here and leaves the session-level copy empty.
|
|
*/
|
|
accountCapability<T>(accountId: Id, cap: string): T | undefined {
|
|
const acc = this.session?.accounts[accountId];
|
|
return acc?.accountCapabilities[cap] as T | undefined;
|
|
}
|
|
|
|
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;
|
|
// Anywhere counts: a capability advertised per-account is one the server
|
|
// has, and Stalwart advertises its own that way and no other.
|
|
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(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 && res.sessionState !== this.announcedState) {
|
|
this.announcedState = res.sessionState;
|
|
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 withBase(`/api/upload/${encodeURIComponent(accountId)}`);
|
|
}
|
|
|
|
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
|
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
|
const u = withBase(`/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 canceled"));
|
|
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] ?? "";
|
|
// Stalwart's registry: accounts, domains, credentials. Advertised per
|
|
// account rather than in the session, which supportedUsing() allows for.
|
|
if (type.startsWith("x:")) return [STALWART_CAP];
|
|
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 so a /get or /set call stays under the server's per-call maximum. */
|
|
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}`;
|
|
}
|