@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.
@@ -1,2 +1,4 @@
1
1
  export * from './classes.jmapclient.js';
2
+ export * from './interfaces.backend.js';
3
+ export * from './classes.memorymailbackend.js';
2
4
  export * from './classes.jmapserver.js';
package/dist_ts/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './classes.jmapclient.js';
2
+ export * from './interfaces.backend.js';
3
+ export * from './classes.memorymailbackend.js';
2
4
  export * from './classes.jmapserver.js';
3
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHlCQUF5QixDQUFDO0FBQ3hDLGNBQWMseUJBQXlCLENBQUMifQ==
5
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxjQUFjLHlCQUF5QixDQUFDO0FBQ3hDLGNBQWMseUJBQXlCLENBQUM7QUFDeEMsY0FBYyxnQ0FBZ0MsQ0FBQztBQUMvQyxjQUFjLHlCQUF5QixDQUFDIn0=
@@ -0,0 +1,349 @@
1
+ import type { IJmapEmailAddress, IJmapEmailBodyPart, IJmapEmailBodyValue } from './classes.jmapclient.js';
2
+ /**
3
+ * The authenticated principal a JmapServer `authenticate` hook resolves from an
4
+ * incoming Request. The backend maps a principal to a JMAP account via
5
+ * `resolveAccount`.
6
+ */
7
+ export type TJmapPrincipal = {
8
+ /** Stable identifier of the authenticated user, e.g. a username or token subject. */
9
+ username: string;
10
+ };
11
+ /** Account metadata returned by `IJmapMailBackend.resolveAccount`. */
12
+ export interface IJmapAccountInfo {
13
+ /** The JMAP accountId used in method calls and URL templates. */
14
+ accountId: string;
15
+ /** Display name of the account, shown in the session object. */
16
+ name: string;
17
+ /** Defaults to true when omitted. */
18
+ isPersonal?: boolean;
19
+ /** Defaults to false when omitted. */
20
+ isReadOnly?: boolean;
21
+ }
22
+ /** A Mailbox record (RFC 8621 §2) as stored/served by a backend. */
23
+ export interface IJmapMailboxRecord {
24
+ id: string;
25
+ name: string;
26
+ role: string | null;
27
+ parentId: string | null;
28
+ sortOrder: number;
29
+ totalEmails: number;
30
+ unreadEmails: number;
31
+ totalThreads: number;
32
+ unreadThreads: number;
33
+ /** Defaults to true when omitted. */
34
+ isSubscribed?: boolean;
35
+ /** Defaults to an all-permissive rights object when omitted. */
36
+ myRights?: Record<string, boolean>;
37
+ }
38
+ /**
39
+ * An Email record (RFC 8621 §4) as stored/served by a backend — the full
40
+ * superset of properties the server can project from. `bodyValues` must hold
41
+ * the complete body values; the server applies the Email/get fetch flags and
42
+ * `maxBodyValueBytes` truncation on top.
43
+ */
44
+ export interface IJmapEmailRecord {
45
+ id: string;
46
+ blobId: string;
47
+ threadId: string;
48
+ mailboxIds: Record<string, boolean>;
49
+ keywords: Record<string, boolean>;
50
+ size: number;
51
+ receivedAt: string;
52
+ sentAt: string | null;
53
+ messageId?: string[] | null;
54
+ inReplyTo?: string[] | null;
55
+ references?: string[] | null;
56
+ sender?: IJmapEmailAddress[] | null;
57
+ subject: string | null;
58
+ from: IJmapEmailAddress[] | null;
59
+ to: IJmapEmailAddress[] | null;
60
+ cc: IJmapEmailAddress[] | null;
61
+ bcc: IJmapEmailAddress[] | null;
62
+ replyTo: IJmapEmailAddress[] | null;
63
+ hasAttachment: boolean;
64
+ preview: string;
65
+ bodyValues: Record<string, IJmapEmailBodyValue>;
66
+ textBody: IJmapEmailBodyPart[];
67
+ htmlBody: IJmapEmailBodyPart[];
68
+ attachments: IJmapEmailBodyPart[];
69
+ }
70
+ /** A Thread record (RFC 8621 §3): emailIds sorted oldest first. */
71
+ export interface IJmapThreadRecord {
72
+ id: string;
73
+ emailIds: string[];
74
+ }
75
+ /** A stored blob: raw bytes plus the content type given at upload/creation. */
76
+ export interface IJmapBlobRecord {
77
+ data: Uint8Array;
78
+ type: string;
79
+ name?: string;
80
+ }
81
+ /** An Identity record (RFC 8621 §6). */
82
+ export interface IJmapIdentityRecord {
83
+ id: string;
84
+ name: string;
85
+ email: string;
86
+ mayDelete: boolean;
87
+ }
88
+ /** An EmailSubmission record (RFC 8621 §7) as returned by `submitEmail`. */
89
+ export interface IJmapSubmissionRecord {
90
+ id: string;
91
+ emailId: string;
92
+ identityId: string;
93
+ undoStatus: string;
94
+ sendAt: string;
95
+ }
96
+ /** SMTP-style envelope handed to the backend on submission (RFC 8621 §7). */
97
+ export interface IJmapEnvelope {
98
+ mailFrom: {
99
+ email: string;
100
+ };
101
+ rcptTo: Array<{
102
+ email: string;
103
+ }>;
104
+ }
105
+ /** Everything the backend needs to actually send a message. */
106
+ export interface IJmapSubmissionInput {
107
+ emailId: string;
108
+ identityId: string;
109
+ /** Envelope from the client, or derived by the server from the email's addresses. */
110
+ envelope: IJmapEnvelope;
111
+ /** The raw RFC 5322 payload (the email's blob) — the backend performs the actual sending. */
112
+ message: Uint8Array;
113
+ }
114
+ /**
115
+ * Authenticated, admitted upload handed to a streaming backend. The backend
116
+ * must reserve quota before reading `stream`, consume it through EOF, and
117
+ * abort its storage operation when `signal` fires.
118
+ */
119
+ export interface IJmapBlobUploadStream {
120
+ stream: ReadableStream<Uint8Array>;
121
+ size: number;
122
+ type: string;
123
+ signal: AbortSignal;
124
+ }
125
+ /** Standard result shape for the /get-style backend reads. */
126
+ export interface IJmapGetResult<T> {
127
+ /** Backend-owned opaque state string for the collection at read time. */
128
+ state: string;
129
+ list: T[];
130
+ notFound: string[];
131
+ }
132
+ /** Result shape for the /changes-style backend reads (RFC 8620 §5.2). */
133
+ export interface IJmapChanges {
134
+ oldState: string;
135
+ newState: string;
136
+ hasMoreChanges: boolean;
137
+ created: string[];
138
+ updated: string[];
139
+ destroyed: string[];
140
+ }
141
+ /** The Email/query filter subset a backend must support (RFC 8621 §4.4.1). */
142
+ export interface IJmapEmailFilter {
143
+ inMailbox?: string;
144
+ /** Case-insensitive match in subject, body text, or any address field. */
145
+ text?: string;
146
+ subject?: string;
147
+ from?: string;
148
+ to?: string;
149
+ /** receivedAt strictly before this UTC date. */
150
+ before?: string;
151
+ /** receivedAt at or after this UTC date. */
152
+ after?: string;
153
+ hasKeyword?: string;
154
+ notKeyword?: string;
155
+ }
156
+ /** A query sort comparator; the server only forwards supported properties. */
157
+ export interface IJmapComparator {
158
+ property: string;
159
+ isAscending?: boolean;
160
+ }
161
+ /** Options passed to `queryEmails`; the server has already validated them. */
162
+ export interface IJmapEmailQueryOptions {
163
+ filter?: IJmapEmailFilter | null;
164
+ /** Only `receivedAt` comparators are forwarded by the server. */
165
+ sort?: IJmapComparator[];
166
+ /** Zero-based offset into the full result list. */
167
+ position?: number;
168
+ limit?: number | null;
169
+ /** When true the result must include `total`. */
170
+ calculateTotal?: boolean;
171
+ }
172
+ export interface IJmapEmailQueryResult {
173
+ queryState: string;
174
+ ids: string[];
175
+ position: number;
176
+ /** Present when `calculateTotal` was requested. */
177
+ total?: number;
178
+ }
179
+ /** An Email creation spec (server-validated Email/set create arguments). */
180
+ export interface IJmapEmailCreate {
181
+ mailboxIds: Record<string, boolean>;
182
+ keywords?: Record<string, boolean>;
183
+ from?: IJmapEmailAddress[] | null;
184
+ to?: IJmapEmailAddress[] | null;
185
+ cc?: IJmapEmailAddress[] | null;
186
+ bcc?: IJmapEmailAddress[] | null;
187
+ replyTo?: IJmapEmailAddress[] | null;
188
+ sender?: IJmapEmailAddress[] | null;
189
+ messageId?: string[] | null;
190
+ inReplyTo?: string[] | null;
191
+ references?: string[] | null;
192
+ subject?: string | null;
193
+ sentAt?: string | null;
194
+ receivedAt?: string;
195
+ bodyValues?: Record<string, {
196
+ value: string;
197
+ }>;
198
+ textBody?: IJmapEmailBodyPart[];
199
+ htmlBody?: IJmapEmailBodyPart[];
200
+ /** Attachment body parts referencing previously uploaded blobs via `blobId`. */
201
+ attachments?: IJmapEmailBodyPart[];
202
+ }
203
+ /**
204
+ * An Email update, normalized by the server from RFC 8620 §5.3 patches:
205
+ * full replacements and per-key patches never overlap for the same property
206
+ * (the server rejects such patches before calling the backend).
207
+ */
208
+ export interface IJmapEmailUpdate {
209
+ /** Full replacement of the keywords object. */
210
+ keywords?: Record<string, boolean>;
211
+ /** Full replacement of the mailboxIds object. */
212
+ mailboxIds?: Record<string, boolean>;
213
+ /** Per-keyword patch: true adds, null removes. */
214
+ keywordsPatch?: Record<string, true | null>;
215
+ /** Per-mailbox patch: true adds, null removes. */
216
+ mailboxIdsPatch?: Record<string, true | null>;
217
+ }
218
+ /** A JMAP SetError (RFC 8620 §5.3). */
219
+ export interface IJmapSetError {
220
+ type: string;
221
+ description?: string;
222
+ properties?: string[];
223
+ }
224
+ /** The batch handed to `setEmails`; entries are already server-validated. */
225
+ export interface IJmapEmailSetRequest {
226
+ /** Keyed by client creation id. */
227
+ create?: Record<string, IJmapEmailCreate>;
228
+ /** Keyed by email id. */
229
+ update?: Record<string, IJmapEmailUpdate>;
230
+ destroy?: string[];
231
+ }
232
+ /** The outcome of a `setEmails` batch; `created` carries the full new records. */
233
+ export interface IJmapEmailSetResult {
234
+ oldState: string;
235
+ newState: string;
236
+ created: Record<string, IJmapEmailRecord>;
237
+ notCreated: Record<string, IJmapSetError>;
238
+ updated: string[];
239
+ notUpdated: Record<string, IJmapSetError>;
240
+ destroyed: string[];
241
+ notDestroyed: Record<string, IJmapSetError>;
242
+ }
243
+ /**
244
+ * A change notification the backend emits when data mutates — both for
245
+ * mutations performed through the JMAP server and for out-of-band mutations
246
+ * (e.g. new mail arriving via an IMAP bridge). `changed` maps JMAP type names
247
+ * ('Email', 'Mailbox', 'Thread', ...) to the new opaque state string.
248
+ */
249
+ export interface IJmapStateChange {
250
+ accountId: string;
251
+ changed: Record<string, string>;
252
+ }
253
+ export type TJmapChangeListener = (change: IJmapStateChange) => void;
254
+ /**
255
+ * Error a backend may throw from set-style operations to control the JMAP
256
+ * SetError `type` the server reports (e.g. 'notFound', 'invalidProperties').
257
+ * Any other thrown error is mapped to a generic SetError.
258
+ */
259
+ export declare class JmapBackendError extends Error {
260
+ type: string;
261
+ constructor(type: string, description?: string);
262
+ }
263
+ /**
264
+ * The storage contract `JmapServer` dispatches into. Implement this against a
265
+ * real store (SQL, MongoDB, ...) to expose it as a JMAP server; the bundled
266
+ * `MemoryMailBackend` is the in-memory reference implementation.
267
+ *
268
+ * All state strings are backend-owned opaque strings: the server never
269
+ * interprets them beyond equality comparison and echoing them to clients.
270
+ */
271
+ export interface IJmapMailBackend {
272
+ /**
273
+ * Maps an authenticated principal to its JMAP account, or null when the
274
+ * principal has no account (the server then responds 403).
275
+ */
276
+ resolveAccount(principal: TJmapPrincipal): Promise<IJmapAccountInfo | null>;
277
+ /**
278
+ * Returns mailboxes by id (`ids: null` returns all mailboxes of the
279
+ * account) together with the current Mailbox collection state. Counter
280
+ * fields (totalEmails, unreadEmails, ...) must be accurate at read time.
281
+ */
282
+ getMailboxes(accountId: string, ids: string[] | null): Promise<IJmapGetResult<IJmapMailboxRecord>>;
283
+ /**
284
+ * Returns mailbox changes since the given state, or null when the state is
285
+ * unknown/too old (the server then answers `cannotCalculateChanges`).
286
+ * When `maxChanges` is given, the result may be partial with
287
+ * `hasMoreChanges: true` and an intermediate `newState` to resume from.
288
+ */
289
+ getMailboxChanges(accountId: string, sinceState: string, maxChanges?: number): Promise<IJmapChanges | null>;
290
+ /**
291
+ * Returns emails by id together with the current Email collection state.
292
+ * `properties` is a projection hint (null/undefined = all): backends may
293
+ * use it to avoid loading bodies, but returning full records is always
294
+ * correct — the server applies the final projection itself.
295
+ */
296
+ getEmails(accountId: string, ids: string[], properties?: string[] | null): Promise<IJmapGetResult<IJmapEmailRecord>>;
297
+ /**
298
+ * Runs an Email query: apply the filter, sort, then slice by
299
+ * position/limit. `total` must be included when `calculateTotal` is set.
300
+ */
301
+ queryEmails(accountId: string, options: IJmapEmailQueryOptions): Promise<IJmapEmailQueryResult>;
302
+ /**
303
+ * Returns email changes since the given state, or null when the state is
304
+ * unknown/too old (the server then answers `cannotCalculateChanges`).
305
+ */
306
+ getEmailChanges(accountId: string, sinceState: string, maxChanges?: number): Promise<IJmapChanges | null>;
307
+ /**
308
+ * Applies an Email/set batch: creates (assigning ids, blobIds, and thread
309
+ * ids), updates (keyword/mailbox replacements and patches), and destroys.
310
+ * Per-item failures go into the not* maps; the whole batch must never
311
+ * throw for item-level problems. Must bump the Email state exactly when
312
+ * something changed and report old/new state.
313
+ */
314
+ setEmails(accountId: string, request: IJmapEmailSetRequest): Promise<IJmapEmailSetResult>;
315
+ /**
316
+ * Returns threads by id (each thread lists its emailIds oldest first)
317
+ * together with the current Thread collection state.
318
+ */
319
+ getThreads(accountId: string, ids: string[]): Promise<IJmapGetResult<IJmapThreadRecord>>;
320
+ /** Stores an uploaded blob and returns its id and byte size (RFC 8620 §6.1). */
321
+ uploadBlob(accountId: string, data: Uint8Array, type: string): Promise<{
322
+ blobId: string;
323
+ size: number;
324
+ }>;
325
+ /**
326
+ * Optional streaming upload path. When present, JmapServer requires a valid
327
+ * Content-Length and never buffers the request before invoking this method.
328
+ */
329
+ uploadBlobStream?(accountId: string, upload: IJmapBlobUploadStream): Promise<{
330
+ blobId: string;
331
+ size: number;
332
+ }>;
333
+ /** Returns a blob's bytes and metadata, or null when unknown. */
334
+ getBlob(accountId: string, blobId: string): Promise<IJmapBlobRecord | null>;
335
+ /** Returns the sending identities of the account with their collection state. */
336
+ getIdentities(accountId: string): Promise<IJmapGetResult<IJmapIdentityRecord>>;
337
+ /**
338
+ * Accepts an email for sending: the server has already resolved the
339
+ * envelope and fetched the raw RFC 5322 payload — the backend performs (or
340
+ * queues) the actual delivery and returns the submission record.
341
+ */
342
+ submitEmail(accountId: string, submission: IJmapSubmissionInput): Promise<IJmapSubmissionRecord>;
343
+ /**
344
+ * Subscribes to change notifications so the server can emit StateChange
345
+ * pushes — including for mutations that happen outside a JMAP request.
346
+ * Returns an unsubscribe function; the server always calls it on teardown.
347
+ */
348
+ subscribeToChanges(listener: TJmapChangeListener): () => void;
349
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error a backend may throw from set-style operations to control the JMAP
3
+ * SetError `type` the server reports (e.g. 'notFound', 'invalidProperties').
4
+ * Any other thrown error is mapped to a generic SetError.
5
+ */
6
+ export class JmapBackendError extends Error {
7
+ constructor(type, description) {
8
+ super(description ?? type);
9
+ this.type = type;
10
+ this.name = 'JmapBackendError';
11
+ }
12
+ }
13
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5iYWNrZW5kLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvaW50ZXJmYWNlcy5iYWNrZW5kLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQW9SQTs7OztHQUlHO0FBQ0gsTUFBTSxPQUFPLGdCQUFpQixTQUFRLEtBQUs7SUFDekMsWUFBbUIsSUFBWSxFQUFFLFdBQW9CO1FBQ25ELEtBQUssQ0FBQyxXQUFXLElBQUksSUFBSSxDQUFDLENBQUM7UUFEVixTQUFJLEdBQUosSUFBSSxDQUFRO1FBRTdCLElBQUksQ0FBQyxJQUFJLEdBQUcsa0JBQWtCLENBQUM7SUFDakMsQ0FBQztDQUNGIn0=
package/npmextra.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "githost": "code.foss.global",
6
6
  "gitscope": "push.rocks",
7
7
  "gitrepo": "smartjmap",
8
- "shortDescription": "JMAP client for mail (RFC 8620/8621)",
9
- "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.",
8
+ "shortDescription": "JMAP client and server core for mail (RFC 8620/8621)",
9
+ "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.",
10
10
  "npmPackagename": "@push.rocks/smartjmap",
11
11
  "license": "MIT",
12
12
  "projectDomain": "push.rocks",
@@ -21,6 +21,8 @@
21
21
  "json",
22
22
  "http",
23
23
  "client",
24
+ "server",
25
+ "deno",
24
26
  "typescript",
25
27
  "event-driven",
26
28
  "push",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@push.rocks/smartjmap",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
- "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.",
5
+ "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.",
6
6
  "exports": {
7
7
  ".": "./dist_ts/index.js"
8
8
  },
@@ -12,12 +12,11 @@
12
12
  "author": "Task Venture Capital GmbH <hello@task.vc>",
13
13
  "license": "MIT",
14
14
  "devDependencies": {
15
- "@git.zone/tsbuild": "^4.4.0",
16
- "@git.zone/tsrun": "^2.0.3",
17
- "@git.zone/tstest": "^3.6.3",
18
- "@types/node": "^25.6.0"
15
+ "@git.zone/tsbuild": "^4.4.2",
16
+ "@git.zone/tsrun": "^2.0.5",
17
+ "@git.zone/tstest": "^3.6.6",
18
+ "@types/node": "^26.1.1"
19
19
  },
20
- "dependencies": {},
21
20
  "repository": {
22
21
  "type": "git",
23
22
  "url": "git+https://code.foss.global/push.rocks/smartjmap.git"
@@ -54,6 +53,8 @@
54
53
  "json",
55
54
  "http",
56
55
  "client",
56
+ "server",
57
+ "deno",
57
58
  "typescript",
58
59
  "event-driven",
59
60
  "push",
package/readme.hints.md CHANGED
@@ -2,33 +2,44 @@
2
2
 
3
3
  Design notes for @push.rocks/smartjmap.
4
4
 
5
+ ## Architecture (2.x)
6
+
7
+ - Three layers: `JmapClient` (consumer), `JmapServer` (protocol core), `IJmapMailBackend` (storage contract, `ts/interfaces.backend.ts`). The server owns everything protocol-shaped (request envelope, limits, back-references, projection, bodyValue fetch flags, patch normalization, envelope derivation, SSE); the backend owns storage, ids, and state strings (opaque to the server, compared only for equality).
8
+ - `MemoryMailBackend` is the reference backend and the default, keeping the no-options `JmapServer` an offline test helper. Accounts are keyed by username (`accountId = account_<username>`) and auto-created on first reference, so `resolveAccount` never fails for the default auth registry.
9
+ - Auth is two-step: `authenticate(request) -> TJmapPrincipal | null` (custom hook replaces the built-in Basic/Bearer registry entirely), then `backend.resolveAccount(principal)` (null -> 403). `addUser`/`addBearerToken` only feed the default registry.
10
+ - Email/set update patches are normalized server-side into `IJmapEmailUpdate` (full `keywords`/`mailboxIds` replacements vs `keywordsPatch`/`mailboxIdsPatch` per-key patches, mutually exclusive per property) so real backends can apply patches natively (e.g. MongoDB `$set`/`$unset`). Emails are otherwise immutable per RFC 8621 §4.6; any other patched property is rejected.
11
+ - `submitEmail` hands the backend the resolved envelope (client-supplied or derived from from/to/cc/bcc) plus the raw RFC 5322 payload from the email's blob; the app performs actual delivery.
12
+
5
13
  ## HTTP layer
6
14
 
7
- - Zero runtime dependencies: global `fetch` (Node.js 18+ floor; developed against @types/node 25). smartrequest would add no value for plain JSON-over-HTTP.
8
- - SSE is parsed by hand over a fetch body stream (~50 lines in `runEventStream`). The global `EventSource` was rejected deliberately: it is only default-enabled from Node 22.3 and, more importantly, the WHATWG EventSource API cannot send the `Authorization` header JMAP requires.
15
+ - Zero runtime dependencies: global `fetch`/`Request`/`Response`/`ReadableStream` (Node.js 18+ floor; developed against @types/node 25). `fetchHandler(request)` is the primary server API and is runtime-agnostic (Deno/Bun-mountable, covered by tests that use bare Request objects); `start(port)` wraps it in node:http, buffering request bodies (capped at maxSizeUpload + 1 -> 413) and piping response streams to the socket, cancelling the stream reader when the client disconnects.
16
+ - Client SSE is parsed by hand over a fetch body stream (~50 lines in `runEventStream`). The global `EventSource` was rejected deliberately: it is only default-enabled from Node 22.3 and, more importantly, the WHATWG EventSource API cannot send the `Authorization` header JMAP requires.
17
+ - Server SSE is a `ReadableStream` Response; per-stream state (ping interval timer, `types` filter, closeafter flag, last-pushed state per type) is registered in a set and torn down on stream cancel, `closeafter=state` delivery, enqueue failure, `stop()`, and stream-setup failure (a throwing `backend.subscribeToChanges` unwinds the ping timer and registry entry before the request 500s). The backend change-feed subscription is created when the first stream connects and dropped when the last one closes, so an idle server holds no listener.
18
+ - Push dedupe: a mutation may be reported twice (backend change feed fires during the request + the server pushes after mutating requests for backends whose feed only covers out-of-band changes). Streams track the last state pushed per type and skip repeats.
9
19
 
10
- ## Watching semantics
20
+ ## Watching semantics (client)
11
21
 
12
22
  - `connect()`: session GET → capability/account validation → mailbox resolution (role match on the lowercased mailbox name, e.g. 'INBOX' → role 'inbox', falling back to case-insensitive name match) → Email state snapshot → 'connected' → initial sweep (Email/query + Email/get, emitted oldest-first) → event stream.
13
23
  - The Email state is snapshotted *before* the initial sweep and `fetchDeltas()` runs once right after the SSE stream is established, so mail arriving between connect and stream establishment is not lost; `seenEmailIds` deduplicates the overlap.
14
24
  - Event stream errors/end → silent fallback to `Email/changes` polling on `pollIntervalMs`. `cannotCalculateChanges` → full re-query resync.
15
25
  - `connect()` emits 'error' instead of throwing (mirrors smartimap). Unlike smartimap, the same instance may call `connect()` again — JMAP is stateless HTTP.
16
- - `disconnect()` aborts the SSE fetch and clears the poll timer; the connect-failure path also tears both down, so no handles leak either way.
26
+ - `disconnect()` aborts the SSE fetch and clears the poll timer; the connect-failure path also tears both down, so no handles leak either way. The teardown-at-connect-entry, abort-in-catch, last-stream-wins, and guarded done-path pieces in `runEventStream` are all deliberate lifecycle fixes — keep them.
17
27
 
18
- ## RFC subset covered (1.0.0)
28
+ ## RFC subset covered (2.x)
19
29
 
20
- - RFC 8620: session resource, method calls/responses, `error` method responses, RFC 7807 problem details, `#creationId` back-references (client-side use; server resolves them in EmailSubmission/set), download URL template expansion, event source (SSE StateChange).
21
- - RFC 8621: Mailbox/get(+query on server), Email/query, Email/get with `fetchAllBodyValues`, Email/changes, Email/set (create/update with keyword patches/destroy), Identity/get, EmailSubmission/set.
30
+ - RFC 8620: session resource, method calls/responses, `error` method responses, RFC 7807 problem details, enforced limits (`limit` request errors, `requestTooLarge` method errors), `using` validation (`unknownCapability`), `#creationId` back-references and full `ResultReference` `{resultOf, name, path}` resolution with `/*` array expansion, request/response `createdIds` maps, upload/download endpoints, event source (§7.3: `types`, `closeafter=state`, `ping` keepalives).
31
+ - RFC 8621: Mailbox/get(+query, +changes), Thread/get, Email/get (projection, bodyValue fetch flags, `maxBodyValueBytes`), Email/query (flat FilterCondition subset + receivedAt sort + position/limit/calculateTotal), Email/changes (incl. maxChanges), Email/set (create/update with §5.3 patches for keywords/mailboxIds/destroy, `ifInState`), Identity/get, EmailSubmission/set (envelope derivation, onSuccessUpdateEmail/onSuccessDestroyEmail with the implicit Email/set response).
22
32
 
23
- ## Known gaps
33
+ ## Known gaps / phase-1 exclusions
24
34
 
25
- - No upload endpoint / Blob upload (`uploadUrl` is advertised by the test server but not implemented), so `sendEmail` supports text/html bodies but not attachments.
26
- - No Thread/*, SearchSnippet/*, VacationResponse/*, Mailbox/set, Mailbox/changes, EmailSubmission onSuccessUpdateEmail, or result references (`resultOf`) the client issues explicit-id calls instead.
27
- - `Email/get` on the test server ignores the `properties` projection and always returns the full superset; `Email/query` supports only `inMailbox`/`subject`/`text` filters and `receivedAt` sorting.
28
- - The client requests event-source pushes with `types=*` and reacts to Email state changes only; Mailbox counters are not tracked incrementally.
29
- - Initial-sweep race: an email created between the Email/query and Email/get of the sweep is picked up by the following delta fetch, not the sweep itself (dedupe prevents double emission).
35
+ - No `Email/queryChanges` (explicit `cannotCalculateChanges` error, not unknownMethod clients like the bundled one re-query), no `Mailbox/set`, no `Email/copy`/`Email/import`/`Email/parse`, no `SearchSnippet/*`, no `VacationResponse/*`, no `PushSubscription` (§7.2) — the §7.3 event source is the only push channel.
36
+ - No `FilterOperator` trees (AND/OR/NOT `unsupportedFilter`) and no `anchor` pagination (`invalidArguments`); Email sorting is `receivedAt` only, Mailbox sorting `name`/`sortOrder` only.
37
+ - EmailSubmission has no state tracking (`newState` is constant '0', updates/destroys are rejected with `forbidden`) and no EmailSubmission/get|changes.
38
+ - Session `state` and `sessionState` are the constant '0': the session object never changes at runtime for a given account.
39
+ - Memory backend simplifications: thread ids derive from normalized subjects (reply/forward prefixes stripped), not References headers — emails with an empty subject get their own thread; raw RFC 5322 blobs render headers + text body only (attachments stay separate blobs, not MIME-encoded into the raw message); date filters compare ISO strings lexicographically, assuming normalized UTC ('Z') timestamps.
40
+ - Initial-sweep race (client): an email created between the Email/query and Email/get of the sweep is picked up by the following delta fetch, not the sweep itself (dedupe prevents double emission).
30
41
 
31
42
  ## By-design unbounded state (accepted)
32
43
 
33
44
  - Client: `seenEmailIds` retains every emitted email id for the instance lifetime — required for dedupe across reconnects and sweep/delta overlap; on a very long-lived, high-volume connection this grows without a pruning hook. Recreate the client to reset it.
34
- - Test server (in-memory helper, not production code): the per-account change log grows one entry per mutation (needed by `Email/changes`), destroyed emails leave their blobs in the blob store, and request bodies are buffered without enforcing the advertised `maxSizeRequest`.
45
+ - MemoryMailBackend (in-memory helper, not production storage): the per-account email and mailbox change logs grow one entry per mutation (needed by the /changes methods), destroyed emails leave their blobs and thread-subject mappings in place, and uploaded blobs are never expired. The node wrapper buffers request bodies in memory up to maxSizeUpload + 1 bytes before dispatch (test-helper convenience; the JMAP per-endpoint limits are enforced inside `fetchHandler`).