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;
}
+104
View File
@@ -0,0 +1,104 @@
import type { Id, StateChange } from "./types";
export type PushListener = (accountId: Id, type: string, newState: string) => void;
/**
* JMAP push over Server-Sent Events (proxied through our server).
* Emits per-type state changes so stores can refresh incrementally.
*/
class PushManager {
private es: EventSource | null = null;
private listeners = new Set<PushListener>();
private connectionListeners = new Set<(connected: boolean) => void>();
private backoff = 1000;
private reconnectTimer: number | null = null;
private stopped = true;
private lastStates = new Map<string, string>();
connected = false;
start(): void {
this.stopped = false;
this.connect();
document.addEventListener("visibilitychange", this.onVisibility);
window.addEventListener("online", this.onOnline);
}
stop(): void {
this.stopped = true;
document.removeEventListener("visibilitychange", this.onVisibility);
window.removeEventListener("online", this.onOnline);
if (this.reconnectTimer) window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.es?.close();
this.es = null;
this.setConnected(false);
}
subscribe(fn: PushListener): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
onConnection(fn: (connected: boolean) => void): () => void {
this.connectionListeners.add(fn);
return () => this.connectionListeners.delete(fn);
}
private setConnected(v: boolean) {
if (this.connected === v) return;
this.connected = v;
for (const fn of this.connectionListeners) fn(v);
}
private onVisibility = () => {
if (document.visibilityState === "visible" && !this.es && !this.stopped) this.connect();
};
private onOnline = () => {
if (!this.es && !this.stopped) this.connect();
};
private connect(): void {
if (this.stopped || this.es) return;
const url = `/api/events?types=*&closeafter=no&ping=30`;
const es = new EventSource(url, { withCredentials: true });
this.es = es;
es.onopen = () => {
this.backoff = 1000;
this.setConnected(true);
};
es.addEventListener("state", (ev) => {
try {
const data = JSON.parse((ev as MessageEvent).data as string) as StateChange;
if (data["@type"] !== "StateChange") return;
for (const [accountId, types] of Object.entries(data.changed)) {
for (const [type, state] of Object.entries(types)) {
const key = `${accountId}/${type}`;
if (this.lastStates.get(key) === state) continue;
this.lastStates.set(key, state);
for (const fn of this.listeners) fn(accountId, type, state);
}
}
} catch {
/* ignore malformed */
}
});
es.addEventListener("ping", () => {
/* keepalive */
});
es.onerror = () => {
es.close();
this.es = null;
this.setConnected(false);
if (this.stopped) return;
const delay = Math.min(this.backoff, 60_000);
this.backoff = Math.min(this.backoff * 2, 60_000);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
};
}
}
export const push = new PushManager();
+775
View File
@@ -0,0 +1,775 @@
/* ------------------------------------------------------------------ */
/* JMAP core (RFC 8620) */
/* ------------------------------------------------------------------ */
export type Id = string;
export type UTCDate = string; // "2024-01-01T10:00:00Z"
export type LocalDate = string; // "2024-01-01T10:00:00"
export interface Account {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
accountCapabilities: Record<string, unknown>;
}
export interface JmapSession {
capabilities: Record<string, unknown>;
accounts: Record<Id, Account>;
primaryAccounts: Record<string, Id>;
username: string;
apiUrl: string;
downloadUrl: string;
uploadUrl: string;
eventSourceUrl: string;
state: string;
ihasmail?: {
appName: string;
imageProxy: boolean;
maxUploadBytes: number;
sessionId: string;
loginName: string;
remember: boolean;
};
}
export interface CoreCapabilities {
maxSizeUpload: number;
maxConcurrentUpload: number;
maxSizeRequest: number;
maxConcurrentRequests: number;
maxCallsInRequest: number;
maxObjectsInGet: number;
maxObjectsInSet: number;
collationAlgorithms: string[];
}
export interface MailCapabilities {
maxMailboxesPerEmail: number | null;
maxMailboxDepth: number | null;
maxSizeMailboxName: number;
maxSizeAttachmentsPerEmail: number;
emailQuerySortOptions: string[];
mayCreateTopLevelMailbox: boolean;
}
export type Invocation = [name: string, args: Record<string, unknown>, callId: string];
export interface JmapResponse {
methodResponses: Invocation[];
sessionState: string;
createdIds?: Record<string, Id>;
}
export interface MethodError {
type: string;
description?: string;
[k: string]: unknown;
}
export interface SetError {
type: string;
description?: string;
properties?: string[];
[k: string]: unknown;
}
export interface SetResponse<T = Record<string, unknown>> {
accountId: Id;
oldState: string | null;
newState: string;
created?: Record<string, T>;
updated?: Record<string, T | null>;
destroyed?: Id[];
notCreated?: Record<string, SetError>;
notUpdated?: Record<string, SetError>;
notDestroyed?: Record<string, SetError>;
}
export interface GetResponse<T> {
accountId: Id;
state: string;
list: T[];
notFound: Id[];
}
export interface QueryResponse {
accountId: Id;
queryState: string;
canCalculateChanges: boolean;
position: number;
ids: Id[];
total?: number;
limit?: number;
}
export interface ChangesResponse {
accountId: Id;
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: Id[];
updated: Id[];
destroyed: Id[];
}
export interface StateChange {
"@type": "StateChange";
changed: Record<Id, Record<string, string>>;
}
/* ------------------------------------------------------------------ */
/* Mail (RFC 8621) */
/* ------------------------------------------------------------------ */
export type MailboxRole =
| "inbox"
| "archive"
| "drafts"
| "sent"
| "trash"
| "junk"
| "important"
| "all"
| "flagged"
| "subscribed"
| null;
export interface MailboxRights {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
}
export interface Mailbox {
id: Id;
name: string;
parentId: Id | null;
role: MailboxRole;
sortOrder: number;
totalEmails: number;
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: MailboxRights;
isSubscribed: boolean;
shareWith?: Record<Id, Partial<MailboxRights>> | null;
}
export interface EmailAddress {
name: string | null;
email: string;
}
export interface EmailAddressGroup {
name: string | null;
addresses: EmailAddress[];
}
export interface EmailHeader {
name: string;
value: string;
}
export interface EmailBodyPart {
partId: string | null;
blobId: Id | null;
size: number;
headers?: EmailHeader[];
name: string | null;
type: string;
charset: string | null;
disposition: string | null;
cid: string | null;
language?: string[] | null;
location?: string | null;
subParts?: EmailBodyPart[] | null;
}
export interface EmailBodyValue {
value: string;
isEncodingProblem: boolean;
isTruncated: boolean;
}
export interface Email {
id: Id;
blobId: Id;
threadId: Id;
mailboxIds: Record<Id, boolean>;
keywords: Record<string, boolean>;
size: number;
receivedAt: UTCDate;
messageId?: string[] | null;
inReplyTo?: string[] | null;
references?: string[] | null;
sender?: EmailAddress[] | null;
from?: EmailAddress[] | null;
to?: EmailAddress[] | null;
cc?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
replyTo?: EmailAddress[] | null;
subject?: string | null;
sentAt?: string | null;
hasAttachment?: boolean;
preview?: string;
bodyStructure?: EmailBodyPart;
bodyValues?: Record<string, EmailBodyValue>;
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
attachments?: EmailBodyPart[];
headers?: EmailHeader[];
// convenience header fetches
"header:List-Unsubscribe:asText"?: string | null;
"header:List-Unsubscribe-Post:asText"?: string | null;
"header:List-Id:asText"?: string | null;
"header:Disposition-Notification-To:asAddresses"?: EmailAddress[] | null;
"header:X-Priority:asText"?: string | null;
"header:Importance:asText"?: string | null;
"header:Auto-Submitted:asText"?: string | null;
"header:Return-Path:asText"?: string | null;
"header:Authentication-Results:asText"?: string | null;
"header:Received:asText:all"?: string[] | null;
"header:X-Spam-Status:asText"?: string | null;
"header:X-Spam-Result:asText"?: string | null;
}
export interface Thread {
id: Id;
emailIds: Id[];
}
export interface Identity {
id: Id;
name: string;
email: string;
replyTo: EmailAddress[] | null;
bcc: EmailAddress[] | null;
textSignature: string;
htmlSignature: string;
mayDelete: boolean;
}
export interface EmailSubmission {
id: Id;
identityId: Id;
emailId: Id;
threadId: Id;
envelope: { mailFrom: { email: string; parameters?: Record<string, unknown> | null }; rcptTo: { email: string }[] } | null;
sendAt: UTCDate;
undoStatus: "pending" | "final" | "canceled";
deliveryStatus: Record<string, { smtpReply: string; delivered: string; displayed: string }> | null;
}
export interface VacationResponse {
id: "singleton";
isEnabled: boolean;
fromDate: UTCDate | null;
toDate: UTCDate | null;
subject: string | null;
textBody: string | null;
htmlBody: string | null;
}
export interface SearchSnippet {
emailId: Id;
subject: string | null;
preview: string | null;
}
export interface EmailFilterCondition {
inMailbox?: Id;
inMailboxOtherThan?: Id[];
before?: UTCDate;
after?: UTCDate;
minSize?: number;
maxSize?: number;
allInThreadHaveKeyword?: string;
someInThreadHaveKeyword?: string;
noneInThreadHaveKeyword?: string;
hasKeyword?: string;
notKeyword?: string;
hasAttachment?: boolean;
text?: string;
from?: string;
to?: string;
cc?: string;
bcc?: string;
subject?: string;
body?: string;
header?: string[];
}
export interface FilterOperator<T> {
operator: "AND" | "OR" | "NOT";
conditions: Array<T | FilterOperator<T>>;
}
export type EmailFilter = EmailFilterCondition | FilterOperator<EmailFilterCondition>;
export interface Comparator {
property: string;
isAscending?: boolean;
collation?: string;
keyword?: string;
}
/* ------------------------------------------------------------------ */
/* Quota (RFC 9425) */
/* ------------------------------------------------------------------ */
export interface Quota {
id: Id;
resourceType: "count" | "octets";
used: number;
hardLimit: number;
scope: "account" | "domain" | "global";
name: string;
types: string[];
warnLimit?: number | null;
softLimit?: number | null;
description?: string | null;
}
/* ------------------------------------------------------------------ */
/* Sieve (RFC 9661) */
/* ------------------------------------------------------------------ */
export interface SieveScript {
id: Id;
name: string;
blobId: Id;
isActive: boolean;
}
/* ------------------------------------------------------------------ */
/* Principals (RFC 9670) */
/* ------------------------------------------------------------------ */
export interface Principal {
id: Id;
type: "individual" | "group" | "resource" | "location" | "other";
name: string;
description: string | null;
email: string | null;
timeZone: string | null;
capabilities?: Record<string, unknown>;
accounts?: Record<Id, Account> | null;
}
export interface BusyPeriod {
utcStart: UTCDate;
utcEnd: UTCDate;
busyStatus: "confirmed" | "tentative" | "unavailable";
event: JSCalendarEvent | null;
}
/* ------------------------------------------------------------------ */
/* Contacts (RFC 9610 / JSContact RFC 9553) */
/* ------------------------------------------------------------------ */
export interface AddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare: boolean;
mayDelete: boolean;
}
export interface AddressBook {
id: Id;
name: string;
description: string | null;
sortOrder: number;
isDefault: boolean;
isSubscribed: boolean;
shareWith: Record<Id, AddressBookRights> | null;
myRights: AddressBookRights;
}
export interface JSContactNameComponent {
"@type"?: "NameComponent";
kind: "title" | "given" | "given2" | "surname" | "surname2" | "credential" | "generation" | "separator";
value: string;
}
export interface JSContactName {
"@type"?: "Name";
components?: JSContactNameComponent[];
isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
sortAs?: Record<string, string>;
}
export interface JSContactEmail {
"@type"?: "EmailAddress";
address: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
}
export interface JSContactPhone {
"@type"?: "Phone";
number: string;
features?: Record<string, boolean>;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
}
export interface JSContactAddressComponent {
"@type"?: "AddressComponent";
kind: string;
value: string;
}
export interface JSContactAddress {
"@type"?: "Address";
components?: JSContactAddressComponent[];
isOrdered?: boolean;
countryCode?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
full?: string;
defaultSeparator?: string;
pref?: number;
}
export interface JSContactOrganization {
"@type"?: "Organization";
name?: string;
units?: { "@type"?: "OrgUnit"; name: string }[];
sortAs?: string;
contexts?: Record<string, boolean>;
}
export interface JSContactTitle {
"@type"?: "Title";
name: string;
kind?: "title" | "role";
organizationId?: string;
}
export interface JSContactAnniversary {
"@type"?: "Anniversary";
kind: "birth" | "death" | "wedding" | string;
date: { "@type"?: "PartialDate" | "Timestamp"; year?: number; month?: number; day?: number; utc?: string };
place?: JSContactAddress;
}
export interface JSContactNote {
"@type"?: "Note";
note: string;
created?: string;
author?: { name?: string; uri?: string };
}
export interface JSContactOnlineService {
"@type"?: "OnlineService";
service?: string;
uri?: string;
user?: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
}
export interface JSContactMedia {
"@type"?: "Media";
kind: "photo" | "sound" | "logo";
uri?: string;
blobId?: Id;
mediaType?: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
}
export interface JSContactRelation {
"@type"?: "Relation";
relation?: Record<string, boolean>;
}
export interface ContactCard {
id: Id;
addressBookIds: Record<Id, boolean>;
"@type"?: "Card";
version?: "1.0";
uid: string;
kind?: "individual" | "group" | "org" | "location" | "device" | "application";
created?: UTCDate;
updated?: UTCDate;
language?: string;
prodId?: string;
members?: Record<string, boolean>;
name?: JSContactName;
nicknames?: Record<string, { "@type"?: "Nickname"; name: string; contexts?: Record<string, boolean>; pref?: number }>;
organizations?: Record<string, JSContactOrganization>;
titles?: Record<string, JSContactTitle>;
emails?: Record<string, JSContactEmail>;
phones?: Record<string, JSContactPhone>;
addresses?: Record<string, JSContactAddress>;
onlineServices?: Record<string, JSContactOnlineService>;
anniversaries?: Record<string, JSContactAnniversary>;
notes?: Record<string, JSContactNote>;
keywords?: Record<string, boolean>;
media?: Record<string, JSContactMedia>;
relatedTo?: Record<string, JSContactRelation>;
links?: Record<string, { "@type"?: "Link"; uri: string; kind?: string; label?: string }>;
preferredLanguages?: Record<string, { "@type"?: "LanguagePref"; language: string; pref?: number; contexts?: Record<string, boolean> }>;
speakToAs?: { "@type"?: "SpeakToAs"; grammaticalGender?: string; pronouns?: Record<string, { pronouns: string }> };
calendars?: Record<string, { "@type"?: "Calendar"; kind?: string; uri: string }>;
schedulingAddresses?: Record<string, { "@type"?: "SchedulingAddress"; uri: string }>;
personalInfo?: Record<string, { "@type"?: "PersonalInfo"; kind: string; value: string; level?: string }>;
}
/* ------------------------------------------------------------------ */
/* Calendars (draft-ietf-jmap-calendars / JSCalendar RFC 8984) */
/* ------------------------------------------------------------------ */
export interface CalendarRights {
mayReadFreeBusy: boolean;
mayReadItems: boolean;
mayWriteAll: boolean;
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayShare: boolean;
mayDelete: boolean;
}
export interface Calendar {
id: Id;
name: string;
description: string | null;
color: string | null;
sortOrder: number;
isSubscribed: boolean;
isVisible: boolean;
isDefault: boolean;
includeInAvailability: "all" | "attending" | "none";
defaultAlertsWithTime: Record<string, JSCalendarAlert> | null;
defaultAlertsWithoutTime: Record<string, JSCalendarAlert> | null;
timeZone: string | null;
shareWith: Record<Id, CalendarRights> | null;
myRights: CalendarRights;
}
export interface JSCalendarAlert {
"@type"?: "Alert";
trigger:
| { "@type"?: "OffsetTrigger"; offset: string; relativeTo?: "start" | "end" }
| { "@type"?: "AbsoluteTrigger"; when: UTCDate };
acknowledged?: UTCDate;
action?: "display" | "email";
relatedTo?: Record<string, JSContactRelation>;
}
export interface JSCalendarNDay {
"@type"?: "NDay";
day: "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
nthOfPeriod?: number;
}
export interface JSCalendarRecurrenceRule {
"@type"?: "RecurrenceRule";
frequency: "yearly" | "monthly" | "weekly" | "daily" | "hourly" | "minutely" | "secondly";
interval?: number;
rscale?: string;
skip?: string;
firstDayOfWeek?: string;
byDay?: JSCalendarNDay[];
byMonthDay?: number[];
byMonth?: string[];
byYearDay?: number[];
byWeekNo?: number[];
byHour?: number[];
byMinute?: number[];
bySecond?: number[];
bySetPosition?: number[];
count?: number;
until?: LocalDate;
}
export interface JSCalendarParticipant {
"@type"?: "Participant";
name?: string;
email?: string;
description?: string;
sendTo?: Record<string, string>;
kind?: "individual" | "group" | "location" | "resource";
roles: Record<string, boolean>;
locationId?: string;
language?: string;
participationStatus?: "needs-action" | "accepted" | "declined" | "tentative" | "delegated";
participationComment?: string;
expectReply?: boolean;
scheduleAgent?: "server" | "client" | "none";
scheduleForceSend?: boolean;
scheduleSequence?: number;
scheduleStatus?: string[];
scheduleUpdated?: UTCDate;
sentBy?: string;
invitedBy?: string;
delegatedTo?: Record<string, boolean>;
delegatedFrom?: Record<string, boolean>;
memberOf?: Record<string, boolean>;
links?: Record<string, unknown>;
progress?: string;
percentComplete?: number;
}
export interface JSCalendarLocation {
"@type"?: "Location";
name?: string;
description?: string;
locationTypes?: Record<string, boolean>;
relativeTo?: "start" | "end";
timeZone?: string;
coordinates?: string;
links?: Record<string, unknown>;
}
export interface JSCalendarVirtualLocation {
"@type"?: "VirtualLocation";
name?: string;
description?: string;
uri: string;
features?: Record<string, boolean>;
}
export interface JSCalendarEvent {
"@type"?: "Event";
uid: string;
relatedTo?: Record<string, JSContactRelation>;
prodId?: string;
created?: UTCDate;
updated?: UTCDate;
sequence?: number;
method?: string;
title?: string;
description?: string;
descriptionContentType?: string;
showWithoutTime?: boolean;
locations?: Record<string, JSCalendarLocation>;
virtualLocations?: Record<string, JSCalendarVirtualLocation>;
links?: Record<string, { "@type"?: "Link"; href: string; contentType?: string; size?: number; rel?: string; display?: string; title?: string }>;
locale?: string;
keywords?: Record<string, boolean>;
categories?: Record<string, boolean>;
color?: string;
recurrenceId?: LocalDate;
recurrenceIdTimeZone?: string;
recurrenceRules?: JSCalendarRecurrenceRule[];
excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
excluded?: boolean;
priority?: number;
freeBusyStatus?: "free" | "busy";
privacy?: "public" | "private" | "secret";
replyTo?: Record<string, string>;
sentBy?: string;
participants?: Record<string, JSCalendarParticipant>;
requestStatus?: string;
useDefaultAlerts?: boolean;
alerts?: Record<string, JSCalendarAlert>;
localizations?: Record<string, Record<string, unknown>>;
timeZone?: string | null;
start: LocalDate;
duration?: string;
status?: "confirmed" | "cancelled" | "tentative";
}
export interface CalendarEvent extends JSCalendarEvent {
id: Id;
baseEventId?: Id | null;
calendarIds: Record<Id, boolean>;
isDraft?: boolean;
isOrigin?: boolean;
utcStart?: UTCDate;
utcEnd?: UTCDate;
mayInviteSelf?: boolean;
mayInviteOthers?: boolean;
hideAttendees?: boolean;
}
export interface ParticipantIdentity {
id: Id;
name: string;
calendarAddress: string;
sendTo: Record<string, string>;
isDefault: boolean;
}
export interface CalendarEventNotification {
id: Id;
created: UTCDate;
changedBy: { name: string; email: string | null; principalId: Id | null; calendarAddress?: string | null };
comment: string | null;
type: "created" | "updated" | "destroyed";
calendarEventId: Id;
isDraft?: boolean;
event: JSCalendarEvent;
eventPatch?: Record<string, unknown>;
}
/* ------------------------------------------------------------------ */
/* Files (draft-ietf-jmap-filenode) */
/* ------------------------------------------------------------------ */
export interface FilesRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
export interface FileNode {
id: Id;
parentId: Id | null;
nodeType: "file" | "directory" | "symlink";
blobId: Id | null;
target?: string[] | null;
size: number | null;
name: string;
type: string | null;
created: UTCDate;
modified: UTCDate | null;
accessed?: UTCDate | null;
changed?: UTCDate;
executable?: boolean;
isSubscribed?: boolean;
myRights: FilesRights;
shareWith?: Record<Id, FilesRights> | null;
role?: string | null;
}
/* ------------------------------------------------------------------ */
/* Blob (RFC 9404) */
/* ------------------------------------------------------------------ */
export interface UploadResponse {
accountId: Id;
blobId: Id;
type: string;
size: number;
}
export interface BlobGetResponse {
id: Id;
"data:asText"?: string | null;
"data:asBase64"?: string | null;
size?: number;
isEncodingProblem?: boolean;
isTruncated?: boolean;
}