ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+348
View File
@@ -0,0 +1,348 @@
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);
}
}
/** 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, 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":
case "Identity":
return [CAP.mail];
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;
}