@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,69 +1,173 @@
1
- import * as plugins from './smartjmap.plugins.js';
2
- import type { IJmapEmailAddress } from './classes.jmapclient.js';
3
-
4
- export interface IJmapServerMailbox {
5
- id: string;
6
- name: string;
7
- role: string | null;
8
- parentId: string | null;
9
- sortOrder: number;
10
- }
1
+ import * as plugins from "./smartjmap.plugins.js";
2
+ import {
3
+ type IJmapEmailBodyValue,
4
+ JMAP_CORE_CAPABILITY,
5
+ JMAP_MAIL_CAPABILITY,
6
+ JMAP_SUBMISSION_CAPABILITY,
7
+ type TJmapMethodResponse,
8
+ } from "./classes.jmapclient.js";
9
+ import {
10
+ type IJmapAccountInfo,
11
+ type IJmapBlobUploadStream,
12
+ type IJmapEmailCreate,
13
+ type IJmapEmailFilter,
14
+ type IJmapEmailRecord,
15
+ type IJmapEmailSetRequest,
16
+ type IJmapEmailUpdate,
17
+ type IJmapEnvelope,
18
+ type IJmapMailBackend,
19
+ type IJmapSetError,
20
+ type TJmapPrincipal,
21
+ } from "./interfaces.backend.js";
22
+ import { MemoryMailBackend } from "./classes.memorymailbackend.js";
11
23
 
12
- export interface IJmapServerBlob {
13
- data: Buffer;
14
- type: string;
15
- name?: string;
16
- }
24
+ /**
25
+ * Semantic capability marker for the bounded streaming-upload server surface.
26
+ * Consumers must check this exact value before relying on streaming backend
27
+ * uploads, upload concurrency limits, Bearer-only challenges, or error mapping.
28
+ */
29
+ export const JMAP_SERVER_STREAMING_API_VERSION = 1 as const;
17
30
 
18
- export interface IJmapServerChangeEntry {
19
- state: number;
20
- created: string[];
21
- updated: string[];
22
- destroyed: string[];
31
+ /** Options for constructing a JmapServer. */
32
+ export interface IJmapServerOptions {
33
+ /** The storage backend; defaults to a fresh in-memory `MemoryMailBackend`. */
34
+ backend?: IJmapMailBackend;
35
+ /**
36
+ * Resolves an incoming Request to an authenticated principal (return null
37
+ * to reject with 401). Defaults to a static Basic/Bearer registry fed via
38
+ * `addUser`/`addBearerToken`.
39
+ */
40
+ authenticate?: (request: Request) => Promise<TJmapPrincipal | null>;
41
+ /**
42
+ * WWW-Authenticate challenges advertised on a 401 response. Defaults to
43
+ * Bearer and Basic for the built-in registry. Custom authenticators should
44
+ * list only the schemes they actually accept.
45
+ */
46
+ authenticationChallenges?: string[];
47
+ /**
48
+ * Absolute base URL (e.g. `https://mail.example.com`) prefixed to the URLs
49
+ * advertised in the session object. Defaults to relative URLs, which JMAP
50
+ * clients resolve against the session resource URL.
51
+ */
52
+ baseUrl?: string;
53
+ /** Overrides for the limits advertised in the JMAP session and enforced by this server. */
54
+ limits?: Partial<IJmapServerLimits>;
55
+ /**
56
+ * Process-wide upload ceiling across authenticated principals. Must be at
57
+ * least the advertised per-principal maxConcurrentUpload value.
58
+ */
59
+ maxConcurrentUploadServer?: number;
60
+ /** Maps backend upload failures to an RFC 7807 response instead of a generic 500. */
61
+ mapUploadError?: (error: unknown) => IJmapUploadProblem | null | undefined;
23
62
  }
24
63
 
25
- export interface IJmapServerIdentity {
26
- id: string;
27
- name: string;
28
- email: string;
29
- mayDelete: boolean;
64
+ export interface IJmapServerLimits {
65
+ maxSizeUpload: number;
66
+ maxConcurrentUpload: number;
67
+ maxSizeRequest: number;
68
+ maxConcurrentRequests: number;
69
+ maxCallsInRequest: number;
70
+ maxObjectsInGet: number;
71
+ maxObjectsInSet: number;
30
72
  }
31
73
 
32
- export interface IJmapServerSubmission {
33
- id: string;
34
- emailId: string;
35
- identityId: string;
36
- undoStatus: string;
37
- sendAt: string;
74
+ export interface IJmapUploadProblem {
75
+ status: number;
76
+ type?: string;
77
+ detail: string;
78
+ limit?: string;
79
+ retryAfterSeconds?: number;
38
80
  }
39
81
 
40
- export interface IJmapServerUser {
41
- username: string;
42
- password: string;
43
- bearerTokens: Set<string>;
44
- accountId: string;
45
- mailboxes: Map<string, IJmapServerMailbox>;
46
- emails: Map<string, Record<string, any>>;
47
- blobs: Map<string, IJmapServerBlob>;
48
- submissions: Map<string, IJmapServerSubmission>;
49
- identities: IJmapServerIdentity[];
50
- state: number;
51
- changes: IJmapServerChangeEntry[];
52
- idCounter: number;
53
- }
82
+ /** The advertised — and enforced — RFC 8620 core limits. */
83
+ export const JMAP_SERVER_LIMITS: IJmapServerLimits = {
84
+ maxSizeUpload: 50_000_000,
85
+ maxConcurrentUpload: 4,
86
+ maxSizeRequest: 10_000_000,
87
+ maxConcurrentRequests: 4,
88
+ maxCallsInRequest: 16,
89
+ maxObjectsInGet: 500,
90
+ maxObjectsInSet: 500,
91
+ };
54
92
 
55
- export interface IJmapServerEmailInput {
56
- from: IJmapEmailAddress | IJmapEmailAddress[];
57
- to: IJmapEmailAddress | IJmapEmailAddress[];
58
- cc?: IJmapEmailAddress[];
59
- bcc?: IJmapEmailAddress[];
60
- subject?: string;
61
- textBody?: string;
62
- htmlBody?: string;
63
- keywords?: Record<string, boolean>;
64
- receivedAt?: string;
65
- attachments?: Array<{ name: string; type: string; content: string | Uint8Array }>;
66
- }
93
+ /** The session object never changes at runtime, so its state is constant. */
94
+ const SESSION_STATE = "0";
95
+
96
+ /** Default Email/get properties per RFC 8621 §4.2. */
97
+ const DEFAULT_EMAIL_PROPERTIES = [
98
+ "id",
99
+ "blobId",
100
+ "threadId",
101
+ "mailboxIds",
102
+ "keywords",
103
+ "size",
104
+ "receivedAt",
105
+ "messageId",
106
+ "inReplyTo",
107
+ "references",
108
+ "sender",
109
+ "from",
110
+ "to",
111
+ "cc",
112
+ "bcc",
113
+ "replyTo",
114
+ "subject",
115
+ "sentAt",
116
+ "hasAttachment",
117
+ "preview",
118
+ "bodyValues",
119
+ "textBody",
120
+ "htmlBody",
121
+ "attachments",
122
+ ];
123
+ const KNOWN_EMAIL_PROPERTIES = new Set(DEFAULT_EMAIL_PROPERTIES);
124
+
125
+ const MAILBOX_PROPERTIES = [
126
+ "id",
127
+ "name",
128
+ "parentId",
129
+ "role",
130
+ "sortOrder",
131
+ "totalEmails",
132
+ "unreadEmails",
133
+ "totalThreads",
134
+ "unreadThreads",
135
+ "myRights",
136
+ "isSubscribed",
137
+ ];
138
+ const KNOWN_MAILBOX_PROPERTIES = new Set(MAILBOX_PROPERTIES);
139
+
140
+ const SUPPORTED_EMAIL_FILTER_KEYS = new Set([
141
+ "inMailbox",
142
+ "text",
143
+ "subject",
144
+ "from",
145
+ "to",
146
+ "before",
147
+ "after",
148
+ "hasKeyword",
149
+ "notKeyword",
150
+ ]);
151
+
152
+ const DEFAULT_MAILBOX_RIGHTS = {
153
+ mayReadItems: true,
154
+ mayAddItems: true,
155
+ mayRemoveItems: true,
156
+ maySetSeen: true,
157
+ maySetKeywords: true,
158
+ mayCreateChild: true,
159
+ mayRename: true,
160
+ mayDelete: true,
161
+ maySubmit: true,
162
+ };
163
+
164
+ const textEncoder = new TextEncoder();
165
+
166
+ /** Ensures bytes are backed by a plain ArrayBuffer so they satisfy BodyInit. */
167
+ const asBodyBytes = (data: Uint8Array): Uint8Array<ArrayBuffer> =>
168
+ data.buffer instanceof ArrayBuffer
169
+ ? (data as Uint8Array<ArrayBuffer>)
170
+ : new Uint8Array(data);
67
171
 
68
172
  /** Error type used inside method dispatch; rendered as a JMAP `error` method response. */
69
173
  class JmapServerMethodError extends Error {
@@ -72,948 +176,2472 @@ class JmapServerMethodError extends Error {
72
176
  }
73
177
  }
74
178
 
179
+ class JmapRequestBodyError extends Error {
180
+ constructor(
181
+ public readonly reason: "invalidLength" | "lengthMismatch" | "tooLarge",
182
+ ) {
183
+ super(reason);
184
+ }
185
+ }
186
+
187
+ /** One live event-source stream (SSE) with its filters and keepalive timer. */
188
+ interface IEventStreamState {
189
+ accountId: string;
190
+ /** null = all types ('*'). */
191
+ types: Set<string> | null;
192
+ closeAfterState: boolean;
193
+ controller: ReadableStreamDefaultController<Uint8Array> | null;
194
+ pingTimer: ReturnType<typeof setInterval> | null;
195
+ /** Last state pushed per type, used to deduplicate double notifications. */
196
+ lastSent: Map<string, string>;
197
+ closed: boolean;
198
+ }
199
+
200
+ /** UTF-8-safe truncation: cuts at a codepoint boundary at or below maxBytes. */
201
+ const truncateUtf8 = (value: string, maxBytes: number): string => {
202
+ const bytes = textEncoder.encode(value);
203
+ if (bytes.length <= maxBytes) {
204
+ return value;
205
+ }
206
+ let end = maxBytes;
207
+ while (end > 0 && (bytes[end]! & 0b1100_0000) === 0b1000_0000) {
208
+ end--;
209
+ }
210
+ return new TextDecoder().decode(bytes.subarray(0, end));
211
+ };
212
+
213
+ /**
214
+ * Evaluates an RFC 6901 JSON pointer with the RFC 8620 §3.7 extension: a `*`
215
+ * token applied to an array maps the rest of the pointer over every item,
216
+ * flattening one array level. Throws on any unresolvable path.
217
+ */
218
+ const evalJsonPointer = (root: any, pointer: string): any => {
219
+ if (pointer === "") {
220
+ return root;
221
+ }
222
+ if (!pointer.startsWith("/")) {
223
+ throw new Error(`Invalid JSON pointer "${pointer}".`);
224
+ }
225
+ const tokens = pointer.slice(1).split("/");
226
+ const evaluate = (value: any, index: number): any => {
227
+ if (index === tokens.length) {
228
+ return value;
229
+ }
230
+ const token = tokens[index]!.replaceAll("~1", "/").replaceAll("~0", "~");
231
+ if (Array.isArray(value)) {
232
+ if (token === "*") {
233
+ const results = value.map((item) => evaluate(item, index + 1));
234
+ return results.some((result) => Array.isArray(result))
235
+ ? results.flatMap((
236
+ result,
237
+ ) => (Array.isArray(result) ? result : [result]))
238
+ : results;
239
+ }
240
+ const arrayIndex = Number(token);
241
+ if (
242
+ !Number.isInteger(arrayIndex) || arrayIndex < 0 ||
243
+ arrayIndex >= value.length
244
+ ) {
245
+ throw new Error(`JSON pointer index "${token}" is out of range.`);
246
+ }
247
+ return evaluate(value[arrayIndex], index + 1);
248
+ }
249
+ if (value !== null && typeof value === "object" && token in value) {
250
+ return evaluate(value[token], index + 1);
251
+ }
252
+ throw new Error(`JSON pointer token "${token}" does not resolve.`);
253
+ };
254
+ return evaluate(root, 0);
255
+ };
256
+
75
257
  /**
76
- * A minimal in-memory JMAP server (RFC 8620 core + RFC 8621 mail subset)
77
- * for offline testing. Serves the session resource at /.well-known/jmap and
78
- * /jmap/session, the API at /jmap/api, blob downloads at /jmap/download/...,
79
- * and StateChange pushes via SSE at /jmap/eventsource.
258
+ * A JMAP server core (RFC 8620) with the RFC 8621 mail method surface,
259
+ * dispatching all storage into a pluggable `IJmapMailBackend`.
260
+ *
261
+ * The primary API is the web-standard `fetchHandler(request)` — mount it in
262
+ * any Request/Response runtime (Deno, Bun, service workers, Node ≥18). The
263
+ * `start(port)`/`stop()` pair wraps it in a node:http server for convenience.
264
+ *
265
+ * Endpoints: session (`/.well-known/jmap`, `/jmap/session`), API
266
+ * (`/jmap/api`), upload (`/jmap/upload/{accountId}`), download
267
+ * (`/jmap/download/{accountId}/{blobId}/{name}?type=`), and event source
268
+ * (`/jmap/eventsource` — SSE StateChange pushes per RFC 8620 §7.3).
269
+ *
270
+ * With no options this behaves as the offline test helper: an in-memory
271
+ * backend plus a static credential registry (`addUser`/`addBearerToken`).
80
272
  */
81
273
  export class JmapServer {
82
- public users: Map<string, IJmapServerUser> = new Map();
83
- private server: plugins.http.Server;
274
+ public readonly backend: IJmapMailBackend;
275
+ private options: IJmapServerOptions;
276
+ private readonly limits: IJmapServerLimits;
277
+ private readonly maxConcurrentUploadServer: number;
278
+ private registry: Map<
279
+ string,
280
+ { password: string; bearerTokens: Set<string> }
281
+ > = new Map();
282
+ private httpServer: plugins.http.Server | null = null;
84
283
  private sockets: Set<plugins.net.Socket> = new Set();
85
- private eventStreams: Map<plugins.http.ServerResponse, IJmapServerUser> = new Map();
284
+ private eventStreams: Set<IEventStreamState> = new Set();
285
+ private changeFeedUnsubscribe: (() => void) | null = null;
286
+ private cleanupErrors: unknown[] = [];
287
+ private activeApiRequests = 0;
288
+ private activeUploadRequests = 0;
289
+ private activeUploadsByPrincipal = new Map<string, number>();
290
+ private activeNodeRequests = new Set<Promise<void>>();
291
+ private nodeRequestControllers = new Set<AbortController>();
292
+ private stopping = false;
293
+ private authenticationChallenges: string;
86
294
 
87
- constructor() {
88
- this.server = plugins.http.createServer((req, res) => {
89
- this.handleRequest(req, res);
90
- });
91
- this.server.on('connection', (socket) => {
92
- this.sockets.add(socket);
93
- socket.on('close', () => {
94
- this.sockets.delete(socket);
95
- });
96
- });
295
+ constructor(options: IJmapServerOptions = {}) {
296
+ this.options = options;
297
+ this.backend = options.backend ?? new MemoryMailBackend();
298
+ const authenticationChallenges = options.authenticationChallenges ?? [
299
+ 'Bearer realm="jmap"',
300
+ 'Basic realm="jmap"',
301
+ ];
302
+ if (
303
+ authenticationChallenges.length === 0 ||
304
+ authenticationChallenges.some((challenge) =>
305
+ typeof challenge !== "string" || !challenge.trim() ||
306
+ /[\r\n]/.test(challenge)
307
+ )
308
+ ) {
309
+ throw new Error(
310
+ "authenticationChallenges must contain safe non-empty challenge values.",
311
+ );
312
+ }
313
+ this.authenticationChallenges = authenticationChallenges
314
+ .map((value) => value.trim())
315
+ .join(", ");
316
+ this.limits = { ...JMAP_SERVER_LIMITS, ...options.limits };
317
+ for (const [name, value] of Object.entries(this.limits)) {
318
+ if (!Number.isSafeInteger(value) || value <= 0) {
319
+ throw new Error(`JMAP limit ${name} must be a positive safe integer.`);
320
+ }
321
+ }
322
+ this.maxConcurrentUploadServer = options.maxConcurrentUploadServer ??
323
+ this.limits.maxConcurrentUpload;
324
+ if (
325
+ !Number.isSafeInteger(this.maxConcurrentUploadServer) ||
326
+ this.maxConcurrentUploadServer < this.limits.maxConcurrentUpload
327
+ ) {
328
+ throw new Error(
329
+ "maxConcurrentUploadServer must be a safe integer at least as large as maxConcurrentUpload.",
330
+ );
331
+ }
97
332
  }
98
333
 
99
334
  // ======================
100
- // store management
335
+ // default auth registry
101
336
  // ======================
102
337
 
338
+ /**
339
+ * Registers Basic credentials in the default authenticator's registry.
340
+ * Only consulted when no custom `authenticate` option is set. Account data
341
+ * itself lives in the backend (the memory backend auto-creates accounts).
342
+ */
103
343
  public addUser(username: string, password: string): void {
104
- if (this.users.has(username)) {
344
+ if (this.registry.has(username)) {
105
345
  throw new Error(`User "${username}" already exists.`);
106
346
  }
107
- const email = username.includes('@') ? username : `${username}@example.com`;
108
- this.users.set(username, {
109
- username,
110
- password,
111
- bearerTokens: new Set(),
112
- accountId: `account_${username}`,
113
- mailboxes: new Map(),
114
- emails: new Map(),
115
- blobs: new Map(),
116
- submissions: new Map(),
117
- identities: [{ id: 'identity_1', name: username, email, mayDelete: false }],
118
- state: 0,
119
- changes: [],
120
- idCounter: 0,
121
- });
347
+ this.registry.set(username, { password, bearerTokens: new Set() });
122
348
  }
123
349
 
124
- /** Registers a valid Bearer token for a user. */
350
+ /** Registers a valid Bearer token for a user in the default authenticator. */
125
351
  public addBearerToken(username: string, token: string): void {
126
- const user = this.requireUser(username);
127
- user.bearerTokens.add(token);
128
- }
129
-
130
- public createMailbox(username: string, name: string, role?: string): string {
131
- const user = this.requireUser(username);
132
- for (const mailbox of user.mailboxes.values()) {
133
- if (mailbox.name === name) {
134
- throw new Error(`Mailbox "${name}" already exists for user "${username}".`);
135
- }
136
- }
137
- const id = `mailbox_${++user.idCounter}`;
138
- user.mailboxes.set(id, {
139
- id,
140
- name,
141
- role: role ?? null,
142
- parentId: null,
143
- sortOrder: user.mailboxes.size,
144
- });
145
- return id;
146
- }
147
-
148
- /** Seeds an email into a user's mailbox; bumps state and pushes a StateChange. Returns the email id. */
149
- public addEmail(username: string, mailboxName: string, input: IJmapServerEmailInput): string {
150
- const user = this.requireUser(username);
151
- const mailbox = Array.from(user.mailboxes.values()).find(
152
- (candidate) => candidate.name === mailboxName || candidate.role === mailboxName
153
- );
154
- if (!mailbox) {
155
- throw new Error(`Mailbox "${mailboxName}" does not exist for user "${username}".`);
156
- }
157
-
158
- const from = Array.isArray(input.from) ? input.from : [input.from];
159
- const to = Array.isArray(input.to) ? input.to : [input.to];
160
- const id = `email_${++user.idCounter}`;
161
- const receivedAt = input.receivedAt ?? new Date().toISOString();
162
- const textBody = input.textBody ?? '';
163
-
164
- // raw message blob for Email/get blobId + downloads
165
- const blobId = `blob_${id}`;
166
- const rawLines = [
167
- `From: ${from.map((address) => address.email).join(', ')}`,
168
- `To: ${to.map((address) => address.email).join(', ')}`,
169
- `Subject: ${input.subject ?? ''}`,
170
- `Date: ${receivedAt}`,
171
- '',
172
- textBody,
173
- ];
174
- user.blobs.set(blobId, {
175
- data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
176
- type: 'message/rfc822',
177
- name: `${id}.eml`,
178
- });
179
-
180
- const bodyValues: Record<string, { value: string; isTruncated: boolean }> = {
181
- text: { value: textBody, isTruncated: false },
182
- };
183
- const textBodyParts = [{ partId: 'text', type: 'text/plain', size: textBody.length }];
184
- const htmlBodyParts: Array<Record<string, any>> = [];
185
- if (typeof input.htmlBody === 'string') {
186
- bodyValues.html = { value: input.htmlBody, isTruncated: false };
187
- htmlBodyParts.push({ partId: 'html', type: 'text/html', size: input.htmlBody.length });
188
- }
189
-
190
- const attachmentParts: Array<Record<string, any>> = [];
191
- for (const [index, attachment] of (input.attachments ?? []).entries()) {
192
- const attachmentBlobId = `blob_${id}_att${index + 1}`;
193
- const data = Buffer.from(attachment.content as any);
194
- user.blobs.set(attachmentBlobId, {
195
- data,
196
- type: attachment.type,
197
- name: attachment.name,
198
- });
199
- attachmentParts.push({
200
- partId: null,
201
- blobId: attachmentBlobId,
202
- name: attachment.name,
203
- type: attachment.type,
204
- size: data.length,
205
- disposition: 'attachment',
206
- });
352
+ const entry = this.registry.get(username);
353
+ if (!entry) {
354
+ throw new Error(`User "${username}" does not exist.`);
207
355
  }
208
-
209
- const email: Record<string, any> = {
210
- id,
211
- blobId,
212
- threadId: `thread_${id}`,
213
- mailboxIds: { [mailbox.id]: true },
214
- keywords: input.keywords ?? {},
215
- size: textBody.length,
216
- receivedAt,
217
- sentAt: receivedAt,
218
- subject: input.subject ?? null,
219
- from,
220
- to,
221
- cc: input.cc ?? null,
222
- bcc: input.bcc ?? null,
223
- replyTo: null,
224
- hasAttachment: attachmentParts.length > 0,
225
- preview: textBody.slice(0, 100),
226
- bodyValues,
227
- textBody: textBodyParts,
228
- htmlBody: htmlBodyParts,
229
- attachments: attachmentParts,
230
- };
231
- user.emails.set(id, email);
232
- this.bumpState(user, { created: [id] });
233
- return id;
356
+ entry.bearerTokens.add(token);
234
357
  }
235
358
 
236
359
  // ======================
237
- // lifecycle
360
+ // lifecycle (node wrapper)
238
361
  // ======================
239
362
 
240
- /** Starts the server. Resolves with the bound port (pass 0 for an ephemeral port). */
363
+ /** Starts a node:http server around fetchHandler. Resolves with the bound port (pass 0 for ephemeral). */
241
364
  public start(port: number): Promise<number> {
365
+ if (this.httpServer) {
366
+ throw new Error("JmapServer is already started.");
367
+ }
368
+ this.stopping = false;
369
+ const server = plugins.http.createServer((req, res) => {
370
+ if (this.stopping) {
371
+ res.writeHead(503, {
372
+ "content-type": "application/problem+json",
373
+ "connection": "close",
374
+ });
375
+ res.end(JSON.stringify({
376
+ type: "about:blank",
377
+ status: 503,
378
+ detail: "JMAP server is shutting down.",
379
+ }));
380
+ return;
381
+ }
382
+ const requestAbortController = new AbortController();
383
+ this.nodeRequestControllers.add(requestAbortController);
384
+ let requestTask!: Promise<void>;
385
+ requestTask = this.handleNodeRequest(req, res, requestAbortController)
386
+ .finally(() => {
387
+ this.nodeRequestControllers.delete(requestAbortController);
388
+ this.activeNodeRequests.delete(requestTask);
389
+ });
390
+ this.activeNodeRequests.add(requestTask);
391
+ void requestTask;
392
+ });
393
+ const onConnection = (socket: plugins.net.Socket): void => {
394
+ this.sockets.add(socket);
395
+ socket.on("close", () => {
396
+ this.sockets.delete(socket);
397
+ });
398
+ };
399
+ server.on("connection", onConnection);
400
+ this.httpServer = server;
242
401
  return new Promise((resolve, reject) => {
243
- this.server.once('error', reject);
244
- this.server.listen(port, () => {
245
- const address = this.server.address();
246
- const boundPort =
247
- typeof address === 'object' && address ? address.port : port;
402
+ const onStartupError = (error: Error): void => {
403
+ server.off("listening", onListening);
404
+ server.off("connection", onConnection);
405
+ if (this.httpServer === server) {
406
+ this.httpServer = null;
407
+ }
408
+ try {
409
+ server.close(() => undefined);
410
+ } catch {
411
+ // The failed listener never reached the listening state.
412
+ }
413
+ reject(error);
414
+ };
415
+ const onListening = (): void => {
416
+ server.off("error", onStartupError);
417
+ server.on("error", (error) => {
418
+ this.cleanupErrors.push(error);
419
+ });
420
+ const address = server.address();
421
+ const boundPort = typeof address === "object" && address
422
+ ? address.port
423
+ : port;
248
424
  resolve(boundPort);
249
- });
425
+ };
426
+ server.once("error", onStartupError);
427
+ server.once("listening", onListening);
428
+ server.listen(port);
250
429
  });
251
430
  }
252
431
 
253
- /** Stops the server, ending open event streams and destroying open connections. */
254
- public stop(): Promise<void> {
255
- for (const response of this.eventStreams.keys()) {
256
- try {
257
- response.end();
258
- } catch {
259
- // response already gone
432
+ /**
433
+ * Stops the server: closes all event streams (clearing their ping timers
434
+ * and the backend change-feed subscription), destroys open connections,
435
+ * and closes the node listener when one was started.
436
+ */
437
+ public async stop(): Promise<void> {
438
+ this.stopping = true;
439
+ const server = this.httpServer;
440
+ this.httpServer = null;
441
+ const listenerClosed = server
442
+ ? new Promise<void>((resolve) => {
443
+ try {
444
+ server.close(() => resolve());
445
+ } catch {
446
+ resolve();
447
+ }
448
+ })
449
+ : Promise.resolve();
450
+ for (const stream of Array.from(this.eventStreams)) {
451
+ this.closeStream(stream);
452
+ }
453
+ for (const controller of this.nodeRequestControllers) {
454
+ if (!controller.signal.aborted) {
455
+ controller.abort(new Error("JMAP server is shutting down."));
260
456
  }
261
457
  }
262
- this.eventStreams.clear();
263
458
  for (const socket of this.sockets) {
264
459
  socket.destroy();
265
460
  }
266
461
  this.sockets.clear();
267
- return new Promise((resolve) => {
268
- this.server.close(() => {
269
- resolve();
270
- });
271
- });
462
+ await listenerClosed;
463
+ while (this.activeNodeRequests.size) {
464
+ await Promise.allSettled([...this.activeNodeRequests]);
465
+ }
466
+ if (this.cleanupErrors.length) {
467
+ const errors = this.cleanupErrors.splice(0);
468
+ throw new AggregateError(errors, "JmapServer cleanup failed");
469
+ }
272
470
  }
273
471
 
274
472
  // ======================
275
- // http handling
473
+ // web-standard core
276
474
  // ======================
277
475
 
278
- private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
476
+ /**
477
+ * Handles a JMAP request end to end and returns the Response. This is the
478
+ * runtime-agnostic core — mount it directly as a Deno/Bun fetch handler.
479
+ */
480
+ public async fetchHandler(request: Request): Promise<Response> {
279
481
  try {
280
- const user = this.authenticate(req);
281
- if (!user) {
282
- res.writeHead(401, {
283
- 'www-authenticate': 'Bearer realm="jmap", Basic realm="jmap"',
284
- 'content-type': 'application/problem+json',
285
- });
286
- res.end(
287
- JSON.stringify({ type: 'about:blank', status: 401, detail: 'Authentication required' })
482
+ const principal = await this.authenticatePrincipal(request);
483
+ if (!principal) {
484
+ await this.cancelRequestBody(request, "authentication rejected");
485
+ return this.problemResponse(
486
+ 401,
487
+ "about:blank",
488
+ "Authentication required",
489
+ {
490
+ "www-authenticate": this.authenticationChallenges,
491
+ },
492
+ );
493
+ }
494
+ const account = await this.backend.resolveAccount(principal);
495
+ if (!account) {
496
+ await this.cancelRequestBody(request, "account rejected");
497
+ return this.problemResponse(
498
+ 403,
499
+ "about:blank",
500
+ "No account for this principal",
288
501
  );
289
- return;
290
502
  }
291
503
 
292
- const url = new URL(req.url ?? '/', 'http://localhost');
504
+ const url = new URL(request.url);
293
505
  const pathname = url.pathname;
506
+ const method = request.method.toUpperCase();
294
507
 
295
- if (req.method === 'GET' && (pathname === '/.well-known/jmap' || pathname === '/jmap/session')) {
296
- this.handleSession(user, res);
297
- return;
508
+ if (
509
+ method === "GET" &&
510
+ (pathname === "/.well-known/jmap" || pathname === "/jmap/session")
511
+ ) {
512
+ return this.handleSession(account, principal);
298
513
  }
299
- if (req.method === 'POST' && pathname === '/jmap/api') {
300
- let body = '';
301
- req.on('data', (chunk) => {
302
- body += chunk;
303
- });
304
- req.on('end', () => {
305
- this.handleApi(user, body, res);
306
- });
307
- return;
514
+ if (method === "POST" && pathname === "/jmap/api") {
515
+ return this.handleApi(account, request);
308
516
  }
309
- if (req.method === 'GET' && pathname === '/jmap/eventsource') {
310
- this.handleEventSource(user, req, res);
311
- return;
517
+ if (method === "POST" && pathname.startsWith("/jmap/upload/")) {
518
+ return this.handleUpload(account, principal, pathname, request);
312
519
  }
313
- if (req.method === 'GET' && pathname.startsWith('/jmap/download/')) {
314
- this.handleDownload(user, pathname, url, res);
315
- return;
520
+ if (method === "GET" && pathname.startsWith("/jmap/download/")) {
521
+ return this.handleDownload(account, pathname, url);
316
522
  }
317
-
318
- res.writeHead(404, { 'content-type': 'application/problem+json' });
319
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Not found' }));
523
+ if (method === "GET" && pathname === "/jmap/eventsource") {
524
+ return this.handleEventSource(account, url);
525
+ }
526
+ await this.cancelRequestBody(request, "route rejected");
527
+ return this.problemResponse(404, "about:blank", "Not found");
320
528
  } catch (error) {
321
529
  const message = error instanceof Error ? error.message : String(error);
322
- if (!res.headersSent) {
323
- res.writeHead(500, { 'content-type': 'application/problem+json' });
324
- }
325
- res.end(JSON.stringify({ type: 'about:blank', status: 500, detail: message }));
530
+ return this.problemResponse(500, "about:blank", message);
326
531
  }
327
532
  }
328
533
 
329
- private authenticate(req: plugins.http.IncomingMessage): IJmapServerUser | null {
330
- const header = req.headers.authorization;
534
+ // ======================
535
+ // authentication
536
+ // ======================
537
+
538
+ private async authenticatePrincipal(
539
+ request: Request,
540
+ ): Promise<TJmapPrincipal | null> {
541
+ if (this.options.authenticate) {
542
+ return this.options.authenticate(request);
543
+ }
544
+ const header = request.headers.get("authorization");
331
545
  if (!header) {
332
546
  return null;
333
547
  }
334
- const spaceIndex = header.indexOf(' ');
548
+ const spaceIndex = header.indexOf(" ");
335
549
  if (spaceIndex === -1) {
336
550
  return null;
337
551
  }
338
552
  const scheme = header.slice(0, spaceIndex).toLowerCase();
339
553
  const value = header.slice(spaceIndex + 1).trim();
340
- if (scheme === 'bearer') {
341
- for (const user of this.users.values()) {
342
- if (user.bearerTokens.has(value)) {
343
- return user;
554
+ if (scheme === "bearer") {
555
+ for (const [username, entry] of this.registry) {
556
+ if (entry.bearerTokens.has(value)) {
557
+ return { username };
344
558
  }
345
559
  }
346
560
  return null;
347
561
  }
348
- if (scheme === 'basic') {
349
- const decoded = Buffer.from(value, 'base64').toString('utf8');
350
- const colonIndex = decoded.indexOf(':');
562
+ if (scheme === "basic") {
563
+ let decoded: string;
564
+ try {
565
+ const binary = atob(value);
566
+ decoded = new TextDecoder().decode(
567
+ Uint8Array.from(binary, (char) => char.charCodeAt(0)),
568
+ );
569
+ } catch {
570
+ return null;
571
+ }
572
+ const colonIndex = decoded.indexOf(":");
351
573
  if (colonIndex === -1) {
352
574
  return null;
353
575
  }
354
576
  const username = decoded.slice(0, colonIndex);
355
577
  const password = decoded.slice(colonIndex + 1);
356
- const user = this.users.get(username);
357
- if (user && user.password === password) {
358
- return user;
578
+ const entry = this.registry.get(username);
579
+ if (entry && entry.password === password) {
580
+ return { username };
359
581
  }
360
582
  return null;
361
583
  }
362
584
  return null;
363
585
  }
364
586
 
365
- private handleSession(user: IJmapServerUser, res: plugins.http.ServerResponse): void {
587
+ // ======================
588
+ // session
589
+ // ======================
590
+
591
+ private handleSession(
592
+ account: IJmapAccountInfo,
593
+ principal: TJmapPrincipal,
594
+ ): Response {
366
595
  const session = {
367
596
  capabilities: {
368
- 'urn:ietf:params:jmap:core': {
369
- maxSizeUpload: 50_000_000,
370
- maxConcurrentUpload: 4,
371
- maxSizeRequest: 10_000_000,
372
- maxConcurrentRequests: 4,
373
- maxCallsInRequest: 16,
374
- maxObjectsInGet: 500,
375
- maxObjectsInSet: 500,
376
- collationAlgorithms: ['i;ascii-numeric', 'i;ascii-casemap'],
597
+ [JMAP_CORE_CAPABILITY]: {
598
+ ...this.limits,
599
+ collationAlgorithms: ["i;ascii-casemap"],
377
600
  },
378
- 'urn:ietf:params:jmap:mail': {},
379
- 'urn:ietf:params:jmap:submission': {},
601
+ [JMAP_MAIL_CAPABILITY]: {},
602
+ [JMAP_SUBMISSION_CAPABILITY]: {},
380
603
  },
381
604
  accounts: {
382
- [user.accountId]: {
383
- name: user.username,
384
- isPersonal: true,
385
- isReadOnly: false,
605
+ [account.accountId]: {
606
+ name: account.name,
607
+ isPersonal: account.isPersonal ?? true,
608
+ isReadOnly: account.isReadOnly ?? false,
386
609
  accountCapabilities: {
387
- 'urn:ietf:params:jmap:mail': {},
388
- 'urn:ietf:params:jmap:submission': {},
610
+ [JMAP_MAIL_CAPABILITY]: {
611
+ maxMailboxesPerEmail: null,
612
+ maxMailboxDepth: null,
613
+ maxSizeMailboxName: 490,
614
+ maxSizeAttachmentsPerEmail: this.limits.maxSizeUpload,
615
+ emailQuerySortOptions: ["receivedAt"],
616
+ mayCreateTopLevelMailbox: false,
617
+ },
618
+ [JMAP_SUBMISSION_CAPABILITY]: {
619
+ maxDelayedSend: 0,
620
+ submissionExtensions: {},
621
+ },
389
622
  },
390
623
  },
391
624
  },
392
625
  primaryAccounts: {
393
- 'urn:ietf:params:jmap:core': user.accountId,
394
- 'urn:ietf:params:jmap:mail': user.accountId,
395
- 'urn:ietf:params:jmap:submission': user.accountId,
626
+ [JMAP_CORE_CAPABILITY]: account.accountId,
627
+ [JMAP_MAIL_CAPABILITY]: account.accountId,
628
+ [JMAP_SUBMISSION_CAPABILITY]: account.accountId,
396
629
  },
397
- username: user.username,
398
- apiUrl: '/jmap/api',
399
- downloadUrl: '/jmap/download/{accountId}/{blobId}/{name}?type={type}',
400
- uploadUrl: '/jmap/upload/{accountId}',
401
- eventSourceUrl: '/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}',
402
- state: String(user.state),
630
+ username: principal.username,
631
+ apiUrl: this.buildUrl("/jmap/api"),
632
+ downloadUrl: this.buildUrl(
633
+ "/jmap/download/{accountId}/{blobId}/{name}?type={type}",
634
+ ),
635
+ uploadUrl: this.buildUrl("/jmap/upload/{accountId}"),
636
+ eventSourceUrl: this.buildUrl(
637
+ "/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}",
638
+ ),
639
+ state: SESSION_STATE,
403
640
  };
404
- res.writeHead(200, { 'content-type': 'application/json' });
405
- res.end(JSON.stringify(session));
641
+ return this.jsonResponse(200, session);
406
642
  }
407
643
 
408
- private handleApi(user: IJmapServerUser, body: string, res: plugins.http.ServerResponse): void {
409
- let parsed: any;
410
- try {
411
- parsed = JSON.parse(body);
412
- } catch {
413
- res.writeHead(400, { 'content-type': 'application/problem+json' });
414
- res.end(
415
- JSON.stringify({
416
- type: 'urn:ietf:params:jmap:error:notJSON',
417
- status: 400,
418
- detail: 'The request body is not valid JSON.',
419
- })
644
+ private buildUrl(path: string): string {
645
+ const base = this.options.baseUrl;
646
+ return base ? `${base.replace(/\/+$/, "")}${path}` : path;
647
+ }
648
+
649
+ // ======================
650
+ // api endpoint
651
+ // ======================
652
+
653
+ private async handleApi(
654
+ account: IJmapAccountInfo,
655
+ request: Request,
656
+ ): Promise<Response> {
657
+ if (this.activeApiRequests >= this.limits.maxConcurrentRequests) {
658
+ await this.cancelRequestBody(
659
+ request,
660
+ "request concurrency limit reached",
420
661
  );
421
- return;
422
- }
423
- if (!Array.isArray(parsed?.methodCalls)) {
424
- res.writeHead(400, { 'content-type': 'application/problem+json' });
425
- res.end(
426
- JSON.stringify({
427
- type: 'urn:ietf:params:jmap:error:notRequest',
428
- status: 400,
429
- detail: 'The request body is not a valid JMAP request object.',
430
- })
662
+ return this.limitResponse(
663
+ 429,
664
+ "maxConcurrentRequests",
665
+ "Too many concurrent requests.",
431
666
  );
432
- return;
433
667
  }
434
-
435
- const createdIds = new Map<string, string>();
436
- const methodResponses: Array<[string, Record<string, any>, string]> = [];
437
- for (const call of parsed.methodCalls) {
438
- const [methodName, args, callId] = call as [string, Record<string, any>, string];
668
+ this.activeApiRequests++;
669
+ try {
670
+ let bytes: Uint8Array;
439
671
  try {
440
- const result = this.dispatchMethod(user, methodName, args ?? {}, createdIds);
441
- methodResponses.push([methodName, result, callId]);
672
+ bytes = await this.readRequestBodyBounded(
673
+ request,
674
+ this.limits.maxSizeRequest,
675
+ );
442
676
  } catch (error) {
443
- if (error instanceof JmapServerMethodError) {
444
- methodResponses.push([
445
- 'error',
446
- { type: error.type, description: error.message },
447
- callId,
448
- ]);
449
- } else {
450
- const message = error instanceof Error ? error.message : String(error);
451
- methodResponses.push(['error', { type: 'serverFail', description: message }, callId]);
677
+ if (error instanceof JmapRequestBodyError) {
678
+ if (error.reason === "invalidLength") {
679
+ return this.problemResponse(
680
+ 400,
681
+ "about:blank",
682
+ "Invalid Content-Length header.",
683
+ );
684
+ }
685
+ return this.limitResponse(
686
+ 400,
687
+ "maxSizeRequest",
688
+ `The request is larger than ${this.limits.maxSizeRequest} bytes.`,
689
+ );
452
690
  }
691
+ throw error;
692
+ }
693
+ let parsed: any;
694
+ try {
695
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
696
+ } catch {
697
+ return this.problemResponse(
698
+ 400,
699
+ "urn:ietf:params:jmap:error:notJSON",
700
+ "The request body is not valid JSON.",
701
+ );
702
+ }
703
+ if (
704
+ !parsed ||
705
+ typeof parsed !== "object" ||
706
+ !Array.isArray(parsed.using) ||
707
+ !Array.isArray(parsed.methodCalls)
708
+ ) {
709
+ return this.problemResponse(
710
+ 400,
711
+ "urn:ietf:params:jmap:error:notRequest",
712
+ "The request body is not a valid JMAP request object.",
713
+ );
714
+ }
715
+ const knownCapabilities = new Set([
716
+ JMAP_CORE_CAPABILITY,
717
+ JMAP_MAIL_CAPABILITY,
718
+ JMAP_SUBMISSION_CAPABILITY,
719
+ ]);
720
+ const using = new Set<string>(parsed.using);
721
+ for (const capability of using) {
722
+ if (!knownCapabilities.has(capability)) {
723
+ return this.problemResponse(
724
+ 400,
725
+ "urn:ietf:params:jmap:error:unknownCapability",
726
+ `The capability "${capability}" is not supported by this server.`,
727
+ );
728
+ }
729
+ }
730
+ if (parsed.methodCalls.length > this.limits.maxCallsInRequest) {
731
+ return this.limitResponse(
732
+ 400,
733
+ "maxCallsInRequest",
734
+ `The request has more than ${this.limits.maxCallsInRequest} method calls.`,
735
+ );
453
736
  }
454
- }
455
737
 
456
- res.writeHead(200, { 'content-type': 'application/json' });
457
- res.end(JSON.stringify({ methodResponses, sessionState: String(user.state) }));
458
- }
738
+ const createdIds = new Map<string, string>(
739
+ Object.entries((parsed.createdIds ?? {}) as Record<string, string>),
740
+ );
741
+ const methodResponses: TJmapMethodResponse[] = [];
742
+ let mutated = false;
743
+ for (const call of parsed.methodCalls) {
744
+ if (
745
+ !Array.isArray(call) || typeof call[0] !== "string" ||
746
+ typeof call[2] !== "string"
747
+ ) {
748
+ return this.problemResponse(
749
+ 400,
750
+ "urn:ietf:params:jmap:error:notRequest",
751
+ "Each method call must be a [name, arguments, callId] tuple.",
752
+ );
753
+ }
754
+ const [methodName, rawArgs, callId] = call as [
755
+ string,
756
+ Record<string, any>,
757
+ string,
758
+ ];
759
+ try {
760
+ const args = this.resolveBackReferences(
761
+ rawArgs ?? {},
762
+ methodResponses,
763
+ );
764
+ const results = await this.dispatchMethod(
765
+ account,
766
+ methodName,
767
+ args,
768
+ using,
769
+ createdIds,
770
+ );
771
+ for (const entry of results) {
772
+ methodResponses.push([entry.name, entry.result, callId]);
773
+ if (entry.mutated) {
774
+ mutated = true;
775
+ }
776
+ }
777
+ } catch (error) {
778
+ if (error instanceof JmapServerMethodError) {
779
+ methodResponses.push([
780
+ "error",
781
+ { type: error.type, description: error.message },
782
+ callId,
783
+ ]);
784
+ } else {
785
+ const message = error instanceof Error
786
+ ? error.message
787
+ : String(error);
788
+ methodResponses.push(["error", {
789
+ type: "serverFail",
790
+ description: message,
791
+ }, callId]);
792
+ }
793
+ }
794
+ }
459
795
 
460
- private handleEventSource(
461
- user: IJmapServerUser,
462
- req: plugins.http.IncomingMessage,
463
- res: plugins.http.ServerResponse
464
- ): void {
465
- res.writeHead(200, {
466
- 'content-type': 'text/event-stream',
467
- 'cache-control': 'no-cache',
468
- connection: 'keep-alive',
469
- });
470
- res.write(': jmap event stream\n\n');
471
- this.eventStreams.set(res, user);
472
- req.on('close', () => {
473
- this.eventStreams.delete(res);
474
- });
475
- }
796
+ if (mutated) {
797
+ await this.pushAfterMutation(account);
798
+ }
476
799
 
477
- private handleDownload(
478
- user: IJmapServerUser,
479
- pathname: string,
480
- url: URL,
481
- res: plugins.http.ServerResponse
482
- ): void {
483
- // /jmap/download/{accountId}/{blobId}/{name}
484
- const segments = pathname.split('/').filter((segment) => segment.length > 0);
485
- const accountId = decodeURIComponent(segments[2] ?? '');
486
- const blobId = decodeURIComponent(segments[3] ?? '');
487
- if (accountId !== user.accountId) {
488
- res.writeHead(404, { 'content-type': 'application/problem+json' });
489
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown account' }));
490
- return;
800
+ const responseBody: Record<string, any> = {
801
+ methodResponses,
802
+ sessionState: SESSION_STATE,
803
+ };
804
+ if (parsed.createdIds !== undefined) {
805
+ responseBody.createdIds = Object.fromEntries(createdIds);
806
+ }
807
+ return this.jsonResponse(200, responseBody);
808
+ } finally {
809
+ this.activeApiRequests--;
491
810
  }
492
- const blob = user.blobs.get(blobId);
493
- if (!blob) {
494
- res.writeHead(404, { 'content-type': 'application/problem+json' });
495
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown blob' }));
496
- return;
811
+ }
812
+
813
+ /**
814
+ * Resolves `#argument` ResultReference objects (RFC 8620 §3.7) against the
815
+ * responses of earlier method calls in the same request.
816
+ */
817
+ private resolveBackReferences(
818
+ args: Record<string, any>,
819
+ priorResponses: TJmapMethodResponse[],
820
+ ): Record<string, any> {
821
+ const resolved: Record<string, any> = {};
822
+ for (const [key, value] of Object.entries(args)) {
823
+ if (!key.startsWith("#")) {
824
+ resolved[key] = value;
825
+ continue;
826
+ }
827
+ const name = key.slice(1);
828
+ if (name in args) {
829
+ throw new JmapServerMethodError(
830
+ "invalidArguments",
831
+ `The arguments contain both "${name}" and "#${name}".`,
832
+ );
833
+ }
834
+ if (
835
+ !value ||
836
+ typeof value !== "object" ||
837
+ typeof value.resultOf !== "string" ||
838
+ typeof value.name !== "string" ||
839
+ typeof value.path !== "string"
840
+ ) {
841
+ throw new JmapServerMethodError(
842
+ "invalidResultReference",
843
+ `"#${name}" is not a valid ResultReference object.`,
844
+ );
845
+ }
846
+ const source = priorResponses.find(
847
+ (entry) => entry[2] === value.resultOf && entry[0] === value.name,
848
+ );
849
+ if (!source) {
850
+ throw new JmapServerMethodError(
851
+ "invalidResultReference",
852
+ `No response found for resultOf "${value.resultOf}" and name "${value.name}".`,
853
+ );
854
+ }
855
+ try {
856
+ resolved[name] = evalJsonPointer(source[1], value.path);
857
+ } catch (error) {
858
+ throw new JmapServerMethodError(
859
+ "invalidResultReference",
860
+ error instanceof Error ? error.message : String(error),
861
+ );
862
+ }
497
863
  }
498
- const type = url.searchParams.get('type') ?? blob.type;
499
- res.writeHead(200, { 'content-type': type, 'content-length': blob.data.length });
500
- res.end(blob.data);
864
+ return resolved;
501
865
  }
502
866
 
503
867
  // ======================
504
- // jmap method dispatch
868
+ // method dispatch
505
869
  // ======================
506
870
 
507
- private dispatchMethod(
508
- user: IJmapServerUser,
871
+ private async dispatchMethod(
872
+ account: IJmapAccountInfo,
509
873
  methodName: string,
510
874
  args: Record<string, any>,
511
- createdIds: Map<string, string>
512
- ): Record<string, any> {
875
+ using: Set<string>,
876
+ createdIds: Map<string, string>,
877
+ ): Promise<
878
+ Array<{ name: string; result: Record<string, any>; mutated?: boolean }>
879
+ > {
880
+ const requiredCapability: Record<string, string> = {
881
+ "Core/echo": JMAP_CORE_CAPABILITY,
882
+ "Mailbox/get": JMAP_MAIL_CAPABILITY,
883
+ "Mailbox/query": JMAP_MAIL_CAPABILITY,
884
+ "Mailbox/changes": JMAP_MAIL_CAPABILITY,
885
+ "Thread/get": JMAP_MAIL_CAPABILITY,
886
+ "Email/get": JMAP_MAIL_CAPABILITY,
887
+ "Email/query": JMAP_MAIL_CAPABILITY,
888
+ "Email/changes": JMAP_MAIL_CAPABILITY,
889
+ "Email/queryChanges": JMAP_MAIL_CAPABILITY,
890
+ "Email/set": JMAP_MAIL_CAPABILITY,
891
+ "Identity/get": JMAP_SUBMISSION_CAPABILITY,
892
+ "EmailSubmission/set": JMAP_SUBMISSION_CAPABILITY,
893
+ };
894
+ const capability = requiredCapability[methodName];
895
+ if (!capability) {
896
+ throw new JmapServerMethodError(
897
+ "unknownMethod",
898
+ `Unknown method "${methodName}".`,
899
+ );
900
+ }
901
+ if (!using.has(capability)) {
902
+ throw new JmapServerMethodError(
903
+ "unknownMethod",
904
+ `Method "${methodName}" requires the capability "${capability}" in "using".`,
905
+ );
906
+ }
907
+ if (methodName !== "Core/echo") {
908
+ this.requireAccountId(account, args);
909
+ }
910
+
513
911
  switch (methodName) {
514
- case 'Core/echo':
515
- return args;
516
- case 'Mailbox/get':
517
- return this.methodMailboxGet(user, args);
518
- case 'Mailbox/query':
519
- return this.methodMailboxQuery(user, args);
520
- case 'Email/get':
521
- return this.methodEmailGet(user, args);
522
- case 'Email/query':
523
- return this.methodEmailQuery(user, args);
524
- case 'Email/changes':
525
- return this.methodEmailChanges(user, args);
526
- case 'Email/set':
527
- return this.methodEmailSet(user, args, createdIds);
528
- case 'Identity/get':
529
- return this.methodIdentityGet(user);
530
- case 'EmailSubmission/set':
531
- return this.methodEmailSubmissionSet(user, args, createdIds);
912
+ case "Core/echo":
913
+ return [{ name: methodName, result: args }];
914
+ case "Mailbox/get":
915
+ return [{
916
+ name: methodName,
917
+ result: await this.methodMailboxGet(account, args),
918
+ }];
919
+ case "Mailbox/query":
920
+ return [{
921
+ name: methodName,
922
+ result: await this.methodMailboxQuery(account, args),
923
+ }];
924
+ case "Mailbox/changes":
925
+ return [{
926
+ name: methodName,
927
+ result: await this.methodMailboxChanges(account, args),
928
+ }];
929
+ case "Thread/get":
930
+ return [{
931
+ name: methodName,
932
+ result: await this.methodThreadGet(account, args),
933
+ }];
934
+ case "Email/get":
935
+ return [{
936
+ name: methodName,
937
+ result: await this.methodEmailGet(account, args),
938
+ }];
939
+ case "Email/query":
940
+ return [{
941
+ name: methodName,
942
+ result: await this.methodEmailQuery(account, args),
943
+ }];
944
+ case "Email/changes":
945
+ return [{
946
+ name: methodName,
947
+ result: await this.methodEmailChanges(account, args),
948
+ }];
949
+ case "Email/queryChanges":
950
+ // Explicitly unsupported in phase 1: clients fall back to Email/query.
951
+ throw new JmapServerMethodError(
952
+ "cannotCalculateChanges",
953
+ "Email/queryChanges is not supported; re-run the query instead.",
954
+ );
955
+ case "Email/set": {
956
+ const result = await this.methodEmailSet(account, args, createdIds);
957
+ return [{
958
+ name: methodName,
959
+ result,
960
+ mutated: result.newState !== result.oldState,
961
+ }];
962
+ }
963
+ case "Identity/get":
964
+ return [{
965
+ name: methodName,
966
+ result: await this.methodIdentityGet(account, args),
967
+ }];
968
+ case "EmailSubmission/set":
969
+ return this.methodEmailSubmissionSet(account, args, createdIds);
532
970
  default:
533
- throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
971
+ throw new JmapServerMethodError(
972
+ "unknownMethod",
973
+ `Unknown method "${methodName}".`,
974
+ );
534
975
  }
535
976
  }
536
977
 
537
- private methodMailboxGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
538
- const all = Array.from(user.mailboxes.values());
539
- let list: IJmapServerMailbox[];
540
- const notFound: string[] = [];
541
- if (args.ids == null) {
542
- list = all;
543
- } else {
544
- list = [];
545
- for (const id of args.ids as string[]) {
546
- const mailbox = user.mailboxes.get(id);
547
- if (mailbox) {
548
- list.push(mailbox);
549
- } else {
550
- notFound.push(id);
551
- }
552
- }
978
+ private requireAccountId(
979
+ account: IJmapAccountInfo,
980
+ args: Record<string, any>,
981
+ ): void {
982
+ if (typeof args.accountId !== "string") {
983
+ throw new JmapServerMethodError(
984
+ "invalidArguments",
985
+ "accountId is required.",
986
+ );
553
987
  }
554
- const augmented = list.map((mailbox) => {
555
- const emails = Array.from(user.emails.values()).filter(
556
- (email) => email.mailboxIds?.[mailbox.id]
988
+ if (args.accountId !== account.accountId) {
989
+ throw new JmapServerMethodError(
990
+ "accountNotFound",
991
+ `Account "${args.accountId}" is not accessible with these credentials.`,
557
992
  );
558
- return {
993
+ }
994
+ }
995
+
996
+ // ======================
997
+ // mailbox methods
998
+ // ======================
999
+
1000
+ private async methodMailboxGet(
1001
+ account: IJmapAccountInfo,
1002
+ args: Record<string, any>,
1003
+ ): Promise<Record<string, any>> {
1004
+ let ids: string[] | null = null;
1005
+ if (args.ids !== null && args.ids !== undefined) {
1006
+ if (!Array.isArray(args.ids)) {
1007
+ throw new JmapServerMethodError(
1008
+ "invalidArguments",
1009
+ "ids must be an array of ids or null.",
1010
+ );
1011
+ }
1012
+ if (args.ids.length > this.limits.maxObjectsInGet) {
1013
+ throw new JmapServerMethodError(
1014
+ "requestTooLarge",
1015
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
1016
+ );
1017
+ }
1018
+ ids = args.ids as string[];
1019
+ }
1020
+ const properties = this.validateProperties(
1021
+ args.properties,
1022
+ KNOWN_MAILBOX_PROPERTIES,
1023
+ );
1024
+ const result = await this.backend.getMailboxes(account.accountId, ids);
1025
+ const list = result.list.map((mailbox) => {
1026
+ const full: Record<string, any> = {
559
1027
  ...mailbox,
560
- totalEmails: emails.length,
561
- unreadEmails: emails.filter((email) => !email.keywords?.$seen).length,
562
- totalThreads: emails.length,
563
- unreadThreads: emails.filter((email) => !email.keywords?.$seen).length,
564
- myRights: {
565
- mayReadItems: true,
566
- mayAddItems: true,
567
- mayRemoveItems: true,
568
- maySetSeen: true,
569
- maySetKeywords: true,
570
- mayCreateChild: true,
571
- mayRename: true,
572
- mayDelete: true,
573
- maySubmit: true,
574
- },
575
- isSubscribed: true,
1028
+ isSubscribed: mailbox.isSubscribed ?? true,
1029
+ myRights: mailbox.myRights ?? { ...DEFAULT_MAILBOX_RIGHTS },
576
1030
  };
1031
+ return this.projectObject(full, properties ?? MAILBOX_PROPERTIES);
577
1032
  });
578
1033
  return {
579
- accountId: user.accountId,
580
- state: String(user.state),
581
- list: augmented,
582
- notFound,
1034
+ accountId: account.accountId,
1035
+ state: result.state,
1036
+ list,
1037
+ notFound: result.notFound,
583
1038
  };
584
1039
  }
585
1040
 
586
- private methodMailboxQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
587
- const filter = args.filter ?? {};
588
- let mailboxes = Array.from(user.mailboxes.values());
589
- if (typeof filter.role === 'string') {
1041
+ private async methodMailboxQuery(
1042
+ account: IJmapAccountInfo,
1043
+ args: Record<string, any>,
1044
+ ): Promise<Record<string, any>> {
1045
+ const filter = (args.filter ?? {}) as Record<string, any>;
1046
+ if ("operator" in filter) {
1047
+ throw new JmapServerMethodError(
1048
+ "unsupportedFilter",
1049
+ "FilterOperator is not supported for Mailbox/query.",
1050
+ );
1051
+ }
1052
+ for (const key of Object.keys(filter)) {
1053
+ if (!["role", "name", "parentId"].includes(key)) {
1054
+ throw new JmapServerMethodError(
1055
+ "unsupportedFilter",
1056
+ `Mailbox filter condition "${key}" is not supported.`,
1057
+ );
1058
+ }
1059
+ }
1060
+ for (const comparator of (args.sort ?? []) as Array<Record<string, any>>) {
1061
+ if (!["name", "sortOrder"].includes(comparator.property)) {
1062
+ throw new JmapServerMethodError(
1063
+ "unsupportedSort",
1064
+ `Mailbox sort property "${comparator.property}" is not supported.`,
1065
+ );
1066
+ }
1067
+ }
1068
+ const result = await this.backend.getMailboxes(account.accountId, null);
1069
+ let mailboxes = result.list;
1070
+ if (typeof filter.role === "string") {
590
1071
  mailboxes = mailboxes.filter((mailbox) => mailbox.role === filter.role);
591
1072
  }
592
- if (typeof filter.name === 'string') {
1073
+ if (typeof filter.name === "string") {
593
1074
  mailboxes = mailboxes.filter((mailbox) => mailbox.name === filter.name);
594
1075
  }
595
- const ids = mailboxes.map((mailbox) => mailbox.id);
596
- return {
597
- accountId: user.accountId,
598
- queryState: String(user.state),
1076
+ if ("parentId" in filter) {
1077
+ mailboxes = mailboxes.filter((mailbox) =>
1078
+ mailbox.parentId === filter.parentId
1079
+ );
1080
+ }
1081
+ const comparators = (args.sort ?? []) as Array<Record<string, any>>;
1082
+ if (comparators.length) {
1083
+ mailboxes = [...mailboxes].sort((a, b) => {
1084
+ for (const comparator of comparators) {
1085
+ const direction = comparator.isAscending === false ? -1 : 1;
1086
+ const property = comparator.property as "name" | "sortOrder";
1087
+ const left = a[property] ?? 0;
1088
+ const right = b[property] ?? 0;
1089
+ if (left < right) {
1090
+ return -direction;
1091
+ }
1092
+ if (left > right) {
1093
+ return direction;
1094
+ }
1095
+ }
1096
+ return 0;
1097
+ });
1098
+ }
1099
+ const position = this.validatePosition(args);
1100
+ let ids = mailboxes.map((mailbox) => mailbox.id).slice(position);
1101
+ if (typeof args.limit === "number") {
1102
+ ids = ids.slice(0, args.limit);
1103
+ }
1104
+ const response: Record<string, any> = {
1105
+ accountId: account.accountId,
1106
+ queryState: result.state,
599
1107
  canCalculateChanges: false,
600
- position: 0,
1108
+ position,
601
1109
  ids,
602
- total: ids.length,
603
1110
  };
1111
+ if (args.calculateTotal === true) {
1112
+ response.total = mailboxes.length;
1113
+ }
1114
+ return response;
604
1115
  }
605
1116
 
606
- private methodEmailGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
607
- if (!Array.isArray(args.ids)) {
608
- throw new JmapServerMethodError('invalidArguments', 'Email/get requires an ids array.');
1117
+ private async methodMailboxChanges(
1118
+ account: IJmapAccountInfo,
1119
+ args: Record<string, any>,
1120
+ ): Promise<Record<string, any>> {
1121
+ const { sinceState, maxChanges } = this.validateChangesArgs(args);
1122
+ const changes = await this.backend.getMailboxChanges(
1123
+ account.accountId,
1124
+ sinceState,
1125
+ maxChanges,
1126
+ );
1127
+ if (!changes) {
1128
+ throw new JmapServerMethodError(
1129
+ "cannotCalculateChanges",
1130
+ `Cannot calculate mailbox changes since state "${sinceState}".`,
1131
+ );
609
1132
  }
610
- const list: Array<Record<string, any>> = [];
611
- const notFound: string[] = [];
612
- for (const id of args.ids as string[]) {
613
- const email = user.emails.get(id);
614
- if (email) {
615
- // Returned as a full superset of the requestable properties;
616
- // property projection is intentionally not implemented.
617
- list.push({ ...email });
618
- } else {
619
- notFound.push(id);
620
- }
1133
+ return { accountId: account.accountId, ...changes };
1134
+ }
1135
+
1136
+ // ======================
1137
+ // thread methods
1138
+ // ======================
1139
+
1140
+ private async methodThreadGet(
1141
+ account: IJmapAccountInfo,
1142
+ args: Record<string, any>,
1143
+ ): Promise<Record<string, any>> {
1144
+ if (!Array.isArray(args.ids)) {
1145
+ throw new JmapServerMethodError(
1146
+ "invalidArguments",
1147
+ "Thread/get requires an ids array.",
1148
+ );
1149
+ }
1150
+ if (args.ids.length > this.limits.maxObjectsInGet) {
1151
+ throw new JmapServerMethodError(
1152
+ "requestTooLarge",
1153
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
1154
+ );
621
1155
  }
1156
+ // Result-reference chains like Email/get -> /list/*/threadId commonly
1157
+ // contain duplicates; deduplicate so each thread is returned once.
1158
+ const ids = Array.from(new Set(args.ids as string[]));
1159
+ const result = await this.backend.getThreads(account.accountId, ids);
622
1160
  return {
623
- accountId: user.accountId,
624
- state: String(user.state),
625
- list,
626
- notFound,
1161
+ accountId: account.accountId,
1162
+ state: result.state,
1163
+ list: result.list,
1164
+ notFound: result.notFound,
627
1165
  };
628
1166
  }
629
1167
 
630
- private methodEmailQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
631
- const filter = args.filter ?? {};
632
- let emails = Array.from(user.emails.values());
633
- if (typeof filter.inMailbox === 'string') {
634
- emails = emails.filter((email) => email.mailboxIds?.[filter.inMailbox]);
635
- }
636
- if (typeof filter.subject === 'string') {
637
- emails = emails.filter((email) =>
638
- String(email.subject ?? '').toLowerCase().includes(filter.subject.toLowerCase())
1168
+ // ======================
1169
+ // email methods
1170
+ // ======================
1171
+
1172
+ private async methodEmailGet(
1173
+ account: IJmapAccountInfo,
1174
+ args: Record<string, any>,
1175
+ ): Promise<Record<string, any>> {
1176
+ if (args.ids === null || args.ids === undefined) {
1177
+ // RFC 8621 allows rejecting "fetch all" for the potentially huge Email type.
1178
+ throw new JmapServerMethodError(
1179
+ "requestTooLarge",
1180
+ "Email/get requires an explicit ids array on this server.",
639
1181
  );
640
1182
  }
641
- if (typeof filter.text === 'string') {
642
- const needle = filter.text.toLowerCase();
643
- emails = emails.filter(
644
- (email) =>
645
- String(email.subject ?? '').toLowerCase().includes(needle) ||
646
- String(email.bodyValues?.text?.value ?? '').toLowerCase().includes(needle)
1183
+ if (!Array.isArray(args.ids)) {
1184
+ throw new JmapServerMethodError(
1185
+ "invalidArguments",
1186
+ "Email/get requires an ids array.",
647
1187
  );
648
1188
  }
649
- const sort = Array.isArray(args.sort) && args.sort.length ? args.sort[0] : null;
650
- if (sort?.property === 'receivedAt') {
651
- emails.sort((a, b) => {
652
- const comparison = String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? ''));
653
- return sort.isAscending === false ? -comparison : comparison;
654
- });
655
- }
656
- const position = typeof args.position === 'number' ? args.position : 0;
657
- let ids = emails.map((email) => email.id as string).slice(position);
658
- const total = emails.length;
659
- if (typeof args.limit === 'number') {
660
- ids = ids.slice(0, args.limit);
1189
+ if (args.ids.length > this.limits.maxObjectsInGet) {
1190
+ throw new JmapServerMethodError(
1191
+ "requestTooLarge",
1192
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
1193
+ );
661
1194
  }
1195
+ const properties = this.validateProperties(
1196
+ args.properties,
1197
+ KNOWN_EMAIL_PROPERTIES,
1198
+ );
1199
+ const result = await this.backend.getEmails(
1200
+ account.accountId,
1201
+ args.ids as string[],
1202
+ properties,
1203
+ );
1204
+ const effectiveProperties = properties ?? DEFAULT_EMAIL_PROPERTIES;
1205
+ const list = result.list.map((record) => {
1206
+ const projected = this.projectObject(record as any, effectiveProperties);
1207
+ if (effectiveProperties.includes("bodyValues")) {
1208
+ projected.bodyValues = this.buildBodyValues(record, args);
1209
+ }
1210
+ return projected;
1211
+ });
662
1212
  return {
663
- accountId: user.accountId,
664
- queryState: String(user.state),
665
- canCalculateChanges: false,
666
- position,
667
- ids,
668
- total,
1213
+ accountId: account.accountId,
1214
+ state: result.state,
1215
+ list,
1216
+ notFound: result.notFound,
669
1217
  };
670
1218
  }
671
1219
 
672
- private methodEmailChanges(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
673
- const since = Number(args.sinceState);
674
- if (!Number.isFinite(since) || since < 0 || since > user.state) {
675
- throw new JmapServerMethodError(
676
- 'cannotCalculateChanges',
677
- `Cannot calculate changes since state "${args.sinceState}".`
678
- );
1220
+ /** Applies the Email/get body-value fetch flags and maxBodyValueBytes truncation. */
1221
+ private buildBodyValues(
1222
+ record: IJmapEmailRecord,
1223
+ args: Record<string, any>,
1224
+ ): Record<string, IJmapEmailBodyValue> {
1225
+ const wanted = new Set<string>();
1226
+ if (args.fetchAllBodyValues === true) {
1227
+ for (const partId of Object.keys(record.bodyValues)) {
1228
+ wanted.add(partId);
1229
+ }
679
1230
  }
680
- const created = new Set<string>();
681
- const updated = new Set<string>();
682
- const destroyed = new Set<string>();
683
- for (const entry of user.changes) {
684
- if (entry.state <= since) {
685
- continue;
1231
+ if (args.fetchTextBodyValues === true) {
1232
+ for (const part of record.textBody) {
1233
+ if (part.partId != null) {
1234
+ wanted.add(part.partId);
1235
+ }
686
1236
  }
687
- for (const id of entry.created) {
688
- created.add(id);
1237
+ }
1238
+ if (args.fetchHTMLBodyValues === true) {
1239
+ for (const part of record.htmlBody) {
1240
+ if (part.partId != null) {
1241
+ wanted.add(part.partId);
1242
+ }
689
1243
  }
690
- for (const id of entry.updated) {
691
- updated.add(id);
1244
+ }
1245
+ const maxBytes =
1246
+ typeof args.maxBodyValueBytes === "number" && args.maxBodyValueBytes > 0
1247
+ ? args.maxBodyValueBytes
1248
+ : null;
1249
+ const result: Record<string, IJmapEmailBodyValue> = {};
1250
+ for (const partId of wanted) {
1251
+ const bodyValue = record.bodyValues[partId];
1252
+ if (!bodyValue) {
1253
+ continue;
692
1254
  }
693
- for (const id of entry.destroyed) {
694
- if (created.has(id)) {
695
- // created and destroyed within the window: omit entirely
696
- created.delete(id);
697
- } else {
698
- destroyed.add(id);
1255
+ let value = bodyValue.value;
1256
+ let isTruncated = bodyValue.isTruncated === true;
1257
+ if (maxBytes !== null) {
1258
+ const truncated = truncateUtf8(value, maxBytes);
1259
+ if (truncated.length < value.length) {
1260
+ value = truncated;
1261
+ isTruncated = true;
699
1262
  }
700
- updated.delete(id);
701
1263
  }
1264
+ result[partId] = { value, isTruncated };
702
1265
  }
703
- for (const id of created) {
704
- updated.delete(id);
1266
+ return result;
1267
+ }
1268
+
1269
+ private async methodEmailQuery(
1270
+ account: IJmapAccountInfo,
1271
+ args: Record<string, any>,
1272
+ ): Promise<Record<string, any>> {
1273
+ const filter = (args.filter ?? {}) as Record<string, any>;
1274
+ if ("operator" in filter) {
1275
+ throw new JmapServerMethodError(
1276
+ "unsupportedFilter",
1277
+ "FilterOperator is not supported; use a flat FilterCondition.",
1278
+ );
705
1279
  }
706
- return {
707
- accountId: user.accountId,
708
- oldState: String(since),
709
- newState: String(user.state),
710
- hasMoreChanges: false,
711
- created: Array.from(created),
712
- updated: Array.from(updated),
713
- destroyed: Array.from(destroyed),
1280
+ for (const key of Object.keys(filter)) {
1281
+ if (!SUPPORTED_EMAIL_FILTER_KEYS.has(key)) {
1282
+ throw new JmapServerMethodError(
1283
+ "unsupportedFilter",
1284
+ `Email filter condition "${key}" is not supported.`,
1285
+ );
1286
+ }
1287
+ }
1288
+ const sort = (args.sort ?? []) as Array<Record<string, any>>;
1289
+ for (const comparator of sort) {
1290
+ if (comparator.property !== "receivedAt") {
1291
+ throw new JmapServerMethodError(
1292
+ "unsupportedSort",
1293
+ `Email sort property "${comparator.property}" is not supported.`,
1294
+ );
1295
+ }
1296
+ }
1297
+ if (args.anchor !== undefined && args.anchor !== null) {
1298
+ throw new JmapServerMethodError(
1299
+ "invalidArguments",
1300
+ "anchor is not supported.",
1301
+ );
1302
+ }
1303
+ const position = this.validatePosition(args);
1304
+ const result = await this.backend.queryEmails(account.accountId, {
1305
+ filter: filter as IJmapEmailFilter,
1306
+ sort: sort as Array<{ property: string; isAscending?: boolean }>,
1307
+ position,
1308
+ limit: typeof args.limit === "number" ? args.limit : null,
1309
+ calculateTotal: args.calculateTotal === true,
1310
+ });
1311
+ const response: Record<string, any> = {
1312
+ accountId: account.accountId,
1313
+ queryState: result.queryState,
1314
+ canCalculateChanges: false,
1315
+ position: result.position,
1316
+ ids: result.ids,
714
1317
  };
1318
+ if (args.calculateTotal === true) {
1319
+ response.total = result.total ?? 0;
1320
+ }
1321
+ return response;
715
1322
  }
716
1323
 
717
- private methodEmailSet(
718
- user: IJmapServerUser,
1324
+ private async methodEmailChanges(
1325
+ account: IJmapAccountInfo,
719
1326
  args: Record<string, any>,
720
- createdIds: Map<string, string>
721
- ): Record<string, any> {
722
- const oldState = String(user.state);
723
- const created: Record<string, any> = {};
724
- const notCreated: Record<string, any> = {};
725
- const updated: Record<string, any> = {};
726
- const notUpdated: Record<string, any> = {};
727
- const destroyed: string[] = [];
728
- const notDestroyed: Record<string, any> = {};
729
- const changedCreated: string[] = [];
730
- const changedUpdated: string[] = [];
731
- const changedDestroyed: string[] = [];
732
-
733
- for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
734
- try {
735
- const email = this.createEmailFromSpec(user, spec);
736
- user.emails.set(email.id, email);
737
- createdIds.set(creationId, email.id);
738
- changedCreated.push(email.id);
739
- created[creationId] = {
740
- id: email.id,
741
- blobId: email.blobId,
742
- threadId: email.threadId,
743
- size: email.size,
744
- };
745
- } catch (error) {
746
- const message = error instanceof Error ? error.message : String(error);
747
- notCreated[creationId] = { type: 'invalidProperties', description: message };
748
- }
1327
+ ): Promise<Record<string, any>> {
1328
+ const { sinceState, maxChanges } = this.validateChangesArgs(args);
1329
+ const changes = await this.backend.getEmailChanges(
1330
+ account.accountId,
1331
+ sinceState,
1332
+ maxChanges,
1333
+ );
1334
+ if (!changes) {
1335
+ throw new JmapServerMethodError(
1336
+ "cannotCalculateChanges",
1337
+ `Cannot calculate changes since state "${sinceState}".`,
1338
+ );
749
1339
  }
1340
+ return { accountId: account.accountId, ...changes };
1341
+ }
750
1342
 
751
- for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
752
- const email = user.emails.get(id);
753
- if (!email) {
754
- notUpdated[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
755
- continue;
756
- }
757
- try {
758
- this.applyPatch(email, patch);
759
- changedUpdated.push(id);
760
- updated[id] = null;
761
- } catch (error) {
762
- const message = error instanceof Error ? error.message : String(error);
763
- notUpdated[id] = { type: 'invalidPatch', description: message };
1343
+ private async methodEmailSet(
1344
+ account: IJmapAccountInfo,
1345
+ args: Record<string, any>,
1346
+ createdIds: Map<string, string>,
1347
+ ): Promise<Record<string, any>> {
1348
+ const createEntries = Object.entries(
1349
+ (args.create ?? {}) as Record<string, any>,
1350
+ );
1351
+ const updateEntries = Object.entries(
1352
+ (args.update ?? {}) as Record<string, any>,
1353
+ );
1354
+ const destroyIds = (args.destroy ?? []) as string[];
1355
+ if (
1356
+ createEntries.length + updateEntries.length + destroyIds.length >
1357
+ this.limits.maxObjectsInSet
1358
+ ) {
1359
+ throw new JmapServerMethodError(
1360
+ "requestTooLarge",
1361
+ `The set request exceeds maxObjectsInSet (${this.limits.maxObjectsInSet}).`,
1362
+ );
1363
+ }
1364
+ if (typeof args.ifInState === "string") {
1365
+ const current = await this.backend.getEmails(account.accountId, []);
1366
+ if (current.state !== args.ifInState) {
1367
+ throw new JmapServerMethodError(
1368
+ "stateMismatch",
1369
+ `ifInState "${args.ifInState}" does not match the current state "${current.state}".`,
1370
+ );
764
1371
  }
765
1372
  }
766
1373
 
767
- for (const id of (args.destroy ?? []) as string[]) {
768
- if (user.emails.delete(id)) {
769
- changedDestroyed.push(id);
770
- destroyed.push(id);
771
- } else {
772
- notDestroyed[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
1374
+ const backendRequest: IJmapEmailSetRequest = {};
1375
+ const notUpdatedEarly: Record<string, IJmapSetError> = {};
1376
+
1377
+ if (createEntries.length) {
1378
+ backendRequest.create = {};
1379
+ for (const [creationId, spec] of createEntries) {
1380
+ backendRequest.create[creationId] = spec as IJmapEmailCreate;
773
1381
  }
774
1382
  }
775
-
776
- if (changedCreated.length || changedUpdated.length || changedDestroyed.length) {
777
- this.bumpState(user, {
778
- created: changedCreated,
779
- updated: changedUpdated,
780
- destroyed: changedDestroyed,
781
- });
1383
+ if (updateEntries.length) {
1384
+ backendRequest.update = {};
1385
+ for (const [id, patch] of updateEntries) {
1386
+ try {
1387
+ backendRequest.update[id] = this.parseEmailUpdatePatch(
1388
+ patch as Record<string, any>,
1389
+ );
1390
+ } catch (error) {
1391
+ notUpdatedEarly[id] = error instanceof JmapServerMethodError
1392
+ ? { type: error.type, description: error.message }
1393
+ : { type: "invalidPatch", description: String(error) };
1394
+ }
1395
+ }
782
1396
  }
1397
+ if (destroyIds.length) {
1398
+ backendRequest.destroy = destroyIds;
1399
+ }
1400
+
1401
+ const result = await this.backend.setEmails(
1402
+ account.accountId,
1403
+ backendRequest,
1404
+ );
783
1405
 
1406
+ const created: Record<string, any> = {};
1407
+ for (const [creationId, record] of Object.entries(result.created)) {
1408
+ createdIds.set(creationId, record.id);
1409
+ created[creationId] = {
1410
+ id: record.id,
1411
+ blobId: record.blobId,
1412
+ threadId: record.threadId,
1413
+ size: record.size,
1414
+ };
1415
+ }
1416
+ const updated: Record<string, null> = {};
1417
+ for (const id of result.updated) {
1418
+ updated[id] = null;
1419
+ }
784
1420
  return {
785
- accountId: user.accountId,
786
- oldState,
787
- newState: String(user.state),
1421
+ accountId: account.accountId,
1422
+ oldState: result.oldState,
1423
+ newState: result.newState,
788
1424
  created,
789
- notCreated,
1425
+ notCreated: result.notCreated,
790
1426
  updated,
791
- notUpdated,
792
- destroyed,
793
- notDestroyed,
1427
+ notUpdated: { ...result.notUpdated, ...notUpdatedEarly },
1428
+ destroyed: result.destroyed,
1429
+ notDestroyed: result.notDestroyed,
794
1430
  };
795
1431
  }
796
1432
 
797
- private methodIdentityGet(user: IJmapServerUser): Record<string, any> {
1433
+ /**
1434
+ * Normalizes an Email/set update patch (RFC 8620 §5.3): full `keywords`/
1435
+ * `mailboxIds` replacements and single-key JSON-pointer patches
1436
+ * (`keywords/$seen`, `mailboxIds/<id>`). Email properties other than these
1437
+ * two are immutable (RFC 8621 §4.6).
1438
+ */
1439
+ private parseEmailUpdatePatch(patch: Record<string, any>): IJmapEmailUpdate {
1440
+ const update: IJmapEmailUpdate = {};
1441
+ const unescape = (token: string): string =>
1442
+ token.replaceAll("~1", "/").replaceAll("~0", "~");
1443
+ for (const [path, value] of Object.entries(patch)) {
1444
+ if (path === "keywords" || path === "mailboxIds") {
1445
+ if (
1446
+ value === null || typeof value !== "object" || Array.isArray(value)
1447
+ ) {
1448
+ throw new JmapServerMethodError(
1449
+ "invalidProperties",
1450
+ `"${path}" must be an object.`,
1451
+ );
1452
+ }
1453
+ const normalized: Record<string, boolean> = {};
1454
+ for (
1455
+ const [key, entryValue] of Object.entries(
1456
+ value as Record<string, any>,
1457
+ )
1458
+ ) {
1459
+ if (entryValue !== true) {
1460
+ throw new JmapServerMethodError(
1461
+ "invalidProperties",
1462
+ `Values in "${path}" must be true; remove entries instead of setting them false.`,
1463
+ );
1464
+ }
1465
+ normalized[key] = true;
1466
+ }
1467
+ update[path] = normalized;
1468
+ continue;
1469
+ }
1470
+ const slashIndex = path.indexOf("/");
1471
+ if (slashIndex === -1) {
1472
+ throw new JmapServerMethodError(
1473
+ "invalidProperties",
1474
+ `Email property "${path}" is immutable or unknown.`,
1475
+ );
1476
+ }
1477
+ const property = path.slice(0, slashIndex);
1478
+ const rest = path.slice(slashIndex + 1);
1479
+ if (
1480
+ (property !== "keywords" && property !== "mailboxIds") ||
1481
+ rest.includes("/") || !rest
1482
+ ) {
1483
+ throw new JmapServerMethodError(
1484
+ "invalidPatch",
1485
+ `Unsupported patch path "${path}".`,
1486
+ );
1487
+ }
1488
+ const key = unescape(rest);
1489
+ const normalizedValue = value === true
1490
+ ? true
1491
+ : value === null || value === false
1492
+ ? null
1493
+ : undefined;
1494
+ if (normalizedValue === undefined) {
1495
+ throw new JmapServerMethodError(
1496
+ "invalidPatch",
1497
+ `Patch value for "${path}" must be true or null.`,
1498
+ );
1499
+ }
1500
+ if (property === "keywords") {
1501
+ update.keywordsPatch = update.keywordsPatch ?? {};
1502
+ update.keywordsPatch[key] = normalizedValue;
1503
+ } else {
1504
+ update.mailboxIdsPatch = update.mailboxIdsPatch ?? {};
1505
+ update.mailboxIdsPatch[key] = normalizedValue;
1506
+ }
1507
+ }
1508
+ if (update.keywords && update.keywordsPatch) {
1509
+ throw new JmapServerMethodError(
1510
+ "invalidPatch",
1511
+ 'A patch must not set "keywords" and patch "keywords/..." at the same time.',
1512
+ );
1513
+ }
1514
+ if (update.mailboxIds && update.mailboxIdsPatch) {
1515
+ throw new JmapServerMethodError(
1516
+ "invalidPatch",
1517
+ 'A patch must not set "mailboxIds" and patch "mailboxIds/..." at the same time.',
1518
+ );
1519
+ }
1520
+ return update;
1521
+ }
1522
+
1523
+ // ======================
1524
+ // identity + submission methods
1525
+ // ======================
1526
+
1527
+ private async methodIdentityGet(
1528
+ account: IJmapAccountInfo,
1529
+ args: Record<string, any>,
1530
+ ): Promise<Record<string, any>> {
1531
+ const result = await this.backend.getIdentities(account.accountId);
1532
+ let list = result.list;
1533
+ const notFound: string[] = [];
1534
+ if (Array.isArray(args.ids)) {
1535
+ const wanted = new Set(args.ids as string[]);
1536
+ list = list.filter((identity) => wanted.has(identity.id));
1537
+ for (const id of wanted) {
1538
+ if (!list.some((identity) => identity.id === id)) {
1539
+ notFound.push(id);
1540
+ }
1541
+ }
1542
+ }
798
1543
  return {
799
- accountId: user.accountId,
800
- state: '0',
801
- list: user.identities,
802
- notFound: [],
1544
+ accountId: account.accountId,
1545
+ state: result.state,
1546
+ list,
1547
+ notFound,
803
1548
  };
804
1549
  }
805
1550
 
806
- private methodEmailSubmissionSet(
807
- user: IJmapServerUser,
1551
+ private async methodEmailSubmissionSet(
1552
+ account: IJmapAccountInfo,
808
1553
  args: Record<string, any>,
809
- createdIds: Map<string, string>
810
- ): Record<string, any> {
1554
+ createdIds: Map<string, string>,
1555
+ ): Promise<
1556
+ Array<{ name: string; result: Record<string, any>; mutated?: boolean }>
1557
+ > {
811
1558
  const created: Record<string, any> = {};
812
- const notCreated: Record<string, any> = {};
813
- for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
1559
+ const notCreated: Record<string, IJmapSetError> = {};
1560
+ const notUpdated: Record<string, IJmapSetError> = {};
1561
+ const notDestroyed: Record<string, IJmapSetError> = {};
1562
+ /** creationId -> { submissionId, emailId } for onSuccess* resolution. */
1563
+ const createdSubmissions = new Map<
1564
+ string,
1565
+ { submissionId: string; emailId: string }
1566
+ >();
1567
+
1568
+ const identitiesResult = await this.backend.getIdentities(
1569
+ account.accountId,
1570
+ );
1571
+
1572
+ for (
1573
+ const [creationId, spec] of Object.entries(
1574
+ (args.create ?? {}) as Record<string, any>,
1575
+ )
1576
+ ) {
814
1577
  let emailId: string = spec.emailId;
815
- if (typeof emailId === 'string' && emailId.startsWith('#')) {
1578
+ if (typeof emailId === "string" && emailId.startsWith("#")) {
816
1579
  const resolved = createdIds.get(emailId.slice(1));
817
1580
  if (!resolved) {
818
1581
  notCreated[creationId] = {
819
- type: 'invalidProperties',
1582
+ type: "invalidProperties",
820
1583
  description: `Unknown creation id reference "${emailId}".`,
821
1584
  };
822
1585
  continue;
823
1586
  }
824
1587
  emailId = resolved;
825
1588
  }
826
- if (!user.emails.has(emailId)) {
1589
+ const emailResult = await this.backend.getEmails(account.accountId, [
1590
+ emailId,
1591
+ ]);
1592
+ const email = emailResult.list[0];
1593
+ if (!email) {
827
1594
  notCreated[creationId] = {
828
- type: 'invalidProperties',
1595
+ type: "invalidProperties",
829
1596
  description: `Email "${emailId}" does not exist.`,
830
1597
  };
831
1598
  continue;
832
1599
  }
833
- const identity = user.identities.find((candidate) => candidate.id === spec.identityId);
1600
+ const identity = identitiesResult.list.find(
1601
+ (candidate) => candidate.id === spec.identityId,
1602
+ );
834
1603
  if (!identity) {
835
1604
  notCreated[creationId] = {
836
- type: 'invalidProperties',
1605
+ type: "invalidProperties",
837
1606
  description: `Identity "${spec.identityId}" does not exist.`,
838
1607
  };
839
1608
  continue;
840
1609
  }
841
- const id = `submission_${++user.idCounter}`;
842
- const submission: IJmapServerSubmission = {
843
- id,
844
- emailId,
845
- identityId: identity.id,
846
- undoStatus: 'final',
847
- sendAt: new Date().toISOString(),
1610
+ const envelope = this.resolveEnvelope(
1611
+ spec.envelope,
1612
+ identity.email,
1613
+ email,
1614
+ );
1615
+ if (!envelope.rcptTo.length) {
1616
+ notCreated[creationId] = {
1617
+ type: "noRecipients",
1618
+ description:
1619
+ "The email has no to/cc/bcc recipients and no envelope was given.",
1620
+ };
1621
+ continue;
1622
+ }
1623
+ const blob = await this.backend.getBlob(account.accountId, email.blobId);
1624
+ if (!blob) {
1625
+ notCreated[creationId] = {
1626
+ type: "invalidProperties",
1627
+ description: `Raw message blob "${email.blobId}" does not exist.`,
1628
+ };
1629
+ continue;
1630
+ }
1631
+ try {
1632
+ const submission = await this.backend.submitEmail(account.accountId, {
1633
+ emailId,
1634
+ identityId: identity.id,
1635
+ envelope,
1636
+ message: blob.data,
1637
+ });
1638
+ createdSubmissions.set(creationId, {
1639
+ submissionId: submission.id,
1640
+ emailId,
1641
+ });
1642
+ created[creationId] = {
1643
+ id: submission.id,
1644
+ undoStatus: submission.undoStatus,
1645
+ sendAt: submission.sendAt,
1646
+ };
1647
+ } catch (error) {
1648
+ notCreated[creationId] = {
1649
+ type: "forbiddenToSend",
1650
+ description: error instanceof Error ? error.message : String(error),
1651
+ };
1652
+ }
1653
+ }
1654
+
1655
+ for (const id of Object.keys((args.update ?? {}) as Record<string, any>)) {
1656
+ notUpdated[id] = {
1657
+ type: "forbidden",
1658
+ description: "EmailSubmission updates are not supported.",
848
1659
  };
849
- user.submissions.set(id, submission);
850
- created[creationId] = { id, undoStatus: submission.undoStatus, sendAt: submission.sendAt };
1660
+ }
1661
+ for (const id of (args.destroy ?? []) as string[]) {
1662
+ notDestroyed[id] = {
1663
+ type: "forbidden",
1664
+ description: "EmailSubmission destroys are not supported.",
1665
+ };
1666
+ }
1667
+
1668
+ const responses: Array<
1669
+ { name: string; result: Record<string, any>; mutated?: boolean }
1670
+ > = [
1671
+ {
1672
+ name: "EmailSubmission/set",
1673
+ result: {
1674
+ accountId: account.accountId,
1675
+ oldState: null,
1676
+ newState: "0",
1677
+ created,
1678
+ notCreated,
1679
+ updated: {},
1680
+ notUpdated,
1681
+ destroyed: [],
1682
+ notDestroyed,
1683
+ },
1684
+ mutated: createdSubmissions.size > 0,
1685
+ },
1686
+ ];
1687
+
1688
+ // onSuccessUpdateEmail / onSuccessDestroyEmail (RFC 8621 §7.5): apply an
1689
+ // implicit Email/set for successfully created submissions and append its
1690
+ // response after the EmailSubmission/set response.
1691
+ const resolveSubmissionRef = (
1692
+ reference: string,
1693
+ ): { submissionId: string; emailId: string } | null => {
1694
+ if (reference.startsWith("#")) {
1695
+ return createdSubmissions.get(reference.slice(1)) ?? null;
1696
+ }
1697
+ for (const entry of createdSubmissions.values()) {
1698
+ if (entry.submissionId === reference) {
1699
+ return entry;
1700
+ }
1701
+ }
1702
+ return null;
1703
+ };
1704
+ const implicitSet: Record<string, any> = { accountId: account.accountId };
1705
+ let hasImplicitSet = false;
1706
+ if (
1707
+ args.onSuccessUpdateEmail && typeof args.onSuccessUpdateEmail === "object"
1708
+ ) {
1709
+ const update: Record<string, any> = {};
1710
+ for (
1711
+ const [reference, patch] of Object.entries(
1712
+ args.onSuccessUpdateEmail as Record<string, any>,
1713
+ )
1714
+ ) {
1715
+ const entry = resolveSubmissionRef(reference);
1716
+ if (entry) {
1717
+ update[entry.emailId] = patch;
1718
+ hasImplicitSet = true;
1719
+ }
1720
+ }
1721
+ implicitSet.update = update;
1722
+ }
1723
+ if (Array.isArray(args.onSuccessDestroyEmail)) {
1724
+ const destroy: string[] = [];
1725
+ for (const reference of args.onSuccessDestroyEmail as string[]) {
1726
+ const entry = resolveSubmissionRef(reference);
1727
+ if (entry) {
1728
+ destroy.push(entry.emailId);
1729
+ hasImplicitSet = true;
1730
+ }
1731
+ }
1732
+ implicitSet.destroy = destroy;
1733
+ }
1734
+ if (hasImplicitSet) {
1735
+ const result = await this.methodEmailSet(
1736
+ account,
1737
+ implicitSet,
1738
+ createdIds,
1739
+ );
1740
+ responses.push({
1741
+ name: "Email/set",
1742
+ result,
1743
+ mutated: result.newState !== result.oldState,
1744
+ });
1745
+ }
1746
+ return responses;
1747
+ }
1748
+
1749
+ /** Uses the client-provided envelope or derives it from the email's addresses. */
1750
+ private resolveEnvelope(
1751
+ specEnvelope: any,
1752
+ identityEmail: string,
1753
+ email: IJmapEmailRecord,
1754
+ ): IJmapEnvelope {
1755
+ if (
1756
+ specEnvelope &&
1757
+ typeof specEnvelope === "object" &&
1758
+ specEnvelope.mailFrom?.email &&
1759
+ Array.isArray(specEnvelope.rcptTo)
1760
+ ) {
1761
+ return {
1762
+ mailFrom: { email: String(specEnvelope.mailFrom.email) },
1763
+ rcptTo: specEnvelope.rcptTo
1764
+ .filter((recipient: any) => typeof recipient?.email === "string")
1765
+ .map((recipient: any) => ({ email: recipient.email })),
1766
+ };
1767
+ }
1768
+ const recipients = new Set<string>();
1769
+ for (const list of [email.to, email.cc, email.bcc]) {
1770
+ for (const address of list ?? []) {
1771
+ recipients.add(address.email);
1772
+ }
851
1773
  }
852
1774
  return {
853
- accountId: user.accountId,
854
- oldState: String(user.state),
855
- newState: String(user.state),
856
- created,
857
- notCreated,
858
- updated: {},
859
- notUpdated: {},
860
- destroyed: [],
861
- notDestroyed: {},
1775
+ mailFrom: { email: email.from?.[0]?.email ?? identityEmail },
1776
+ rcptTo: Array.from(recipients).map((address) => ({ email: address })),
862
1777
  };
863
1778
  }
864
1779
 
865
1780
  // ======================
866
- // internal helpers
1781
+ // upload + download
867
1782
  // ======================
868
1783
 
869
- private requireUser(username: string): IJmapServerUser {
870
- const user = this.users.get(username);
871
- if (!user) {
872
- throw new Error(`User "${username}" does not exist.`);
1784
+ private async cancelRequestBody(
1785
+ request: Request,
1786
+ reason: string,
1787
+ ): Promise<void> {
1788
+ try {
1789
+ if (request.body && !request.body.locked) {
1790
+ await request.body.cancel(reason);
1791
+ }
1792
+ } catch {
1793
+ // The runtime or peer may already have closed the transport stream.
873
1794
  }
874
- return user;
875
- }
876
-
877
- private createEmailFromSpec(user: IJmapServerUser, spec: Record<string, any>): Record<string, any> {
878
- const mailboxIds = spec.mailboxIds as Record<string, boolean> | undefined;
879
- if (!mailboxIds || !Object.keys(mailboxIds).length) {
880
- throw new Error('Email create requires mailboxIds.');
881
- }
882
- for (const mailboxId of Object.keys(mailboxIds)) {
883
- if (!user.mailboxes.has(mailboxId)) {
884
- throw new Error(`Mailbox "${mailboxId}" does not exist.`);
885
- }
886
- }
887
- const id = `email_${++user.idCounter}`;
888
- const receivedAt = new Date().toISOString();
889
- const bodyValues = (spec.bodyValues ?? {}) as Record<string, { value: string }>;
890
- const resolvePart = (part: Record<string, any>): string =>
891
- part?.partId != null ? bodyValues[part.partId]?.value ?? '' : '';
892
- const textBodyParts = ((spec.textBody ?? []) as Array<Record<string, any>>).map((part) => ({
893
- partId: part.partId ?? null,
894
- type: part.type ?? 'text/plain',
895
- size: resolvePart(part).length,
896
- }));
897
- const htmlBodyParts = ((spec.htmlBody ?? []) as Array<Record<string, any>>).map((part) => ({
898
- partId: part.partId ?? null,
899
- type: part.type ?? 'text/html',
900
- size: resolvePart(part).length,
901
- }));
902
- const textValue = textBodyParts.map((part) => bodyValues[part.partId ?? '']?.value ?? '').join('\n');
903
-
904
- const from = (spec.from ?? []) as IJmapEmailAddress[];
905
- const to = (spec.to ?? []) as IJmapEmailAddress[];
906
- const blobId = `blob_${id}`;
907
- const rawLines = [
908
- `From: ${from.map((address) => address.email).join(', ')}`,
909
- `To: ${to.map((address) => address.email).join(', ')}`,
910
- `Subject: ${spec.subject ?? ''}`,
911
- `Date: ${receivedAt}`,
912
- '',
913
- textValue,
914
- ];
915
- user.blobs.set(blobId, {
916
- data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
917
- type: 'message/rfc822',
918
- name: `${id}.eml`,
919
- });
1795
+ }
920
1796
 
921
- const normalizedBodyValues: Record<string, { value: string; isTruncated: boolean }> = {};
922
- for (const [partId, bodyValue] of Object.entries(bodyValues)) {
923
- normalizedBodyValues[partId] = { value: bodyValue.value ?? '', isTruncated: false };
1797
+ private async readRequestBodyBounded(
1798
+ request: Request,
1799
+ maxBytes: number,
1800
+ ): Promise<Uint8Array> {
1801
+ const declaredLength = request.headers.get("content-length");
1802
+ if (declaredLength !== null) {
1803
+ if (
1804
+ !/^\d+$/.test(declaredLength) ||
1805
+ !Number.isSafeInteger(Number(declaredLength))
1806
+ ) {
1807
+ await this.cancelRequestBody(request, "invalid content length");
1808
+ throw new JmapRequestBodyError("invalidLength");
1809
+ }
1810
+ if (Number(declaredLength) > maxBytes) {
1811
+ await this.cancelRequestBody(request, "request body too large");
1812
+ throw new JmapRequestBodyError("tooLarge");
1813
+ }
1814
+ }
1815
+ if (!request.body) {
1816
+ return new Uint8Array();
924
1817
  }
925
1818
 
1819
+ const reader = request.body.getReader();
1820
+ const chunks: Uint8Array[] = [];
1821
+ let total = 0;
1822
+ try {
1823
+ while (true) {
1824
+ const { done, value } = await reader.read();
1825
+ if (done) break;
1826
+ total += value.byteLength;
1827
+ if (total > maxBytes) {
1828
+ await reader.cancel("request body too large");
1829
+ throw new JmapRequestBodyError("tooLarge");
1830
+ }
1831
+ chunks.push(value);
1832
+ }
1833
+ } finally {
1834
+ reader.releaseLock();
1835
+ }
1836
+
1837
+ const data = new Uint8Array(total);
1838
+ let offset = 0;
1839
+ for (const chunk of chunks) {
1840
+ data.set(chunk, offset);
1841
+ offset += chunk.byteLength;
1842
+ }
1843
+ return data;
1844
+ }
1845
+
1846
+ private createExactUploadBody(
1847
+ request: Request,
1848
+ expectedSize: number,
1849
+ ): {
1850
+ stream: ReadableStream<Uint8Array>;
1851
+ signal: AbortSignal;
1852
+ isComplete(): boolean;
1853
+ cancel(reason: unknown): Promise<void>;
1854
+ dispose(): void;
1855
+ } {
1856
+ const abortController = new AbortController();
1857
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
1858
+ let bytesRead = 0;
1859
+ let complete = false;
1860
+ let cancelled = false;
1861
+
1862
+ const cancel = async (reason: unknown): Promise<void> => {
1863
+ if (cancelled || complete) return;
1864
+ cancelled = true;
1865
+ abortController.abort(reason);
1866
+ try {
1867
+ if (reader) {
1868
+ await reader.cancel(reason);
1869
+ } else if (request.body && !request.body.locked) {
1870
+ await request.body.cancel(reason);
1871
+ }
1872
+ } catch {
1873
+ // The peer or consumer may already have closed the stream.
1874
+ }
1875
+ };
1876
+ const onRequestAbort = (): void => {
1877
+ void cancel(request.signal.reason ?? new Error("Request aborted."));
1878
+ };
1879
+ request.signal.addEventListener("abort", onRequestAbort, { once: true });
1880
+ if (request.signal.aborted) onRequestAbort();
1881
+
1882
+ const stream = new ReadableStream<Uint8Array>({
1883
+ async pull(controller) {
1884
+ if (cancelled) {
1885
+ controller.error(
1886
+ abortController.signal.reason ?? new Error("Upload cancelled."),
1887
+ );
1888
+ return;
1889
+ }
1890
+ if (!request.body) {
1891
+ if (expectedSize === 0) {
1892
+ complete = true;
1893
+ controller.close();
1894
+ } else {
1895
+ const error = new JmapRequestBodyError("lengthMismatch");
1896
+ abortController.abort(error);
1897
+ controller.error(error);
1898
+ }
1899
+ return;
1900
+ }
1901
+ reader ??= request.body.getReader();
1902
+ const { done, value } = await reader.read();
1903
+ if (done) {
1904
+ if (bytesRead !== expectedSize) {
1905
+ const error = new JmapRequestBodyError("lengthMismatch");
1906
+ abortController.abort(error);
1907
+ controller.error(error);
1908
+ return;
1909
+ }
1910
+ complete = true;
1911
+ controller.close();
1912
+ return;
1913
+ }
1914
+ bytesRead += value.byteLength;
1915
+ if (bytesRead > expectedSize) {
1916
+ const error = new JmapRequestBodyError("lengthMismatch");
1917
+ await cancel(error);
1918
+ controller.error(error);
1919
+ return;
1920
+ }
1921
+ controller.enqueue(value);
1922
+ },
1923
+ cancel,
1924
+ }, { highWaterMark: 0 });
1925
+
926
1926
  return {
927
- id,
928
- blobId,
929
- threadId: `thread_${id}`,
930
- mailboxIds: { ...mailboxIds },
931
- keywords: spec.keywords ?? {},
932
- size: textValue.length,
933
- receivedAt,
934
- sentAt: receivedAt,
935
- subject: spec.subject ?? null,
936
- from,
937
- to,
938
- cc: spec.cc ?? null,
939
- bcc: spec.bcc ?? null,
940
- replyTo: spec.replyTo ?? null,
941
- hasAttachment: false,
942
- preview: textValue.slice(0, 100),
943
- bodyValues: normalizedBodyValues,
944
- textBody: textBodyParts,
945
- htmlBody: htmlBodyParts,
946
- attachments: [],
1927
+ stream,
1928
+ signal: abortController.signal,
1929
+ isComplete: () => complete,
1930
+ cancel,
1931
+ dispose: () =>
1932
+ request.signal.removeEventListener("abort", onRequestAbort),
1933
+ };
1934
+ }
1935
+
1936
+ private mappedUploadErrorResponse(error: unknown): Response | null {
1937
+ const problem = this.options.mapUploadError?.(error);
1938
+ if (!problem) return null;
1939
+ if (
1940
+ !Number.isInteger(problem.status) || problem.status < 400 ||
1941
+ problem.status > 599 ||
1942
+ !problem.detail
1943
+ ) {
1944
+ throw new Error("mapUploadError returned an invalid upload problem.");
1945
+ }
1946
+ const body = {
1947
+ type: problem.type ?? "about:blank",
1948
+ status: problem.status,
1949
+ detail: problem.detail,
1950
+ ...(problem.limit ? { limit: problem.limit } : {}),
1951
+ };
1952
+ const headers: Record<string, string> = {
1953
+ "content-type": "application/problem+json",
1954
+ };
1955
+ if (problem.retryAfterSeconds !== undefined) {
1956
+ headers["retry-after"] = String(problem.retryAfterSeconds);
1957
+ }
1958
+ return new Response(JSON.stringify(body), {
1959
+ status: problem.status,
1960
+ headers,
1961
+ });
1962
+ }
1963
+
1964
+ private requestBodyErrorResponse(
1965
+ error: JmapRequestBodyError,
1966
+ limit: string,
1967
+ ): Response {
1968
+ if (error.reason === "invalidLength") {
1969
+ return this.problemResponse(
1970
+ 400,
1971
+ "about:blank",
1972
+ "Invalid Content-Length header.",
1973
+ );
1974
+ }
1975
+ if (error.reason === "lengthMismatch") {
1976
+ return this.problemResponse(
1977
+ 400,
1978
+ "about:blank",
1979
+ "The request body does not match Content-Length.",
1980
+ );
1981
+ }
1982
+ const maximum = limit === "maxSizeUpload"
1983
+ ? this.limits.maxSizeUpload
1984
+ : this.limits.maxSizeRequest;
1985
+ return this.limitResponse(
1986
+ 400,
1987
+ limit,
1988
+ `The request is larger than ${maximum} bytes.`,
1989
+ );
1990
+ }
1991
+
1992
+ private async handleStreamingUpload(
1993
+ account: IJmapAccountInfo,
1994
+ request: Request,
1995
+ type: string,
1996
+ ): Promise<Response> {
1997
+ const contentLength = request.headers.get("content-length");
1998
+ if (contentLength === null) {
1999
+ await this.cancelRequestBody(request, "content length required");
2000
+ return this.problemResponse(
2001
+ 411,
2002
+ "about:blank",
2003
+ "Content-Length is required.",
2004
+ );
2005
+ }
2006
+ if (
2007
+ !/^\d+$/.test(contentLength) ||
2008
+ !Number.isSafeInteger(Number(contentLength))
2009
+ ) {
2010
+ await this.cancelRequestBody(request, "invalid content length");
2011
+ return this.problemResponse(
2012
+ 400,
2013
+ "about:blank",
2014
+ "Invalid Content-Length header.",
2015
+ );
2016
+ }
2017
+ const declaredSize = Number(contentLength);
2018
+ if (declaredSize > this.limits.maxSizeUpload) {
2019
+ await this.cancelRequestBody(request, "request body too large");
2020
+ return this.limitResponse(
2021
+ 400,
2022
+ "maxSizeUpload",
2023
+ `The upload is larger than ${this.limits.maxSizeUpload} bytes.`,
2024
+ );
2025
+ }
2026
+ if (!request.body && declaredSize !== 0) {
2027
+ return this.problemResponse(
2028
+ 400,
2029
+ "about:blank",
2030
+ "The request body does not match Content-Length.",
2031
+ );
2032
+ }
2033
+
2034
+ const body = this.createExactUploadBody(request, declaredSize);
2035
+ const upload: IJmapBlobUploadStream = {
2036
+ stream: body.stream,
2037
+ size: declaredSize,
2038
+ type,
2039
+ signal: body.signal,
2040
+ };
2041
+ try {
2042
+ const result = await this.backend.uploadBlobStream!(
2043
+ account.accountId,
2044
+ upload,
2045
+ );
2046
+ if (!body.isComplete()) {
2047
+ await body.cancel(new JmapRequestBodyError("lengthMismatch"));
2048
+ return this.problemResponse(
2049
+ 400,
2050
+ "about:blank",
2051
+ "The upload backend did not consume the exact request body.",
2052
+ );
2053
+ }
2054
+ if (result.size !== declaredSize) {
2055
+ throw new Error(
2056
+ `Upload backend reported ${result.size} bytes for a ${declaredSize}-byte request.`,
2057
+ );
2058
+ }
2059
+ return this.jsonResponse(200, {
2060
+ accountId: account.accountId,
2061
+ blobId: result.blobId,
2062
+ type,
2063
+ size: result.size,
2064
+ });
2065
+ } catch (error) {
2066
+ await body.cancel(error);
2067
+ if (error instanceof JmapRequestBodyError) {
2068
+ return this.requestBodyErrorResponse(error, "maxSizeUpload");
2069
+ }
2070
+ const mapped = this.mappedUploadErrorResponse(error);
2071
+ if (mapped) return mapped;
2072
+ throw error;
2073
+ } finally {
2074
+ body.dispose();
2075
+ }
2076
+ }
2077
+
2078
+ private async handleUpload(
2079
+ account: IJmapAccountInfo,
2080
+ principal: TJmapPrincipal,
2081
+ pathname: string,
2082
+ request: Request,
2083
+ ): Promise<Response> {
2084
+ const segments = pathname.split("/").filter((segment) =>
2085
+ segment.length > 0
2086
+ );
2087
+ const accountId = decodeURIComponent(segments[2] ?? "");
2088
+ if (accountId !== account.accountId) {
2089
+ await this.cancelRequestBody(request, "unknown account");
2090
+ return this.problemResponse(404, "about:blank", "Unknown account");
2091
+ }
2092
+
2093
+ const principalUploads =
2094
+ this.activeUploadsByPrincipal.get(principal.username) ?? 0;
2095
+ if (
2096
+ principalUploads >= this.limits.maxConcurrentUpload ||
2097
+ this.activeUploadRequests >= this.maxConcurrentUploadServer
2098
+ ) {
2099
+ await this.cancelRequestBody(request, "upload concurrency limit reached");
2100
+ return this.limitResponse(
2101
+ 429,
2102
+ "maxConcurrentUpload",
2103
+ "Too many concurrent uploads.",
2104
+ );
2105
+ }
2106
+
2107
+ this.activeUploadRequests++;
2108
+ this.activeUploadsByPrincipal.set(principal.username, principalUploads + 1);
2109
+ try {
2110
+ const type = request.headers.get("content-type") ??
2111
+ "application/octet-stream";
2112
+ if (typeof this.backend.uploadBlobStream === "function") {
2113
+ return await this.handleStreamingUpload(account, request, type);
2114
+ }
2115
+ let data: Uint8Array;
2116
+ try {
2117
+ data = await this.readRequestBodyBounded(
2118
+ request,
2119
+ this.limits.maxSizeUpload,
2120
+ );
2121
+ } catch (error) {
2122
+ if (error instanceof JmapRequestBodyError) {
2123
+ return this.requestBodyErrorResponse(error, "maxSizeUpload");
2124
+ }
2125
+ throw error;
2126
+ }
2127
+ try {
2128
+ const { blobId, size } = await this.backend.uploadBlob(
2129
+ account.accountId,
2130
+ data,
2131
+ type,
2132
+ );
2133
+ return this.jsonResponse(200, {
2134
+ accountId: account.accountId,
2135
+ blobId,
2136
+ type,
2137
+ size,
2138
+ });
2139
+ } catch (error) {
2140
+ const mapped = this.mappedUploadErrorResponse(error);
2141
+ if (mapped) return mapped;
2142
+ throw error;
2143
+ }
2144
+ } finally {
2145
+ this.activeUploadRequests--;
2146
+ const remainingPrincipalUploads =
2147
+ (this.activeUploadsByPrincipal.get(principal.username) ?? 1) - 1;
2148
+ if (remainingPrincipalUploads <= 0) {
2149
+ this.activeUploadsByPrincipal.delete(principal.username);
2150
+ } else {
2151
+ this.activeUploadsByPrincipal.set(
2152
+ principal.username,
2153
+ remainingPrincipalUploads,
2154
+ );
2155
+ }
2156
+ }
2157
+ }
2158
+
2159
+ private async handleDownload(
2160
+ account: IJmapAccountInfo,
2161
+ pathname: string,
2162
+ url: URL,
2163
+ ): Promise<Response> {
2164
+ // /jmap/download/{accountId}/{blobId}/{name}
2165
+ const segments = pathname.split("/").filter((segment) =>
2166
+ segment.length > 0
2167
+ );
2168
+ const accountId = decodeURIComponent(segments[2] ?? "");
2169
+ const blobId = decodeURIComponent(segments[3] ?? "");
2170
+ const name = decodeURIComponent(segments[4] ?? "blob");
2171
+ if (accountId !== account.accountId) {
2172
+ return this.problemResponse(404, "about:blank", "Unknown account");
2173
+ }
2174
+ const blob = await this.backend.getBlob(account.accountId, blobId);
2175
+ if (!blob) {
2176
+ return this.problemResponse(404, "about:blank", "Unknown blob");
2177
+ }
2178
+ const type = url.searchParams.get("type") ?? blob.type;
2179
+ return new Response(asBodyBytes(blob.data), {
2180
+ status: 200,
2181
+ headers: {
2182
+ "content-type": type,
2183
+ "content-length": String(blob.data.length),
2184
+ "content-disposition": `attachment; filename="${
2185
+ name.replaceAll('"', "")
2186
+ }"`,
2187
+ },
2188
+ });
2189
+ }
2190
+
2191
+ // ======================
2192
+ // event source (SSE)
2193
+ // ======================
2194
+
2195
+ private handleEventSource(account: IJmapAccountInfo, url: URL): Response {
2196
+ const typesParam = url.searchParams.get("types") ?? "*";
2197
+ const types = typesParam === "*" || typesParam === "" ? null : new Set(
2198
+ typesParam
2199
+ .split(",")
2200
+ .map((entry) => entry.trim())
2201
+ .filter((entry) => entry.length > 0),
2202
+ );
2203
+ const closeAfterState = url.searchParams.get("closeafter") === "state";
2204
+ const pingRaw = Number(url.searchParams.get("ping") ?? "0");
2205
+ const pingSeconds = Number.isFinite(pingRaw) && pingRaw > 0
2206
+ ? Math.max(1, Math.floor(pingRaw))
2207
+ : 0;
2208
+
2209
+ const state: IEventStreamState = {
2210
+ accountId: account.accountId,
2211
+ types,
2212
+ closeAfterState,
2213
+ controller: null,
2214
+ pingTimer: null,
2215
+ lastSent: new Map(),
2216
+ closed: false,
947
2217
  };
2218
+ const stream = new ReadableStream<Uint8Array>({
2219
+ start: (controller) => {
2220
+ state.controller = controller;
2221
+ controller.enqueue(textEncoder.encode(": jmap event stream\n\n"));
2222
+ if (pingSeconds > 0) {
2223
+ state.pingTimer = setInterval(() => {
2224
+ this.writeToStream(
2225
+ state,
2226
+ `event: ping\ndata: {"interval":${pingSeconds}}\n\n`,
2227
+ );
2228
+ }, pingSeconds * 1000);
2229
+ }
2230
+ try {
2231
+ this.registerStream(state);
2232
+ } catch (error) {
2233
+ // a throwing backend.subscribeToChanges must not strand the ping timer
2234
+ this.unregisterStream(state);
2235
+ throw error;
2236
+ }
2237
+ },
2238
+ cancel: () => {
2239
+ this.unregisterStream(state);
2240
+ },
2241
+ });
2242
+ return new Response(stream, {
2243
+ status: 200,
2244
+ headers: {
2245
+ "content-type": "text/event-stream",
2246
+ "cache-control": "no-cache",
2247
+ },
2248
+ });
2249
+ }
2250
+
2251
+ private registerStream(state: IEventStreamState): void {
2252
+ if (!this.changeFeedUnsubscribe) {
2253
+ this.changeFeedUnsubscribe = this.backend.subscribeToChanges((change) => {
2254
+ this.pushStateChange(change.accountId, change.changed);
2255
+ });
2256
+ }
2257
+ this.eventStreams.add(state);
2258
+ }
2259
+
2260
+ private unregisterStream(state: IEventStreamState): void {
2261
+ state.closed = true;
2262
+ if (state.pingTimer) {
2263
+ clearInterval(state.pingTimer);
2264
+ state.pingTimer = null;
2265
+ }
2266
+ this.eventStreams.delete(state);
2267
+ if (!this.eventStreams.size && this.changeFeedUnsubscribe) {
2268
+ const unsubscribe = this.changeFeedUnsubscribe;
2269
+ this.changeFeedUnsubscribe = null;
2270
+ try {
2271
+ unsubscribe();
2272
+ } catch (error) {
2273
+ this.cleanupErrors.push(error);
2274
+ }
2275
+ }
2276
+ }
2277
+
2278
+ private closeStream(state: IEventStreamState): void {
2279
+ this.unregisterStream(state);
2280
+ try {
2281
+ state.controller?.close();
2282
+ } catch {
2283
+ // already closed or errored
2284
+ }
2285
+ }
2286
+
2287
+ private writeToStream(state: IEventStreamState, payload: string): void {
2288
+ if (state.closed || !state.controller) {
2289
+ return;
2290
+ }
2291
+ try {
2292
+ state.controller.enqueue(textEncoder.encode(payload));
2293
+ } catch {
2294
+ this.closeStream(state);
2295
+ }
948
2296
  }
949
2297
 
950
2298
  /**
951
- * Applies an Email/set update patch: either whole properties (e.g. keywords)
952
- * or JSON-pointer style paths like "keywords/$seen" (RFC 8620 §5.3).
2299
+ * Pushes a StateChange to matching event streams. Per-type states are
2300
+ * deduplicated per stream, so a mutation notified both by the backend
2301
+ * change feed and by the post-request push is delivered once.
953
2302
  */
954
- private applyPatch(email: Record<string, any>, patch: Record<string, any>): void {
955
- for (const [path, value] of Object.entries(patch)) {
956
- const segments = path.split('/');
957
- if (segments.length === 1) {
958
- if (value === null) {
959
- delete email[path];
960
- } else {
961
- email[path] = value;
962
- }
2303
+ private pushStateChange(
2304
+ accountId: string,
2305
+ changed: Record<string, string>,
2306
+ ): void {
2307
+ for (const state of Array.from(this.eventStreams)) {
2308
+ if (state.accountId !== accountId) {
963
2309
  continue;
964
2310
  }
965
- let target = email;
966
- for (const segment of segments.slice(0, -1)) {
967
- if (typeof target[segment] !== 'object' || target[segment] === null) {
968
- target[segment] = {};
2311
+ const filtered: Record<string, string> = {};
2312
+ for (const [type, typeState] of Object.entries(changed)) {
2313
+ if (state.types && !state.types.has(type)) {
2314
+ continue;
2315
+ }
2316
+ if (state.lastSent.get(type) === typeState) {
2317
+ continue;
969
2318
  }
970
- target = target[segment];
2319
+ filtered[type] = typeState;
971
2320
  }
972
- const leaf = segments[segments.length - 1];
973
- if (value === null || value === false) {
974
- delete target[leaf];
975
- } else {
976
- target[leaf] = value;
2321
+ if (!Object.keys(filtered).length) {
2322
+ continue;
2323
+ }
2324
+ for (const [type, typeState] of Object.entries(filtered)) {
2325
+ state.lastSent.set(type, typeState);
2326
+ }
2327
+ const stateChange = {
2328
+ "@type": "StateChange",
2329
+ changed: { [accountId]: filtered },
2330
+ };
2331
+ this.writeToStream(
2332
+ state,
2333
+ `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`,
2334
+ );
2335
+ if (state.closeAfterState) {
2336
+ this.closeStream(state);
977
2337
  }
978
2338
  }
979
2339
  }
980
2340
 
981
- /** Increments the account state, records the change, and pushes a StateChange to open event streams. */
982
- private bumpState(
983
- user: IJmapServerUser,
984
- change: { created?: string[]; updated?: string[]; destroyed?: string[] }
985
- ): void {
986
- user.state += 1;
987
- user.changes.push({
988
- state: user.state,
989
- created: change.created ?? [],
990
- updated: change.updated ?? [],
991
- destroyed: change.destroyed ?? [],
2341
+ /**
2342
+ * Emits a StateChange after a mutating JMAP request, covering backends
2343
+ * whose change feed only reports out-of-band mutations. Deduplicated
2344
+ * against feed-driven pushes via the per-stream state tracking.
2345
+ */
2346
+ private async pushAfterMutation(account: IJmapAccountInfo): Promise<void> {
2347
+ if (!this.eventStreams.size) {
2348
+ return;
2349
+ }
2350
+ const [emails, mailboxes, threads] = await Promise.all([
2351
+ this.backend.getEmails(account.accountId, []),
2352
+ this.backend.getMailboxes(account.accountId, []),
2353
+ this.backend.getThreads(account.accountId, []),
2354
+ ]);
2355
+ this.pushStateChange(account.accountId, {
2356
+ Email: emails.state,
2357
+ Thread: threads.state,
2358
+ Mailbox: mailboxes.state,
992
2359
  });
993
- this.notifyStateChange(user);
994
2360
  }
995
2361
 
996
- private notifyStateChange(user: IJmapServerUser): void {
997
- const stateChange = {
998
- '@type': 'StateChange',
999
- changed: {
1000
- [user.accountId]: {
1001
- Email: String(user.state),
1002
- Mailbox: String(user.state),
1003
- Thread: String(user.state),
1004
- },
2362
+ // ======================
2363
+ // node req/res adapter
2364
+ // ======================
2365
+
2366
+ private nodeRequestBody(
2367
+ req: plugins.http.IncomingMessage,
2368
+ ): ReadableStream<Uint8Array> {
2369
+ let closed = false;
2370
+ let cleanup = (): void => {};
2371
+ return new ReadableStream<Uint8Array>({
2372
+ start(controller) {
2373
+ req.pause();
2374
+ const onData = (chunk: Buffer): void => {
2375
+ if (closed) return;
2376
+ controller.enqueue(
2377
+ new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
2378
+ );
2379
+ if ((controller.desiredSize ?? 0) <= 0) req.pause();
2380
+ };
2381
+ const onEnd = (): void => {
2382
+ if (closed) return;
2383
+ closed = true;
2384
+ cleanup();
2385
+ controller.close();
2386
+ };
2387
+ const onAborted = (): void => {
2388
+ if (closed) return;
2389
+ closed = true;
2390
+ cleanup();
2391
+ controller.error(new Error("Request body stream was aborted."));
2392
+ };
2393
+ const onError = (error: Error): void => {
2394
+ if (closed) return;
2395
+ closed = true;
2396
+ cleanup();
2397
+ controller.error(error);
2398
+ };
2399
+ cleanup = () => {
2400
+ req.off("data", onData);
2401
+ req.off("end", onEnd);
2402
+ req.off("aborted", onAborted);
2403
+ req.off("error", onError);
2404
+ };
2405
+ req.on("data", onData);
2406
+ req.once("end", onEnd);
2407
+ req.once("aborted", onAborted);
2408
+ req.once("error", onError);
2409
+ },
2410
+ pull() {
2411
+ if (!closed) req.resume();
1005
2412
  },
2413
+ cancel() {
2414
+ if (closed) return;
2415
+ closed = true;
2416
+ cleanup();
2417
+ // Drain without buffering so the response can reuse the connection.
2418
+ req.resume();
2419
+ },
2420
+ });
2421
+ }
2422
+
2423
+ private async handleNodeRequest(
2424
+ req: plugins.http.IncomingMessage,
2425
+ res: plugins.http.ServerResponse,
2426
+ requestAbortController: AbortController,
2427
+ ): Promise<void> {
2428
+ const abortRequest = (reason: unknown): void => {
2429
+ if (!requestAbortController.signal.aborted) {
2430
+ requestAbortController.abort(reason);
2431
+ }
1006
2432
  };
1007
- const payload = `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`;
1008
- for (const [response, streamUser] of this.eventStreams) {
1009
- if (streamUser !== user) {
1010
- continue;
2433
+ const onRequestAborted = (): void => {
2434
+ abortRequest(new Error("Node request was aborted by the client."));
2435
+ };
2436
+ const onRequestError = (error: Error): void => {
2437
+ abortRequest(error);
2438
+ };
2439
+ const onRequestClose = (): void => {
2440
+ // IncomingMessage emits close after normal completion too. Only a
2441
+ // close before the complete HTTP message is a transport abort.
2442
+ if (!req.complete) onRequestAborted();
2443
+ };
2444
+ const onResponseClose = (): void => {
2445
+ if (!res.writableEnded) {
2446
+ abortRequest(
2447
+ new Error("Node response connection closed before completion."),
2448
+ );
2449
+ }
2450
+ };
2451
+ req.once("aborted", onRequestAborted);
2452
+ req.once("error", onRequestError);
2453
+ req.once("close", onRequestClose);
2454
+ res.once("close", onResponseClose);
2455
+ try {
2456
+ const method = (req.method ?? "GET").toUpperCase();
2457
+ const headers = new Headers();
2458
+ const skippedHeaders = new Set([
2459
+ "connection",
2460
+ "expect",
2461
+ "keep-alive",
2462
+ "proxy-connection",
2463
+ "transfer-encoding",
2464
+ "upgrade",
2465
+ ]);
2466
+ for (const [key, value] of Object.entries(req.headers)) {
2467
+ if (value === undefined || skippedHeaders.has(key.toLowerCase())) {
2468
+ continue;
2469
+ }
2470
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
2471
+ }
2472
+ const host = req.headers.host ?? "127.0.0.1";
2473
+ const requestInit: RequestInit & { duplex?: "half" } = {
2474
+ method,
2475
+ headers,
2476
+ signal: requestAbortController.signal,
2477
+ };
2478
+ if (method !== "GET" && method !== "HEAD") {
2479
+ requestInit.body = this.nodeRequestBody(req);
2480
+ requestInit.duplex = "half";
1011
2481
  }
2482
+ const request = new Request(
2483
+ `http://${host}${req.url ?? "/"}`,
2484
+ requestInit,
2485
+ );
2486
+
2487
+ const response = await this.fetchHandler(request);
2488
+
2489
+ const responseHeaders: Record<string, string> = {};
2490
+ response.headers.forEach((value, key) => {
2491
+ responseHeaders[key] = value;
2492
+ });
2493
+ res.writeHead(response.status, responseHeaders);
2494
+ if (!response.body) {
2495
+ res.end();
2496
+ return;
2497
+ }
2498
+ const reader = response.body.getReader();
2499
+ let clientClosed = false;
2500
+ res.on("close", () => {
2501
+ clientClosed = true;
2502
+ // Cancelling the reader triggers the stream's cancel handler, which
2503
+ // releases any per-stream resources (SSE registration, ping timer).
2504
+ void reader.cancel().catch(() => {});
2505
+ });
1012
2506
  try {
1013
- response.write(payload);
2507
+ while (true) {
2508
+ const { done, value } = await reader.read();
2509
+ if (done || clientClosed || res.destroyed) {
2510
+ break;
2511
+ }
2512
+ res.write(value);
2513
+ }
1014
2514
  } catch {
1015
- this.eventStreams.delete(response);
2515
+ // stream cancelled or connection gone
2516
+ }
2517
+ if (!res.destroyed) {
2518
+ res.end();
2519
+ }
2520
+ } catch (error) {
2521
+ const message = error instanceof Error ? error.message : String(error);
2522
+ if (!res.headersSent) {
2523
+ res.writeHead(500, { "content-type": "application/problem+json" });
2524
+ }
2525
+ res.end(
2526
+ JSON.stringify({ type: "about:blank", status: 500, detail: message }),
2527
+ );
2528
+ } finally {
2529
+ req.off("aborted", onRequestAborted);
2530
+ req.off("error", onRequestError);
2531
+ req.off("close", onRequestClose);
2532
+ res.off("close", onResponseClose);
2533
+ }
2534
+ }
2535
+
2536
+ // ======================
2537
+ // shared validators + responses
2538
+ // ======================
2539
+
2540
+ private validateProperties(
2541
+ requested: any,
2542
+ known: Set<string>,
2543
+ ): string[] | null {
2544
+ if (requested === null || requested === undefined) {
2545
+ return null;
2546
+ }
2547
+ if (!Array.isArray(requested)) {
2548
+ throw new JmapServerMethodError(
2549
+ "invalidArguments",
2550
+ "properties must be an array or null.",
2551
+ );
2552
+ }
2553
+ for (const property of requested) {
2554
+ if (typeof property !== "string" || !known.has(property)) {
2555
+ throw new JmapServerMethodError(
2556
+ "invalidArguments",
2557
+ `Unknown property "${String(property)}".`,
2558
+ );
1016
2559
  }
1017
2560
  }
2561
+ return requested.includes("id") ? requested : ["id", ...requested];
2562
+ }
2563
+
2564
+ private projectObject(
2565
+ source: Record<string, any>,
2566
+ properties: string[],
2567
+ ): Record<string, any> {
2568
+ const projected: Record<string, any> = {};
2569
+ for (const property of properties) {
2570
+ projected[property] = source[property] === undefined
2571
+ ? null
2572
+ : source[property];
2573
+ }
2574
+ return projected;
2575
+ }
2576
+
2577
+ private validatePosition(args: Record<string, any>): number {
2578
+ if (args.position === undefined || args.position === null) {
2579
+ return 0;
2580
+ }
2581
+ if (!Number.isInteger(args.position) || args.position < 0) {
2582
+ throw new JmapServerMethodError(
2583
+ "invalidArguments",
2584
+ "position must be a non-negative integer.",
2585
+ );
2586
+ }
2587
+ return args.position as number;
2588
+ }
2589
+
2590
+ private validateChangesArgs(args: Record<string, any>): {
2591
+ sinceState: string;
2592
+ maxChanges?: number;
2593
+ } {
2594
+ if (typeof args.sinceState !== "string") {
2595
+ throw new JmapServerMethodError(
2596
+ "invalidArguments",
2597
+ "sinceState is required.",
2598
+ );
2599
+ }
2600
+ let maxChanges: number | undefined;
2601
+ if (args.maxChanges !== undefined && args.maxChanges !== null) {
2602
+ if (!Number.isInteger(args.maxChanges) || args.maxChanges <= 0) {
2603
+ throw new JmapServerMethodError(
2604
+ "invalidArguments",
2605
+ "maxChanges must be a positive integer.",
2606
+ );
2607
+ }
2608
+ maxChanges = args.maxChanges as number;
2609
+ }
2610
+ return { sinceState: args.sinceState, maxChanges };
2611
+ }
2612
+
2613
+ private jsonResponse(status: number, body: unknown): Response {
2614
+ return new Response(JSON.stringify(body), {
2615
+ status,
2616
+ headers: { "content-type": "application/json" },
2617
+ });
2618
+ }
2619
+
2620
+ private problemResponse(
2621
+ status: number,
2622
+ type: string,
2623
+ detail: string,
2624
+ extraHeaders: Record<string, string> = {},
2625
+ ): Response {
2626
+ return new Response(JSON.stringify({ type, status, detail }), {
2627
+ status,
2628
+ headers: { "content-type": "application/problem+json", ...extraHeaders },
2629
+ });
2630
+ }
2631
+
2632
+ private limitResponse(
2633
+ status: number,
2634
+ limit: string,
2635
+ detail: string,
2636
+ ): Response {
2637
+ return new Response(
2638
+ JSON.stringify({
2639
+ type: "urn:ietf:params:jmap:error:limit",
2640
+ limit,
2641
+ status,
2642
+ detail,
2643
+ }),
2644
+ { status, headers: { "content-type": "application/problem+json" } },
2645
+ );
1018
2646
  }
1019
2647
  }