@adukilabs/sdk 0.0.0-stage → 0.1.1
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 +72 -2
- package/dist/api/api.d.ts +223 -0
- package/dist/api/api.js +361 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/api/key.d.ts +16 -0
- package/dist/api/key.js +16 -0
- package/dist/api/model.d.ts +226 -0
- package/dist/envelope.d.ts +59 -0
- package/dist/envelope.js +114 -0
- package/dist/errors.d.ts +153 -0
- package/dist/errors.js +190 -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/package.json +42 -4
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adukilabs/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Aduki API client for programs that use API keys (Authorization: Key): Aduki Mail, Contacts and Calendar over REST and JMAP. TypeScript, web platform APIs only, no dependencies; Node 20+, Deno, Bun, workers.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"main": "./dist/api/index.js",
|
|
12
|
+
"types": "./dist/api/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/api/index.d.ts",
|
|
16
|
+
"default": "./dist/api/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./api": {
|
|
19
|
+
"types": "./dist/api/index.d.ts",
|
|
20
|
+
"default": "./dist/api/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/adukilabs/ts.git"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"aduki",
|
|
38
|
+
"api",
|
|
39
|
+
"mail",
|
|
40
|
+
"jmap",
|
|
41
|
+
"calendar",
|
|
42
|
+
"contacts"
|
|
43
|
+
]
|
|
44
|
+
}
|