@adukilabs/sdk 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +184 -2
- package/dist/api/api.d.ts +226 -0
- package/dist/api/api.js +365 -0
- package/dist/api/id.d.ts +75 -0
- package/dist/api/id.js +184 -0
- package/dist/api/index.d.ts +23 -0
- package/dist/api/index.js +19 -0
- package/dist/api/key.d.ts +16 -0
- package/dist/api/key.js +16 -0
- package/dist/api/model.d.ts +476 -0
- package/dist/api/space.d.ts +147 -0
- package/dist/api/space.js +218 -0
- package/dist/api/store.d.ts +181 -0
- package/dist/api/store.js +352 -0
- package/dist/api/tenant.d.ts +319 -0
- package/dist/api/tenant.js +429 -0
- package/dist/base64.d.ts +11 -0
- package/dist/base64.js +40 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +22 -0
- package/dist/dpop.d.ts +82 -0
- package/dist/dpop.js +156 -0
- package/dist/envelope.d.ts +59 -0
- package/dist/envelope.js +114 -0
- package/dist/errors.d.ts +165 -0
- package/dist/errors.js +206 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +22 -0
- package/dist/jmap.d.ts +100 -0
- package/dist/jmap.js +180 -0
- package/dist/space/model.d.ts +147 -0
- package/dist/store/blake3.d.ts +4 -0
- package/dist/store/blake3.js +130 -0
- package/dist/store/cdc.d.ts +12 -0
- package/dist/store/cdc.js +127 -0
- package/dist/store/chunks.d.ts +23 -0
- package/dist/store/chunks.js +63 -0
- package/dist/store/model.d.ts +202 -0
- package/dist/store/model.js +38 -0
- package/package.json +45 -4
package/dist/errors.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Errors mirror the Kotlin SDK's AdukiException hierarchy (aduki-kt core/errors).
|
|
2
|
+
/** Base class of every error the SDK throws. */
|
|
3
|
+
export class AdukiError extends Error {
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
6
|
+
this.name = new.target.name;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Transport failure or a non-2xx answer that is not a 401. `status` is the
|
|
11
|
+
* HTTP status when there was one (undefined: the server was not reached).
|
|
12
|
+
* `retryAfter` holds the seconds the server asked to wait, when it said.
|
|
13
|
+
* The subclasses below name the answers a client acts on; see `refusal`.
|
|
14
|
+
*/
|
|
15
|
+
export class NetworkError extends AdukiError {
|
|
16
|
+
constructor(message, options = {}) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.status = options.status;
|
|
19
|
+
this.kind = options.kind ?? '';
|
|
20
|
+
this.details = options.details;
|
|
21
|
+
this.retryAfter = options.retryAfter;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** `429`: too many requests (`rate.limited`, `mail.queue_busy`, or the gateway's plain 429). Wait `retryAfter` seconds when given. */
|
|
25
|
+
export class RateLimitedError extends NetworkError {
|
|
26
|
+
}
|
|
27
|
+
/** `403 auth.stepup`: the action needs a fresh second factor; the session stays valid. */
|
|
28
|
+
export class StepUpError extends NetworkError {
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A package limit or quota was reached: `403 plan.limit` or `507 quota.exceeded`.
|
|
32
|
+
* `key`, `limit` and `used` come from the details when the server sent them.
|
|
33
|
+
*/
|
|
34
|
+
export class LimitError extends NetworkError {
|
|
35
|
+
constructor(message, options = {}) {
|
|
36
|
+
super(message, options);
|
|
37
|
+
const details = options.details && typeof options.details === 'object' ? options.details : {};
|
|
38
|
+
this.key = typeof details.key === 'string' ? details.key : '';
|
|
39
|
+
this.limit = typeof details.limit === 'number' ? details.limit : undefined;
|
|
40
|
+
this.used = typeof details.used === 'number' ? details.used : undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** `410 sync.reset`: the sync token is no longer known; sync again without one. */
|
|
44
|
+
export class SyncResetError extends NetworkError {
|
|
45
|
+
}
|
|
46
|
+
/** A `5xx` answer (`server.error`, `auth.unavailable`, a gateway 502, 503 or 504); `retryAfter` when the server said. */
|
|
47
|
+
export class ServerError extends NetworkError {
|
|
48
|
+
}
|
|
49
|
+
/** The kinds after which the session is over and its tokens are useless (aduki-kt `Unauthorized.ENDED`). */
|
|
50
|
+
export const ENDED = Object.freeze(['auth.invalid', 'auth.reused', 'auth.revoked', 'auth.expired']);
|
|
51
|
+
/** The server refused the credentials (401): wrong password, spent refresh token, stale token. */
|
|
52
|
+
export class UnauthorizedError extends AdukiError {
|
|
53
|
+
constructor(message, options = {}) {
|
|
54
|
+
super(message, options);
|
|
55
|
+
this.status = 401;
|
|
56
|
+
this.kind = options.kind ?? '';
|
|
57
|
+
this.details = options.details;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* True when the kind says the session is over (`auth.invalid`,
|
|
61
|
+
* `auth.reused`, `auth.revoked`, `auth.expired`); false for other kinds
|
|
62
|
+
* (`auth.factor`, `auth.stale`, `auth.dpop`) and for a 401 without one.
|
|
63
|
+
*/
|
|
64
|
+
get ended() {
|
|
65
|
+
return ENDED.includes(this.kind);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `401 auth.dpop` or `401 auth.nonce`: the DPoP proof was refused (a key or
|
|
70
|
+
* clock problem), not the credentials (aduki-kt `AdukiException.Proof`). A
|
|
71
|
+
* subclass of UnauthorizedError, so existing `instanceof` checks still match.
|
|
72
|
+
*/
|
|
73
|
+
export class ProofError extends UnauthorizedError {
|
|
74
|
+
}
|
|
75
|
+
/** `details.reason` of a refusal, or ''. */
|
|
76
|
+
function reasonOf(details) {
|
|
77
|
+
const value = details && typeof details === 'object' ? details.reason : undefined;
|
|
78
|
+
return typeof value === 'string' ? value : '';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `401` with `details.reason = "token.required"`: the server needs the Aduki
|
|
82
|
+
* ID token and its proof with the request (the Chats handshake on an Aduki
|
|
83
|
+
* deployment). A client that cannot send them is too old: tell the user to
|
|
84
|
+
* update instead of retrying.
|
|
85
|
+
*/
|
|
86
|
+
export class TokenRequiredError extends UnauthorizedError {
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `401` with `details.reason = "session.expired"` or `"session.ended"`: the
|
|
90
|
+
* Chats session is over (its time ran out, or Aduki ID ended the sign-in
|
|
91
|
+
* session that authorized it). Repeat the handshake; after `session.ended`
|
|
92
|
+
* that needs a current token (the person may have to sign in again).
|
|
93
|
+
*/
|
|
94
|
+
export class SessionEndedError extends UnauthorizedError {
|
|
95
|
+
/** `session.expired` or `session.ended` */
|
|
96
|
+
get reason() {
|
|
97
|
+
return reasonOf(this.details);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `403` with `details.reason = "subject"` (the token is for another account
|
|
102
|
+
* than the device's) or `"jkt.mismatch"` (the device is bound to another
|
|
103
|
+
* Aduki ID key than the token's). Retrying with the same key does not help.
|
|
104
|
+
*/
|
|
105
|
+
export class IdentityMismatchError extends NetworkError {
|
|
106
|
+
/** `subject` or `jkt.mismatch` */
|
|
107
|
+
get reason() {
|
|
108
|
+
return reasonOf(this.details);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `403 access.denied` with `details.reason = "client"`: the route is not for
|
|
113
|
+
* a program. A workspace's service client (machine access) is refused where
|
|
114
|
+
* only a person may act: in Store shares, links, devices, reports, search,
|
|
115
|
+
* tickets and the change feed; in Space the strict and money routes. Another
|
|
116
|
+
* token does not help: the route needs a person signed in.
|
|
117
|
+
*/
|
|
118
|
+
export class ClientRefusedError extends NetworkError {
|
|
119
|
+
/** always `client` */
|
|
120
|
+
get reason() {
|
|
121
|
+
return reasonOf(this.details);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A 2xx authentication answer that could not be used. `refresh` carries a
|
|
126
|
+
* rotated refresh token the answer did include, so callers never lose a token
|
|
127
|
+
* the server already swapped in.
|
|
128
|
+
*/
|
|
129
|
+
export class AuthError extends AdukiError {
|
|
130
|
+
constructor(message, options = {}) {
|
|
131
|
+
super(message, options);
|
|
132
|
+
this.refresh = options.refresh ?? '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A protocol violation: bad state, issuer mismatch, bad id token, missing field. */
|
|
136
|
+
export class ProtocolError extends AdukiError {
|
|
137
|
+
}
|
|
138
|
+
/** A storage backend failed. */
|
|
139
|
+
export class StorageError extends AdukiError {
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The one mapping from a refused answer to a typed error, used by every
|
|
143
|
+
* service module. A 401 is UnauthorizedError (`auth.dpop`, `auth.nonce`:
|
|
144
|
+
* ProofError; `details.reason` `token.required`: TokenRequiredError;
|
|
145
|
+
* `session.expired`, `session.ended`: SessionEndedError); a 429
|
|
146
|
+
* RateLimitedError; a 403 with `details.reason` `subject` or `jkt.mismatch`
|
|
147
|
+
* IdentityMismatchError; a 403 with `details.reason` `client`
|
|
148
|
+
* ClientRefusedError; `403 auth.stepup` StepUpError;
|
|
149
|
+
* `plan.limit` and `quota.exceeded` LimitError; `sync.reset` SyncResetError;
|
|
150
|
+
* a 5xx ServerError; anything else NetworkError. Every one but the 401s is a
|
|
151
|
+
* NetworkError with `status`, `kind`, `details` and `retryAfter` (from the
|
|
152
|
+
* `Retry-After` header, else `details.retry_after` or `details.retry`).
|
|
153
|
+
*/
|
|
154
|
+
export function refusal({ status, kind = '', message, details, retry, what }) {
|
|
155
|
+
const text = message || `HTTP ${status}`;
|
|
156
|
+
const reason = reasonOf(details);
|
|
157
|
+
if (status === 401) {
|
|
158
|
+
const Type = reason === 'token.required' ? TokenRequiredError
|
|
159
|
+
: reason === 'session.expired' || reason === 'session.ended' ? SessionEndedError
|
|
160
|
+
: kind === 'auth.dpop' || kind === 'auth.nonce' ? ProofError : UnauthorizedError;
|
|
161
|
+
return new Type(kind ? `${kind}: ${text}` : text, { kind, details });
|
|
162
|
+
}
|
|
163
|
+
const options = { status, kind, details };
|
|
164
|
+
const wait = retryAfter(retry) ?? waited(details);
|
|
165
|
+
if (wait !== undefined)
|
|
166
|
+
options.retryAfter = wait;
|
|
167
|
+
const full = `${what ?? 'Request'} failed: ${text}`;
|
|
168
|
+
if (status === 429)
|
|
169
|
+
return new RateLimitedError(full, options);
|
|
170
|
+
if (status === 403 && (reason === 'subject' || reason === 'jkt.mismatch'))
|
|
171
|
+
return new IdentityMismatchError(full, options);
|
|
172
|
+
if (status === 403 && reason === 'client')
|
|
173
|
+
return new ClientRefusedError(full, options);
|
|
174
|
+
if (kind === 'auth.stepup')
|
|
175
|
+
return new StepUpError(full, options);
|
|
176
|
+
if (kind === 'plan.limit' || kind === 'quota.exceeded')
|
|
177
|
+
return new LimitError(full, options);
|
|
178
|
+
if (kind === 'sync.reset')
|
|
179
|
+
return new SyncResetError(full, options);
|
|
180
|
+
if (status >= 500 && status <= 599)
|
|
181
|
+
return new ServerError(full, options);
|
|
182
|
+
return new NetworkError(full, options);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The seconds of a `Retry-After` value (delta seconds or an HTTP date), or
|
|
186
|
+
* undefined when absent or unreadable. Never negative.
|
|
187
|
+
*/
|
|
188
|
+
export function retryAfter(value, now = Date.now()) {
|
|
189
|
+
const text = value?.trim();
|
|
190
|
+
if (!text)
|
|
191
|
+
return undefined;
|
|
192
|
+
if (/^\d+$/.test(text))
|
|
193
|
+
return Number(text);
|
|
194
|
+
const at = Date.parse(text);
|
|
195
|
+
if (Number.isNaN(at))
|
|
196
|
+
return undefined;
|
|
197
|
+
return Math.max(0, Math.ceil((at - now) / 1000));
|
|
198
|
+
}
|
|
199
|
+
/** `details.retry_after` (Aduki Mail) or `details.retry` (Aduki ID) in seconds. */
|
|
200
|
+
function waited(details) {
|
|
201
|
+
if (!details || typeof details !== 'object')
|
|
202
|
+
return undefined;
|
|
203
|
+
const o = details;
|
|
204
|
+
const value = o.retry_after ?? o.retry;
|
|
205
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.ceil(value) : undefined;
|
|
206
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `base` and `path` joined with exactly one slash. */
|
|
2
|
+
export declare const url: (base: string, path: string) => string;
|
|
3
|
+
/** Runs fetch and turns a transport failure into NetworkError (an abort is passed through). */
|
|
4
|
+
export declare function send(fetcher: typeof fetch, input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
|
5
|
+
/** The headers and body of a JSON request. */
|
|
6
|
+
export declare const json: (body: unknown) => {
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': string;
|
|
9
|
+
};
|
|
10
|
+
body: string;
|
|
11
|
+
};
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Small HTTP helpers shared by every service module (Aduki ID today; mail and
|
|
2
|
+
// the other APIs next): joining a base and a path, a JSON request body, and a
|
|
3
|
+
// fetch that reports a transport failure as NetworkError.
|
|
4
|
+
import { NetworkError } from './errors.js';
|
|
5
|
+
/** `base` and `path` joined with exactly one slash. */
|
|
6
|
+
export const url = (base, path) => `${base.replace(/\/+$/, '')}/${path}`;
|
|
7
|
+
/** Runs fetch and turns a transport failure into NetworkError (an abort is passed through). */
|
|
8
|
+
export async function send(fetcher, input, init) {
|
|
9
|
+
try {
|
|
10
|
+
return await fetcher(input, init);
|
|
11
|
+
}
|
|
12
|
+
catch (cause) {
|
|
13
|
+
if (cause instanceof Error && cause.name === 'AbortError')
|
|
14
|
+
throw cause;
|
|
15
|
+
throw new NetworkError(`Request failed: ${cause instanceof Error ? cause.message : cause}`, { cause });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The headers and body of a JSON request. */
|
|
19
|
+
export const json = (body) => ({
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
package/dist/jmap.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { AdukiError } from './errors.js';
|
|
2
|
+
export declare const CORE = "urn:ietf:params:jmap:core";
|
|
3
|
+
export declare const MAIL = "urn:ietf:params:jmap:mail";
|
|
4
|
+
export declare const SUBMISSION = "urn:ietf:params:jmap:submission";
|
|
5
|
+
/** Aduki body reading: `Email/get` takes `aduki:bodyMode` (ADK-MAIL-001 section 1.4, v47). */
|
|
6
|
+
export declare const READING = "urn:aduki:params:jmap:reading";
|
|
7
|
+
/** Core and mail: enough for `Mailbox`, `Email` and `Thread`. */
|
|
8
|
+
export declare const USING: readonly string[];
|
|
9
|
+
/** An authenticated fetch for one audience: `Session` and `Client` both have it. */
|
|
10
|
+
export interface AudienceFetch {
|
|
11
|
+
fetch(audience: string, input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
|
12
|
+
}
|
|
13
|
+
/** The part of the JMAP session resource the SDK uses. */
|
|
14
|
+
export interface JmapSession {
|
|
15
|
+
/** the primary mail account id */
|
|
16
|
+
account: string;
|
|
17
|
+
api: string;
|
|
18
|
+
/** URL template with `{accountId}` */
|
|
19
|
+
upload: string;
|
|
20
|
+
/** URL template with `{accountId}`, `{blobId}`, `{name}`, `{type}` */
|
|
21
|
+
download: string;
|
|
22
|
+
/** URL template with `{types}`, `{closeafter}`, `{ping}` */
|
|
23
|
+
eventSource: string;
|
|
24
|
+
/** every session capability advertised */
|
|
25
|
+
capabilities: string[];
|
|
26
|
+
/** `urn:ietf:params:jmap:core` limits (`maxSizeUpload`, `maxCallsInRequest`, ...) */
|
|
27
|
+
limits: Record<string, unknown>;
|
|
28
|
+
/** the server reads bodies (`aduki:bodyMode`), see READING */
|
|
29
|
+
reading: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** One method call: `[name, arguments, call id]`. */
|
|
32
|
+
export type Invocation = [name: string, args: Record<string, unknown>, id: string];
|
|
33
|
+
/** An uploaded blob. */
|
|
34
|
+
export interface Uploaded {
|
|
35
|
+
blob: string;
|
|
36
|
+
type: string;
|
|
37
|
+
size: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A JMAP method error (`["error", {type, description}, id]`) or a per-object
|
|
41
|
+
* refusal of a `/set` call (`notCreated`, `notUpdated`, `notDestroyed`).
|
|
42
|
+
* `type` is the JMAP error type (`invalidArguments`, `forbiddenFrom`,
|
|
43
|
+
* `notFound`, ...); `id` names the object for a per-object refusal.
|
|
44
|
+
*/
|
|
45
|
+
export declare class JmapError extends AdukiError {
|
|
46
|
+
type: string;
|
|
47
|
+
description: string;
|
|
48
|
+
properties: string[];
|
|
49
|
+
method: string;
|
|
50
|
+
id: string;
|
|
51
|
+
constructor(method: string, error: Record<string, any>, id?: string);
|
|
52
|
+
}
|
|
53
|
+
export interface JmapOptions {
|
|
54
|
+
/** where the token comes from: a `Session` or a `Client` from `createClient` */
|
|
55
|
+
session: AudienceFetch;
|
|
56
|
+
/** the mail host root without `/v1` (default `https://mail.aduki.pro`) */
|
|
57
|
+
root?: string | undefined;
|
|
58
|
+
/** the token audience (default `mail`) */
|
|
59
|
+
audience?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
export declare const ROOT = "https://mail.aduki.pro";
|
|
62
|
+
export declare class Jmap {
|
|
63
|
+
#private;
|
|
64
|
+
constructor(options: JmapOptions);
|
|
65
|
+
/** Sends an authenticated request to the mail host (a path or an absolute URL). */
|
|
66
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
67
|
+
/** The session (`GET /jmap/session`), fetched once; `refresh` fetches it again. */
|
|
68
|
+
session(refresh?: boolean): Promise<JmapSession>;
|
|
69
|
+
/** The primary mail account id. */
|
|
70
|
+
account(): Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Runs `calls` in one request and returns every response in order
|
|
73
|
+
* (`[name, args, id]`). A method `error` response throws JmapError;
|
|
74
|
+
* per-object refusals (`notCreated`, ...) are left to the caller.
|
|
75
|
+
*/
|
|
76
|
+
call(calls: Invocation[], using?: readonly string[]): Promise<Invocation[]>;
|
|
77
|
+
/**
|
|
78
|
+
* Runs one method with `accountId` set and returns its arguments (the
|
|
79
|
+
* first response with the call id; implicit responses are dropped).
|
|
80
|
+
*/
|
|
81
|
+
method(name: string, args?: Record<string, unknown>, using?: readonly string[]): Promise<Record<string, any>>;
|
|
82
|
+
/** Uploads `data` as a blob of `type` (RFC 8620 section 6.1). */
|
|
83
|
+
upload(data: Blob | ArrayBuffer | Uint8Array, type?: string): Promise<Uploaded>;
|
|
84
|
+
/**
|
|
85
|
+
* Downloads blob `blob` (RFC 8620 section 6.2). Returns the open response
|
|
86
|
+
* (read it with `blob()`, `arrayBuffer()` or `body`); a failure throws.
|
|
87
|
+
* `name` and `type` label the download (`Content-Disposition`, `Content-Type`).
|
|
88
|
+
*/
|
|
89
|
+
download(blob: string, options?: {
|
|
90
|
+
name?: string;
|
|
91
|
+
type?: string;
|
|
92
|
+
}): Promise<Response>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Throws for a non-2xx answer (`envelope.rejection`): 401 is
|
|
96
|
+
* UnauthorizedError, anything else a NetworkError (or a subclass) with the
|
|
97
|
+
* status and the kind of the envelope or the type of the RFC 7807 problem
|
|
98
|
+
* document JMAP uses for request-level errors.
|
|
99
|
+
*/
|
|
100
|
+
export declare function refuse(response: Response, what: string): Promise<never>;
|
package/dist/jmap.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// JMAP (RFC 8620) over the authenticated fetch, shared by the services that
|
|
2
|
+
// speak it (mail, contacts; `sdk/jmap`): the session resource, batched
|
|
3
|
+
// method calls with back-references, and blob upload and download
|
|
4
|
+
// (aduki-kt net/http/jmap.kt). JMAP ids are the REST hexes: an `Email` id is
|
|
5
|
+
// a message hex, a `Mailbox` id a mailbox hex.
|
|
6
|
+
import { rejection } from './envelope.js';
|
|
7
|
+
import { AdukiError, ProtocolError } from './errors.js';
|
|
8
|
+
import { url } from './http.js';
|
|
9
|
+
export const CORE = 'urn:ietf:params:jmap:core';
|
|
10
|
+
export const MAIL = 'urn:ietf:params:jmap:mail';
|
|
11
|
+
export const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
|
12
|
+
/** Aduki body reading: `Email/get` takes `aduki:bodyMode` (ADK-MAIL-001 section 1.4, v47). */
|
|
13
|
+
export const READING = 'urn:aduki:params:jmap:reading';
|
|
14
|
+
/** Core and mail: enough for `Mailbox`, `Email` and `Thread`. */
|
|
15
|
+
export const USING = Object.freeze([CORE, MAIL]);
|
|
16
|
+
/**
|
|
17
|
+
* A JMAP method error (`["error", {type, description}, id]`) or a per-object
|
|
18
|
+
* refusal of a `/set` call (`notCreated`, `notUpdated`, `notDestroyed`).
|
|
19
|
+
* `type` is the JMAP error type (`invalidArguments`, `forbiddenFrom`,
|
|
20
|
+
* `notFound`, ...); `id` names the object for a per-object refusal.
|
|
21
|
+
*/
|
|
22
|
+
export class JmapError extends AdukiError {
|
|
23
|
+
constructor(method, error, id = '') {
|
|
24
|
+
const type = String(error?.type ?? 'serverFail');
|
|
25
|
+
const description = String(error?.description ?? '');
|
|
26
|
+
super(`${method} refused${id ? ` ${id}` : ''}: ${type}${description ? ` (${description})` : ''}`);
|
|
27
|
+
this.type = type;
|
|
28
|
+
this.description = description;
|
|
29
|
+
this.properties = Array.isArray(error?.properties) ? error.properties.map(String) : [];
|
|
30
|
+
this.method = method;
|
|
31
|
+
this.id = id;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const ROOT = 'https://mail.aduki.pro';
|
|
35
|
+
export class Jmap {
|
|
36
|
+
#session;
|
|
37
|
+
#root;
|
|
38
|
+
#audience;
|
|
39
|
+
#cached = null;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.#session = options.session;
|
|
42
|
+
this.#root = (options.root ?? ROOT).replace(/\/+$/, '');
|
|
43
|
+
this.#audience = options.audience ?? 'mail';
|
|
44
|
+
}
|
|
45
|
+
/** Sends an authenticated request to the mail host (a path or an absolute URL). */
|
|
46
|
+
fetch(input, init) {
|
|
47
|
+
const target = /^https?:\/\//.test(input) ? input : url(this.#root, input.replace(/^\/+/, ''));
|
|
48
|
+
return this.#session.fetch(this.#audience, target, init);
|
|
49
|
+
}
|
|
50
|
+
/** The session (`GET /jmap/session`), fetched once; `refresh` fetches it again. */
|
|
51
|
+
session(refresh = false) {
|
|
52
|
+
if (!this.#cached || refresh) {
|
|
53
|
+
const pending = this.#load();
|
|
54
|
+
this.#cached = pending;
|
|
55
|
+
pending.catch(() => {
|
|
56
|
+
if (this.#cached === pending)
|
|
57
|
+
this.#cached = null;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return this.#cached;
|
|
61
|
+
}
|
|
62
|
+
async #load() {
|
|
63
|
+
const data = await object(await this.fetch('jmap/session'), 'JMAP session');
|
|
64
|
+
const primary = data.primaryAccounts ?? {};
|
|
65
|
+
const account = String(primary[MAIL] ?? primary[CORE] ?? '');
|
|
66
|
+
if (!account)
|
|
67
|
+
throw new ProtocolError('JMAP session has no mail account');
|
|
68
|
+
const capabilities = data.capabilities && typeof data.capabilities === 'object' ? data.capabilities : {};
|
|
69
|
+
const field = (name) => {
|
|
70
|
+
const value = data[name];
|
|
71
|
+
if (typeof value !== 'string' || !value)
|
|
72
|
+
throw new ProtocolError(`JMAP session has no ${name}`);
|
|
73
|
+
return new URL(value, `${this.#root}/`).href.replace(/%7B/g, '{').replace(/%7D/g, '}');
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
account,
|
|
77
|
+
api: field('apiUrl'),
|
|
78
|
+
upload: field('uploadUrl'),
|
|
79
|
+
download: field('downloadUrl'),
|
|
80
|
+
eventSource: typeof data.eventSourceUrl === 'string' ? field('eventSourceUrl') : '',
|
|
81
|
+
capabilities: Object.keys(capabilities),
|
|
82
|
+
limits: capabilities[CORE] && typeof capabilities[CORE] === 'object' ? { ...capabilities[CORE] } : {},
|
|
83
|
+
reading: READING in capabilities,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** The primary mail account id. */
|
|
87
|
+
async account() {
|
|
88
|
+
return (await this.session()).account;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Runs `calls` in one request and returns every response in order
|
|
92
|
+
* (`[name, args, id]`). A method `error` response throws JmapError;
|
|
93
|
+
* per-object refusals (`notCreated`, ...) are left to the caller.
|
|
94
|
+
*/
|
|
95
|
+
async call(calls, using = USING) {
|
|
96
|
+
const session = await this.session();
|
|
97
|
+
const body = JSON.stringify({ using: [...using], methodCalls: calls });
|
|
98
|
+
const response = await this.fetch(session.api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
99
|
+
const data = await object(response, 'JMAP request');
|
|
100
|
+
if (!Array.isArray(data.methodResponses))
|
|
101
|
+
throw new ProtocolError('JMAP reply has no methodResponses');
|
|
102
|
+
return data.methodResponses.map((item, i) => {
|
|
103
|
+
if (!Array.isArray(item) || typeof item[0] !== 'string' || !item[1] || typeof item[1] !== 'object') {
|
|
104
|
+
throw new ProtocolError(`JMAP method response ${i} is malformed`);
|
|
105
|
+
}
|
|
106
|
+
const [name, args, id] = item;
|
|
107
|
+
if (name === 'error') {
|
|
108
|
+
const call = calls.find((c) => c[2] === id);
|
|
109
|
+
throw new JmapError(call?.[0] ?? 'JMAP', args);
|
|
110
|
+
}
|
|
111
|
+
return [name, args, String(id ?? '')];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Runs one method with `accountId` set and returns its arguments (the
|
|
116
|
+
* first response with the call id; implicit responses are dropped).
|
|
117
|
+
*/
|
|
118
|
+
async method(name, args = {}, using = USING) {
|
|
119
|
+
const accountId = await this.account();
|
|
120
|
+
const replies = await this.call([[name, { accountId, ...args }, '0']], using);
|
|
121
|
+
const reply = replies.find((r) => r[2] === '0');
|
|
122
|
+
if (!reply)
|
|
123
|
+
throw new ProtocolError(`JMAP reply has no response to ${name}`);
|
|
124
|
+
return reply[1];
|
|
125
|
+
}
|
|
126
|
+
/** Uploads `data` as a blob of `type` (RFC 8620 section 6.1). */
|
|
127
|
+
async upload(data, type = 'application/octet-stream') {
|
|
128
|
+
const session = await this.session();
|
|
129
|
+
const target = session.upload.replace('{accountId}', encodeURIComponent(session.account));
|
|
130
|
+
const body = data instanceof Uint8Array ? new Uint8Array(data) : data;
|
|
131
|
+
const response = await this.fetch(target, { method: 'POST', headers: { 'Content-Type': type || 'application/octet-stream' }, body });
|
|
132
|
+
const out = await object(response, 'JMAP upload');
|
|
133
|
+
if (typeof out.blobId !== 'string' || !out.blobId)
|
|
134
|
+
throw new ProtocolError('JMAP upload answer has no blobId');
|
|
135
|
+
return { blob: out.blobId, type: String(out.type ?? type), size: Number(out.size) || 0 };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Downloads blob `blob` (RFC 8620 section 6.2). Returns the open response
|
|
139
|
+
* (read it with `blob()`, `arrayBuffer()` or `body`); a failure throws.
|
|
140
|
+
* `name` and `type` label the download (`Content-Disposition`, `Content-Type`).
|
|
141
|
+
*/
|
|
142
|
+
async download(blob, options = {}) {
|
|
143
|
+
const session = await this.session();
|
|
144
|
+
const target = session.download
|
|
145
|
+
.replace('{accountId}', encodeURIComponent(session.account))
|
|
146
|
+
.replace('{blobId}', encodeURIComponent(blob))
|
|
147
|
+
.replace('{name}', encodeURIComponent(options.name || 'blob'))
|
|
148
|
+
.replace('{type}', encodeURIComponent(options.type || 'application/octet-stream'));
|
|
149
|
+
const response = await this.fetch(target);
|
|
150
|
+
if (!response.ok)
|
|
151
|
+
await refuse(response, 'JMAP download');
|
|
152
|
+
return response;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** The JSON object of a 2xx answer; a failure throws (see refuse). */
|
|
156
|
+
async function object(response, what) {
|
|
157
|
+
if (!response.ok)
|
|
158
|
+
await refuse(response, what);
|
|
159
|
+
const text = await response.text();
|
|
160
|
+
let value;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(text);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new ProtocolError(`${what} answer is not JSON`);
|
|
166
|
+
}
|
|
167
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
168
|
+
throw new ProtocolError(`${what} answer is not a JSON object`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Throws for a non-2xx answer (`envelope.rejection`): 401 is
|
|
173
|
+
* UnauthorizedError, anything else a NetworkError (or a subclass) with the
|
|
174
|
+
* status and the kind of the envelope or the type of the RFC 7807 problem
|
|
175
|
+
* document JMAP uses for request-level errors.
|
|
176
|
+
*/
|
|
177
|
+
export async function refuse(response, what) {
|
|
178
|
+
const text = await response.text().catch(() => '');
|
|
179
|
+
throw rejection(response, text, what, { problem: true });
|
|
180
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** One page of a list: `next` is the cursor of the following page, null on the last. */
|
|
2
|
+
export interface Page<T> {
|
|
3
|
+
items: T[];
|
|
4
|
+
next: string | null;
|
|
5
|
+
}
|
|
6
|
+
/** A success answer: the envelope's `data`, the HTTP status and the `ETag` (the row's version, without quotes). */
|
|
7
|
+
export interface Reply<T> {
|
|
8
|
+
data: T;
|
|
9
|
+
status: number;
|
|
10
|
+
etag: string | null;
|
|
11
|
+
}
|
|
12
|
+
/** A task. The money fields are present only for members who may see them. */
|
|
13
|
+
export interface Task {
|
|
14
|
+
id: string;
|
|
15
|
+
number: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
priority: string;
|
|
19
|
+
status: string;
|
|
20
|
+
due: string | null;
|
|
21
|
+
client: {
|
|
22
|
+
id: string | null;
|
|
23
|
+
name: string | null;
|
|
24
|
+
} | null;
|
|
25
|
+
service: string | null;
|
|
26
|
+
team: string | null;
|
|
27
|
+
author: string | null;
|
|
28
|
+
assignees: unknown;
|
|
29
|
+
created: string;
|
|
30
|
+
updated: string;
|
|
31
|
+
archived: string | null;
|
|
32
|
+
billable?: boolean;
|
|
33
|
+
mode?: string | null;
|
|
34
|
+
rate?: number | null;
|
|
35
|
+
amount?: number | null;
|
|
36
|
+
minutes?: number;
|
|
37
|
+
currency?: string;
|
|
38
|
+
invoice?: string | null;
|
|
39
|
+
billed?: string | null;
|
|
40
|
+
[field: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
/** A check-in: work reported on a task, in minutes, waiting for review or reviewed. */
|
|
43
|
+
export interface Checkin {
|
|
44
|
+
id: string;
|
|
45
|
+
task: string;
|
|
46
|
+
number: string;
|
|
47
|
+
account: string | null;
|
|
48
|
+
body: string;
|
|
49
|
+
minutes: number;
|
|
50
|
+
proof: string | null;
|
|
51
|
+
status: string;
|
|
52
|
+
reviewer: string | null;
|
|
53
|
+
note: string | null;
|
|
54
|
+
reviewed: string | null;
|
|
55
|
+
created: string;
|
|
56
|
+
[field: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
/** A support ticket. */
|
|
59
|
+
export interface SupportTicket {
|
|
60
|
+
id: string;
|
|
61
|
+
number: string;
|
|
62
|
+
client: unknown;
|
|
63
|
+
contact: string | null;
|
|
64
|
+
assignee: string | null;
|
|
65
|
+
service: string | null;
|
|
66
|
+
node: string | null;
|
|
67
|
+
escalated: boolean;
|
|
68
|
+
task: unknown;
|
|
69
|
+
title: string;
|
|
70
|
+
status: string;
|
|
71
|
+
priority: string;
|
|
72
|
+
channel: string;
|
|
73
|
+
clock: string;
|
|
74
|
+
replied: string | null;
|
|
75
|
+
warned: string | null;
|
|
76
|
+
breached: string | null;
|
|
77
|
+
resolved: string | null;
|
|
78
|
+
closed: string | null;
|
|
79
|
+
created: string;
|
|
80
|
+
updated: string;
|
|
81
|
+
[field: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
/** A message of a ticket's thread; `id` is an integer (the thread is an append-only log); `internal` notes are staff only. */
|
|
84
|
+
export interface Message {
|
|
85
|
+
id: number;
|
|
86
|
+
side: string;
|
|
87
|
+
author: string | null;
|
|
88
|
+
internal: boolean;
|
|
89
|
+
body: string;
|
|
90
|
+
created: string;
|
|
91
|
+
[field: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
/** A team of the workspace (from the Aduki ID directory). */
|
|
94
|
+
export interface Team {
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
handle: string;
|
|
98
|
+
members: unknown;
|
|
99
|
+
}
|
|
100
|
+
/** A member of the workspace in the directory: name, handle and team ids. */
|
|
101
|
+
export interface Member {
|
|
102
|
+
id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
handle: string;
|
|
105
|
+
teams: string[];
|
|
106
|
+
}
|
|
107
|
+
/** An activity entry: the workspace's for `space.admin`, your own otherwise. */
|
|
108
|
+
export interface Activity {
|
|
109
|
+
id: string;
|
|
110
|
+
/** who acted (hex); null for the system, or for a program of the workspace (then `kind` is `client`) */
|
|
111
|
+
account: string | null;
|
|
112
|
+
/** `client` when a program of the workspace acted (machine access); absent for a person or the system */
|
|
113
|
+
kind?: string;
|
|
114
|
+
entity: string;
|
|
115
|
+
action: string;
|
|
116
|
+
payload: unknown;
|
|
117
|
+
created: string;
|
|
118
|
+
}
|
|
119
|
+
/** The workspace settings (no secrets). */
|
|
120
|
+
export interface Settings {
|
|
121
|
+
currency: string;
|
|
122
|
+
locale: string;
|
|
123
|
+
zone: string;
|
|
124
|
+
krapin: string | null;
|
|
125
|
+
/** hundredths of a percent */
|
|
126
|
+
vat: number;
|
|
127
|
+
brand: unknown;
|
|
128
|
+
prefixes: unknown;
|
|
129
|
+
cutoff: number;
|
|
130
|
+
keep: number;
|
|
131
|
+
desk: string | null;
|
|
132
|
+
smscountries: string[];
|
|
133
|
+
solo: boolean;
|
|
134
|
+
autopayroll: boolean;
|
|
135
|
+
caps: unknown;
|
|
136
|
+
updated: string;
|
|
137
|
+
[field: string]: unknown;
|
|
138
|
+
}
|
|
139
|
+
/** Search ids: read each by its own route. `source` is `typesense` or `postgres` (the fallback; `stale` if behind). */
|
|
140
|
+
export interface Found {
|
|
141
|
+
items: {
|
|
142
|
+
collection: string;
|
|
143
|
+
id: string;
|
|
144
|
+
}[];
|
|
145
|
+
source: string;
|
|
146
|
+
stale: boolean;
|
|
147
|
+
}
|