@push.rocks/smartjmap 1.0.0 → 2.1.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 +5 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/classes.jmapclient.d.ts +43 -13
- package/dist_ts/classes.jmapclient.js +326 -149
- package/dist_ts/classes.jmapserver.d.ts +154 -84
- package/dist_ts/classes.jmapserver.js +1788 -660
- 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 +349 -0
- package/dist_ts/interfaces.backend.js +13 -0
- package/npmextra.json +4 -2
- package/package.json +8 -7
- package/readme.hints.md +25 -14
- package/readme.md +149 -18
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/classes.jmapclient.ts +414 -153
- package/ts/classes.jmapserver.ts +2389 -761
- package/ts/classes.memorymailbackend.ts +850 -0
- package/ts/index.ts +2 -0
- package/ts/interfaces.backend.ts +413 -0
package/readme.md
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
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
|
|
|
7
|
-
|
|
7
|
+
Install `@push.rocks/smartjmap` with pnpm:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
pnpm
|
|
10
|
+
pnpm add @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 = {
|
|
@@ -141,8 +144,16 @@ const message = await jmapClient.getEmail(messages[0].id);
|
|
|
141
144
|
// Convenience: set the $seen keyword
|
|
142
145
|
await jmapClient.markSeen(message.id);
|
|
143
146
|
|
|
147
|
+
// Mark unread, toggle flagged, move, or permanently destroy
|
|
148
|
+
await jmapClient.markUnseen(message.id);
|
|
149
|
+
await jmapClient.setFlagged(message.id, true);
|
|
150
|
+
await jmapClient.setMailboxes(message.id, ['archive-mailbox-id']);
|
|
151
|
+
|
|
144
152
|
// Replace the full keywords object
|
|
145
153
|
await jmapClient.setKeywords(message.id, { $seen: true, $flagged: true });
|
|
154
|
+
|
|
155
|
+
// Permanently destroy the email after any other mutations are complete
|
|
156
|
+
await jmapClient.destroyEmail(message.id);
|
|
146
157
|
```
|
|
147
158
|
|
|
148
159
|
### Sending Mail
|
|
@@ -161,6 +172,25 @@ console.log(result.emailId, result.submissionId);
|
|
|
161
172
|
|
|
162
173
|
When `from` is omitted, the first identity of the account is used.
|
|
163
174
|
|
|
175
|
+
### Uploads and Attachments
|
|
176
|
+
|
|
177
|
+
`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:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const upload = await jmapClient.uploadBlob(
|
|
181
|
+
new TextEncoder().encode('quarterly report data'),
|
|
182
|
+
'text/plain'
|
|
183
|
+
);
|
|
184
|
+
console.log(upload.blobId, upload.size);
|
|
185
|
+
|
|
186
|
+
await jmapClient.sendEmail({
|
|
187
|
+
to: [{ email: 'friend@example.com' }],
|
|
188
|
+
subject: 'Report attached',
|
|
189
|
+
textBody: 'Please find the report attached.',
|
|
190
|
+
attachments: [{ blobId: upload.blobId, type: 'text/plain', name: 'report.txt' }],
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
164
194
|
### Downloading Blobs
|
|
165
195
|
|
|
166
196
|
Raw messages and attachments are blobs; download them via the session's download URL template:
|
|
@@ -194,25 +224,41 @@ await jmapClient.disconnect();
|
|
|
194
224
|
|
|
195
225
|
`disconnect()` tears down the event stream and any polling timer — no handles are left open — and emits `disconnected`.
|
|
196
226
|
|
|
197
|
-
|
|
227
|
+
## Running a JMAP server
|
|
228
|
+
|
|
229
|
+
`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).
|
|
198
230
|
|
|
199
|
-
|
|
231
|
+
Production consumers that rely on bounded streaming uploads can verify the
|
|
232
|
+
server surface before starting a listener:
|
|
200
233
|
|
|
201
234
|
```typescript
|
|
202
|
-
import {
|
|
235
|
+
import { JMAP_SERVER_STREAMING_API_VERSION } from '@push.rocks/smartjmap';
|
|
203
236
|
|
|
204
|
-
|
|
237
|
+
if (JMAP_SERVER_STREAMING_API_VERSION !== 1) {
|
|
238
|
+
throw new Error('This application requires smartjmap streaming server API v1.');
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### The Offline Test Server
|
|
243
|
+
|
|
244
|
+
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):
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { JmapClient, JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';
|
|
248
|
+
|
|
249
|
+
const backend = new MemoryMailBackend();
|
|
250
|
+
const jmapServer = new JmapServer({ backend });
|
|
205
251
|
jmapServer.addUser('testuser', 'testpass');
|
|
206
252
|
jmapServer.addBearerToken('testuser', 'test-token');
|
|
207
|
-
|
|
208
|
-
|
|
253
|
+
backend.createMailbox('testuser', 'INBOX', 'inbox');
|
|
254
|
+
backend.addEmail('testuser', 'INBOX', {
|
|
209
255
|
from: { email: 'alice@example.com' },
|
|
210
256
|
to: { email: 'testuser@example.com' },
|
|
211
257
|
subject: 'Welcome',
|
|
212
258
|
textBody: 'Hello from the test server!',
|
|
213
259
|
});
|
|
214
260
|
|
|
215
|
-
const port = await jmapServer.start(0); // resolves with the bound port
|
|
261
|
+
const port = await jmapServer.start(0); // node:http wrapper; resolves with the bound port
|
|
216
262
|
|
|
217
263
|
const client = new JmapClient({
|
|
218
264
|
sessionUrl: `http://127.0.0.1:${port}/.well-known/jmap`,
|
|
@@ -221,8 +267,9 @@ const client = new JmapClient({
|
|
|
221
267
|
client.on('message', (message) => console.log(message.subject));
|
|
222
268
|
await client.connect();
|
|
223
269
|
|
|
224
|
-
//
|
|
225
|
-
|
|
270
|
+
// Backend mutations outside a JMAP request (e.g. seeding, an IMAP bridge)
|
|
271
|
+
// flow through the change feed and are pushed to clients via SSE:
|
|
272
|
+
backend.addEmail('testuser', 'INBOX', {
|
|
226
273
|
from: { email: 'bob@example.com' },
|
|
227
274
|
to: { email: 'testuser@example.com' },
|
|
228
275
|
subject: 'Live push',
|
|
@@ -230,12 +277,96 @@ jmapServer.addEmail('testuser', 'INBOX', {
|
|
|
230
277
|
});
|
|
231
278
|
|
|
232
279
|
await client.disconnect();
|
|
233
|
-
await jmapServer.stop(); // closes
|
|
280
|
+
await jmapServer.stop(); // closes event streams, timers, and open connections
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Mounting fetchHandler (Deno, Bun, anywhere)
|
|
284
|
+
|
|
285
|
+
`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:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// deno run --allow-net server.ts
|
|
289
|
+
import { JmapServer, MemoryMailBackend } from '@push.rocks/smartjmap';
|
|
290
|
+
|
|
291
|
+
const backend = new MemoryMailBackend();
|
|
292
|
+
const jmapServer = new JmapServer({ backend, baseUrl: 'http://localhost:8080' });
|
|
293
|
+
jmapServer.addUser('demo', 'demopass');
|
|
294
|
+
backend.createMailbox('demo', 'INBOX', 'inbox');
|
|
295
|
+
|
|
296
|
+
Deno.serve({ port: 8080 }, (request) => jmapServer.fetchHandler(request));
|
|
234
297
|
```
|
|
235
298
|
|
|
236
|
-
|
|
299
|
+
`baseUrl` makes the URLs advertised in the session object absolute; without it they are relative and JMAP clients resolve them against the session URL.
|
|
300
|
+
|
|
301
|
+
### Custom Authentication
|
|
302
|
+
|
|
303
|
+
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`:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
import { JmapServer } from '@push.rocks/smartjmap';
|
|
307
|
+
|
|
308
|
+
const jmapServer = new JmapServer({
|
|
309
|
+
authenticationChallenges: ['Bearer realm="jmap"'],
|
|
310
|
+
authenticate: async (request: Request) => {
|
|
311
|
+
const match = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i);
|
|
312
|
+
const token = match?.[1];
|
|
313
|
+
return token === 'sesame' ? { username: 'appuser' } : null;
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Set `authenticationChallenges` to the exact schemes accepted by a custom
|
|
319
|
+
authenticator. The built-in registry advertises both Bearer and Basic by
|
|
320
|
+
default.
|
|
321
|
+
|
|
322
|
+
### Implementing a Real Backend
|
|
323
|
+
|
|
324
|
+
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):
|
|
325
|
+
|
|
326
|
+
| Method | Serves |
|
|
327
|
+
| --- | --- |
|
|
328
|
+
| `resolveAccount(principal)` | principal → account mapping for the session |
|
|
329
|
+
| `getMailboxes(accountId, ids)` / `getMailboxChanges(accountId, sinceState, maxChanges?)` | `Mailbox/get`, `Mailbox/query`, `Mailbox/changes` |
|
|
330
|
+
| `getEmails(accountId, ids, properties?)` | `Email/get` (the server applies projection and body-value fetch flags) |
|
|
331
|
+
| `queryEmails(accountId, options)` | `Email/query` (filter subset, receivedAt sort, position/limit/total) |
|
|
332
|
+
| `getEmailChanges(accountId, sinceState, maxChanges?)` | `Email/changes` (return null for `cannotCalculateChanges`) |
|
|
333
|
+
| `setEmails(accountId, request)` | `Email/set` (creates, normalized keyword/mailbox updates, destroys) |
|
|
334
|
+
| `getThreads(accountId, ids)` | `Thread/get` |
|
|
335
|
+
| `uploadBlob(accountId, data, type)` / optional `uploadBlobStream(accountId, upload)` / `getBlob(accountId, blobId)` | buffered or streaming upload, download endpoints, and raw-message access |
|
|
336
|
+
| `getIdentities(accountId)` | `Identity/get` |
|
|
337
|
+
| `submitEmail(accountId, submission)` | `EmailSubmission/set` — receives the resolved envelope plus the raw RFC 5322 payload; your backend performs the actual sending |
|
|
338
|
+
| `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 |
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import { JmapServer, type IJmapMailBackend } from '@push.rocks/smartjmap';
|
|
342
|
+
|
|
343
|
+
declare const myBackend: IJmapMailBackend; // your implementation
|
|
344
|
+
|
|
345
|
+
const jmapServer = new JmapServer({
|
|
346
|
+
backend: myBackend,
|
|
347
|
+
baseUrl: 'https://mail.example.com',
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`MemoryMailBackend` is the reference implementation — a readable starting point for the expected semantics.
|
|
352
|
+
|
|
353
|
+
When `uploadBlobStream` is implemented, the server authenticates and admits the
|
|
354
|
+
request before reading, requires `Content-Length`, and passes `stream`, exact
|
|
355
|
+
`size`, media `type`, and an abort `signal`. The backend must consume exactly
|
|
356
|
+
`size` bytes, reserve quota before reading the stream, reject short or oversized
|
|
357
|
+
input, honor cancellation, and remove partial storage on failure.
|
|
358
|
+
|
|
359
|
+
### What the Server Advertises (and What It Doesn't)
|
|
360
|
+
|
|
361
|
+
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, `maxConcurrentRequests` 4, and per-principal `maxConcurrentUpload` 4 — violations produce request-level `limit` errors, `requestTooLarge` method errors, or upload problem details. Constructor `limits` override advertised limits; `maxConcurrentUploadServer` adds a process-wide upload ceiling, and `mapUploadError` maps storage failures to application-specific RFC 7807 responses. `maxDelayedSend` is 0 (no delayed send) and `mayCreateTopLevelMailbox` is false (no `Mailbox/set`).
|
|
362
|
+
|
|
363
|
+
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.
|
|
364
|
+
|
|
365
|
+
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).
|
|
366
|
+
|
|
367
|
+
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
368
|
|
|
238
|
-
|
|
369
|
+
## Error Handling
|
|
239
370
|
|
|
240
371
|
`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
372
|
|
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: '1.0
|
|
7
|
-
description: 'A TypeScript JMAP client for reading, sending, and watching mail
|
|
6
|
+
version: '2.1.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
|
}
|