@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/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 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
 
@@ -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` 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 = {
@@ -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
- ### The Bundled Test Server
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` 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`.
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 jmapServer = new JmapServer();
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
- jmapServer.createMailbox('testuser', 'INBOX', 'inbox');
208
- jmapServer.addEmail('testuser', 'INBOX', {
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
- // Emails added while a client is connected are pushed via SSE:
225
- jmapServer.addEmail('testuser', 'INBOX', {
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 the server and destroys open connections
261
+ await jmapServer.stop(); // closes event streams, timers, and open connections
234
262
  ```
235
263
 
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.
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
- ### Error Handling
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
 
@@ -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.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
  }
@@ -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}).