@push.rocks/smartjmap 2.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,13 +1,14 @@
1
- import * as plugins from './smartjmap.plugins.js';
1
+ import * as plugins from "./smartjmap.plugins.js";
2
2
  import {
3
+ type IJmapEmailBodyValue,
3
4
  JMAP_CORE_CAPABILITY,
4
5
  JMAP_MAIL_CAPABILITY,
5
6
  JMAP_SUBMISSION_CAPABILITY,
6
- type IJmapEmailBodyValue,
7
7
  type TJmapMethodResponse,
8
- } from './classes.jmapclient.js';
8
+ } from "./classes.jmapclient.js";
9
9
  import {
10
10
  type IJmapAccountInfo,
11
+ type IJmapBlobUploadStream,
11
12
  type IJmapEmailCreate,
12
13
  type IJmapEmailFilter,
13
14
  type IJmapEmailRecord,
@@ -17,8 +18,15 @@ import {
17
18
  type IJmapMailBackend,
18
19
  type IJmapSetError,
19
20
  type TJmapPrincipal,
20
- } from './interfaces.backend.js';
21
- import { MemoryMailBackend } from './classes.memorymailbackend.js';
21
+ } from "./interfaces.backend.js";
22
+ import { MemoryMailBackend } from "./classes.memorymailbackend.js";
23
+
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;
22
30
 
23
31
  /** Options for constructing a JmapServer. */
24
32
  export interface IJmapServerOptions {
@@ -30,16 +38,49 @@ export interface IJmapServerOptions {
30
38
  * `addUser`/`addBearerToken`.
31
39
  */
32
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[];
33
47
  /**
34
48
  * Absolute base URL (e.g. `https://mail.example.com`) prefixed to the URLs
35
49
  * advertised in the session object. Defaults to relative URLs, which JMAP
36
50
  * clients resolve against the session resource URL.
37
51
  */
38
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;
62
+ }
63
+
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;
72
+ }
73
+
74
+ export interface IJmapUploadProblem {
75
+ status: number;
76
+ type?: string;
77
+ detail: string;
78
+ limit?: string;
79
+ retryAfterSeconds?: number;
39
80
  }
40
81
 
41
82
  /** The advertised — and enforced — RFC 8620 core limits. */
42
- export const JMAP_SERVER_LIMITS = {
83
+ export const JMAP_SERVER_LIMITS: IJmapServerLimits = {
43
84
  maxSizeUpload: 50_000_000,
44
85
  maxConcurrentUpload: 4,
45
86
  maxSizeRequest: 10_000_000,
@@ -50,62 +91,62 @@ export const JMAP_SERVER_LIMITS = {
50
91
  };
51
92
 
52
93
  /** The session object never changes at runtime, so its state is constant. */
53
- const SESSION_STATE = '0';
94
+ const SESSION_STATE = "0";
54
95
 
55
96
  /** Default Email/get properties per RFC 8621 §4.2. */
56
97
  const DEFAULT_EMAIL_PROPERTIES = [
57
- 'id',
58
- 'blobId',
59
- 'threadId',
60
- 'mailboxIds',
61
- 'keywords',
62
- 'size',
63
- 'receivedAt',
64
- 'messageId',
65
- 'inReplyTo',
66
- 'references',
67
- 'sender',
68
- 'from',
69
- 'to',
70
- 'cc',
71
- 'bcc',
72
- 'replyTo',
73
- 'subject',
74
- 'sentAt',
75
- 'hasAttachment',
76
- 'preview',
77
- 'bodyValues',
78
- 'textBody',
79
- 'htmlBody',
80
- 'attachments',
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",
81
122
  ];
82
123
  const KNOWN_EMAIL_PROPERTIES = new Set(DEFAULT_EMAIL_PROPERTIES);
83
124
 
84
125
  const MAILBOX_PROPERTIES = [
85
- 'id',
86
- 'name',
87
- 'parentId',
88
- 'role',
89
- 'sortOrder',
90
- 'totalEmails',
91
- 'unreadEmails',
92
- 'totalThreads',
93
- 'unreadThreads',
94
- 'myRights',
95
- 'isSubscribed',
126
+ "id",
127
+ "name",
128
+ "parentId",
129
+ "role",
130
+ "sortOrder",
131
+ "totalEmails",
132
+ "unreadEmails",
133
+ "totalThreads",
134
+ "unreadThreads",
135
+ "myRights",
136
+ "isSubscribed",
96
137
  ];
97
138
  const KNOWN_MAILBOX_PROPERTIES = new Set(MAILBOX_PROPERTIES);
98
139
 
99
140
  const SUPPORTED_EMAIL_FILTER_KEYS = new Set([
100
- 'inMailbox',
101
- 'text',
102
- 'subject',
103
- 'from',
104
- 'to',
105
- 'before',
106
- 'after',
107
- 'hasKeyword',
108
- 'notKeyword',
141
+ "inMailbox",
142
+ "text",
143
+ "subject",
144
+ "from",
145
+ "to",
146
+ "before",
147
+ "after",
148
+ "hasKeyword",
149
+ "notKeyword",
109
150
  ]);
110
151
 
111
152
  const DEFAULT_MAILBOX_RIGHTS = {
@@ -124,7 +165,9 @@ const textEncoder = new TextEncoder();
124
165
 
125
166
  /** Ensures bytes are backed by a plain ArrayBuffer so they satisfy BodyInit. */
126
167
  const asBodyBytes = (data: Uint8Array): Uint8Array<ArrayBuffer> =>
127
- data.buffer instanceof ArrayBuffer ? (data as Uint8Array<ArrayBuffer>) : new Uint8Array(data);
168
+ data.buffer instanceof ArrayBuffer
169
+ ? (data as Uint8Array<ArrayBuffer>)
170
+ : new Uint8Array(data);
128
171
 
129
172
  /** Error type used inside method dispatch; rendered as a JMAP `error` method response. */
130
173
  class JmapServerMethodError extends Error {
@@ -133,6 +176,14 @@ class JmapServerMethodError extends Error {
133
176
  }
134
177
  }
135
178
 
179
+ class JmapRequestBodyError extends Error {
180
+ constructor(
181
+ public readonly reason: "invalidLength" | "lengthMismatch" | "tooLarge",
182
+ ) {
183
+ super(reason);
184
+ }
185
+ }
186
+
136
187
  /** One live event-source stream (SSE) with its filters and keepalive timer. */
137
188
  interface IEventStreamState {
138
189
  accountId: string;
@@ -165,32 +216,37 @@ const truncateUtf8 = (value: string, maxBytes: number): string => {
165
216
  * flattening one array level. Throws on any unresolvable path.
166
217
  */
167
218
  const evalJsonPointer = (root: any, pointer: string): any => {
168
- if (pointer === '') {
219
+ if (pointer === "") {
169
220
  return root;
170
221
  }
171
- if (!pointer.startsWith('/')) {
222
+ if (!pointer.startsWith("/")) {
172
223
  throw new Error(`Invalid JSON pointer "${pointer}".`);
173
224
  }
174
- const tokens = pointer.slice(1).split('/');
225
+ const tokens = pointer.slice(1).split("/");
175
226
  const evaluate = (value: any, index: number): any => {
176
227
  if (index === tokens.length) {
177
228
  return value;
178
229
  }
179
- const token = tokens[index]!.replaceAll('~1', '/').replaceAll('~0', '~');
230
+ const token = tokens[index]!.replaceAll("~1", "/").replaceAll("~0", "~");
180
231
  if (Array.isArray(value)) {
181
- if (token === '*') {
232
+ if (token === "*") {
182
233
  const results = value.map((item) => evaluate(item, index + 1));
183
234
  return results.some((result) => Array.isArray(result))
184
- ? results.flatMap((result) => (Array.isArray(result) ? result : [result]))
235
+ ? results.flatMap((
236
+ result,
237
+ ) => (Array.isArray(result) ? result : [result]))
185
238
  : results;
186
239
  }
187
240
  const arrayIndex = Number(token);
188
- if (!Number.isInteger(arrayIndex) || arrayIndex < 0 || arrayIndex >= value.length) {
241
+ if (
242
+ !Number.isInteger(arrayIndex) || arrayIndex < 0 ||
243
+ arrayIndex >= value.length
244
+ ) {
189
245
  throw new Error(`JSON pointer index "${token}" is out of range.`);
190
246
  }
191
247
  return evaluate(value[arrayIndex], index + 1);
192
248
  }
193
- if (value !== null && typeof value === 'object' && token in value) {
249
+ if (value !== null && typeof value === "object" && token in value) {
194
250
  return evaluate(value[token], index + 1);
195
251
  }
196
252
  throw new Error(`JSON pointer token "${token}" does not resolve.`);
@@ -217,16 +273,62 @@ const evalJsonPointer = (root: any, pointer: string): any => {
217
273
  export class JmapServer {
218
274
  public readonly backend: IJmapMailBackend;
219
275
  private options: IJmapServerOptions;
220
- private registry: Map<string, { password: string; bearerTokens: Set<string> }> = new Map();
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();
221
282
  private httpServer: plugins.http.Server | null = null;
222
283
  private sockets: Set<plugins.net.Socket> = new Set();
223
284
  private eventStreams: Set<IEventStreamState> = new Set();
224
285
  private changeFeedUnsubscribe: (() => void) | null = null;
286
+ private cleanupErrors: unknown[] = [];
225
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;
226
294
 
227
295
  constructor(options: IJmapServerOptions = {}) {
228
296
  this.options = options;
229
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
+ }
230
332
  }
231
333
 
232
334
  // ======================
@@ -261,25 +363,69 @@ export class JmapServer {
261
363
  /** Starts a node:http server around fetchHandler. Resolves with the bound port (pass 0 for ephemeral). */
262
364
  public start(port: number): Promise<number> {
263
365
  if (this.httpServer) {
264
- throw new Error('JmapServer is already started.');
366
+ throw new Error("JmapServer is already started.");
265
367
  }
368
+ this.stopping = false;
266
369
  const server = plugins.http.createServer((req, res) => {
267
- void this.handleNodeRequest(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;
268
392
  });
269
- server.on('connection', (socket) => {
393
+ const onConnection = (socket: plugins.net.Socket): void => {
270
394
  this.sockets.add(socket);
271
- socket.on('close', () => {
395
+ socket.on("close", () => {
272
396
  this.sockets.delete(socket);
273
397
  });
274
- });
398
+ };
399
+ server.on("connection", onConnection);
275
400
  this.httpServer = server;
276
401
  return new Promise((resolve, reject) => {
277
- server.once('error', reject);
278
- server.listen(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
+ });
279
420
  const address = server.address();
280
- const boundPort = typeof address === 'object' && address ? address.port : port;
421
+ const boundPort = typeof address === "object" && address
422
+ ? address.port
423
+ : port;
281
424
  resolve(boundPort);
282
- });
425
+ };
426
+ server.once("error", onStartupError);
427
+ server.once("listening", onListening);
428
+ server.listen(port);
283
429
  });
284
430
  }
285
431
 
@@ -289,21 +435,37 @@ export class JmapServer {
289
435
  * and closes the node listener when one was started.
290
436
  */
291
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();
292
450
  for (const stream of Array.from(this.eventStreams)) {
293
451
  this.closeStream(stream);
294
452
  }
453
+ for (const controller of this.nodeRequestControllers) {
454
+ if (!controller.signal.aborted) {
455
+ controller.abort(new Error("JMAP server is shutting down."));
456
+ }
457
+ }
295
458
  for (const socket of this.sockets) {
296
459
  socket.destroy();
297
460
  }
298
461
  this.sockets.clear();
299
- const server = this.httpServer;
300
- this.httpServer = null;
301
- if (server) {
302
- await new Promise<void>((resolve) => {
303
- server.close(() => {
304
- resolve();
305
- });
306
- });
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");
307
469
  }
308
470
  }
309
471
 
@@ -319,38 +481,53 @@ export class JmapServer {
319
481
  try {
320
482
  const principal = await this.authenticatePrincipal(request);
321
483
  if (!principal) {
322
- return this.problemResponse(401, 'about:blank', 'Authentication required', {
323
- 'www-authenticate': 'Bearer realm="jmap", Basic realm="jmap"',
324
- });
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
+ );
325
493
  }
326
494
  const account = await this.backend.resolveAccount(principal);
327
495
  if (!account) {
328
- return this.problemResponse(403, 'about:blank', 'No account for this principal');
496
+ await this.cancelRequestBody(request, "account rejected");
497
+ return this.problemResponse(
498
+ 403,
499
+ "about:blank",
500
+ "No account for this principal",
501
+ );
329
502
  }
330
503
 
331
504
  const url = new URL(request.url);
332
505
  const pathname = url.pathname;
333
506
  const method = request.method.toUpperCase();
334
507
 
335
- if (method === 'GET' && (pathname === '/.well-known/jmap' || pathname === '/jmap/session')) {
508
+ if (
509
+ method === "GET" &&
510
+ (pathname === "/.well-known/jmap" || pathname === "/jmap/session")
511
+ ) {
336
512
  return this.handleSession(account, principal);
337
513
  }
338
- if (method === 'POST' && pathname === '/jmap/api') {
514
+ if (method === "POST" && pathname === "/jmap/api") {
339
515
  return this.handleApi(account, request);
340
516
  }
341
- if (method === 'POST' && pathname.startsWith('/jmap/upload/')) {
342
- return this.handleUpload(account, pathname, request);
517
+ if (method === "POST" && pathname.startsWith("/jmap/upload/")) {
518
+ return this.handleUpload(account, principal, pathname, request);
343
519
  }
344
- if (method === 'GET' && pathname.startsWith('/jmap/download/')) {
520
+ if (method === "GET" && pathname.startsWith("/jmap/download/")) {
345
521
  return this.handleDownload(account, pathname, url);
346
522
  }
347
- if (method === 'GET' && pathname === '/jmap/eventsource') {
523
+ if (method === "GET" && pathname === "/jmap/eventsource") {
348
524
  return this.handleEventSource(account, url);
349
525
  }
350
- return this.problemResponse(404, 'about:blank', 'Not found');
526
+ await this.cancelRequestBody(request, "route rejected");
527
+ return this.problemResponse(404, "about:blank", "Not found");
351
528
  } catch (error) {
352
529
  const message = error instanceof Error ? error.message : String(error);
353
- return this.problemResponse(500, 'about:blank', message);
530
+ return this.problemResponse(500, "about:blank", message);
354
531
  }
355
532
  }
356
533
 
@@ -358,21 +535,23 @@ export class JmapServer {
358
535
  // authentication
359
536
  // ======================
360
537
 
361
- private async authenticatePrincipal(request: Request): Promise<TJmapPrincipal | null> {
538
+ private async authenticatePrincipal(
539
+ request: Request,
540
+ ): Promise<TJmapPrincipal | null> {
362
541
  if (this.options.authenticate) {
363
542
  return this.options.authenticate(request);
364
543
  }
365
- const header = request.headers.get('authorization');
544
+ const header = request.headers.get("authorization");
366
545
  if (!header) {
367
546
  return null;
368
547
  }
369
- const spaceIndex = header.indexOf(' ');
548
+ const spaceIndex = header.indexOf(" ");
370
549
  if (spaceIndex === -1) {
371
550
  return null;
372
551
  }
373
552
  const scheme = header.slice(0, spaceIndex).toLowerCase();
374
553
  const value = header.slice(spaceIndex + 1).trim();
375
- if (scheme === 'bearer') {
554
+ if (scheme === "bearer") {
376
555
  for (const [username, entry] of this.registry) {
377
556
  if (entry.bearerTokens.has(value)) {
378
557
  return { username };
@@ -380,15 +559,17 @@ export class JmapServer {
380
559
  }
381
560
  return null;
382
561
  }
383
- if (scheme === 'basic') {
562
+ if (scheme === "basic") {
384
563
  let decoded: string;
385
564
  try {
386
565
  const binary = atob(value);
387
- decoded = new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
566
+ decoded = new TextDecoder().decode(
567
+ Uint8Array.from(binary, (char) => char.charCodeAt(0)),
568
+ );
388
569
  } catch {
389
570
  return null;
390
571
  }
391
- const colonIndex = decoded.indexOf(':');
572
+ const colonIndex = decoded.indexOf(":");
392
573
  if (colonIndex === -1) {
393
574
  return null;
394
575
  }
@@ -407,12 +588,15 @@ export class JmapServer {
407
588
  // session
408
589
  // ======================
409
590
 
410
- private handleSession(account: IJmapAccountInfo, principal: TJmapPrincipal): Response {
591
+ private handleSession(
592
+ account: IJmapAccountInfo,
593
+ principal: TJmapPrincipal,
594
+ ): Response {
411
595
  const session = {
412
596
  capabilities: {
413
597
  [JMAP_CORE_CAPABILITY]: {
414
- ...JMAP_SERVER_LIMITS,
415
- collationAlgorithms: ['i;ascii-casemap'],
598
+ ...this.limits,
599
+ collationAlgorithms: ["i;ascii-casemap"],
416
600
  },
417
601
  [JMAP_MAIL_CAPABILITY]: {},
418
602
  [JMAP_SUBMISSION_CAPABILITY]: {},
@@ -427,8 +611,8 @@ export class JmapServer {
427
611
  maxMailboxesPerEmail: null,
428
612
  maxMailboxDepth: null,
429
613
  maxSizeMailboxName: 490,
430
- maxSizeAttachmentsPerEmail: JMAP_SERVER_LIMITS.maxSizeUpload,
431
- emailQuerySortOptions: ['receivedAt'],
614
+ maxSizeAttachmentsPerEmail: this.limits.maxSizeUpload,
615
+ emailQuerySortOptions: ["receivedAt"],
432
616
  mayCreateTopLevelMailbox: false,
433
617
  },
434
618
  [JMAP_SUBMISSION_CAPABILITY]: {
@@ -444,11 +628,13 @@ export class JmapServer {
444
628
  [JMAP_SUBMISSION_CAPABILITY]: account.accountId,
445
629
  },
446
630
  username: principal.username,
447
- apiUrl: this.buildUrl('/jmap/api'),
448
- downloadUrl: this.buildUrl('/jmap/download/{accountId}/{blobId}/{name}?type={type}'),
449
- uploadUrl: this.buildUrl('/jmap/upload/{accountId}'),
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}"),
450
636
  eventSourceUrl: this.buildUrl(
451
- '/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}'
637
+ "/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}",
452
638
  ),
453
639
  state: SESSION_STATE,
454
640
  };
@@ -457,26 +643,52 @@ export class JmapServer {
457
643
 
458
644
  private buildUrl(path: string): string {
459
645
  const base = this.options.baseUrl;
460
- return base ? `${base.replace(/\/+$/, '')}${path}` : path;
646
+ return base ? `${base.replace(/\/+$/, "")}${path}` : path;
461
647
  }
462
648
 
463
649
  // ======================
464
650
  // api endpoint
465
651
  // ======================
466
652
 
467
- private async handleApi(account: IJmapAccountInfo, request: Request): Promise<Response> {
468
- if (this.activeApiRequests >= JMAP_SERVER_LIMITS.maxConcurrentRequests) {
469
- return this.limitResponse(429, 'maxConcurrentRequests', 'Too many concurrent requests.');
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",
661
+ );
662
+ return this.limitResponse(
663
+ 429,
664
+ "maxConcurrentRequests",
665
+ "Too many concurrent requests.",
666
+ );
470
667
  }
471
668
  this.activeApiRequests++;
472
669
  try {
473
- const bytes = new Uint8Array(await request.arrayBuffer());
474
- if (bytes.length > JMAP_SERVER_LIMITS.maxSizeRequest) {
475
- return this.limitResponse(
476
- 400,
477
- 'maxSizeRequest',
478
- `The request is larger than ${JMAP_SERVER_LIMITS.maxSizeRequest} bytes.`
670
+ let bytes: Uint8Array;
671
+ try {
672
+ bytes = await this.readRequestBodyBounded(
673
+ request,
674
+ this.limits.maxSizeRequest,
479
675
  );
676
+ } catch (error) {
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
+ );
690
+ }
691
+ throw error;
480
692
  }
481
693
  let parsed: any;
482
694
  try {
@@ -484,20 +696,20 @@ export class JmapServer {
484
696
  } catch {
485
697
  return this.problemResponse(
486
698
  400,
487
- 'urn:ietf:params:jmap:error:notJSON',
488
- 'The request body is not valid JSON.'
699
+ "urn:ietf:params:jmap:error:notJSON",
700
+ "The request body is not valid JSON.",
489
701
  );
490
702
  }
491
703
  if (
492
704
  !parsed ||
493
- typeof parsed !== 'object' ||
705
+ typeof parsed !== "object" ||
494
706
  !Array.isArray(parsed.using) ||
495
707
  !Array.isArray(parsed.methodCalls)
496
708
  ) {
497
709
  return this.problemResponse(
498
710
  400,
499
- 'urn:ietf:params:jmap:error:notRequest',
500
- 'The request body is not a valid JMAP request object.'
711
+ "urn:ietf:params:jmap:error:notRequest",
712
+ "The request body is not a valid JMAP request object.",
501
713
  );
502
714
  }
503
715
  const knownCapabilities = new Set([
@@ -510,36 +722,52 @@ export class JmapServer {
510
722
  if (!knownCapabilities.has(capability)) {
511
723
  return this.problemResponse(
512
724
  400,
513
- 'urn:ietf:params:jmap:error:unknownCapability',
514
- `The capability "${capability}" is not supported by this server.`
725
+ "urn:ietf:params:jmap:error:unknownCapability",
726
+ `The capability "${capability}" is not supported by this server.`,
515
727
  );
516
728
  }
517
729
  }
518
- if (parsed.methodCalls.length > JMAP_SERVER_LIMITS.maxCallsInRequest) {
730
+ if (parsed.methodCalls.length > this.limits.maxCallsInRequest) {
519
731
  return this.limitResponse(
520
732
  400,
521
- 'maxCallsInRequest',
522
- `The request has more than ${JMAP_SERVER_LIMITS.maxCallsInRequest} method calls.`
733
+ "maxCallsInRequest",
734
+ `The request has more than ${this.limits.maxCallsInRequest} method calls.`,
523
735
  );
524
736
  }
525
737
 
526
738
  const createdIds = new Map<string, string>(
527
- Object.entries((parsed.createdIds ?? {}) as Record<string, string>)
739
+ Object.entries((parsed.createdIds ?? {}) as Record<string, string>),
528
740
  );
529
741
  const methodResponses: TJmapMethodResponse[] = [];
530
742
  let mutated = false;
531
743
  for (const call of parsed.methodCalls) {
532
- if (!Array.isArray(call) || typeof call[0] !== 'string' || typeof call[2] !== 'string') {
744
+ if (
745
+ !Array.isArray(call) || typeof call[0] !== "string" ||
746
+ typeof call[2] !== "string"
747
+ ) {
533
748
  return this.problemResponse(
534
749
  400,
535
- 'urn:ietf:params:jmap:error:notRequest',
536
- 'Each method call must be a [name, arguments, callId] tuple.'
750
+ "urn:ietf:params:jmap:error:notRequest",
751
+ "Each method call must be a [name, arguments, callId] tuple.",
537
752
  );
538
753
  }
539
- const [methodName, rawArgs, callId] = call as [string, Record<string, any>, string];
754
+ const [methodName, rawArgs, callId] = call as [
755
+ string,
756
+ Record<string, any>,
757
+ string,
758
+ ];
540
759
  try {
541
- const args = this.resolveBackReferences(rawArgs ?? {}, methodResponses);
542
- const results = await this.dispatchMethod(account, methodName, args, using, createdIds);
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
+ );
543
771
  for (const entry of results) {
544
772
  methodResponses.push([entry.name, entry.result, callId]);
545
773
  if (entry.mutated) {
@@ -549,13 +777,18 @@ export class JmapServer {
549
777
  } catch (error) {
550
778
  if (error instanceof JmapServerMethodError) {
551
779
  methodResponses.push([
552
- 'error',
780
+ "error",
553
781
  { type: error.type, description: error.message },
554
782
  callId,
555
783
  ]);
556
784
  } else {
557
- const message = error instanceof Error ? error.message : String(error);
558
- methodResponses.push(['error', { type: 'serverFail', description: message }, callId]);
785
+ const message = error instanceof Error
786
+ ? error.message
787
+ : String(error);
788
+ methodResponses.push(["error", {
789
+ type: "serverFail",
790
+ description: message,
791
+ }, callId]);
559
792
  }
560
793
  }
561
794
  }
@@ -583,48 +816,48 @@ export class JmapServer {
583
816
  */
584
817
  private resolveBackReferences(
585
818
  args: Record<string, any>,
586
- priorResponses: TJmapMethodResponse[]
819
+ priorResponses: TJmapMethodResponse[],
587
820
  ): Record<string, any> {
588
821
  const resolved: Record<string, any> = {};
589
822
  for (const [key, value] of Object.entries(args)) {
590
- if (!key.startsWith('#')) {
823
+ if (!key.startsWith("#")) {
591
824
  resolved[key] = value;
592
825
  continue;
593
826
  }
594
827
  const name = key.slice(1);
595
828
  if (name in args) {
596
829
  throw new JmapServerMethodError(
597
- 'invalidArguments',
598
- `The arguments contain both "${name}" and "#${name}".`
830
+ "invalidArguments",
831
+ `The arguments contain both "${name}" and "#${name}".`,
599
832
  );
600
833
  }
601
834
  if (
602
835
  !value ||
603
- typeof value !== 'object' ||
604
- typeof value.resultOf !== 'string' ||
605
- typeof value.name !== 'string' ||
606
- typeof value.path !== 'string'
836
+ typeof value !== "object" ||
837
+ typeof value.resultOf !== "string" ||
838
+ typeof value.name !== "string" ||
839
+ typeof value.path !== "string"
607
840
  ) {
608
841
  throw new JmapServerMethodError(
609
- 'invalidResultReference',
610
- `"#${name}" is not a valid ResultReference object.`
842
+ "invalidResultReference",
843
+ `"#${name}" is not a valid ResultReference object.`,
611
844
  );
612
845
  }
613
846
  const source = priorResponses.find(
614
- (entry) => entry[2] === value.resultOf && entry[0] === value.name
847
+ (entry) => entry[2] === value.resultOf && entry[0] === value.name,
615
848
  );
616
849
  if (!source) {
617
850
  throw new JmapServerMethodError(
618
- 'invalidResultReference',
619
- `No response found for resultOf "${value.resultOf}" and name "${value.name}".`
851
+ "invalidResultReference",
852
+ `No response found for resultOf "${value.resultOf}" and name "${value.name}".`,
620
853
  );
621
854
  }
622
855
  try {
623
856
  resolved[name] = evalJsonPointer(source[1], value.path);
624
857
  } catch (error) {
625
858
  throw new JmapServerMethodError(
626
- 'invalidResultReference',
627
- error instanceof Error ? error.message : String(error)
859
+ "invalidResultReference",
860
+ error instanceof Error ? error.message : String(error),
628
861
  );
629
862
  }
630
863
  }
@@ -640,80 +873,122 @@ export class JmapServer {
640
873
  methodName: string,
641
874
  args: Record<string, any>,
642
875
  using: Set<string>,
643
- createdIds: Map<string, string>
644
- ): Promise<Array<{ name: string; result: Record<string, any>; mutated?: boolean }>> {
876
+ createdIds: Map<string, string>,
877
+ ): Promise<
878
+ Array<{ name: string; result: Record<string, any>; mutated?: boolean }>
879
+ > {
645
880
  const requiredCapability: Record<string, string> = {
646
- 'Core/echo': JMAP_CORE_CAPABILITY,
647
- 'Mailbox/get': JMAP_MAIL_CAPABILITY,
648
- 'Mailbox/query': JMAP_MAIL_CAPABILITY,
649
- 'Mailbox/changes': JMAP_MAIL_CAPABILITY,
650
- 'Thread/get': JMAP_MAIL_CAPABILITY,
651
- 'Email/get': JMAP_MAIL_CAPABILITY,
652
- 'Email/query': JMAP_MAIL_CAPABILITY,
653
- 'Email/changes': JMAP_MAIL_CAPABILITY,
654
- 'Email/queryChanges': JMAP_MAIL_CAPABILITY,
655
- 'Email/set': JMAP_MAIL_CAPABILITY,
656
- 'Identity/get': JMAP_SUBMISSION_CAPABILITY,
657
- 'EmailSubmission/set': JMAP_SUBMISSION_CAPABILITY,
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,
658
893
  };
659
894
  const capability = requiredCapability[methodName];
660
895
  if (!capability) {
661
- throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
896
+ throw new JmapServerMethodError(
897
+ "unknownMethod",
898
+ `Unknown method "${methodName}".`,
899
+ );
662
900
  }
663
901
  if (!using.has(capability)) {
664
902
  throw new JmapServerMethodError(
665
- 'unknownMethod',
666
- `Method "${methodName}" requires the capability "${capability}" in "using".`
903
+ "unknownMethod",
904
+ `Method "${methodName}" requires the capability "${capability}" in "using".`,
667
905
  );
668
906
  }
669
- if (methodName !== 'Core/echo') {
907
+ if (methodName !== "Core/echo") {
670
908
  this.requireAccountId(account, args);
671
909
  }
672
910
 
673
911
  switch (methodName) {
674
- case 'Core/echo':
912
+ case "Core/echo":
675
913
  return [{ name: methodName, result: args }];
676
- case 'Mailbox/get':
677
- return [{ name: methodName, result: await this.methodMailboxGet(account, args) }];
678
- case 'Mailbox/query':
679
- return [{ name: methodName, result: await this.methodMailboxQuery(account, args) }];
680
- case 'Mailbox/changes':
681
- return [{ name: methodName, result: await this.methodMailboxChanges(account, args) }];
682
- case 'Thread/get':
683
- return [{ name: methodName, result: await this.methodThreadGet(account, args) }];
684
- case 'Email/get':
685
- return [{ name: methodName, result: await this.methodEmailGet(account, args) }];
686
- case 'Email/query':
687
- return [{ name: methodName, result: await this.methodEmailQuery(account, args) }];
688
- case 'Email/changes':
689
- return [{ name: methodName, result: await this.methodEmailChanges(account, args) }];
690
- case 'Email/queryChanges':
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":
691
950
  // Explicitly unsupported in phase 1: clients fall back to Email/query.
692
951
  throw new JmapServerMethodError(
693
- 'cannotCalculateChanges',
694
- 'Email/queryChanges is not supported; re-run the query instead.'
952
+ "cannotCalculateChanges",
953
+ "Email/queryChanges is not supported; re-run the query instead.",
695
954
  );
696
- case 'Email/set': {
955
+ case "Email/set": {
697
956
  const result = await this.methodEmailSet(account, args, createdIds);
698
- return [{ name: methodName, result, mutated: result.newState !== result.oldState }];
699
- }
700
- case 'Identity/get':
701
- return [{ name: methodName, result: await this.methodIdentityGet(account, args) }];
702
- case 'EmailSubmission/set':
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":
703
969
  return this.methodEmailSubmissionSet(account, args, createdIds);
704
970
  default:
705
- throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
971
+ throw new JmapServerMethodError(
972
+ "unknownMethod",
973
+ `Unknown method "${methodName}".`,
974
+ );
706
975
  }
707
976
  }
708
977
 
709
- private requireAccountId(account: IJmapAccountInfo, args: Record<string, any>): void {
710
- if (typeof args.accountId !== 'string') {
711
- throw new JmapServerMethodError('invalidArguments', 'accountId is required.');
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
+ );
712
987
  }
713
988
  if (args.accountId !== account.accountId) {
714
989
  throw new JmapServerMethodError(
715
- 'accountNotFound',
716
- `Account "${args.accountId}" is not accessible with these credentials.`
990
+ "accountNotFound",
991
+ `Account "${args.accountId}" is not accessible with these credentials.`,
717
992
  );
718
993
  }
719
994
  }
@@ -724,22 +999,28 @@ export class JmapServer {
724
999
 
725
1000
  private async methodMailboxGet(
726
1001
  account: IJmapAccountInfo,
727
- args: Record<string, any>
1002
+ args: Record<string, any>,
728
1003
  ): Promise<Record<string, any>> {
729
1004
  let ids: string[] | null = null;
730
1005
  if (args.ids !== null && args.ids !== undefined) {
731
1006
  if (!Array.isArray(args.ids)) {
732
- throw new JmapServerMethodError('invalidArguments', 'ids must be an array of ids or null.');
1007
+ throw new JmapServerMethodError(
1008
+ "invalidArguments",
1009
+ "ids must be an array of ids or null.",
1010
+ );
733
1011
  }
734
- if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
1012
+ if (args.ids.length > this.limits.maxObjectsInGet) {
735
1013
  throw new JmapServerMethodError(
736
- 'requestTooLarge',
737
- `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
1014
+ "requestTooLarge",
1015
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
738
1016
  );
739
1017
  }
740
1018
  ids = args.ids as string[];
741
1019
  }
742
- const properties = this.validateProperties(args.properties, KNOWN_MAILBOX_PROPERTIES);
1020
+ const properties = this.validateProperties(
1021
+ args.properties,
1022
+ KNOWN_MAILBOX_PROPERTIES,
1023
+ );
743
1024
  const result = await this.backend.getMailboxes(account.accountId, ids);
744
1025
  const list = result.list.map((mailbox) => {
745
1026
  const full: Record<string, any> = {
@@ -759,48 +1040,50 @@ export class JmapServer {
759
1040
 
760
1041
  private async methodMailboxQuery(
761
1042
  account: IJmapAccountInfo,
762
- args: Record<string, any>
1043
+ args: Record<string, any>,
763
1044
  ): Promise<Record<string, any>> {
764
1045
  const filter = (args.filter ?? {}) as Record<string, any>;
765
- if ('operator' in filter) {
1046
+ if ("operator" in filter) {
766
1047
  throw new JmapServerMethodError(
767
- 'unsupportedFilter',
768
- 'FilterOperator is not supported for Mailbox/query.'
1048
+ "unsupportedFilter",
1049
+ "FilterOperator is not supported for Mailbox/query.",
769
1050
  );
770
1051
  }
771
1052
  for (const key of Object.keys(filter)) {
772
- if (!['role', 'name', 'parentId'].includes(key)) {
1053
+ if (!["role", "name", "parentId"].includes(key)) {
773
1054
  throw new JmapServerMethodError(
774
- 'unsupportedFilter',
775
- `Mailbox filter condition "${key}" is not supported.`
1055
+ "unsupportedFilter",
1056
+ `Mailbox filter condition "${key}" is not supported.`,
776
1057
  );
777
1058
  }
778
1059
  }
779
1060
  for (const comparator of (args.sort ?? []) as Array<Record<string, any>>) {
780
- if (!['name', 'sortOrder'].includes(comparator.property)) {
1061
+ if (!["name", "sortOrder"].includes(comparator.property)) {
781
1062
  throw new JmapServerMethodError(
782
- 'unsupportedSort',
783
- `Mailbox sort property "${comparator.property}" is not supported.`
1063
+ "unsupportedSort",
1064
+ `Mailbox sort property "${comparator.property}" is not supported.`,
784
1065
  );
785
1066
  }
786
1067
  }
787
1068
  const result = await this.backend.getMailboxes(account.accountId, null);
788
1069
  let mailboxes = result.list;
789
- if (typeof filter.role === 'string') {
1070
+ if (typeof filter.role === "string") {
790
1071
  mailboxes = mailboxes.filter((mailbox) => mailbox.role === filter.role);
791
1072
  }
792
- if (typeof filter.name === 'string') {
1073
+ if (typeof filter.name === "string") {
793
1074
  mailboxes = mailboxes.filter((mailbox) => mailbox.name === filter.name);
794
1075
  }
795
- if ('parentId' in filter) {
796
- mailboxes = mailboxes.filter((mailbox) => mailbox.parentId === filter.parentId);
1076
+ if ("parentId" in filter) {
1077
+ mailboxes = mailboxes.filter((mailbox) =>
1078
+ mailbox.parentId === filter.parentId
1079
+ );
797
1080
  }
798
1081
  const comparators = (args.sort ?? []) as Array<Record<string, any>>;
799
1082
  if (comparators.length) {
800
1083
  mailboxes = [...mailboxes].sort((a, b) => {
801
1084
  for (const comparator of comparators) {
802
1085
  const direction = comparator.isAscending === false ? -1 : 1;
803
- const property = comparator.property as 'name' | 'sortOrder';
1086
+ const property = comparator.property as "name" | "sortOrder";
804
1087
  const left = a[property] ?? 0;
805
1088
  const right = b[property] ?? 0;
806
1089
  if (left < right) {
@@ -815,7 +1098,7 @@ export class JmapServer {
815
1098
  }
816
1099
  const position = this.validatePosition(args);
817
1100
  let ids = mailboxes.map((mailbox) => mailbox.id).slice(position);
818
- if (typeof args.limit === 'number') {
1101
+ if (typeof args.limit === "number") {
819
1102
  ids = ids.slice(0, args.limit);
820
1103
  }
821
1104
  const response: Record<string, any> = {
@@ -833,14 +1116,18 @@ export class JmapServer {
833
1116
 
834
1117
  private async methodMailboxChanges(
835
1118
  account: IJmapAccountInfo,
836
- args: Record<string, any>
1119
+ args: Record<string, any>,
837
1120
  ): Promise<Record<string, any>> {
838
1121
  const { sinceState, maxChanges } = this.validateChangesArgs(args);
839
- const changes = await this.backend.getMailboxChanges(account.accountId, sinceState, maxChanges);
1122
+ const changes = await this.backend.getMailboxChanges(
1123
+ account.accountId,
1124
+ sinceState,
1125
+ maxChanges,
1126
+ );
840
1127
  if (!changes) {
841
1128
  throw new JmapServerMethodError(
842
- 'cannotCalculateChanges',
843
- `Cannot calculate mailbox changes since state "${sinceState}".`
1129
+ "cannotCalculateChanges",
1130
+ `Cannot calculate mailbox changes since state "${sinceState}".`,
844
1131
  );
845
1132
  }
846
1133
  return { accountId: account.accountId, ...changes };
@@ -852,15 +1139,18 @@ export class JmapServer {
852
1139
 
853
1140
  private async methodThreadGet(
854
1141
  account: IJmapAccountInfo,
855
- args: Record<string, any>
1142
+ args: Record<string, any>,
856
1143
  ): Promise<Record<string, any>> {
857
1144
  if (!Array.isArray(args.ids)) {
858
- throw new JmapServerMethodError('invalidArguments', 'Thread/get requires an ids array.');
1145
+ throw new JmapServerMethodError(
1146
+ "invalidArguments",
1147
+ "Thread/get requires an ids array.",
1148
+ );
859
1149
  }
860
- if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
1150
+ if (args.ids.length > this.limits.maxObjectsInGet) {
861
1151
  throw new JmapServerMethodError(
862
- 'requestTooLarge',
863
- `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
1152
+ "requestTooLarge",
1153
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
864
1154
  );
865
1155
  }
866
1156
  // Result-reference chains like Email/get -> /list/*/threadId commonly
@@ -881,30 +1171,40 @@ export class JmapServer {
881
1171
 
882
1172
  private async methodEmailGet(
883
1173
  account: IJmapAccountInfo,
884
- args: Record<string, any>
1174
+ args: Record<string, any>,
885
1175
  ): Promise<Record<string, any>> {
886
1176
  if (args.ids === null || args.ids === undefined) {
887
1177
  // RFC 8621 allows rejecting "fetch all" for the potentially huge Email type.
888
1178
  throw new JmapServerMethodError(
889
- 'requestTooLarge',
890
- 'Email/get requires an explicit ids array on this server.'
1179
+ "requestTooLarge",
1180
+ "Email/get requires an explicit ids array on this server.",
891
1181
  );
892
1182
  }
893
1183
  if (!Array.isArray(args.ids)) {
894
- throw new JmapServerMethodError('invalidArguments', 'Email/get requires an ids array.');
1184
+ throw new JmapServerMethodError(
1185
+ "invalidArguments",
1186
+ "Email/get requires an ids array.",
1187
+ );
895
1188
  }
896
- if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
1189
+ if (args.ids.length > this.limits.maxObjectsInGet) {
897
1190
  throw new JmapServerMethodError(
898
- 'requestTooLarge',
899
- `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
1191
+ "requestTooLarge",
1192
+ `ids exceeds maxObjectsInGet (${this.limits.maxObjectsInGet}).`,
900
1193
  );
901
1194
  }
902
- const properties = this.validateProperties(args.properties, KNOWN_EMAIL_PROPERTIES);
903
- const result = await this.backend.getEmails(account.accountId, args.ids as string[], properties);
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
+ );
904
1204
  const effectiveProperties = properties ?? DEFAULT_EMAIL_PROPERTIES;
905
1205
  const list = result.list.map((record) => {
906
1206
  const projected = this.projectObject(record as any, effectiveProperties);
907
- if (effectiveProperties.includes('bodyValues')) {
1207
+ if (effectiveProperties.includes("bodyValues")) {
908
1208
  projected.bodyValues = this.buildBodyValues(record, args);
909
1209
  }
910
1210
  return projected;
@@ -920,7 +1220,7 @@ export class JmapServer {
920
1220
  /** Applies the Email/get body-value fetch flags and maxBodyValueBytes truncation. */
921
1221
  private buildBodyValues(
922
1222
  record: IJmapEmailRecord,
923
- args: Record<string, any>
1223
+ args: Record<string, any>,
924
1224
  ): Record<string, IJmapEmailBodyValue> {
925
1225
  const wanted = new Set<string>();
926
1226
  if (args.fetchAllBodyValues === true) {
@@ -943,7 +1243,7 @@ export class JmapServer {
943
1243
  }
944
1244
  }
945
1245
  const maxBytes =
946
- typeof args.maxBodyValueBytes === 'number' && args.maxBodyValueBytes > 0
1246
+ typeof args.maxBodyValueBytes === "number" && args.maxBodyValueBytes > 0
947
1247
  ? args.maxBodyValueBytes
948
1248
  : null;
949
1249
  const result: Record<string, IJmapEmailBodyValue> = {};
@@ -968,41 +1268,44 @@ export class JmapServer {
968
1268
 
969
1269
  private async methodEmailQuery(
970
1270
  account: IJmapAccountInfo,
971
- args: Record<string, any>
1271
+ args: Record<string, any>,
972
1272
  ): Promise<Record<string, any>> {
973
1273
  const filter = (args.filter ?? {}) as Record<string, any>;
974
- if ('operator' in filter) {
1274
+ if ("operator" in filter) {
975
1275
  throw new JmapServerMethodError(
976
- 'unsupportedFilter',
977
- 'FilterOperator is not supported; use a flat FilterCondition.'
1276
+ "unsupportedFilter",
1277
+ "FilterOperator is not supported; use a flat FilterCondition.",
978
1278
  );
979
1279
  }
980
1280
  for (const key of Object.keys(filter)) {
981
1281
  if (!SUPPORTED_EMAIL_FILTER_KEYS.has(key)) {
982
1282
  throw new JmapServerMethodError(
983
- 'unsupportedFilter',
984
- `Email filter condition "${key}" is not supported.`
1283
+ "unsupportedFilter",
1284
+ `Email filter condition "${key}" is not supported.`,
985
1285
  );
986
1286
  }
987
1287
  }
988
1288
  const sort = (args.sort ?? []) as Array<Record<string, any>>;
989
1289
  for (const comparator of sort) {
990
- if (comparator.property !== 'receivedAt') {
1290
+ if (comparator.property !== "receivedAt") {
991
1291
  throw new JmapServerMethodError(
992
- 'unsupportedSort',
993
- `Email sort property "${comparator.property}" is not supported.`
1292
+ "unsupportedSort",
1293
+ `Email sort property "${comparator.property}" is not supported.`,
994
1294
  );
995
1295
  }
996
1296
  }
997
1297
  if (args.anchor !== undefined && args.anchor !== null) {
998
- throw new JmapServerMethodError('invalidArguments', 'anchor is not supported.');
1298
+ throw new JmapServerMethodError(
1299
+ "invalidArguments",
1300
+ "anchor is not supported.",
1301
+ );
999
1302
  }
1000
1303
  const position = this.validatePosition(args);
1001
1304
  const result = await this.backend.queryEmails(account.accountId, {
1002
1305
  filter: filter as IJmapEmailFilter,
1003
1306
  sort: sort as Array<{ property: string; isAscending?: boolean }>,
1004
1307
  position,
1005
- limit: typeof args.limit === 'number' ? args.limit : null,
1308
+ limit: typeof args.limit === "number" ? args.limit : null,
1006
1309
  calculateTotal: args.calculateTotal === true,
1007
1310
  });
1008
1311
  const response: Record<string, any> = {
@@ -1020,14 +1323,18 @@ export class JmapServer {
1020
1323
 
1021
1324
  private async methodEmailChanges(
1022
1325
  account: IJmapAccountInfo,
1023
- args: Record<string, any>
1326
+ args: Record<string, any>,
1024
1327
  ): Promise<Record<string, any>> {
1025
1328
  const { sinceState, maxChanges } = this.validateChangesArgs(args);
1026
- const changes = await this.backend.getEmailChanges(account.accountId, sinceState, maxChanges);
1329
+ const changes = await this.backend.getEmailChanges(
1330
+ account.accountId,
1331
+ sinceState,
1332
+ maxChanges,
1333
+ );
1027
1334
  if (!changes) {
1028
1335
  throw new JmapServerMethodError(
1029
- 'cannotCalculateChanges',
1030
- `Cannot calculate changes since state "${sinceState}".`
1336
+ "cannotCalculateChanges",
1337
+ `Cannot calculate changes since state "${sinceState}".`,
1031
1338
  );
1032
1339
  }
1033
1340
  return { accountId: account.accountId, ...changes };
@@ -1036,26 +1343,30 @@ export class JmapServer {
1036
1343
  private async methodEmailSet(
1037
1344
  account: IJmapAccountInfo,
1038
1345
  args: Record<string, any>,
1039
- createdIds: Map<string, string>
1346
+ createdIds: Map<string, string>,
1040
1347
  ): Promise<Record<string, any>> {
1041
- const createEntries = Object.entries((args.create ?? {}) as Record<string, any>);
1042
- const updateEntries = Object.entries((args.update ?? {}) as 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
+ );
1043
1354
  const destroyIds = (args.destroy ?? []) as string[];
1044
1355
  if (
1045
1356
  createEntries.length + updateEntries.length + destroyIds.length >
1046
- JMAP_SERVER_LIMITS.maxObjectsInSet
1357
+ this.limits.maxObjectsInSet
1047
1358
  ) {
1048
1359
  throw new JmapServerMethodError(
1049
- 'requestTooLarge',
1050
- `The set request exceeds maxObjectsInSet (${JMAP_SERVER_LIMITS.maxObjectsInSet}).`
1360
+ "requestTooLarge",
1361
+ `The set request exceeds maxObjectsInSet (${this.limits.maxObjectsInSet}).`,
1051
1362
  );
1052
1363
  }
1053
- if (typeof args.ifInState === 'string') {
1364
+ if (typeof args.ifInState === "string") {
1054
1365
  const current = await this.backend.getEmails(account.accountId, []);
1055
1366
  if (current.state !== args.ifInState) {
1056
1367
  throw new JmapServerMethodError(
1057
- 'stateMismatch',
1058
- `ifInState "${args.ifInState}" does not match the current state "${current.state}".`
1368
+ "stateMismatch",
1369
+ `ifInState "${args.ifInState}" does not match the current state "${current.state}".`,
1059
1370
  );
1060
1371
  }
1061
1372
  }
@@ -1073,12 +1384,13 @@ export class JmapServer {
1073
1384
  backendRequest.update = {};
1074
1385
  for (const [id, patch] of updateEntries) {
1075
1386
  try {
1076
- backendRequest.update[id] = this.parseEmailUpdatePatch(patch as Record<string, any>);
1387
+ backendRequest.update[id] = this.parseEmailUpdatePatch(
1388
+ patch as Record<string, any>,
1389
+ );
1077
1390
  } catch (error) {
1078
- notUpdatedEarly[id] =
1079
- error instanceof JmapServerMethodError
1080
- ? { type: error.type, description: error.message }
1081
- : { type: 'invalidPatch', description: String(error) };
1391
+ notUpdatedEarly[id] = error instanceof JmapServerMethodError
1392
+ ? { type: error.type, description: error.message }
1393
+ : { type: "invalidPatch", description: String(error) };
1082
1394
  }
1083
1395
  }
1084
1396
  }
@@ -1086,7 +1398,10 @@ export class JmapServer {
1086
1398
  backendRequest.destroy = destroyIds;
1087
1399
  }
1088
1400
 
1089
- const result = await this.backend.setEmails(account.accountId, backendRequest);
1401
+ const result = await this.backend.setEmails(
1402
+ account.accountId,
1403
+ backendRequest,
1404
+ );
1090
1405
 
1091
1406
  const created: Record<string, any> = {};
1092
1407
  for (const [creationId, record] of Object.entries(result.created)) {
@@ -1123,18 +1438,28 @@ export class JmapServer {
1123
1438
  */
1124
1439
  private parseEmailUpdatePatch(patch: Record<string, any>): IJmapEmailUpdate {
1125
1440
  const update: IJmapEmailUpdate = {};
1126
- const unescape = (token: string): string => token.replaceAll('~1', '/').replaceAll('~0', '~');
1441
+ const unescape = (token: string): string =>
1442
+ token.replaceAll("~1", "/").replaceAll("~0", "~");
1127
1443
  for (const [path, value] of Object.entries(patch)) {
1128
- if (path === 'keywords' || path === 'mailboxIds') {
1129
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1130
- throw new JmapServerMethodError('invalidProperties', `"${path}" must be an object.`);
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
+ );
1131
1452
  }
1132
1453
  const normalized: Record<string, boolean> = {};
1133
- for (const [key, entryValue] of Object.entries(value as Record<string, any>)) {
1454
+ for (
1455
+ const [key, entryValue] of Object.entries(
1456
+ value as Record<string, any>,
1457
+ )
1458
+ ) {
1134
1459
  if (entryValue !== true) {
1135
1460
  throw new JmapServerMethodError(
1136
- 'invalidProperties',
1137
- `Values in "${path}" must be true; remove entries instead of setting them false.`
1461
+ "invalidProperties",
1462
+ `Values in "${path}" must be true; remove entries instead of setting them false.`,
1138
1463
  );
1139
1464
  }
1140
1465
  normalized[key] = true;
@@ -1142,27 +1467,37 @@ export class JmapServer {
1142
1467
  update[path] = normalized;
1143
1468
  continue;
1144
1469
  }
1145
- const slashIndex = path.indexOf('/');
1470
+ const slashIndex = path.indexOf("/");
1146
1471
  if (slashIndex === -1) {
1147
1472
  throw new JmapServerMethodError(
1148
- 'invalidProperties',
1149
- `Email property "${path}" is immutable or unknown.`
1473
+ "invalidProperties",
1474
+ `Email property "${path}" is immutable or unknown.`,
1150
1475
  );
1151
1476
  }
1152
1477
  const property = path.slice(0, slashIndex);
1153
1478
  const rest = path.slice(slashIndex + 1);
1154
- if ((property !== 'keywords' && property !== 'mailboxIds') || rest.includes('/') || !rest) {
1155
- throw new JmapServerMethodError('invalidPatch', `Unsupported patch path "${path}".`);
1479
+ if (
1480
+ (property !== "keywords" && property !== "mailboxIds") ||
1481
+ rest.includes("/") || !rest
1482
+ ) {
1483
+ throw new JmapServerMethodError(
1484
+ "invalidPatch",
1485
+ `Unsupported patch path "${path}".`,
1486
+ );
1156
1487
  }
1157
1488
  const key = unescape(rest);
1158
- const normalizedValue = value === true ? true : value === null || value === false ? null : undefined;
1489
+ const normalizedValue = value === true
1490
+ ? true
1491
+ : value === null || value === false
1492
+ ? null
1493
+ : undefined;
1159
1494
  if (normalizedValue === undefined) {
1160
1495
  throw new JmapServerMethodError(
1161
- 'invalidPatch',
1162
- `Patch value for "${path}" must be true or null.`
1496
+ "invalidPatch",
1497
+ `Patch value for "${path}" must be true or null.`,
1163
1498
  );
1164
1499
  }
1165
- if (property === 'keywords') {
1500
+ if (property === "keywords") {
1166
1501
  update.keywordsPatch = update.keywordsPatch ?? {};
1167
1502
  update.keywordsPatch[key] = normalizedValue;
1168
1503
  } else {
@@ -1172,14 +1507,14 @@ export class JmapServer {
1172
1507
  }
1173
1508
  if (update.keywords && update.keywordsPatch) {
1174
1509
  throw new JmapServerMethodError(
1175
- 'invalidPatch',
1176
- 'A patch must not set "keywords" and patch "keywords/..." at the same time.'
1510
+ "invalidPatch",
1511
+ 'A patch must not set "keywords" and patch "keywords/..." at the same time.',
1177
1512
  );
1178
1513
  }
1179
1514
  if (update.mailboxIds && update.mailboxIdsPatch) {
1180
1515
  throw new JmapServerMethodError(
1181
- 'invalidPatch',
1182
- 'A patch must not set "mailboxIds" and patch "mailboxIds/..." at the same time.'
1516
+ "invalidPatch",
1517
+ 'A patch must not set "mailboxIds" and patch "mailboxIds/..." at the same time.',
1183
1518
  );
1184
1519
  }
1185
1520
  return update;
@@ -1191,7 +1526,7 @@ export class JmapServer {
1191
1526
 
1192
1527
  private async methodIdentityGet(
1193
1528
  account: IJmapAccountInfo,
1194
- args: Record<string, any>
1529
+ args: Record<string, any>,
1195
1530
  ): Promise<Record<string, any>> {
1196
1531
  const result = await this.backend.getIdentities(account.accountId);
1197
1532
  let list = result.list;
@@ -1205,67 +1540,90 @@ export class JmapServer {
1205
1540
  }
1206
1541
  }
1207
1542
  }
1208
- return { accountId: account.accountId, state: result.state, list, notFound };
1543
+ return {
1544
+ accountId: account.accountId,
1545
+ state: result.state,
1546
+ list,
1547
+ notFound,
1548
+ };
1209
1549
  }
1210
1550
 
1211
1551
  private async methodEmailSubmissionSet(
1212
1552
  account: IJmapAccountInfo,
1213
1553
  args: Record<string, any>,
1214
- createdIds: Map<string, string>
1215
- ): Promise<Array<{ name: string; result: Record<string, any>; mutated?: boolean }>> {
1554
+ createdIds: Map<string, string>,
1555
+ ): Promise<
1556
+ Array<{ name: string; result: Record<string, any>; mutated?: boolean }>
1557
+ > {
1216
1558
  const created: Record<string, any> = {};
1217
1559
  const notCreated: Record<string, IJmapSetError> = {};
1218
1560
  const notUpdated: Record<string, IJmapSetError> = {};
1219
1561
  const notDestroyed: Record<string, IJmapSetError> = {};
1220
1562
  /** creationId -> { submissionId, emailId } for onSuccess* resolution. */
1221
- const createdSubmissions = new Map<string, { submissionId: string; emailId: string }>();
1563
+ const createdSubmissions = new Map<
1564
+ string,
1565
+ { submissionId: string; emailId: string }
1566
+ >();
1222
1567
 
1223
- const identitiesResult = await this.backend.getIdentities(account.accountId);
1568
+ const identitiesResult = await this.backend.getIdentities(
1569
+ account.accountId,
1570
+ );
1224
1571
 
1225
- for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
1572
+ for (
1573
+ const [creationId, spec] of Object.entries(
1574
+ (args.create ?? {}) as Record<string, any>,
1575
+ )
1576
+ ) {
1226
1577
  let emailId: string = spec.emailId;
1227
- if (typeof emailId === 'string' && emailId.startsWith('#')) {
1578
+ if (typeof emailId === "string" && emailId.startsWith("#")) {
1228
1579
  const resolved = createdIds.get(emailId.slice(1));
1229
1580
  if (!resolved) {
1230
1581
  notCreated[creationId] = {
1231
- type: 'invalidProperties',
1582
+ type: "invalidProperties",
1232
1583
  description: `Unknown creation id reference "${emailId}".`,
1233
1584
  };
1234
1585
  continue;
1235
1586
  }
1236
1587
  emailId = resolved;
1237
1588
  }
1238
- const emailResult = await this.backend.getEmails(account.accountId, [emailId]);
1589
+ const emailResult = await this.backend.getEmails(account.accountId, [
1590
+ emailId,
1591
+ ]);
1239
1592
  const email = emailResult.list[0];
1240
1593
  if (!email) {
1241
1594
  notCreated[creationId] = {
1242
- type: 'invalidProperties',
1595
+ type: "invalidProperties",
1243
1596
  description: `Email "${emailId}" does not exist.`,
1244
1597
  };
1245
1598
  continue;
1246
1599
  }
1247
1600
  const identity = identitiesResult.list.find(
1248
- (candidate) => candidate.id === spec.identityId
1601
+ (candidate) => candidate.id === spec.identityId,
1249
1602
  );
1250
1603
  if (!identity) {
1251
1604
  notCreated[creationId] = {
1252
- type: 'invalidProperties',
1605
+ type: "invalidProperties",
1253
1606
  description: `Identity "${spec.identityId}" does not exist.`,
1254
1607
  };
1255
1608
  continue;
1256
1609
  }
1257
- const envelope = this.resolveEnvelope(spec.envelope, identity.email, email);
1610
+ const envelope = this.resolveEnvelope(
1611
+ spec.envelope,
1612
+ identity.email,
1613
+ email,
1614
+ );
1258
1615
  if (!envelope.rcptTo.length) {
1259
1616
  notCreated[creationId] = {
1260
- type: 'noRecipients',
1261
- description: 'The email has no to/cc/bcc recipients and no envelope was given.',
1617
+ type: "noRecipients",
1618
+ description:
1619
+ "The email has no to/cc/bcc recipients and no envelope was given.",
1262
1620
  };
1263
1621
  continue;
1264
1622
  }
1265
1623
  const blob = await this.backend.getBlob(account.accountId, email.blobId);
1266
1624
  if (!blob) {
1267
1625
  notCreated[creationId] = {
1268
- type: 'invalidProperties',
1626
+ type: "invalidProperties",
1269
1627
  description: `Raw message blob "${email.blobId}" does not exist.`,
1270
1628
  };
1271
1629
  continue;
@@ -1277,7 +1635,10 @@ export class JmapServer {
1277
1635
  envelope,
1278
1636
  message: blob.data,
1279
1637
  });
1280
- createdSubmissions.set(creationId, { submissionId: submission.id, emailId });
1638
+ createdSubmissions.set(creationId, {
1639
+ submissionId: submission.id,
1640
+ emailId,
1641
+ });
1281
1642
  created[creationId] = {
1282
1643
  id: submission.id,
1283
1644
  undoStatus: submission.undoStatus,
@@ -1285,7 +1646,7 @@ export class JmapServer {
1285
1646
  };
1286
1647
  } catch (error) {
1287
1648
  notCreated[creationId] = {
1288
- type: 'forbiddenToSend',
1649
+ type: "forbiddenToSend",
1289
1650
  description: error instanceof Error ? error.message : String(error),
1290
1651
  };
1291
1652
  }
@@ -1293,24 +1654,26 @@ export class JmapServer {
1293
1654
 
1294
1655
  for (const id of Object.keys((args.update ?? {}) as Record<string, any>)) {
1295
1656
  notUpdated[id] = {
1296
- type: 'forbidden',
1297
- description: 'EmailSubmission updates are not supported.',
1657
+ type: "forbidden",
1658
+ description: "EmailSubmission updates are not supported.",
1298
1659
  };
1299
1660
  }
1300
1661
  for (const id of (args.destroy ?? []) as string[]) {
1301
1662
  notDestroyed[id] = {
1302
- type: 'forbidden',
1303
- description: 'EmailSubmission destroys are not supported.',
1663
+ type: "forbidden",
1664
+ description: "EmailSubmission destroys are not supported.",
1304
1665
  };
1305
1666
  }
1306
1667
 
1307
- const responses: Array<{ name: string; result: Record<string, any>; mutated?: boolean }> = [
1668
+ const responses: Array<
1669
+ { name: string; result: Record<string, any>; mutated?: boolean }
1670
+ > = [
1308
1671
  {
1309
- name: 'EmailSubmission/set',
1672
+ name: "EmailSubmission/set",
1310
1673
  result: {
1311
1674
  accountId: account.accountId,
1312
1675
  oldState: null,
1313
- newState: '0',
1676
+ newState: "0",
1314
1677
  created,
1315
1678
  notCreated,
1316
1679
  updated: {},
@@ -1325,8 +1688,10 @@ export class JmapServer {
1325
1688
  // onSuccessUpdateEmail / onSuccessDestroyEmail (RFC 8621 §7.5): apply an
1326
1689
  // implicit Email/set for successfully created submissions and append its
1327
1690
  // response after the EmailSubmission/set response.
1328
- const resolveSubmissionRef = (reference: string): { submissionId: string; emailId: string } | null => {
1329
- if (reference.startsWith('#')) {
1691
+ const resolveSubmissionRef = (
1692
+ reference: string,
1693
+ ): { submissionId: string; emailId: string } | null => {
1694
+ if (reference.startsWith("#")) {
1330
1695
  return createdSubmissions.get(reference.slice(1)) ?? null;
1331
1696
  }
1332
1697
  for (const entry of createdSubmissions.values()) {
@@ -1338,11 +1703,15 @@ export class JmapServer {
1338
1703
  };
1339
1704
  const implicitSet: Record<string, any> = { accountId: account.accountId };
1340
1705
  let hasImplicitSet = false;
1341
- if (args.onSuccessUpdateEmail && typeof args.onSuccessUpdateEmail === 'object') {
1706
+ if (
1707
+ args.onSuccessUpdateEmail && typeof args.onSuccessUpdateEmail === "object"
1708
+ ) {
1342
1709
  const update: Record<string, any> = {};
1343
- for (const [reference, patch] of Object.entries(
1344
- args.onSuccessUpdateEmail as Record<string, any>
1345
- )) {
1710
+ for (
1711
+ const [reference, patch] of Object.entries(
1712
+ args.onSuccessUpdateEmail as Record<string, any>,
1713
+ )
1714
+ ) {
1346
1715
  const entry = resolveSubmissionRef(reference);
1347
1716
  if (entry) {
1348
1717
  update[entry.emailId] = patch;
@@ -1363,9 +1732,13 @@ export class JmapServer {
1363
1732
  implicitSet.destroy = destroy;
1364
1733
  }
1365
1734
  if (hasImplicitSet) {
1366
- const result = await this.methodEmailSet(account, implicitSet, createdIds);
1735
+ const result = await this.methodEmailSet(
1736
+ account,
1737
+ implicitSet,
1738
+ createdIds,
1739
+ );
1367
1740
  responses.push({
1368
- name: 'Email/set',
1741
+ name: "Email/set",
1369
1742
  result,
1370
1743
  mutated: result.newState !== result.oldState,
1371
1744
  });
@@ -1377,18 +1750,18 @@ export class JmapServer {
1377
1750
  private resolveEnvelope(
1378
1751
  specEnvelope: any,
1379
1752
  identityEmail: string,
1380
- email: IJmapEmailRecord
1753
+ email: IJmapEmailRecord,
1381
1754
  ): IJmapEnvelope {
1382
1755
  if (
1383
1756
  specEnvelope &&
1384
- typeof specEnvelope === 'object' &&
1757
+ typeof specEnvelope === "object" &&
1385
1758
  specEnvelope.mailFrom?.email &&
1386
1759
  Array.isArray(specEnvelope.rcptTo)
1387
1760
  ) {
1388
1761
  return {
1389
1762
  mailFrom: { email: String(specEnvelope.mailFrom.email) },
1390
1763
  rcptTo: specEnvelope.rcptTo
1391
- .filter((recipient: any) => typeof recipient?.email === 'string')
1764
+ .filter((recipient: any) => typeof recipient?.email === "string")
1392
1765
  .map((recipient: any) => ({ email: recipient.email })),
1393
1766
  };
1394
1767
  }
@@ -1408,53 +1781,409 @@ export class JmapServer {
1408
1781
  // upload + download
1409
1782
  // ======================
1410
1783
 
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.
1794
+ }
1795
+ }
1796
+
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();
1817
+ }
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
+
1926
+ return {
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
+
1411
2078
  private async handleUpload(
1412
2079
  account: IJmapAccountInfo,
2080
+ principal: TJmapPrincipal,
1413
2081
  pathname: string,
1414
- request: Request
2082
+ request: Request,
1415
2083
  ): Promise<Response> {
1416
- const segments = pathname.split('/').filter((segment) => segment.length > 0);
1417
- const accountId = decodeURIComponent(segments[2] ?? '');
2084
+ const segments = pathname.split("/").filter((segment) =>
2085
+ segment.length > 0
2086
+ );
2087
+ const accountId = decodeURIComponent(segments[2] ?? "");
1418
2088
  if (accountId !== account.accountId) {
1419
- return this.problemResponse(404, 'about:blank', 'Unknown account');
2089
+ await this.cancelRequestBody(request, "unknown account");
2090
+ return this.problemResponse(404, "about:blank", "Unknown account");
1420
2091
  }
1421
- const data = new Uint8Array(await request.arrayBuffer());
1422
- if (data.length > JMAP_SERVER_LIMITS.maxSizeUpload) {
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");
1423
2100
  return this.limitResponse(
1424
- 400,
1425
- 'maxSizeUpload',
1426
- `The upload is larger than ${JMAP_SERVER_LIMITS.maxSizeUpload} bytes.`
2101
+ 429,
2102
+ "maxConcurrentUpload",
2103
+ "Too many concurrent uploads.",
1427
2104
  );
1428
2105
  }
1429
- const type = request.headers.get('content-type') ?? 'application/octet-stream';
1430
- const { blobId, size } = await this.backend.uploadBlob(account.accountId, data, type);
1431
- return this.jsonResponse(200, { accountId: account.accountId, blobId, type, size });
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
+ }
1432
2157
  }
1433
2158
 
1434
2159
  private async handleDownload(
1435
2160
  account: IJmapAccountInfo,
1436
2161
  pathname: string,
1437
- url: URL
2162
+ url: URL,
1438
2163
  ): Promise<Response> {
1439
2164
  // /jmap/download/{accountId}/{blobId}/{name}
1440
- const segments = pathname.split('/').filter((segment) => segment.length > 0);
1441
- const accountId = decodeURIComponent(segments[2] ?? '');
1442
- const blobId = decodeURIComponent(segments[3] ?? '');
1443
- const name = decodeURIComponent(segments[4] ?? 'blob');
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");
1444
2171
  if (accountId !== account.accountId) {
1445
- return this.problemResponse(404, 'about:blank', 'Unknown account');
2172
+ return this.problemResponse(404, "about:blank", "Unknown account");
1446
2173
  }
1447
2174
  const blob = await this.backend.getBlob(account.accountId, blobId);
1448
2175
  if (!blob) {
1449
- return this.problemResponse(404, 'about:blank', 'Unknown blob');
2176
+ return this.problemResponse(404, "about:blank", "Unknown blob");
1450
2177
  }
1451
- const type = url.searchParams.get('type') ?? blob.type;
2178
+ const type = url.searchParams.get("type") ?? blob.type;
1452
2179
  return new Response(asBodyBytes(blob.data), {
1453
2180
  status: 200,
1454
2181
  headers: {
1455
- 'content-type': type,
1456
- 'content-length': String(blob.data.length),
1457
- 'content-disposition': `attachment; filename="${name.replaceAll('"', '')}"`,
2182
+ "content-type": type,
2183
+ "content-length": String(blob.data.length),
2184
+ "content-disposition": `attachment; filename="${
2185
+ name.replaceAll('"', "")
2186
+ }"`,
1458
2187
  },
1459
2188
  });
1460
2189
  }
@@ -1464,20 +2193,18 @@ export class JmapServer {
1464
2193
  // ======================
1465
2194
 
1466
2195
  private handleEventSource(account: IJmapAccountInfo, url: URL): Response {
1467
- const typesParam = url.searchParams.get('types') ?? '*';
1468
- const types =
1469
- typesParam === '*' || typesParam === ''
1470
- ? null
1471
- : new Set(
1472
- typesParam
1473
- .split(',')
1474
- .map((entry) => entry.trim())
1475
- .filter((entry) => entry.length > 0)
1476
- );
1477
- const closeAfterState = url.searchParams.get('closeafter') === 'state';
1478
- const pingRaw = Number(url.searchParams.get('ping') ?? '0');
1479
- const pingSeconds =
1480
- Number.isFinite(pingRaw) && pingRaw > 0 ? Math.max(1, Math.floor(pingRaw)) : 0;
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;
1481
2208
 
1482
2209
  const state: IEventStreamState = {
1483
2210
  accountId: account.accountId,
@@ -1491,10 +2218,13 @@ export class JmapServer {
1491
2218
  const stream = new ReadableStream<Uint8Array>({
1492
2219
  start: (controller) => {
1493
2220
  state.controller = controller;
1494
- controller.enqueue(textEncoder.encode(': jmap event stream\n\n'));
2221
+ controller.enqueue(textEncoder.encode(": jmap event stream\n\n"));
1495
2222
  if (pingSeconds > 0) {
1496
2223
  state.pingTimer = setInterval(() => {
1497
- this.writeToStream(state, `event: ping\ndata: {"interval":${pingSeconds}}\n\n`);
2224
+ this.writeToStream(
2225
+ state,
2226
+ `event: ping\ndata: {"interval":${pingSeconds}}\n\n`,
2227
+ );
1498
2228
  }, pingSeconds * 1000);
1499
2229
  }
1500
2230
  try {
@@ -1512,8 +2242,8 @@ export class JmapServer {
1512
2242
  return new Response(stream, {
1513
2243
  status: 200,
1514
2244
  headers: {
1515
- 'content-type': 'text/event-stream',
1516
- 'cache-control': 'no-cache',
2245
+ "content-type": "text/event-stream",
2246
+ "cache-control": "no-cache",
1517
2247
  },
1518
2248
  });
1519
2249
  }
@@ -1535,8 +2265,13 @@ export class JmapServer {
1535
2265
  }
1536
2266
  this.eventStreams.delete(state);
1537
2267
  if (!this.eventStreams.size && this.changeFeedUnsubscribe) {
1538
- this.changeFeedUnsubscribe();
2268
+ const unsubscribe = this.changeFeedUnsubscribe;
1539
2269
  this.changeFeedUnsubscribe = null;
2270
+ try {
2271
+ unsubscribe();
2272
+ } catch (error) {
2273
+ this.cleanupErrors.push(error);
2274
+ }
1540
2275
  }
1541
2276
  }
1542
2277
 
@@ -1565,7 +2300,10 @@ export class JmapServer {
1565
2300
  * deduplicated per stream, so a mutation notified both by the backend
1566
2301
  * change feed and by the post-request push is delivered once.
1567
2302
  */
1568
- private pushStateChange(accountId: string, changed: Record<string, string>): void {
2303
+ private pushStateChange(
2304
+ accountId: string,
2305
+ changed: Record<string, string>,
2306
+ ): void {
1569
2307
  for (const state of Array.from(this.eventStreams)) {
1570
2308
  if (state.accountId !== accountId) {
1571
2309
  continue;
@@ -1587,10 +2325,13 @@ export class JmapServer {
1587
2325
  state.lastSent.set(type, typeState);
1588
2326
  }
1589
2327
  const stateChange = {
1590
- '@type': 'StateChange',
2328
+ "@type": "StateChange",
1591
2329
  changed: { [accountId]: filtered },
1592
2330
  };
1593
- this.writeToStream(state, `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`);
2331
+ this.writeToStream(
2332
+ state,
2333
+ `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`,
2334
+ );
1594
2335
  if (state.closeAfterState) {
1595
2336
  this.closeStream(state);
1596
2337
  }
@@ -1622,62 +2363,126 @@ export class JmapServer {
1622
2363
  // node req/res adapter
1623
2364
  // ======================
1624
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();
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
+
1625
2423
  private async handleNodeRequest(
1626
2424
  req: plugins.http.IncomingMessage,
1627
- res: plugins.http.ServerResponse
2425
+ res: plugins.http.ServerResponse,
2426
+ requestAbortController: AbortController,
1628
2427
  ): Promise<void> {
1629
- try {
1630
- const method = (req.method ?? 'GET').toUpperCase();
1631
- let body: Uint8Array | undefined;
1632
- if (method !== 'GET' && method !== 'HEAD') {
1633
- const chunks: Buffer[] = [];
1634
- let total = 0;
1635
- // Cap request buffering at maxSizeUpload; larger requests never make
1636
- // it to fetchHandler (which enforces the JMAP per-endpoint limits).
1637
- const maxBuffered = JMAP_SERVER_LIMITS.maxSizeUpload + 1;
1638
- for await (const chunk of req) {
1639
- const buffer = chunk as Buffer;
1640
- total += buffer.length;
1641
- if (total > maxBuffered) {
1642
- res.writeHead(413, { 'content-type': 'application/problem+json' });
1643
- res.end(
1644
- JSON.stringify({
1645
- type: 'urn:ietf:params:jmap:error:limit',
1646
- limit: 'maxSizeUpload',
1647
- status: 413,
1648
- detail: 'Request body too large.',
1649
- })
1650
- );
1651
- req.destroy();
1652
- return;
1653
- }
1654
- chunks.push(buffer);
1655
- }
1656
- body = Buffer.concat(chunks);
2428
+ const abortRequest = (reason: unknown): void => {
2429
+ if (!requestAbortController.signal.aborted) {
2430
+ requestAbortController.abort(reason);
1657
2431
  }
1658
-
2432
+ };
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();
1659
2457
  const headers = new Headers();
1660
2458
  const skippedHeaders = new Set([
1661
- 'connection',
1662
- 'content-length',
1663
- 'expect',
1664
- 'keep-alive',
1665
- 'proxy-connection',
1666
- 'transfer-encoding',
1667
- 'upgrade',
2459
+ "connection",
2460
+ "expect",
2461
+ "keep-alive",
2462
+ "proxy-connection",
2463
+ "transfer-encoding",
2464
+ "upgrade",
1668
2465
  ]);
1669
2466
  for (const [key, value] of Object.entries(req.headers)) {
1670
2467
  if (value === undefined || skippedHeaders.has(key.toLowerCase())) {
1671
2468
  continue;
1672
2469
  }
1673
- headers.set(key, Array.isArray(value) ? value.join(', ') : value);
2470
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value);
1674
2471
  }
1675
- const host = req.headers.host ?? '127.0.0.1';
1676
- const request = new Request(`http://${host}${req.url ?? '/'}`, {
2472
+ const host = req.headers.host ?? "127.0.0.1";
2473
+ const requestInit: RequestInit & { duplex?: "half" } = {
1677
2474
  method,
1678
2475
  headers,
1679
- body: body ? asBodyBytes(body) : undefined,
1680
- });
2476
+ signal: requestAbortController.signal,
2477
+ };
2478
+ if (method !== "GET" && method !== "HEAD") {
2479
+ requestInit.body = this.nodeRequestBody(req);
2480
+ requestInit.duplex = "half";
2481
+ }
2482
+ const request = new Request(
2483
+ `http://${host}${req.url ?? "/"}`,
2484
+ requestInit,
2485
+ );
1681
2486
 
1682
2487
  const response = await this.fetchHandler(request);
1683
2488
 
@@ -1692,7 +2497,7 @@ export class JmapServer {
1692
2497
  }
1693
2498
  const reader = response.body.getReader();
1694
2499
  let clientClosed = false;
1695
- res.on('close', () => {
2500
+ res.on("close", () => {
1696
2501
  clientClosed = true;
1697
2502
  // Cancelling the reader triggers the stream's cancel handler, which
1698
2503
  // releases any per-stream resources (SSE registration, ping timer).
@@ -1715,9 +2520,16 @@ export class JmapServer {
1715
2520
  } catch (error) {
1716
2521
  const message = error instanceof Error ? error.message : String(error);
1717
2522
  if (!res.headersSent) {
1718
- res.writeHead(500, { 'content-type': 'application/problem+json' });
2523
+ res.writeHead(500, { "content-type": "application/problem+json" });
1719
2524
  }
1720
- res.end(JSON.stringify({ type: 'about:blank', status: 500, detail: message }));
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);
1721
2533
  }
1722
2534
  }
1723
2535
 
@@ -1727,29 +2539,37 @@ export class JmapServer {
1727
2539
 
1728
2540
  private validateProperties(
1729
2541
  requested: any,
1730
- known: Set<string>
2542
+ known: Set<string>,
1731
2543
  ): string[] | null {
1732
2544
  if (requested === null || requested === undefined) {
1733
2545
  return null;
1734
2546
  }
1735
2547
  if (!Array.isArray(requested)) {
1736
- throw new JmapServerMethodError('invalidArguments', 'properties must be an array or null.');
2548
+ throw new JmapServerMethodError(
2549
+ "invalidArguments",
2550
+ "properties must be an array or null.",
2551
+ );
1737
2552
  }
1738
2553
  for (const property of requested) {
1739
- if (typeof property !== 'string' || !known.has(property)) {
2554
+ if (typeof property !== "string" || !known.has(property)) {
1740
2555
  throw new JmapServerMethodError(
1741
- 'invalidArguments',
1742
- `Unknown property "${String(property)}".`
2556
+ "invalidArguments",
2557
+ `Unknown property "${String(property)}".`,
1743
2558
  );
1744
2559
  }
1745
2560
  }
1746
- return requested.includes('id') ? requested : ['id', ...requested];
2561
+ return requested.includes("id") ? requested : ["id", ...requested];
1747
2562
  }
1748
2563
 
1749
- private projectObject(source: Record<string, any>, properties: string[]): Record<string, any> {
2564
+ private projectObject(
2565
+ source: Record<string, any>,
2566
+ properties: string[],
2567
+ ): Record<string, any> {
1750
2568
  const projected: Record<string, any> = {};
1751
2569
  for (const property of properties) {
1752
- projected[property] = source[property] === undefined ? null : source[property];
2570
+ projected[property] = source[property] === undefined
2571
+ ? null
2572
+ : source[property];
1753
2573
  }
1754
2574
  return projected;
1755
2575
  }
@@ -1760,8 +2580,8 @@ export class JmapServer {
1760
2580
  }
1761
2581
  if (!Number.isInteger(args.position) || args.position < 0) {
1762
2582
  throw new JmapServerMethodError(
1763
- 'invalidArguments',
1764
- 'position must be a non-negative integer.'
2583
+ "invalidArguments",
2584
+ "position must be a non-negative integer.",
1765
2585
  );
1766
2586
  }
1767
2587
  return args.position as number;
@@ -1771,15 +2591,18 @@ export class JmapServer {
1771
2591
  sinceState: string;
1772
2592
  maxChanges?: number;
1773
2593
  } {
1774
- if (typeof args.sinceState !== 'string') {
1775
- throw new JmapServerMethodError('invalidArguments', 'sinceState is required.');
2594
+ if (typeof args.sinceState !== "string") {
2595
+ throw new JmapServerMethodError(
2596
+ "invalidArguments",
2597
+ "sinceState is required.",
2598
+ );
1776
2599
  }
1777
2600
  let maxChanges: number | undefined;
1778
2601
  if (args.maxChanges !== undefined && args.maxChanges !== null) {
1779
2602
  if (!Number.isInteger(args.maxChanges) || args.maxChanges <= 0) {
1780
2603
  throw new JmapServerMethodError(
1781
- 'invalidArguments',
1782
- 'maxChanges must be a positive integer.'
2604
+ "invalidArguments",
2605
+ "maxChanges must be a positive integer.",
1783
2606
  );
1784
2607
  }
1785
2608
  maxChanges = args.maxChanges as number;
@@ -1790,7 +2613,7 @@ export class JmapServer {
1790
2613
  private jsonResponse(status: number, body: unknown): Response {
1791
2614
  return new Response(JSON.stringify(body), {
1792
2615
  status,
1793
- headers: { 'content-type': 'application/json' },
2616
+ headers: { "content-type": "application/json" },
1794
2617
  });
1795
2618
  }
1796
2619
 
@@ -1798,18 +2621,27 @@ export class JmapServer {
1798
2621
  status: number,
1799
2622
  type: string,
1800
2623
  detail: string,
1801
- extraHeaders: Record<string, string> = {}
2624
+ extraHeaders: Record<string, string> = {},
1802
2625
  ): Response {
1803
2626
  return new Response(JSON.stringify({ type, status, detail }), {
1804
2627
  status,
1805
- headers: { 'content-type': 'application/problem+json', ...extraHeaders },
2628
+ headers: { "content-type": "application/problem+json", ...extraHeaders },
1806
2629
  });
1807
2630
  }
1808
2631
 
1809
- private limitResponse(status: number, limit: string, detail: string): Response {
2632
+ private limitResponse(
2633
+ status: number,
2634
+ limit: string,
2635
+ detail: string,
2636
+ ): Response {
1810
2637
  return new Response(
1811
- JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit, status, detail }),
1812
- { status, headers: { 'content-type': 'application/problem+json' } }
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" } },
1813
2645
  );
1814
2646
  }
1815
2647
  }