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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user