@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/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 via the JSON Meta Application Protocol (RFC 8620/8621), with a bundled test server for offline testing.
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
- To install `@push.rocks/smartjmap`, use pnpm (or npm):
7
+ Install `@push.rocks/smartjmap` with pnpm:
8
8
 
9
9
  ```sh
10
- pnpm install @push.rocks/smartjmap
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` 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, and blob downloads.
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 URL, event source) are resolved relative to this URL automatically.
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
- ### The Bundled Test Server
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
- `JmapServer` is a minimal in-memory JMAP server for offline tests. It serves the session resource at `/.well-known/jmap` (and `/jmap/session`), the API at `/jmap/api`, blob downloads, and StateChange pushes via SSE at `/jmap/eventsource`. It validates Bearer and Basic auth and answers unauthenticated requests with `401` + `WWW-Authenticate`.
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 { JmapClient, JmapServer } from '@push.rocks/smartjmap';
235
+ import { JMAP_SERVER_STREAMING_API_VERSION } from '@push.rocks/smartjmap';
203
236
 
204
- const jmapServer = new JmapServer();
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
- jmapServer.createMailbox('testuser', 'INBOX', 'inbox');
208
- jmapServer.addEmail('testuser', 'INBOX', {
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
- // Emails added while a client is connected are pushed via SSE:
225
- jmapServer.addEmail('testuser', 'INBOX', {
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 the server and destroys open connections
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
- The dispatched methods are `Core/echo`, `Mailbox/get`, `Mailbox/query`, `Email/get`, `Email/query`, `Email/changes`, `Email/set`, `Identity/get`, and `EmailSubmission/set` enough for every public `JmapClient` method to run offline.
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
- ### Error Handling
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
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartjmap',
6
- version: '1.0.0',
7
- description: 'A TypeScript JMAP client for reading, sending, and watching mail via the JSON Meta Application Protocol (RFC 8620/8621), with a bundled test server for offline testing.'
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
  }