@push.rocks/smartjmap 1.0.0 → 2.0.0
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/.smartconfig.json +4 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/classes.jmapclient.d.ts +21 -0
- package/dist_ts/classes.jmapclient.js +37 -1
- package/dist_ts/classes.jmapserver.d.ts +111 -86
- package/dist_ts/classes.jmapserver.js +1250 -644
- package/dist_ts/classes.memorymailbackend.d.ts +109 -0
- package/dist_ts/classes.memorymailbackend.js +670 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/interfaces.backend.d.ts +330 -0
- package/dist_ts/interfaces.backend.js +13 -0
- package/npmextra.json +4 -2
- package/package.json +4 -2
- package/readme.hints.md +25 -14
- package/readme.md +116 -16
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/classes.jmapclient.ts +58 -0
- package/ts/classes.jmapserver.ts +1548 -752
- package/ts/classes.memorymailbackend.ts +850 -0
- package/ts/index.ts +2 -0
- package/ts/interfaces.backend.ts +392 -0
package/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @push.rocks/smartjmap
|
|
2
2
|
|
|
3
|
-
A TypeScript JMAP client for reading, sending, and watching mail
|
|
3
|
+
A TypeScript JMAP toolkit (RFC 8620/8621): an event-driven mail client for reading, sending, and watching mail, plus an embeddable JMAP server core with pluggable storage for exposing your own mail store to JMAP clients.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -10,11 +10,14 @@ To install `@push.rocks/smartjmap`, use pnpm (or npm):
|
|
|
10
10
|
pnpm install @push.rocks/smartjmap
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The package has zero runtime dependencies — it uses the global `fetch` available in Node.js 18
|
|
13
|
+
The package has zero runtime dependencies — it uses the global `fetch`, `Request`/`Response`, and `ReadableStream` available in Node.js 18+ (and in Deno/Bun for the server core).
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
-
`@push.rocks/smartjmap`
|
|
17
|
+
`@push.rocks/smartjmap` ships two sides of the protocol:
|
|
18
|
+
|
|
19
|
+
- `JmapClient` connects to any JMAP server (Fastmail, Stalwart, Cyrus, ...), watches a mailbox for new mail in an event-driven way, and exposes the common mail operations: querying, reading, flagging, sending, uploads, and blob downloads.
|
|
20
|
+
- `JmapServer` is a JMAP server core: it implements the RFC 8620 request layer and the RFC 8621 mail method surface, and dispatches all storage into a pluggable `IJmapMailBackend`. With no options it runs on a bundled in-memory backend, which makes it an offline test server; with your own backend it exposes a real mail store to JMAP clients.
|
|
18
21
|
|
|
19
22
|
### Importing the Required Modules
|
|
20
23
|
|
|
@@ -24,7 +27,7 @@ import { JmapClient, type IJmapClientConfig } from '@push.rocks/smartjmap';
|
|
|
24
27
|
|
|
25
28
|
### Configuration Object
|
|
26
29
|
|
|
27
|
-
Point the client at the JMAP session resource — most servers expose it at the autodiscovery path `https://host/.well-known/jmap`. All URLs inside the session (API endpoint, download
|
|
30
|
+
Point the client at the JMAP session resource — most servers expose it at the autodiscovery path `https://host/.well-known/jmap`. All URLs inside the session (API endpoint, upload/download URLs, event source) are resolved relative to this URL automatically.
|
|
28
31
|
|
|
29
32
|
```typescript
|
|
30
33
|
const jmapConfig: IJmapClientConfig = {
|
|
@@ -161,6 +164,25 @@ console.log(result.emailId, result.submissionId);
|
|
|
161
164
|
|
|
162
165
|
When `from` is omitted, the first identity of the account is used.
|
|
163
166
|
|
|
167
|
+
### Uploads and Attachments
|
|
168
|
+
|
|
169
|
+
`uploadBlob` POSTs raw bytes to the session's upload URL (RFC 8620 §6.1) and returns the stored blob's id, type, and size. Reference the blob id in `sendEmail` to attach it:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
const upload = await jmapClient.uploadBlob(
|
|
173
|
+
new TextEncoder().encode('quarterly report data'),
|
|
174
|
+
'text/plain'
|
|
175
|
+
);
|
|
176
|
+
console.log(upload.blobId, upload.size);
|
|
177
|
+
|
|
178
|
+
await jmapClient.sendEmail({
|
|
179
|
+
to: [{ email: 'friend@example.com' }],
|
|
180
|
+
subject: 'Report attached',
|
|
181
|
+
textBody: 'Please find the report attached.',
|
|
182
|
+
attachments: [{ blobId: upload.blobId, type: 'text/plain', name: 'report.txt' }],
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
164
186
|
### Downloading Blobs
|
|
165
187
|
|
|
166
188
|
Raw messages and attachments are blobs; download them via the session's download URL template:
|
|
@@ -194,25 +216,30 @@ await jmapClient.disconnect();
|
|
|
194
216
|
|
|
195
217
|
`disconnect()` tears down the event stream and any polling timer — no handles are left open — and emits `disconnected`.
|
|
196
218
|
|
|
197
|
-
|
|
219
|
+
## Running a JMAP server
|
|
220
|
+
|
|
221
|
+
`JmapServer` handles the protocol; storage is behind the `IJmapMailBackend` interface. Endpoints served: the session resource (`/.well-known/jmap` and `/jmap/session`), the API (`/jmap/api`), uploads (`/jmap/upload/{accountId}`), downloads (`/jmap/download/{accountId}/{blobId}/{name}?type=`), and StateChange pushes via SSE (`/jmap/eventsource`, RFC 8620 §7.3 with `types`, `closeafter=state`, and `ping` keepalive support).
|
|
222
|
+
|
|
223
|
+
### The Offline Test Server
|
|
198
224
|
|
|
199
|
-
`JmapServer`
|
|
225
|
+
With no options, `JmapServer` uses a fresh `MemoryMailBackend` plus a static credential registry — an offline JMAP server for tests. Seed users on the server (auth) and mail on the backend (storage):
|
|
200
226
|
|
|
201
227
|
```typescript
|
|
202
|
-
import { JmapClient, JmapServer } from '@push.rocks/smartjmap';
|
|
228
|
+
import { JmapClient, JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';
|
|
203
229
|
|
|
204
|
-
const
|
|
230
|
+
const backend = new MemoryMailBackend();
|
|
231
|
+
const jmapServer = new JmapServer({ backend });
|
|
205
232
|
jmapServer.addUser('testuser', 'testpass');
|
|
206
233
|
jmapServer.addBearerToken('testuser', 'test-token');
|
|
207
|
-
|
|
208
|
-
|
|
234
|
+
backend.createMailbox('testuser', 'INBOX', 'inbox');
|
|
235
|
+
backend.addEmail('testuser', 'INBOX', {
|
|
209
236
|
from: { email: 'alice@example.com' },
|
|
210
237
|
to: { email: 'testuser@example.com' },
|
|
211
238
|
subject: 'Welcome',
|
|
212
239
|
textBody: 'Hello from the test server!',
|
|
213
240
|
});
|
|
214
241
|
|
|
215
|
-
const port = await jmapServer.start(0); // resolves with the bound port
|
|
242
|
+
const port = await jmapServer.start(0); // node:http wrapper; resolves with the bound port
|
|
216
243
|
|
|
217
244
|
const client = new JmapClient({
|
|
218
245
|
sessionUrl: `http://127.0.0.1:${port}/.well-known/jmap`,
|
|
@@ -221,8 +248,9 @@ const client = new JmapClient({
|
|
|
221
248
|
client.on('message', (message) => console.log(message.subject));
|
|
222
249
|
await client.connect();
|
|
223
250
|
|
|
224
|
-
//
|
|
225
|
-
|
|
251
|
+
// Backend mutations outside a JMAP request (e.g. seeding, an IMAP bridge)
|
|
252
|
+
// flow through the change feed and are pushed to clients via SSE:
|
|
253
|
+
backend.addEmail('testuser', 'INBOX', {
|
|
226
254
|
from: { email: 'bob@example.com' },
|
|
227
255
|
to: { email: 'testuser@example.com' },
|
|
228
256
|
subject: 'Live push',
|
|
@@ -230,12 +258,84 @@ jmapServer.addEmail('testuser', 'INBOX', {
|
|
|
230
258
|
});
|
|
231
259
|
|
|
232
260
|
await client.disconnect();
|
|
233
|
-
await jmapServer.stop(); // closes
|
|
261
|
+
await jmapServer.stop(); // closes event streams, timers, and open connections
|
|
234
262
|
```
|
|
235
263
|
|
|
236
|
-
|
|
264
|
+
### Mounting fetchHandler (Deno, Bun, anywhere)
|
|
265
|
+
|
|
266
|
+
`fetchHandler(request: Request): Promise<Response>` is the primary API and handles every endpoint, including the SSE event source (served as a `ReadableStream` response). `start(port)` is only a node:http adapter around it. In a Deno app:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
// deno run --allow-net server.ts
|
|
270
|
+
import { JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';
|
|
271
|
+
|
|
272
|
+
const backend = new MemoryMailBackend();
|
|
273
|
+
const jmapServer = new JmapServer({ backend, baseUrl: 'http://localhost:8080' });
|
|
274
|
+
jmapServer.addUser('demo', 'demopass');
|
|
275
|
+
backend.createMailbox('demo', 'INBOX', 'inbox');
|
|
276
|
+
|
|
277
|
+
Deno.serve({ port: 8080 }, (request) => jmapServer.fetchHandler(request));
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`baseUrl` makes the URLs advertised in the session object absolute; without it they are relative and JMAP clients resolve them against the session URL.
|
|
281
|
+
|
|
282
|
+
### Custom Authentication
|
|
283
|
+
|
|
284
|
+
The `authenticate` option replaces the built-in Basic/Bearer registry entirely. It receives the raw `Request` and returns a principal (or null for 401); the backend then maps the principal to an account via `resolveAccount`:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { JmapServer } from '@push.rocks/smartjmap';
|
|
288
|
+
|
|
289
|
+
const jmapServer = new JmapServer({
|
|
290
|
+
authenticate: async (request: Request) => {
|
|
291
|
+
const token = request.headers.get('x-app-token');
|
|
292
|
+
return token === 'sesame' ? { username: 'appuser' } : null;
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Implementing a Real Backend
|
|
298
|
+
|
|
299
|
+
Implement `IJmapMailBackend` against your own store to serve real mail. All state strings are backend-owned opaque strings. The contract (all methods async except `subscribeToChanges`, which returns its unsubscribe function synchronously):
|
|
300
|
+
|
|
301
|
+
| Method | Serves |
|
|
302
|
+
| --- | --- |
|
|
303
|
+
| `resolveAccount(principal)` | principal → account mapping for the session |
|
|
304
|
+
| `getMailboxes(accountId, ids)` / `getMailboxChanges(accountId, sinceState, maxChanges?)` | `Mailbox/get`, `Mailbox/query`, `Mailbox/changes` |
|
|
305
|
+
| `getEmails(accountId, ids, properties?)` | `Email/get` (the server applies projection and body-value fetch flags) |
|
|
306
|
+
| `queryEmails(accountId, options)` | `Email/query` (filter subset, receivedAt sort, position/limit/total) |
|
|
307
|
+
| `getEmailChanges(accountId, sinceState, maxChanges?)` | `Email/changes` (return null for `cannotCalculateChanges`) |
|
|
308
|
+
| `setEmails(accountId, request)` | `Email/set` (creates, normalized keyword/mailbox updates, destroys) |
|
|
309
|
+
| `getThreads(accountId, ids)` | `Thread/get` |
|
|
310
|
+
| `uploadBlob(accountId, data, type)` / `getBlob(accountId, blobId)` | upload/download endpoints and raw-message access |
|
|
311
|
+
| `getIdentities(accountId)` | `Identity/get` |
|
|
312
|
+
| `submitEmail(accountId, submission)` | `EmailSubmission/set` — receives the resolved envelope plus the raw RFC 5322 payload; your backend performs the actual sending |
|
|
313
|
+
| `subscribeToChanges(listener)` | StateChange pushes — the server's only push feed, so notify on all mutations, including those made through JMAP requests, not just out-of-band changes |
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
import { JmapServer, type IJmapMailBackend } from '@push.rocks/smartjmap';
|
|
317
|
+
|
|
318
|
+
declare const myBackend: IJmapMailBackend; // your implementation
|
|
319
|
+
|
|
320
|
+
const jmapServer = new JmapServer({
|
|
321
|
+
backend: myBackend,
|
|
322
|
+
baseUrl: 'https://mail.example.com',
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`MemoryMailBackend` is the reference implementation — a readable starting point for the expected semantics.
|
|
327
|
+
|
|
328
|
+
### What the Server Advertises (and What It Doesn't)
|
|
329
|
+
|
|
330
|
+
The session object advertises `urn:ietf:params:jmap:core`, `urn:ietf:params:jmap:mail`, and `urn:ietf:params:jmap:submission`. The core limits are enforced, not just advertised (exported as `JMAP_SERVER_LIMITS`): `maxSizeRequest` 10 MB, `maxCallsInRequest` 16, `maxObjectsInGet`/`maxObjectsInSet` 500, `maxSizeUpload` 50 MB, and `maxConcurrentRequests` 4 — violations produce request-level `limit` errors or `requestTooLarge` method errors. `maxDelayedSend` is 0 (no delayed send) and `mayCreateTopLevelMailbox` is false (no `Mailbox/set`).
|
|
331
|
+
|
|
332
|
+
The request layer implements `using` validation (`unknownCapability`), `#`-prefixed back-references with full `ResultReference` `{ resultOf, name, path }` JSON-pointer resolution including `/*` array expansion, client-provided `createdIds` maps, and `unknownMethod`/`invalidArguments`/`serverFail` error responses.
|
|
333
|
+
|
|
334
|
+
Dispatched methods: `Core/echo`, `Mailbox/get`, `Mailbox/query`, `Mailbox/changes`, `Thread/get`, `Email/get` (property projection, `fetchTextBodyValues`/`fetchHTMLBodyValues`/`fetchAllBodyValues`, `maxBodyValueBytes` truncation), `Email/query` (filters: `inMailbox`, `text`, `subject`, `from`, `to`, `before`, `after`, `hasKeyword`, `notKeyword`; `receivedAt` sort; `position`/`limit`/`calculateTotal`), `Email/changes`, `Email/set` (create/update/destroy with RFC 8620 §5.3 JSON-pointer patches for `keywords/*` and `mailboxIds/*`, plus `ifInState`), `Identity/get`, and `EmailSubmission/set` (with `onSuccessUpdateEmail`/`onSuccessDestroyEmail` and the implicit `Email/set` response).
|
|
335
|
+
|
|
336
|
+
Not implemented in this phase: `Email/queryChanges` (answered with an explicit `cannotCalculateChanges` error), `Mailbox/set`, `Email/copy`/`Email/import`/`Email/parse`, `SearchSnippet/*`, `VacationResponse/*`, `PushSubscription` (RFC 8620 §7.2 — the §7.3 event source is the push channel), `FilterOperator` trees (`AND`/`OR`/`NOT`), and `anchor` pagination.
|
|
237
337
|
|
|
238
|
-
|
|
338
|
+
## Error Handling
|
|
239
339
|
|
|
240
340
|
`connect()` never throws; failures (unreachable host, wrong credentials, missing capabilities) are emitted as `error` events, and the instance holds no open sockets or timers afterwards. HTTP problem details (RFC 7807) and JMAP method-level errors are surfaced as `JmapError` with `httpStatus`, `problemType`, `problemDetail`, `jmapErrorType`, and `jmapErrorDescription` fields.
|
|
241
341
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@push.rocks/smartjmap',
|
|
6
|
-
version: '
|
|
7
|
-
description: 'A TypeScript JMAP client for reading, sending, and watching mail
|
|
6
|
+
version: '2.0.0',
|
|
7
|
+
description: 'A TypeScript JMAP toolkit (RFC 8620/8621): an event-driven mail client for reading, sending, and watching mail, plus an embeddable JMAP server core with pluggable storage.'
|
|
8
8
|
}
|
package/ts/classes.jmapclient.ts
CHANGED
|
@@ -140,6 +140,13 @@ export interface ISmartJmapMessage {
|
|
|
140
140
|
raw: IJmapEmail;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** An attachment reference for outgoing mail; upload the bytes first via `uploadBlob`. */
|
|
144
|
+
export interface IJmapOutgoingAttachment {
|
|
145
|
+
blobId: string;
|
|
146
|
+
type: string;
|
|
147
|
+
name?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
143
150
|
export interface IJmapOutgoingEmail {
|
|
144
151
|
/** Sender; defaults to the address of the account's first JMAP Identity. */
|
|
145
152
|
from?: IJmapEmailAddress;
|
|
@@ -149,6 +156,8 @@ export interface IJmapOutgoingEmail {
|
|
|
149
156
|
subject?: string;
|
|
150
157
|
textBody?: string;
|
|
151
158
|
htmlBody?: string;
|
|
159
|
+
/** Attachments referencing blobs previously uploaded with `uploadBlob`. */
|
|
160
|
+
attachments?: IJmapOutgoingAttachment[];
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
export interface IJmapSendResult {
|
|
@@ -156,6 +165,14 @@ export interface IJmapSendResult {
|
|
|
156
165
|
submissionId: string;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
/** The upload endpoint's response (RFC 8620 §6.1). */
|
|
169
|
+
export interface IJmapUploadResult {
|
|
170
|
+
accountId: string;
|
|
171
|
+
blobId: string;
|
|
172
|
+
type: string;
|
|
173
|
+
size: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
/** Error thrown for HTTP-level (RFC 7807 problem details) and JMAP method-level errors. */
|
|
160
177
|
export class JmapError extends Error {
|
|
161
178
|
public httpStatus?: number;
|
|
@@ -466,6 +483,14 @@ export class JmapClient extends plugins.events.EventEmitter {
|
|
|
466
483
|
if (htmlBodyParts.length) {
|
|
467
484
|
emailCreate.htmlBody = htmlBodyParts;
|
|
468
485
|
}
|
|
486
|
+
if (outgoing.attachments?.length) {
|
|
487
|
+
emailCreate.attachments = outgoing.attachments.map((attachment) => ({
|
|
488
|
+
blobId: attachment.blobId,
|
|
489
|
+
type: attachment.type,
|
|
490
|
+
name: attachment.name ?? null,
|
|
491
|
+
disposition: 'attachment',
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
469
494
|
|
|
470
495
|
const responses = await this.request([
|
|
471
496
|
['Email/set', { accountId: this.accountId, create: { newEmail: emailCreate } }, 'c0'],
|
|
@@ -500,6 +525,39 @@ export class JmapClient extends plugins.events.EventEmitter {
|
|
|
500
525
|
return { emailId: createdEmail.id, submissionId: createdSubmission.id };
|
|
501
526
|
}
|
|
502
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Uploads raw bytes to the session's uploadUrl (RFC 8620 §6.1) and returns
|
|
530
|
+
* the stored blob's id, type, and size. Use the blobId in
|
|
531
|
+
* `sendEmail({ attachments: [...] })` or any other blob reference.
|
|
532
|
+
*/
|
|
533
|
+
public async uploadBlob(
|
|
534
|
+
data: Uint8Array,
|
|
535
|
+
type: string = 'application/octet-stream'
|
|
536
|
+
): Promise<IJmapUploadResult> {
|
|
537
|
+
const template = this.session?.uploadUrl;
|
|
538
|
+
if (!template) {
|
|
539
|
+
throw new Error('JMAP session provides no uploadUrl — call connect() first.');
|
|
540
|
+
}
|
|
541
|
+
const expanded = template.replace('{accountId}', encodeURIComponent(this.accountId));
|
|
542
|
+
const url = new URL(expanded, this.config.sessionUrl).toString();
|
|
543
|
+
// Re-wrap so the bytes are backed by a plain ArrayBuffer (BodyInit-safe).
|
|
544
|
+
const body =
|
|
545
|
+
data.buffer instanceof ArrayBuffer ? (data as Uint8Array<ArrayBuffer>) : new Uint8Array(data);
|
|
546
|
+
const response = await fetch(url, {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: {
|
|
549
|
+
authorization: this.getAuthHeader(),
|
|
550
|
+
'content-type': type,
|
|
551
|
+
accept: 'application/json',
|
|
552
|
+
},
|
|
553
|
+
body,
|
|
554
|
+
});
|
|
555
|
+
if (!response.ok) {
|
|
556
|
+
throw await this.createHttpError(response, 'Failed to upload blob');
|
|
557
|
+
}
|
|
558
|
+
return (await response.json()) as IJmapUploadResult;
|
|
559
|
+
}
|
|
560
|
+
|
|
503
561
|
/**
|
|
504
562
|
* Downloads a blob's raw bytes using the session downloadUrl template
|
|
505
563
|
* (RFC 8620 §6.2: {accountId}, {blobId}, {type}, {name}).
|