@push.rocks/smartjmap 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,69 +1,130 @@
1
1
  import * as plugins from './smartjmap.plugins.js';
2
- import type { IJmapEmailAddress } from './classes.jmapclient.js';
3
-
4
- export interface IJmapServerMailbox {
5
- id: string;
6
- name: string;
7
- role: string | null;
8
- parentId: string | null;
9
- sortOrder: number;
10
- }
2
+ import {
3
+ JMAP_CORE_CAPABILITY,
4
+ JMAP_MAIL_CAPABILITY,
5
+ JMAP_SUBMISSION_CAPABILITY,
6
+ type IJmapEmailBodyValue,
7
+ type TJmapMethodResponse,
8
+ } from './classes.jmapclient.js';
9
+ import {
10
+ type IJmapAccountInfo,
11
+ type IJmapEmailCreate,
12
+ type IJmapEmailFilter,
13
+ type IJmapEmailRecord,
14
+ type IJmapEmailSetRequest,
15
+ type IJmapEmailUpdate,
16
+ type IJmapEnvelope,
17
+ type IJmapMailBackend,
18
+ type IJmapSetError,
19
+ type TJmapPrincipal,
20
+ } from './interfaces.backend.js';
21
+ import { MemoryMailBackend } from './classes.memorymailbackend.js';
11
22
 
12
- export interface IJmapServerBlob {
13
- data: Buffer;
14
- type: string;
15
- name?: string;
23
+ /** Options for constructing a JmapServer. */
24
+ export interface IJmapServerOptions {
25
+ /** The storage backend; defaults to a fresh in-memory `MemoryMailBackend`. */
26
+ backend?: IJmapMailBackend;
27
+ /**
28
+ * Resolves an incoming Request to an authenticated principal (return null
29
+ * to reject with 401). Defaults to a static Basic/Bearer registry fed via
30
+ * `addUser`/`addBearerToken`.
31
+ */
32
+ authenticate?: (request: Request) => Promise<TJmapPrincipal | null>;
33
+ /**
34
+ * Absolute base URL (e.g. `https://mail.example.com`) prefixed to the URLs
35
+ * advertised in the session object. Defaults to relative URLs, which JMAP
36
+ * clients resolve against the session resource URL.
37
+ */
38
+ baseUrl?: string;
16
39
  }
17
40
 
18
- export interface IJmapServerChangeEntry {
19
- state: number;
20
- created: string[];
21
- updated: string[];
22
- destroyed: string[];
23
- }
41
+ /** The advertised — and enforced — RFC 8620 core limits. */
42
+ export const JMAP_SERVER_LIMITS = {
43
+ maxSizeUpload: 50_000_000,
44
+ maxConcurrentUpload: 4,
45
+ maxSizeRequest: 10_000_000,
46
+ maxConcurrentRequests: 4,
47
+ maxCallsInRequest: 16,
48
+ maxObjectsInGet: 500,
49
+ maxObjectsInSet: 500,
50
+ };
24
51
 
25
- export interface IJmapServerIdentity {
26
- id: string;
27
- name: string;
28
- email: string;
29
- mayDelete: boolean;
30
- }
52
+ /** The session object never changes at runtime, so its state is constant. */
53
+ const SESSION_STATE = '0';
31
54
 
32
- export interface IJmapServerSubmission {
33
- id: string;
34
- emailId: string;
35
- identityId: string;
36
- undoStatus: string;
37
- sendAt: string;
38
- }
55
+ /** Default Email/get properties per RFC 8621 §4.2. */
56
+ 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',
81
+ ];
82
+ const KNOWN_EMAIL_PROPERTIES = new Set(DEFAULT_EMAIL_PROPERTIES);
39
83
 
40
- export interface IJmapServerUser {
41
- username: string;
42
- password: string;
43
- bearerTokens: Set<string>;
44
- accountId: string;
45
- mailboxes: Map<string, IJmapServerMailbox>;
46
- emails: Map<string, Record<string, any>>;
47
- blobs: Map<string, IJmapServerBlob>;
48
- submissions: Map<string, IJmapServerSubmission>;
49
- identities: IJmapServerIdentity[];
50
- state: number;
51
- changes: IJmapServerChangeEntry[];
52
- idCounter: number;
53
- }
84
+ 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',
96
+ ];
97
+ const KNOWN_MAILBOX_PROPERTIES = new Set(MAILBOX_PROPERTIES);
54
98
 
55
- export interface IJmapServerEmailInput {
56
- from: IJmapEmailAddress | IJmapEmailAddress[];
57
- to: IJmapEmailAddress | IJmapEmailAddress[];
58
- cc?: IJmapEmailAddress[];
59
- bcc?: IJmapEmailAddress[];
60
- subject?: string;
61
- textBody?: string;
62
- htmlBody?: string;
63
- keywords?: Record<string, boolean>;
64
- receivedAt?: string;
65
- attachments?: Array<{ name: string; type: string; content: string | Uint8Array }>;
66
- }
99
+ 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',
109
+ ]);
110
+
111
+ const DEFAULT_MAILBOX_RIGHTS = {
112
+ mayReadItems: true,
113
+ mayAddItems: true,
114
+ mayRemoveItems: true,
115
+ maySetSeen: true,
116
+ maySetKeywords: true,
117
+ mayCreateChild: true,
118
+ mayRename: true,
119
+ mayDelete: true,
120
+ maySubmit: true,
121
+ };
122
+
123
+ const textEncoder = new TextEncoder();
124
+
125
+ /** Ensures bytes are backed by a plain ArrayBuffer so they satisfy BodyInit. */
126
+ const asBodyBytes = (data: Uint8Array): Uint8Array<ArrayBuffer> =>
127
+ data.buffer instanceof ArrayBuffer ? (data as Uint8Array<ArrayBuffer>) : new Uint8Array(data);
67
128
 
68
129
  /** Error type used inside method dispatch; rendered as a JMAP `error` method response. */
69
130
  class JmapServerMethodError extends Error {
@@ -72,262 +133,236 @@ class JmapServerMethodError extends Error {
72
133
  }
73
134
  }
74
135
 
136
+ /** One live event-source stream (SSE) with its filters and keepalive timer. */
137
+ interface IEventStreamState {
138
+ accountId: string;
139
+ /** null = all types ('*'). */
140
+ types: Set<string> | null;
141
+ closeAfterState: boolean;
142
+ controller: ReadableStreamDefaultController<Uint8Array> | null;
143
+ pingTimer: ReturnType<typeof setInterval> | null;
144
+ /** Last state pushed per type, used to deduplicate double notifications. */
145
+ lastSent: Map<string, string>;
146
+ closed: boolean;
147
+ }
148
+
149
+ /** UTF-8-safe truncation: cuts at a codepoint boundary at or below maxBytes. */
150
+ const truncateUtf8 = (value: string, maxBytes: number): string => {
151
+ const bytes = textEncoder.encode(value);
152
+ if (bytes.length <= maxBytes) {
153
+ return value;
154
+ }
155
+ let end = maxBytes;
156
+ while (end > 0 && (bytes[end]! & 0b1100_0000) === 0b1000_0000) {
157
+ end--;
158
+ }
159
+ return new TextDecoder().decode(bytes.subarray(0, end));
160
+ };
161
+
162
+ /**
163
+ * Evaluates an RFC 6901 JSON pointer with the RFC 8620 §3.7 extension: a `*`
164
+ * token applied to an array maps the rest of the pointer over every item,
165
+ * flattening one array level. Throws on any unresolvable path.
166
+ */
167
+ const evalJsonPointer = (root: any, pointer: string): any => {
168
+ if (pointer === '') {
169
+ return root;
170
+ }
171
+ if (!pointer.startsWith('/')) {
172
+ throw new Error(`Invalid JSON pointer "${pointer}".`);
173
+ }
174
+ const tokens = pointer.slice(1).split('/');
175
+ const evaluate = (value: any, index: number): any => {
176
+ if (index === tokens.length) {
177
+ return value;
178
+ }
179
+ const token = tokens[index]!.replaceAll('~1', '/').replaceAll('~0', '~');
180
+ if (Array.isArray(value)) {
181
+ if (token === '*') {
182
+ const results = value.map((item) => evaluate(item, index + 1));
183
+ return results.some((result) => Array.isArray(result))
184
+ ? results.flatMap((result) => (Array.isArray(result) ? result : [result]))
185
+ : results;
186
+ }
187
+ const arrayIndex = Number(token);
188
+ if (!Number.isInteger(arrayIndex) || arrayIndex < 0 || arrayIndex >= value.length) {
189
+ throw new Error(`JSON pointer index "${token}" is out of range.`);
190
+ }
191
+ return evaluate(value[arrayIndex], index + 1);
192
+ }
193
+ if (value !== null && typeof value === 'object' && token in value) {
194
+ return evaluate(value[token], index + 1);
195
+ }
196
+ throw new Error(`JSON pointer token "${token}" does not resolve.`);
197
+ };
198
+ return evaluate(root, 0);
199
+ };
200
+
75
201
  /**
76
- * A minimal in-memory JMAP server (RFC 8620 core + RFC 8621 mail subset)
77
- * for offline testing. Serves the session resource at /.well-known/jmap and
78
- * /jmap/session, the API at /jmap/api, blob downloads at /jmap/download/...,
79
- * and StateChange pushes via SSE at /jmap/eventsource.
202
+ * A JMAP server core (RFC 8620) with the RFC 8621 mail method surface,
203
+ * dispatching all storage into a pluggable `IJmapMailBackend`.
204
+ *
205
+ * The primary API is the web-standard `fetchHandler(request)` — mount it in
206
+ * any Request/Response runtime (Deno, Bun, service workers, Node ≥18). The
207
+ * `start(port)`/`stop()` pair wraps it in a node:http server for convenience.
208
+ *
209
+ * Endpoints: session (`/.well-known/jmap`, `/jmap/session`), API
210
+ * (`/jmap/api`), upload (`/jmap/upload/{accountId}`), download
211
+ * (`/jmap/download/{accountId}/{blobId}/{name}?type=`), and event source
212
+ * (`/jmap/eventsource` — SSE StateChange pushes per RFC 8620 §7.3).
213
+ *
214
+ * With no options this behaves as the offline test helper: an in-memory
215
+ * backend plus a static credential registry (`addUser`/`addBearerToken`).
80
216
  */
81
217
  export class JmapServer {
82
- public users: Map<string, IJmapServerUser> = new Map();
83
- private server: plugins.http.Server;
218
+ public readonly backend: IJmapMailBackend;
219
+ private options: IJmapServerOptions;
220
+ private registry: Map<string, { password: string; bearerTokens: Set<string> }> = new Map();
221
+ private httpServer: plugins.http.Server | null = null;
84
222
  private sockets: Set<plugins.net.Socket> = new Set();
85
- private eventStreams: Map<plugins.http.ServerResponse, IJmapServerUser> = new Map();
223
+ private eventStreams: Set<IEventStreamState> = new Set();
224
+ private changeFeedUnsubscribe: (() => void) | null = null;
225
+ private activeApiRequests = 0;
86
226
 
87
- constructor() {
88
- this.server = plugins.http.createServer((req, res) => {
89
- this.handleRequest(req, res);
90
- });
91
- this.server.on('connection', (socket) => {
92
- this.sockets.add(socket);
93
- socket.on('close', () => {
94
- this.sockets.delete(socket);
95
- });
96
- });
227
+ constructor(options: IJmapServerOptions = {}) {
228
+ this.options = options;
229
+ this.backend = options.backend ?? new MemoryMailBackend();
97
230
  }
98
231
 
99
232
  // ======================
100
- // store management
233
+ // default auth registry
101
234
  // ======================
102
235
 
236
+ /**
237
+ * Registers Basic credentials in the default authenticator's registry.
238
+ * Only consulted when no custom `authenticate` option is set. Account data
239
+ * itself lives in the backend (the memory backend auto-creates accounts).
240
+ */
103
241
  public addUser(username: string, password: string): void {
104
- if (this.users.has(username)) {
242
+ if (this.registry.has(username)) {
105
243
  throw new Error(`User "${username}" already exists.`);
106
244
  }
107
- const email = username.includes('@') ? username : `${username}@example.com`;
108
- this.users.set(username, {
109
- username,
110
- password,
111
- bearerTokens: new Set(),
112
- accountId: `account_${username}`,
113
- mailboxes: new Map(),
114
- emails: new Map(),
115
- blobs: new Map(),
116
- submissions: new Map(),
117
- identities: [{ id: 'identity_1', name: username, email, mayDelete: false }],
118
- state: 0,
119
- changes: [],
120
- idCounter: 0,
121
- });
245
+ this.registry.set(username, { password, bearerTokens: new Set() });
122
246
  }
123
247
 
124
- /** Registers a valid Bearer token for a user. */
248
+ /** Registers a valid Bearer token for a user in the default authenticator. */
125
249
  public addBearerToken(username: string, token: string): void {
126
- const user = this.requireUser(username);
127
- user.bearerTokens.add(token);
128
- }
129
-
130
- public createMailbox(username: string, name: string, role?: string): string {
131
- const user = this.requireUser(username);
132
- for (const mailbox of user.mailboxes.values()) {
133
- if (mailbox.name === name) {
134
- throw new Error(`Mailbox "${name}" already exists for user "${username}".`);
135
- }
136
- }
137
- const id = `mailbox_${++user.idCounter}`;
138
- user.mailboxes.set(id, {
139
- id,
140
- name,
141
- role: role ?? null,
142
- parentId: null,
143
- sortOrder: user.mailboxes.size,
144
- });
145
- return id;
146
- }
147
-
148
- /** Seeds an email into a user's mailbox; bumps state and pushes a StateChange. Returns the email id. */
149
- public addEmail(username: string, mailboxName: string, input: IJmapServerEmailInput): string {
150
- const user = this.requireUser(username);
151
- const mailbox = Array.from(user.mailboxes.values()).find(
152
- (candidate) => candidate.name === mailboxName || candidate.role === mailboxName
153
- );
154
- if (!mailbox) {
155
- throw new Error(`Mailbox "${mailboxName}" does not exist for user "${username}".`);
156
- }
157
-
158
- const from = Array.isArray(input.from) ? input.from : [input.from];
159
- const to = Array.isArray(input.to) ? input.to : [input.to];
160
- const id = `email_${++user.idCounter}`;
161
- const receivedAt = input.receivedAt ?? new Date().toISOString();
162
- const textBody = input.textBody ?? '';
163
-
164
- // raw message blob for Email/get blobId + downloads
165
- const blobId = `blob_${id}`;
166
- const rawLines = [
167
- `From: ${from.map((address) => address.email).join(', ')}`,
168
- `To: ${to.map((address) => address.email).join(', ')}`,
169
- `Subject: ${input.subject ?? ''}`,
170
- `Date: ${receivedAt}`,
171
- '',
172
- textBody,
173
- ];
174
- user.blobs.set(blobId, {
175
- data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
176
- type: 'message/rfc822',
177
- name: `${id}.eml`,
178
- });
179
-
180
- const bodyValues: Record<string, { value: string; isTruncated: boolean }> = {
181
- text: { value: textBody, isTruncated: false },
182
- };
183
- const textBodyParts = [{ partId: 'text', type: 'text/plain', size: textBody.length }];
184
- const htmlBodyParts: Array<Record<string, any>> = [];
185
- if (typeof input.htmlBody === 'string') {
186
- bodyValues.html = { value: input.htmlBody, isTruncated: false };
187
- htmlBodyParts.push({ partId: 'html', type: 'text/html', size: input.htmlBody.length });
188
- }
189
-
190
- const attachmentParts: Array<Record<string, any>> = [];
191
- for (const [index, attachment] of (input.attachments ?? []).entries()) {
192
- const attachmentBlobId = `blob_${id}_att${index + 1}`;
193
- const data = Buffer.from(attachment.content as any);
194
- user.blobs.set(attachmentBlobId, {
195
- data,
196
- type: attachment.type,
197
- name: attachment.name,
198
- });
199
- attachmentParts.push({
200
- partId: null,
201
- blobId: attachmentBlobId,
202
- name: attachment.name,
203
- type: attachment.type,
204
- size: data.length,
205
- disposition: 'attachment',
206
- });
250
+ const entry = this.registry.get(username);
251
+ if (!entry) {
252
+ throw new Error(`User "${username}" does not exist.`);
207
253
  }
208
-
209
- const email: Record<string, any> = {
210
- id,
211
- blobId,
212
- threadId: `thread_${id}`,
213
- mailboxIds: { [mailbox.id]: true },
214
- keywords: input.keywords ?? {},
215
- size: textBody.length,
216
- receivedAt,
217
- sentAt: receivedAt,
218
- subject: input.subject ?? null,
219
- from,
220
- to,
221
- cc: input.cc ?? null,
222
- bcc: input.bcc ?? null,
223
- replyTo: null,
224
- hasAttachment: attachmentParts.length > 0,
225
- preview: textBody.slice(0, 100),
226
- bodyValues,
227
- textBody: textBodyParts,
228
- htmlBody: htmlBodyParts,
229
- attachments: attachmentParts,
230
- };
231
- user.emails.set(id, email);
232
- this.bumpState(user, { created: [id] });
233
- return id;
254
+ entry.bearerTokens.add(token);
234
255
  }
235
256
 
236
257
  // ======================
237
- // lifecycle
258
+ // lifecycle (node wrapper)
238
259
  // ======================
239
260
 
240
- /** Starts the server. Resolves with the bound port (pass 0 for an ephemeral port). */
261
+ /** Starts a node:http server around fetchHandler. Resolves with the bound port (pass 0 for ephemeral). */
241
262
  public start(port: number): Promise<number> {
263
+ if (this.httpServer) {
264
+ throw new Error('JmapServer is already started.');
265
+ }
266
+ const server = plugins.http.createServer((req, res) => {
267
+ void this.handleNodeRequest(req, res);
268
+ });
269
+ server.on('connection', (socket) => {
270
+ this.sockets.add(socket);
271
+ socket.on('close', () => {
272
+ this.sockets.delete(socket);
273
+ });
274
+ });
275
+ this.httpServer = server;
242
276
  return new Promise((resolve, reject) => {
243
- this.server.once('error', reject);
244
- this.server.listen(port, () => {
245
- const address = this.server.address();
246
- const boundPort =
247
- typeof address === 'object' && address ? address.port : port;
277
+ server.once('error', reject);
278
+ server.listen(port, () => {
279
+ const address = server.address();
280
+ const boundPort = typeof address === 'object' && address ? address.port : port;
248
281
  resolve(boundPort);
249
282
  });
250
283
  });
251
284
  }
252
285
 
253
- /** Stops the server, ending open event streams and destroying open connections. */
254
- public stop(): Promise<void> {
255
- for (const response of this.eventStreams.keys()) {
256
- try {
257
- response.end();
258
- } catch {
259
- // response already gone
260
- }
286
+ /**
287
+ * Stops the server: closes all event streams (clearing their ping timers
288
+ * and the backend change-feed subscription), destroys open connections,
289
+ * and closes the node listener when one was started.
290
+ */
291
+ public async stop(): Promise<void> {
292
+ for (const stream of Array.from(this.eventStreams)) {
293
+ this.closeStream(stream);
261
294
  }
262
- this.eventStreams.clear();
263
295
  for (const socket of this.sockets) {
264
296
  socket.destroy();
265
297
  }
266
298
  this.sockets.clear();
267
- return new Promise((resolve) => {
268
- this.server.close(() => {
269
- resolve();
299
+ const server = this.httpServer;
300
+ this.httpServer = null;
301
+ if (server) {
302
+ await new Promise<void>((resolve) => {
303
+ server.close(() => {
304
+ resolve();
305
+ });
270
306
  });
271
- });
307
+ }
272
308
  }
273
309
 
274
310
  // ======================
275
- // http handling
311
+ // web-standard core
276
312
  // ======================
277
313
 
278
- private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
314
+ /**
315
+ * Handles a JMAP request end to end and returns the Response. This is the
316
+ * runtime-agnostic core — mount it directly as a Deno/Bun fetch handler.
317
+ */
318
+ public async fetchHandler(request: Request): Promise<Response> {
279
319
  try {
280
- const user = this.authenticate(req);
281
- if (!user) {
282
- res.writeHead(401, {
320
+ const principal = await this.authenticatePrincipal(request);
321
+ if (!principal) {
322
+ return this.problemResponse(401, 'about:blank', 'Authentication required', {
283
323
  'www-authenticate': 'Bearer realm="jmap", Basic realm="jmap"',
284
- 'content-type': 'application/problem+json',
285
324
  });
286
- res.end(
287
- JSON.stringify({ type: 'about:blank', status: 401, detail: 'Authentication required' })
288
- );
289
- return;
325
+ }
326
+ const account = await this.backend.resolveAccount(principal);
327
+ if (!account) {
328
+ return this.problemResponse(403, 'about:blank', 'No account for this principal');
290
329
  }
291
330
 
292
- const url = new URL(req.url ?? '/', 'http://localhost');
331
+ const url = new URL(request.url);
293
332
  const pathname = url.pathname;
333
+ const method = request.method.toUpperCase();
294
334
 
295
- if (req.method === 'GET' && (pathname === '/.well-known/jmap' || pathname === '/jmap/session')) {
296
- this.handleSession(user, res);
297
- return;
335
+ if (method === 'GET' && (pathname === '/.well-known/jmap' || pathname === '/jmap/session')) {
336
+ return this.handleSession(account, principal);
298
337
  }
299
- if (req.method === 'POST' && pathname === '/jmap/api') {
300
- let body = '';
301
- req.on('data', (chunk) => {
302
- body += chunk;
303
- });
304
- req.on('end', () => {
305
- this.handleApi(user, body, res);
306
- });
307
- return;
338
+ if (method === 'POST' && pathname === '/jmap/api') {
339
+ return this.handleApi(account, request);
308
340
  }
309
- if (req.method === 'GET' && pathname === '/jmap/eventsource') {
310
- this.handleEventSource(user, req, res);
311
- return;
341
+ if (method === 'POST' && pathname.startsWith('/jmap/upload/')) {
342
+ return this.handleUpload(account, pathname, request);
312
343
  }
313
- if (req.method === 'GET' && pathname.startsWith('/jmap/download/')) {
314
- this.handleDownload(user, pathname, url, res);
315
- return;
344
+ if (method === 'GET' && pathname.startsWith('/jmap/download/')) {
345
+ return this.handleDownload(account, pathname, url);
316
346
  }
317
-
318
- res.writeHead(404, { 'content-type': 'application/problem+json' });
319
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Not found' }));
347
+ if (method === 'GET' && pathname === '/jmap/eventsource') {
348
+ return this.handleEventSource(account, url);
349
+ }
350
+ return this.problemResponse(404, 'about:blank', 'Not found');
320
351
  } catch (error) {
321
352
  const message = error instanceof Error ? error.message : String(error);
322
- if (!res.headersSent) {
323
- res.writeHead(500, { 'content-type': 'application/problem+json' });
324
- }
325
- res.end(JSON.stringify({ type: 'about:blank', status: 500, detail: message }));
353
+ return this.problemResponse(500, 'about:blank', message);
326
354
  }
327
355
  }
328
356
 
329
- private authenticate(req: plugins.http.IncomingMessage): IJmapServerUser | null {
330
- const header = req.headers.authorization;
357
+ // ======================
358
+ // authentication
359
+ // ======================
360
+
361
+ private async authenticatePrincipal(request: Request): Promise<TJmapPrincipal | null> {
362
+ if (this.options.authenticate) {
363
+ return this.options.authenticate(request);
364
+ }
365
+ const header = request.headers.get('authorization');
331
366
  if (!header) {
332
367
  return null;
333
368
  }
@@ -338,478 +373,855 @@ export class JmapServer {
338
373
  const scheme = header.slice(0, spaceIndex).toLowerCase();
339
374
  const value = header.slice(spaceIndex + 1).trim();
340
375
  if (scheme === 'bearer') {
341
- for (const user of this.users.values()) {
342
- if (user.bearerTokens.has(value)) {
343
- return user;
376
+ for (const [username, entry] of this.registry) {
377
+ if (entry.bearerTokens.has(value)) {
378
+ return { username };
344
379
  }
345
380
  }
346
381
  return null;
347
382
  }
348
383
  if (scheme === 'basic') {
349
- const decoded = Buffer.from(value, 'base64').toString('utf8');
384
+ let decoded: string;
385
+ try {
386
+ const binary = atob(value);
387
+ decoded = new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
388
+ } catch {
389
+ return null;
390
+ }
350
391
  const colonIndex = decoded.indexOf(':');
351
392
  if (colonIndex === -1) {
352
393
  return null;
353
394
  }
354
395
  const username = decoded.slice(0, colonIndex);
355
396
  const password = decoded.slice(colonIndex + 1);
356
- const user = this.users.get(username);
357
- if (user && user.password === password) {
358
- return user;
397
+ const entry = this.registry.get(username);
398
+ if (entry && entry.password === password) {
399
+ return { username };
359
400
  }
360
401
  return null;
361
402
  }
362
403
  return null;
363
404
  }
364
405
 
365
- private handleSession(user: IJmapServerUser, res: plugins.http.ServerResponse): void {
406
+ // ======================
407
+ // session
408
+ // ======================
409
+
410
+ private handleSession(account: IJmapAccountInfo, principal: TJmapPrincipal): Response {
366
411
  const session = {
367
412
  capabilities: {
368
- 'urn:ietf:params:jmap:core': {
369
- maxSizeUpload: 50_000_000,
370
- maxConcurrentUpload: 4,
371
- maxSizeRequest: 10_000_000,
372
- maxConcurrentRequests: 4,
373
- maxCallsInRequest: 16,
374
- maxObjectsInGet: 500,
375
- maxObjectsInSet: 500,
376
- collationAlgorithms: ['i;ascii-numeric', 'i;ascii-casemap'],
413
+ [JMAP_CORE_CAPABILITY]: {
414
+ ...JMAP_SERVER_LIMITS,
415
+ collationAlgorithms: ['i;ascii-casemap'],
377
416
  },
378
- 'urn:ietf:params:jmap:mail': {},
379
- 'urn:ietf:params:jmap:submission': {},
417
+ [JMAP_MAIL_CAPABILITY]: {},
418
+ [JMAP_SUBMISSION_CAPABILITY]: {},
380
419
  },
381
420
  accounts: {
382
- [user.accountId]: {
383
- name: user.username,
384
- isPersonal: true,
385
- isReadOnly: false,
421
+ [account.accountId]: {
422
+ name: account.name,
423
+ isPersonal: account.isPersonal ?? true,
424
+ isReadOnly: account.isReadOnly ?? false,
386
425
  accountCapabilities: {
387
- 'urn:ietf:params:jmap:mail': {},
388
- 'urn:ietf:params:jmap:submission': {},
426
+ [JMAP_MAIL_CAPABILITY]: {
427
+ maxMailboxesPerEmail: null,
428
+ maxMailboxDepth: null,
429
+ maxSizeMailboxName: 490,
430
+ maxSizeAttachmentsPerEmail: JMAP_SERVER_LIMITS.maxSizeUpload,
431
+ emailQuerySortOptions: ['receivedAt'],
432
+ mayCreateTopLevelMailbox: false,
433
+ },
434
+ [JMAP_SUBMISSION_CAPABILITY]: {
435
+ maxDelayedSend: 0,
436
+ submissionExtensions: {},
437
+ },
389
438
  },
390
439
  },
391
440
  },
392
441
  primaryAccounts: {
393
- 'urn:ietf:params:jmap:core': user.accountId,
394
- 'urn:ietf:params:jmap:mail': user.accountId,
395
- 'urn:ietf:params:jmap:submission': user.accountId,
442
+ [JMAP_CORE_CAPABILITY]: account.accountId,
443
+ [JMAP_MAIL_CAPABILITY]: account.accountId,
444
+ [JMAP_SUBMISSION_CAPABILITY]: account.accountId,
396
445
  },
397
- username: user.username,
398
- apiUrl: '/jmap/api',
399
- downloadUrl: '/jmap/download/{accountId}/{blobId}/{name}?type={type}',
400
- uploadUrl: '/jmap/upload/{accountId}',
401
- eventSourceUrl: '/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}',
402
- state: String(user.state),
446
+ 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}'),
450
+ eventSourceUrl: this.buildUrl(
451
+ '/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}'
452
+ ),
453
+ state: SESSION_STATE,
403
454
  };
404
- res.writeHead(200, { 'content-type': 'application/json' });
405
- res.end(JSON.stringify(session));
455
+ return this.jsonResponse(200, session);
406
456
  }
407
457
 
408
- private handleApi(user: IJmapServerUser, body: string, res: plugins.http.ServerResponse): void {
409
- let parsed: any;
410
- try {
411
- parsed = JSON.parse(body);
412
- } catch {
413
- res.writeHead(400, { 'content-type': 'application/problem+json' });
414
- res.end(
415
- JSON.stringify({
416
- type: 'urn:ietf:params:jmap:error:notJSON',
417
- status: 400,
418
- detail: 'The request body is not valid JSON.',
419
- })
420
- );
421
- return;
422
- }
423
- if (!Array.isArray(parsed?.methodCalls)) {
424
- res.writeHead(400, { 'content-type': 'application/problem+json' });
425
- res.end(
426
- JSON.stringify({
427
- type: 'urn:ietf:params:jmap:error:notRequest',
428
- status: 400,
429
- detail: 'The request body is not a valid JMAP request object.',
430
- })
431
- );
432
- return;
433
- }
458
+ private buildUrl(path: string): string {
459
+ const base = this.options.baseUrl;
460
+ return base ? `${base.replace(/\/+$/, '')}${path}` : path;
461
+ }
434
462
 
435
- const createdIds = new Map<string, string>();
436
- const methodResponses: Array<[string, Record<string, any>, string]> = [];
437
- for (const call of parsed.methodCalls) {
438
- const [methodName, args, callId] = call as [string, Record<string, any>, string];
463
+ // ======================
464
+ // api endpoint
465
+ // ======================
466
+
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.');
470
+ }
471
+ this.activeApiRequests++;
472
+ 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.`
479
+ );
480
+ }
481
+ let parsed: any;
439
482
  try {
440
- const result = this.dispatchMethod(user, methodName, args ?? {}, createdIds);
441
- methodResponses.push([methodName, result, callId]);
442
- } catch (error) {
443
- if (error instanceof JmapServerMethodError) {
444
- methodResponses.push([
445
- 'error',
446
- { type: error.type, description: error.message },
447
- callId,
448
- ]);
449
- } else {
450
- const message = error instanceof Error ? error.message : String(error);
451
- methodResponses.push(['error', { type: 'serverFail', description: message }, callId]);
483
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
484
+ } catch {
485
+ return this.problemResponse(
486
+ 400,
487
+ 'urn:ietf:params:jmap:error:notJSON',
488
+ 'The request body is not valid JSON.'
489
+ );
490
+ }
491
+ if (
492
+ !parsed ||
493
+ typeof parsed !== 'object' ||
494
+ !Array.isArray(parsed.using) ||
495
+ !Array.isArray(parsed.methodCalls)
496
+ ) {
497
+ return this.problemResponse(
498
+ 400,
499
+ 'urn:ietf:params:jmap:error:notRequest',
500
+ 'The request body is not a valid JMAP request object.'
501
+ );
502
+ }
503
+ const knownCapabilities = new Set([
504
+ JMAP_CORE_CAPABILITY,
505
+ JMAP_MAIL_CAPABILITY,
506
+ JMAP_SUBMISSION_CAPABILITY,
507
+ ]);
508
+ const using = new Set<string>(parsed.using);
509
+ for (const capability of using) {
510
+ if (!knownCapabilities.has(capability)) {
511
+ return this.problemResponse(
512
+ 400,
513
+ 'urn:ietf:params:jmap:error:unknownCapability',
514
+ `The capability "${capability}" is not supported by this server.`
515
+ );
452
516
  }
453
517
  }
454
- }
518
+ if (parsed.methodCalls.length > JMAP_SERVER_LIMITS.maxCallsInRequest) {
519
+ return this.limitResponse(
520
+ 400,
521
+ 'maxCallsInRequest',
522
+ `The request has more than ${JMAP_SERVER_LIMITS.maxCallsInRequest} method calls.`
523
+ );
524
+ }
455
525
 
456
- res.writeHead(200, { 'content-type': 'application/json' });
457
- res.end(JSON.stringify({ methodResponses, sessionState: String(user.state) }));
458
- }
526
+ const createdIds = new Map<string, string>(
527
+ Object.entries((parsed.createdIds ?? {}) as Record<string, string>)
528
+ );
529
+ const methodResponses: TJmapMethodResponse[] = [];
530
+ let mutated = false;
531
+ for (const call of parsed.methodCalls) {
532
+ if (!Array.isArray(call) || typeof call[0] !== 'string' || typeof call[2] !== 'string') {
533
+ return this.problemResponse(
534
+ 400,
535
+ 'urn:ietf:params:jmap:error:notRequest',
536
+ 'Each method call must be a [name, arguments, callId] tuple.'
537
+ );
538
+ }
539
+ const [methodName, rawArgs, callId] = call as [string, Record<string, any>, string];
540
+ try {
541
+ const args = this.resolveBackReferences(rawArgs ?? {}, methodResponses);
542
+ const results = await this.dispatchMethod(account, methodName, args, using, createdIds);
543
+ for (const entry of results) {
544
+ methodResponses.push([entry.name, entry.result, callId]);
545
+ if (entry.mutated) {
546
+ mutated = true;
547
+ }
548
+ }
549
+ } catch (error) {
550
+ if (error instanceof JmapServerMethodError) {
551
+ methodResponses.push([
552
+ 'error',
553
+ { type: error.type, description: error.message },
554
+ callId,
555
+ ]);
556
+ } else {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+ methodResponses.push(['error', { type: 'serverFail', description: message }, callId]);
559
+ }
560
+ }
561
+ }
459
562
 
460
- private handleEventSource(
461
- user: IJmapServerUser,
462
- req: plugins.http.IncomingMessage,
463
- res: plugins.http.ServerResponse
464
- ): void {
465
- res.writeHead(200, {
466
- 'content-type': 'text/event-stream',
467
- 'cache-control': 'no-cache',
468
- connection: 'keep-alive',
469
- });
470
- res.write(': jmap event stream\n\n');
471
- this.eventStreams.set(res, user);
472
- req.on('close', () => {
473
- this.eventStreams.delete(res);
474
- });
475
- }
563
+ if (mutated) {
564
+ await this.pushAfterMutation(account);
565
+ }
476
566
 
477
- private handleDownload(
478
- user: IJmapServerUser,
479
- pathname: string,
480
- url: URL,
481
- res: plugins.http.ServerResponse
482
- ): void {
483
- // /jmap/download/{accountId}/{blobId}/{name}
484
- const segments = pathname.split('/').filter((segment) => segment.length > 0);
485
- const accountId = decodeURIComponent(segments[2] ?? '');
486
- const blobId = decodeURIComponent(segments[3] ?? '');
487
- if (accountId !== user.accountId) {
488
- res.writeHead(404, { 'content-type': 'application/problem+json' });
489
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown account' }));
490
- return;
567
+ const responseBody: Record<string, any> = {
568
+ methodResponses,
569
+ sessionState: SESSION_STATE,
570
+ };
571
+ if (parsed.createdIds !== undefined) {
572
+ responseBody.createdIds = Object.fromEntries(createdIds);
573
+ }
574
+ return this.jsonResponse(200, responseBody);
575
+ } finally {
576
+ this.activeApiRequests--;
491
577
  }
492
- const blob = user.blobs.get(blobId);
493
- if (!blob) {
494
- res.writeHead(404, { 'content-type': 'application/problem+json' });
495
- res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown blob' }));
496
- return;
578
+ }
579
+
580
+ /**
581
+ * Resolves `#argument` ResultReference objects (RFC 8620 §3.7) against the
582
+ * responses of earlier method calls in the same request.
583
+ */
584
+ private resolveBackReferences(
585
+ args: Record<string, any>,
586
+ priorResponses: TJmapMethodResponse[]
587
+ ): Record<string, any> {
588
+ const resolved: Record<string, any> = {};
589
+ for (const [key, value] of Object.entries(args)) {
590
+ if (!key.startsWith('#')) {
591
+ resolved[key] = value;
592
+ continue;
593
+ }
594
+ const name = key.slice(1);
595
+ if (name in args) {
596
+ throw new JmapServerMethodError(
597
+ 'invalidArguments',
598
+ `The arguments contain both "${name}" and "#${name}".`
599
+ );
600
+ }
601
+ if (
602
+ !value ||
603
+ typeof value !== 'object' ||
604
+ typeof value.resultOf !== 'string' ||
605
+ typeof value.name !== 'string' ||
606
+ typeof value.path !== 'string'
607
+ ) {
608
+ throw new JmapServerMethodError(
609
+ 'invalidResultReference',
610
+ `"#${name}" is not a valid ResultReference object.`
611
+ );
612
+ }
613
+ const source = priorResponses.find(
614
+ (entry) => entry[2] === value.resultOf && entry[0] === value.name
615
+ );
616
+ if (!source) {
617
+ throw new JmapServerMethodError(
618
+ 'invalidResultReference',
619
+ `No response found for resultOf "${value.resultOf}" and name "${value.name}".`
620
+ );
621
+ }
622
+ try {
623
+ resolved[name] = evalJsonPointer(source[1], value.path);
624
+ } catch (error) {
625
+ throw new JmapServerMethodError(
626
+ 'invalidResultReference',
627
+ error instanceof Error ? error.message : String(error)
628
+ );
629
+ }
497
630
  }
498
- const type = url.searchParams.get('type') ?? blob.type;
499
- res.writeHead(200, { 'content-type': type, 'content-length': blob.data.length });
500
- res.end(blob.data);
631
+ return resolved;
501
632
  }
502
633
 
503
634
  // ======================
504
- // jmap method dispatch
635
+ // method dispatch
505
636
  // ======================
506
637
 
507
- private dispatchMethod(
508
- user: IJmapServerUser,
638
+ private async dispatchMethod(
639
+ account: IJmapAccountInfo,
509
640
  methodName: string,
510
641
  args: Record<string, any>,
642
+ using: Set<string>,
511
643
  createdIds: Map<string, string>
512
- ): Record<string, any> {
644
+ ): Promise<Array<{ name: string; result: Record<string, any>; mutated?: boolean }>> {
645
+ 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,
658
+ };
659
+ const capability = requiredCapability[methodName];
660
+ if (!capability) {
661
+ throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
662
+ }
663
+ if (!using.has(capability)) {
664
+ throw new JmapServerMethodError(
665
+ 'unknownMethod',
666
+ `Method "${methodName}" requires the capability "${capability}" in "using".`
667
+ );
668
+ }
669
+ if (methodName !== 'Core/echo') {
670
+ this.requireAccountId(account, args);
671
+ }
672
+
513
673
  switch (methodName) {
514
674
  case 'Core/echo':
515
- return args;
675
+ return [{ name: methodName, result: args }];
516
676
  case 'Mailbox/get':
517
- return this.methodMailboxGet(user, args);
677
+ return [{ name: methodName, result: await this.methodMailboxGet(account, args) }];
518
678
  case 'Mailbox/query':
519
- return this.methodMailboxQuery(user, args);
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) }];
520
684
  case 'Email/get':
521
- return this.methodEmailGet(user, args);
685
+ return [{ name: methodName, result: await this.methodEmailGet(account, args) }];
522
686
  case 'Email/query':
523
- return this.methodEmailQuery(user, args);
687
+ return [{ name: methodName, result: await this.methodEmailQuery(account, args) }];
524
688
  case 'Email/changes':
525
- return this.methodEmailChanges(user, args);
526
- case 'Email/set':
527
- return this.methodEmailSet(user, args, createdIds);
689
+ return [{ name: methodName, result: await this.methodEmailChanges(account, args) }];
690
+ case 'Email/queryChanges':
691
+ // Explicitly unsupported in phase 1: clients fall back to Email/query.
692
+ throw new JmapServerMethodError(
693
+ 'cannotCalculateChanges',
694
+ 'Email/queryChanges is not supported; re-run the query instead.'
695
+ );
696
+ case 'Email/set': {
697
+ const result = await this.methodEmailSet(account, args, createdIds);
698
+ return [{ name: methodName, result, mutated: result.newState !== result.oldState }];
699
+ }
528
700
  case 'Identity/get':
529
- return this.methodIdentityGet(user);
701
+ return [{ name: methodName, result: await this.methodIdentityGet(account, args) }];
530
702
  case 'EmailSubmission/set':
531
- return this.methodEmailSubmissionSet(user, args, createdIds);
703
+ return this.methodEmailSubmissionSet(account, args, createdIds);
532
704
  default:
533
705
  throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
534
706
  }
535
707
  }
536
708
 
537
- private methodMailboxGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
538
- const all = Array.from(user.mailboxes.values());
539
- let list: IJmapServerMailbox[];
540
- const notFound: string[] = [];
541
- if (args.ids == null) {
542
- list = all;
543
- } else {
544
- list = [];
545
- for (const id of args.ids as string[]) {
546
- const mailbox = user.mailboxes.get(id);
547
- if (mailbox) {
548
- list.push(mailbox);
549
- } else {
550
- notFound.push(id);
551
- }
552
- }
709
+ private requireAccountId(account: IJmapAccountInfo, args: Record<string, any>): void {
710
+ if (typeof args.accountId !== 'string') {
711
+ throw new JmapServerMethodError('invalidArguments', 'accountId is required.');
553
712
  }
554
- const augmented = list.map((mailbox) => {
555
- const emails = Array.from(user.emails.values()).filter(
556
- (email) => email.mailboxIds?.[mailbox.id]
713
+ if (args.accountId !== account.accountId) {
714
+ throw new JmapServerMethodError(
715
+ 'accountNotFound',
716
+ `Account "${args.accountId}" is not accessible with these credentials.`
557
717
  );
558
- return {
718
+ }
719
+ }
720
+
721
+ // ======================
722
+ // mailbox methods
723
+ // ======================
724
+
725
+ private async methodMailboxGet(
726
+ account: IJmapAccountInfo,
727
+ args: Record<string, any>
728
+ ): Promise<Record<string, any>> {
729
+ let ids: string[] | null = null;
730
+ if (args.ids !== null && args.ids !== undefined) {
731
+ if (!Array.isArray(args.ids)) {
732
+ throw new JmapServerMethodError('invalidArguments', 'ids must be an array of ids or null.');
733
+ }
734
+ if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
735
+ throw new JmapServerMethodError(
736
+ 'requestTooLarge',
737
+ `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
738
+ );
739
+ }
740
+ ids = args.ids as string[];
741
+ }
742
+ const properties = this.validateProperties(args.properties, KNOWN_MAILBOX_PROPERTIES);
743
+ const result = await this.backend.getMailboxes(account.accountId, ids);
744
+ const list = result.list.map((mailbox) => {
745
+ const full: Record<string, any> = {
559
746
  ...mailbox,
560
- totalEmails: emails.length,
561
- unreadEmails: emails.filter((email) => !email.keywords?.$seen).length,
562
- totalThreads: emails.length,
563
- unreadThreads: emails.filter((email) => !email.keywords?.$seen).length,
564
- myRights: {
565
- mayReadItems: true,
566
- mayAddItems: true,
567
- mayRemoveItems: true,
568
- maySetSeen: true,
569
- maySetKeywords: true,
570
- mayCreateChild: true,
571
- mayRename: true,
572
- mayDelete: true,
573
- maySubmit: true,
574
- },
575
- isSubscribed: true,
747
+ isSubscribed: mailbox.isSubscribed ?? true,
748
+ myRights: mailbox.myRights ?? { ...DEFAULT_MAILBOX_RIGHTS },
576
749
  };
750
+ return this.projectObject(full, properties ?? MAILBOX_PROPERTIES);
577
751
  });
578
752
  return {
579
- accountId: user.accountId,
580
- state: String(user.state),
581
- list: augmented,
582
- notFound,
753
+ accountId: account.accountId,
754
+ state: result.state,
755
+ list,
756
+ notFound: result.notFound,
583
757
  };
584
758
  }
585
759
 
586
- private methodMailboxQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
587
- const filter = args.filter ?? {};
588
- let mailboxes = Array.from(user.mailboxes.values());
589
- if (typeof filter.role === 'string') {
590
- mailboxes = mailboxes.filter((mailbox) => mailbox.role === filter.role);
591
- }
592
- if (typeof filter.name === 'string') {
593
- mailboxes = mailboxes.filter((mailbox) => mailbox.name === filter.name);
760
+ private async methodMailboxQuery(
761
+ account: IJmapAccountInfo,
762
+ args: Record<string, any>
763
+ ): Promise<Record<string, any>> {
764
+ const filter = (args.filter ?? {}) as Record<string, any>;
765
+ if ('operator' in filter) {
766
+ throw new JmapServerMethodError(
767
+ 'unsupportedFilter',
768
+ 'FilterOperator is not supported for Mailbox/query.'
769
+ );
594
770
  }
595
- const ids = mailboxes.map((mailbox) => mailbox.id);
596
- return {
597
- accountId: user.accountId,
598
- queryState: String(user.state),
599
- canCalculateChanges: false,
600
- position: 0,
601
- ids,
602
- total: ids.length,
603
- };
604
- }
605
-
606
- private methodEmailGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
607
- if (!Array.isArray(args.ids)) {
608
- throw new JmapServerMethodError('invalidArguments', 'Email/get requires an ids array.');
771
+ for (const key of Object.keys(filter)) {
772
+ if (!['role', 'name', 'parentId'].includes(key)) {
773
+ throw new JmapServerMethodError(
774
+ 'unsupportedFilter',
775
+ `Mailbox filter condition "${key}" is not supported.`
776
+ );
777
+ }
609
778
  }
610
- const list: Array<Record<string, any>> = [];
611
- const notFound: string[] = [];
612
- for (const id of args.ids as string[]) {
613
- const email = user.emails.get(id);
614
- if (email) {
615
- // Returned as a full superset of the requestable properties;
616
- // property projection is intentionally not implemented.
617
- list.push({ ...email });
618
- } else {
619
- notFound.push(id);
779
+ for (const comparator of (args.sort ?? []) as Array<Record<string, any>>) {
780
+ if (!['name', 'sortOrder'].includes(comparator.property)) {
781
+ throw new JmapServerMethodError(
782
+ 'unsupportedSort',
783
+ `Mailbox sort property "${comparator.property}" is not supported.`
784
+ );
620
785
  }
621
786
  }
622
- return {
623
- accountId: user.accountId,
624
- state: String(user.state),
625
- list,
626
- notFound,
627
- };
628
- }
629
-
630
- private methodEmailQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
631
- const filter = args.filter ?? {};
632
- let emails = Array.from(user.emails.values());
633
- if (typeof filter.inMailbox === 'string') {
634
- emails = emails.filter((email) => email.mailboxIds?.[filter.inMailbox]);
787
+ const result = await this.backend.getMailboxes(account.accountId, null);
788
+ let mailboxes = result.list;
789
+ if (typeof filter.role === 'string') {
790
+ mailboxes = mailboxes.filter((mailbox) => mailbox.role === filter.role);
635
791
  }
636
- if (typeof filter.subject === 'string') {
637
- emails = emails.filter((email) =>
638
- String(email.subject ?? '').toLowerCase().includes(filter.subject.toLowerCase())
639
- );
792
+ if (typeof filter.name === 'string') {
793
+ mailboxes = mailboxes.filter((mailbox) => mailbox.name === filter.name);
640
794
  }
641
- if (typeof filter.text === 'string') {
642
- const needle = filter.text.toLowerCase();
643
- emails = emails.filter(
644
- (email) =>
645
- String(email.subject ?? '').toLowerCase().includes(needle) ||
646
- String(email.bodyValues?.text?.value ?? '').toLowerCase().includes(needle)
647
- );
795
+ if ('parentId' in filter) {
796
+ mailboxes = mailboxes.filter((mailbox) => mailbox.parentId === filter.parentId);
648
797
  }
649
- const sort = Array.isArray(args.sort) && args.sort.length ? args.sort[0] : null;
650
- if (sort?.property === 'receivedAt') {
651
- emails.sort((a, b) => {
652
- const comparison = String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? ''));
653
- return sort.isAscending === false ? -comparison : comparison;
798
+ const comparators = (args.sort ?? []) as Array<Record<string, any>>;
799
+ if (comparators.length) {
800
+ mailboxes = [...mailboxes].sort((a, b) => {
801
+ for (const comparator of comparators) {
802
+ const direction = comparator.isAscending === false ? -1 : 1;
803
+ const property = comparator.property as 'name' | 'sortOrder';
804
+ const left = a[property] ?? 0;
805
+ const right = b[property] ?? 0;
806
+ if (left < right) {
807
+ return -direction;
808
+ }
809
+ if (left > right) {
810
+ return direction;
811
+ }
812
+ }
813
+ return 0;
654
814
  });
655
815
  }
656
- const position = typeof args.position === 'number' ? args.position : 0;
657
- let ids = emails.map((email) => email.id as string).slice(position);
658
- const total = emails.length;
816
+ const position = this.validatePosition(args);
817
+ let ids = mailboxes.map((mailbox) => mailbox.id).slice(position);
659
818
  if (typeof args.limit === 'number') {
660
819
  ids = ids.slice(0, args.limit);
661
820
  }
662
- return {
663
- accountId: user.accountId,
664
- queryState: String(user.state),
821
+ const response: Record<string, any> = {
822
+ accountId: account.accountId,
823
+ queryState: result.state,
665
824
  canCalculateChanges: false,
666
825
  position,
667
826
  ids,
668
- total,
669
827
  };
828
+ if (args.calculateTotal === true) {
829
+ response.total = mailboxes.length;
830
+ }
831
+ return response;
670
832
  }
671
833
 
672
- private methodEmailChanges(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
673
- const since = Number(args.sinceState);
674
- if (!Number.isFinite(since) || since < 0 || since > user.state) {
834
+ private async methodMailboxChanges(
835
+ account: IJmapAccountInfo,
836
+ args: Record<string, any>
837
+ ): Promise<Record<string, any>> {
838
+ const { sinceState, maxChanges } = this.validateChangesArgs(args);
839
+ const changes = await this.backend.getMailboxChanges(account.accountId, sinceState, maxChanges);
840
+ if (!changes) {
675
841
  throw new JmapServerMethodError(
676
842
  'cannotCalculateChanges',
677
- `Cannot calculate changes since state "${args.sinceState}".`
843
+ `Cannot calculate mailbox changes since state "${sinceState}".`
678
844
  );
679
845
  }
680
- const created = new Set<string>();
681
- const updated = new Set<string>();
682
- const destroyed = new Set<string>();
683
- for (const entry of user.changes) {
684
- if (entry.state <= since) {
685
- continue;
686
- }
687
- for (const id of entry.created) {
688
- created.add(id);
689
- }
690
- for (const id of entry.updated) {
691
- updated.add(id);
692
- }
693
- for (const id of entry.destroyed) {
694
- if (created.has(id)) {
695
- // created and destroyed within the window: omit entirely
696
- created.delete(id);
697
- } else {
698
- destroyed.add(id);
699
- }
700
- updated.delete(id);
701
- }
846
+ return { accountId: account.accountId, ...changes };
847
+ }
848
+
849
+ // ======================
850
+ // thread methods
851
+ // ======================
852
+
853
+ private async methodThreadGet(
854
+ account: IJmapAccountInfo,
855
+ args: Record<string, any>
856
+ ): Promise<Record<string, any>> {
857
+ if (!Array.isArray(args.ids)) {
858
+ throw new JmapServerMethodError('invalidArguments', 'Thread/get requires an ids array.');
702
859
  }
703
- for (const id of created) {
704
- updated.delete(id);
860
+ if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
861
+ throw new JmapServerMethodError(
862
+ 'requestTooLarge',
863
+ `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
864
+ );
705
865
  }
866
+ // Result-reference chains like Email/get -> /list/*/threadId commonly
867
+ // contain duplicates; deduplicate so each thread is returned once.
868
+ const ids = Array.from(new Set(args.ids as string[]));
869
+ const result = await this.backend.getThreads(account.accountId, ids);
706
870
  return {
707
- accountId: user.accountId,
708
- oldState: String(since),
709
- newState: String(user.state),
710
- hasMoreChanges: false,
711
- created: Array.from(created),
712
- updated: Array.from(updated),
713
- destroyed: Array.from(destroyed),
871
+ accountId: account.accountId,
872
+ state: result.state,
873
+ list: result.list,
874
+ notFound: result.notFound,
714
875
  };
715
876
  }
716
877
 
717
- private methodEmailSet(
718
- user: IJmapServerUser,
719
- args: Record<string, any>,
720
- createdIds: Map<string, string>
721
- ): Record<string, any> {
722
- const oldState = String(user.state);
723
- const created: Record<string, any> = {};
724
- const notCreated: Record<string, any> = {};
725
- const updated: Record<string, any> = {};
726
- const notUpdated: Record<string, any> = {};
727
- const destroyed: string[] = [];
728
- const notDestroyed: Record<string, any> = {};
729
- const changedCreated: string[] = [];
730
- const changedUpdated: string[] = [];
731
- const changedDestroyed: string[] = [];
878
+ // ======================
879
+ // email methods
880
+ // ======================
732
881
 
733
- for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
734
- try {
735
- const email = this.createEmailFromSpec(user, spec);
736
- user.emails.set(email.id, email);
737
- createdIds.set(creationId, email.id);
738
- changedCreated.push(email.id);
739
- created[creationId] = {
740
- id: email.id,
741
- blobId: email.blobId,
742
- threadId: email.threadId,
743
- size: email.size,
744
- };
745
- } catch (error) {
746
- const message = error instanceof Error ? error.message : String(error);
747
- notCreated[creationId] = { type: 'invalidProperties', description: message };
748
- }
882
+ private async methodEmailGet(
883
+ account: IJmapAccountInfo,
884
+ args: Record<string, any>
885
+ ): Promise<Record<string, any>> {
886
+ if (args.ids === null || args.ids === undefined) {
887
+ // RFC 8621 allows rejecting "fetch all" for the potentially huge Email type.
888
+ throw new JmapServerMethodError(
889
+ 'requestTooLarge',
890
+ 'Email/get requires an explicit ids array on this server.'
891
+ );
749
892
  }
893
+ if (!Array.isArray(args.ids)) {
894
+ throw new JmapServerMethodError('invalidArguments', 'Email/get requires an ids array.');
895
+ }
896
+ if (args.ids.length > JMAP_SERVER_LIMITS.maxObjectsInGet) {
897
+ throw new JmapServerMethodError(
898
+ 'requestTooLarge',
899
+ `ids exceeds maxObjectsInGet (${JMAP_SERVER_LIMITS.maxObjectsInGet}).`
900
+ );
901
+ }
902
+ const properties = this.validateProperties(args.properties, KNOWN_EMAIL_PROPERTIES);
903
+ const result = await this.backend.getEmails(account.accountId, args.ids as string[], properties);
904
+ const effectiveProperties = properties ?? DEFAULT_EMAIL_PROPERTIES;
905
+ const list = result.list.map((record) => {
906
+ const projected = this.projectObject(record as any, effectiveProperties);
907
+ if (effectiveProperties.includes('bodyValues')) {
908
+ projected.bodyValues = this.buildBodyValues(record, args);
909
+ }
910
+ return projected;
911
+ });
912
+ return {
913
+ accountId: account.accountId,
914
+ state: result.state,
915
+ list,
916
+ notFound: result.notFound,
917
+ };
918
+ }
750
919
 
751
- for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
752
- const email = user.emails.get(id);
753
- if (!email) {
754
- notUpdated[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
920
+ /** Applies the Email/get body-value fetch flags and maxBodyValueBytes truncation. */
921
+ private buildBodyValues(
922
+ record: IJmapEmailRecord,
923
+ args: Record<string, any>
924
+ ): Record<string, IJmapEmailBodyValue> {
925
+ const wanted = new Set<string>();
926
+ if (args.fetchAllBodyValues === true) {
927
+ for (const partId of Object.keys(record.bodyValues)) {
928
+ wanted.add(partId);
929
+ }
930
+ }
931
+ if (args.fetchTextBodyValues === true) {
932
+ for (const part of record.textBody) {
933
+ if (part.partId != null) {
934
+ wanted.add(part.partId);
935
+ }
936
+ }
937
+ }
938
+ if (args.fetchHTMLBodyValues === true) {
939
+ for (const part of record.htmlBody) {
940
+ if (part.partId != null) {
941
+ wanted.add(part.partId);
942
+ }
943
+ }
944
+ }
945
+ const maxBytes =
946
+ typeof args.maxBodyValueBytes === 'number' && args.maxBodyValueBytes > 0
947
+ ? args.maxBodyValueBytes
948
+ : null;
949
+ const result: Record<string, IJmapEmailBodyValue> = {};
950
+ for (const partId of wanted) {
951
+ const bodyValue = record.bodyValues[partId];
952
+ if (!bodyValue) {
755
953
  continue;
756
954
  }
757
- try {
758
- this.applyPatch(email, patch);
759
- changedUpdated.push(id);
760
- updated[id] = null;
761
- } catch (error) {
762
- const message = error instanceof Error ? error.message : String(error);
763
- notUpdated[id] = { type: 'invalidPatch', description: message };
955
+ let value = bodyValue.value;
956
+ let isTruncated = bodyValue.isTruncated === true;
957
+ if (maxBytes !== null) {
958
+ const truncated = truncateUtf8(value, maxBytes);
959
+ if (truncated.length < value.length) {
960
+ value = truncated;
961
+ isTruncated = true;
962
+ }
764
963
  }
964
+ result[partId] = { value, isTruncated };
765
965
  }
966
+ return result;
967
+ }
766
968
 
767
- for (const id of (args.destroy ?? []) as string[]) {
768
- if (user.emails.delete(id)) {
769
- changedDestroyed.push(id);
770
- destroyed.push(id);
771
- } else {
772
- notDestroyed[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
969
+ private async methodEmailQuery(
970
+ account: IJmapAccountInfo,
971
+ args: Record<string, any>
972
+ ): Promise<Record<string, any>> {
973
+ const filter = (args.filter ?? {}) as Record<string, any>;
974
+ if ('operator' in filter) {
975
+ throw new JmapServerMethodError(
976
+ 'unsupportedFilter',
977
+ 'FilterOperator is not supported; use a flat FilterCondition.'
978
+ );
979
+ }
980
+ for (const key of Object.keys(filter)) {
981
+ if (!SUPPORTED_EMAIL_FILTER_KEYS.has(key)) {
982
+ throw new JmapServerMethodError(
983
+ 'unsupportedFilter',
984
+ `Email filter condition "${key}" is not supported.`
985
+ );
986
+ }
987
+ }
988
+ const sort = (args.sort ?? []) as Array<Record<string, any>>;
989
+ for (const comparator of sort) {
990
+ if (comparator.property !== 'receivedAt') {
991
+ throw new JmapServerMethodError(
992
+ 'unsupportedSort',
993
+ `Email sort property "${comparator.property}" is not supported.`
994
+ );
995
+ }
996
+ }
997
+ if (args.anchor !== undefined && args.anchor !== null) {
998
+ throw new JmapServerMethodError('invalidArguments', 'anchor is not supported.');
999
+ }
1000
+ const position = this.validatePosition(args);
1001
+ const result = await this.backend.queryEmails(account.accountId, {
1002
+ filter: filter as IJmapEmailFilter,
1003
+ sort: sort as Array<{ property: string; isAscending?: boolean }>,
1004
+ position,
1005
+ limit: typeof args.limit === 'number' ? args.limit : null,
1006
+ calculateTotal: args.calculateTotal === true,
1007
+ });
1008
+ const response: Record<string, any> = {
1009
+ accountId: account.accountId,
1010
+ queryState: result.queryState,
1011
+ canCalculateChanges: false,
1012
+ position: result.position,
1013
+ ids: result.ids,
1014
+ };
1015
+ if (args.calculateTotal === true) {
1016
+ response.total = result.total ?? 0;
1017
+ }
1018
+ return response;
1019
+ }
1020
+
1021
+ private async methodEmailChanges(
1022
+ account: IJmapAccountInfo,
1023
+ args: Record<string, any>
1024
+ ): Promise<Record<string, any>> {
1025
+ const { sinceState, maxChanges } = this.validateChangesArgs(args);
1026
+ const changes = await this.backend.getEmailChanges(account.accountId, sinceState, maxChanges);
1027
+ if (!changes) {
1028
+ throw new JmapServerMethodError(
1029
+ 'cannotCalculateChanges',
1030
+ `Cannot calculate changes since state "${sinceState}".`
1031
+ );
1032
+ }
1033
+ return { accountId: account.accountId, ...changes };
1034
+ }
1035
+
1036
+ private async methodEmailSet(
1037
+ account: IJmapAccountInfo,
1038
+ args: Record<string, any>,
1039
+ createdIds: Map<string, string>
1040
+ ): 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>);
1043
+ const destroyIds = (args.destroy ?? []) as string[];
1044
+ if (
1045
+ createEntries.length + updateEntries.length + destroyIds.length >
1046
+ JMAP_SERVER_LIMITS.maxObjectsInSet
1047
+ ) {
1048
+ throw new JmapServerMethodError(
1049
+ 'requestTooLarge',
1050
+ `The set request exceeds maxObjectsInSet (${JMAP_SERVER_LIMITS.maxObjectsInSet}).`
1051
+ );
1052
+ }
1053
+ if (typeof args.ifInState === 'string') {
1054
+ const current = await this.backend.getEmails(account.accountId, []);
1055
+ if (current.state !== args.ifInState) {
1056
+ throw new JmapServerMethodError(
1057
+ 'stateMismatch',
1058
+ `ifInState "${args.ifInState}" does not match the current state "${current.state}".`
1059
+ );
773
1060
  }
774
1061
  }
775
1062
 
776
- if (changedCreated.length || changedUpdated.length || changedDestroyed.length) {
777
- this.bumpState(user, {
778
- created: changedCreated,
779
- updated: changedUpdated,
780
- destroyed: changedDestroyed,
781
- });
1063
+ const backendRequest: IJmapEmailSetRequest = {};
1064
+ const notUpdatedEarly: Record<string, IJmapSetError> = {};
1065
+
1066
+ if (createEntries.length) {
1067
+ backendRequest.create = {};
1068
+ for (const [creationId, spec] of createEntries) {
1069
+ backendRequest.create[creationId] = spec as IJmapEmailCreate;
1070
+ }
1071
+ }
1072
+ if (updateEntries.length) {
1073
+ backendRequest.update = {};
1074
+ for (const [id, patch] of updateEntries) {
1075
+ try {
1076
+ backendRequest.update[id] = this.parseEmailUpdatePatch(patch as Record<string, any>);
1077
+ } catch (error) {
1078
+ notUpdatedEarly[id] =
1079
+ error instanceof JmapServerMethodError
1080
+ ? { type: error.type, description: error.message }
1081
+ : { type: 'invalidPatch', description: String(error) };
1082
+ }
1083
+ }
1084
+ }
1085
+ if (destroyIds.length) {
1086
+ backendRequest.destroy = destroyIds;
782
1087
  }
783
1088
 
1089
+ const result = await this.backend.setEmails(account.accountId, backendRequest);
1090
+
1091
+ const created: Record<string, any> = {};
1092
+ for (const [creationId, record] of Object.entries(result.created)) {
1093
+ createdIds.set(creationId, record.id);
1094
+ created[creationId] = {
1095
+ id: record.id,
1096
+ blobId: record.blobId,
1097
+ threadId: record.threadId,
1098
+ size: record.size,
1099
+ };
1100
+ }
1101
+ const updated: Record<string, null> = {};
1102
+ for (const id of result.updated) {
1103
+ updated[id] = null;
1104
+ }
784
1105
  return {
785
- accountId: user.accountId,
786
- oldState,
787
- newState: String(user.state),
1106
+ accountId: account.accountId,
1107
+ oldState: result.oldState,
1108
+ newState: result.newState,
788
1109
  created,
789
- notCreated,
1110
+ notCreated: result.notCreated,
790
1111
  updated,
791
- notUpdated,
792
- destroyed,
793
- notDestroyed,
1112
+ notUpdated: { ...result.notUpdated, ...notUpdatedEarly },
1113
+ destroyed: result.destroyed,
1114
+ notDestroyed: result.notDestroyed,
794
1115
  };
795
1116
  }
796
1117
 
797
- private methodIdentityGet(user: IJmapServerUser): Record<string, any> {
798
- return {
799
- accountId: user.accountId,
800
- state: '0',
801
- list: user.identities,
802
- notFound: [],
803
- };
1118
+ /**
1119
+ * Normalizes an Email/set update patch (RFC 8620 §5.3): full `keywords`/
1120
+ * `mailboxIds` replacements and single-key JSON-pointer patches
1121
+ * (`keywords/$seen`, `mailboxIds/<id>`). Email properties other than these
1122
+ * two are immutable (RFC 8621 §4.6).
1123
+ */
1124
+ private parseEmailUpdatePatch(patch: Record<string, any>): IJmapEmailUpdate {
1125
+ const update: IJmapEmailUpdate = {};
1126
+ const unescape = (token: string): string => token.replaceAll('~1', '/').replaceAll('~0', '~');
1127
+ 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.`);
1131
+ }
1132
+ const normalized: Record<string, boolean> = {};
1133
+ for (const [key, entryValue] of Object.entries(value as Record<string, any>)) {
1134
+ if (entryValue !== true) {
1135
+ throw new JmapServerMethodError(
1136
+ 'invalidProperties',
1137
+ `Values in "${path}" must be true; remove entries instead of setting them false.`
1138
+ );
1139
+ }
1140
+ normalized[key] = true;
1141
+ }
1142
+ update[path] = normalized;
1143
+ continue;
1144
+ }
1145
+ const slashIndex = path.indexOf('/');
1146
+ if (slashIndex === -1) {
1147
+ throw new JmapServerMethodError(
1148
+ 'invalidProperties',
1149
+ `Email property "${path}" is immutable or unknown.`
1150
+ );
1151
+ }
1152
+ const property = path.slice(0, slashIndex);
1153
+ const rest = path.slice(slashIndex + 1);
1154
+ if ((property !== 'keywords' && property !== 'mailboxIds') || rest.includes('/') || !rest) {
1155
+ throw new JmapServerMethodError('invalidPatch', `Unsupported patch path "${path}".`);
1156
+ }
1157
+ const key = unescape(rest);
1158
+ const normalizedValue = value === true ? true : value === null || value === false ? null : undefined;
1159
+ if (normalizedValue === undefined) {
1160
+ throw new JmapServerMethodError(
1161
+ 'invalidPatch',
1162
+ `Patch value for "${path}" must be true or null.`
1163
+ );
1164
+ }
1165
+ if (property === 'keywords') {
1166
+ update.keywordsPatch = update.keywordsPatch ?? {};
1167
+ update.keywordsPatch[key] = normalizedValue;
1168
+ } else {
1169
+ update.mailboxIdsPatch = update.mailboxIdsPatch ?? {};
1170
+ update.mailboxIdsPatch[key] = normalizedValue;
1171
+ }
1172
+ }
1173
+ if (update.keywords && update.keywordsPatch) {
1174
+ throw new JmapServerMethodError(
1175
+ 'invalidPatch',
1176
+ 'A patch must not set "keywords" and patch "keywords/..." at the same time.'
1177
+ );
1178
+ }
1179
+ if (update.mailboxIds && update.mailboxIdsPatch) {
1180
+ throw new JmapServerMethodError(
1181
+ 'invalidPatch',
1182
+ 'A patch must not set "mailboxIds" and patch "mailboxIds/..." at the same time.'
1183
+ );
1184
+ }
1185
+ return update;
1186
+ }
1187
+
1188
+ // ======================
1189
+ // identity + submission methods
1190
+ // ======================
1191
+
1192
+ private async methodIdentityGet(
1193
+ account: IJmapAccountInfo,
1194
+ args: Record<string, any>
1195
+ ): Promise<Record<string, any>> {
1196
+ const result = await this.backend.getIdentities(account.accountId);
1197
+ let list = result.list;
1198
+ const notFound: string[] = [];
1199
+ if (Array.isArray(args.ids)) {
1200
+ const wanted = new Set(args.ids as string[]);
1201
+ list = list.filter((identity) => wanted.has(identity.id));
1202
+ for (const id of wanted) {
1203
+ if (!list.some((identity) => identity.id === id)) {
1204
+ notFound.push(id);
1205
+ }
1206
+ }
1207
+ }
1208
+ return { accountId: account.accountId, state: result.state, list, notFound };
804
1209
  }
805
1210
 
806
- private methodEmailSubmissionSet(
807
- user: IJmapServerUser,
1211
+ private async methodEmailSubmissionSet(
1212
+ account: IJmapAccountInfo,
808
1213
  args: Record<string, any>,
809
1214
  createdIds: Map<string, string>
810
- ): Record<string, any> {
1215
+ ): Promise<Array<{ name: string; result: Record<string, any>; mutated?: boolean }>> {
811
1216
  const created: Record<string, any> = {};
812
- const notCreated: Record<string, any> = {};
1217
+ const notCreated: Record<string, IJmapSetError> = {};
1218
+ const notUpdated: Record<string, IJmapSetError> = {};
1219
+ const notDestroyed: Record<string, IJmapSetError> = {};
1220
+ /** creationId -> { submissionId, emailId } for onSuccess* resolution. */
1221
+ const createdSubmissions = new Map<string, { submissionId: string; emailId: string }>();
1222
+
1223
+ const identitiesResult = await this.backend.getIdentities(account.accountId);
1224
+
813
1225
  for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
814
1226
  let emailId: string = spec.emailId;
815
1227
  if (typeof emailId === 'string' && emailId.startsWith('#')) {
@@ -823,14 +1235,18 @@ export class JmapServer {
823
1235
  }
824
1236
  emailId = resolved;
825
1237
  }
826
- if (!user.emails.has(emailId)) {
1238
+ const emailResult = await this.backend.getEmails(account.accountId, [emailId]);
1239
+ const email = emailResult.list[0];
1240
+ if (!email) {
827
1241
  notCreated[creationId] = {
828
1242
  type: 'invalidProperties',
829
1243
  description: `Email "${emailId}" does not exist.`,
830
1244
  };
831
1245
  continue;
832
1246
  }
833
- const identity = user.identities.find((candidate) => candidate.id === spec.identityId);
1247
+ const identity = identitiesResult.list.find(
1248
+ (candidate) => candidate.id === spec.identityId
1249
+ );
834
1250
  if (!identity) {
835
1251
  notCreated[creationId] = {
836
1252
  type: 'invalidProperties',
@@ -838,182 +1254,562 @@ export class JmapServer {
838
1254
  };
839
1255
  continue;
840
1256
  }
841
- const id = `submission_${++user.idCounter}`;
842
- const submission: IJmapServerSubmission = {
843
- id,
844
- emailId,
845
- identityId: identity.id,
846
- undoStatus: 'final',
847
- sendAt: new Date().toISOString(),
1257
+ const envelope = this.resolveEnvelope(spec.envelope, identity.email, email);
1258
+ if (!envelope.rcptTo.length) {
1259
+ notCreated[creationId] = {
1260
+ type: 'noRecipients',
1261
+ description: 'The email has no to/cc/bcc recipients and no envelope was given.',
1262
+ };
1263
+ continue;
1264
+ }
1265
+ const blob = await this.backend.getBlob(account.accountId, email.blobId);
1266
+ if (!blob) {
1267
+ notCreated[creationId] = {
1268
+ type: 'invalidProperties',
1269
+ description: `Raw message blob "${email.blobId}" does not exist.`,
1270
+ };
1271
+ continue;
1272
+ }
1273
+ try {
1274
+ const submission = await this.backend.submitEmail(account.accountId, {
1275
+ emailId,
1276
+ identityId: identity.id,
1277
+ envelope,
1278
+ message: blob.data,
1279
+ });
1280
+ createdSubmissions.set(creationId, { submissionId: submission.id, emailId });
1281
+ created[creationId] = {
1282
+ id: submission.id,
1283
+ undoStatus: submission.undoStatus,
1284
+ sendAt: submission.sendAt,
1285
+ };
1286
+ } catch (error) {
1287
+ notCreated[creationId] = {
1288
+ type: 'forbiddenToSend',
1289
+ description: error instanceof Error ? error.message : String(error),
1290
+ };
1291
+ }
1292
+ }
1293
+
1294
+ for (const id of Object.keys((args.update ?? {}) as Record<string, any>)) {
1295
+ notUpdated[id] = {
1296
+ type: 'forbidden',
1297
+ description: 'EmailSubmission updates are not supported.',
848
1298
  };
849
- user.submissions.set(id, submission);
850
- created[creationId] = { id, undoStatus: submission.undoStatus, sendAt: submission.sendAt };
1299
+ }
1300
+ for (const id of (args.destroy ?? []) as string[]) {
1301
+ notDestroyed[id] = {
1302
+ type: 'forbidden',
1303
+ description: 'EmailSubmission destroys are not supported.',
1304
+ };
1305
+ }
1306
+
1307
+ const responses: Array<{ name: string; result: Record<string, any>; mutated?: boolean }> = [
1308
+ {
1309
+ name: 'EmailSubmission/set',
1310
+ result: {
1311
+ accountId: account.accountId,
1312
+ oldState: null,
1313
+ newState: '0',
1314
+ created,
1315
+ notCreated,
1316
+ updated: {},
1317
+ notUpdated,
1318
+ destroyed: [],
1319
+ notDestroyed,
1320
+ },
1321
+ mutated: createdSubmissions.size > 0,
1322
+ },
1323
+ ];
1324
+
1325
+ // onSuccessUpdateEmail / onSuccessDestroyEmail (RFC 8621 §7.5): apply an
1326
+ // implicit Email/set for successfully created submissions and append its
1327
+ // response after the EmailSubmission/set response.
1328
+ const resolveSubmissionRef = (reference: string): { submissionId: string; emailId: string } | null => {
1329
+ if (reference.startsWith('#')) {
1330
+ return createdSubmissions.get(reference.slice(1)) ?? null;
1331
+ }
1332
+ for (const entry of createdSubmissions.values()) {
1333
+ if (entry.submissionId === reference) {
1334
+ return entry;
1335
+ }
1336
+ }
1337
+ return null;
1338
+ };
1339
+ const implicitSet: Record<string, any> = { accountId: account.accountId };
1340
+ let hasImplicitSet = false;
1341
+ if (args.onSuccessUpdateEmail && typeof args.onSuccessUpdateEmail === 'object') {
1342
+ const update: Record<string, any> = {};
1343
+ for (const [reference, patch] of Object.entries(
1344
+ args.onSuccessUpdateEmail as Record<string, any>
1345
+ )) {
1346
+ const entry = resolveSubmissionRef(reference);
1347
+ if (entry) {
1348
+ update[entry.emailId] = patch;
1349
+ hasImplicitSet = true;
1350
+ }
1351
+ }
1352
+ implicitSet.update = update;
1353
+ }
1354
+ if (Array.isArray(args.onSuccessDestroyEmail)) {
1355
+ const destroy: string[] = [];
1356
+ for (const reference of args.onSuccessDestroyEmail as string[]) {
1357
+ const entry = resolveSubmissionRef(reference);
1358
+ if (entry) {
1359
+ destroy.push(entry.emailId);
1360
+ hasImplicitSet = true;
1361
+ }
1362
+ }
1363
+ implicitSet.destroy = destroy;
1364
+ }
1365
+ if (hasImplicitSet) {
1366
+ const result = await this.methodEmailSet(account, implicitSet, createdIds);
1367
+ responses.push({
1368
+ name: 'Email/set',
1369
+ result,
1370
+ mutated: result.newState !== result.oldState,
1371
+ });
1372
+ }
1373
+ return responses;
1374
+ }
1375
+
1376
+ /** Uses the client-provided envelope or derives it from the email's addresses. */
1377
+ private resolveEnvelope(
1378
+ specEnvelope: any,
1379
+ identityEmail: string,
1380
+ email: IJmapEmailRecord
1381
+ ): IJmapEnvelope {
1382
+ if (
1383
+ specEnvelope &&
1384
+ typeof specEnvelope === 'object' &&
1385
+ specEnvelope.mailFrom?.email &&
1386
+ Array.isArray(specEnvelope.rcptTo)
1387
+ ) {
1388
+ return {
1389
+ mailFrom: { email: String(specEnvelope.mailFrom.email) },
1390
+ rcptTo: specEnvelope.rcptTo
1391
+ .filter((recipient: any) => typeof recipient?.email === 'string')
1392
+ .map((recipient: any) => ({ email: recipient.email })),
1393
+ };
1394
+ }
1395
+ const recipients = new Set<string>();
1396
+ for (const list of [email.to, email.cc, email.bcc]) {
1397
+ for (const address of list ?? []) {
1398
+ recipients.add(address.email);
1399
+ }
851
1400
  }
852
1401
  return {
853
- accountId: user.accountId,
854
- oldState: String(user.state),
855
- newState: String(user.state),
856
- created,
857
- notCreated,
858
- updated: {},
859
- notUpdated: {},
860
- destroyed: [],
861
- notDestroyed: {},
1402
+ mailFrom: { email: email.from?.[0]?.email ?? identityEmail },
1403
+ rcptTo: Array.from(recipients).map((address) => ({ email: address })),
862
1404
  };
863
1405
  }
864
1406
 
865
1407
  // ======================
866
- // internal helpers
1408
+ // upload + download
867
1409
  // ======================
868
1410
 
869
- private requireUser(username: string): IJmapServerUser {
870
- const user = this.users.get(username);
871
- if (!user) {
872
- throw new Error(`User "${username}" does not exist.`);
1411
+ private async handleUpload(
1412
+ account: IJmapAccountInfo,
1413
+ pathname: string,
1414
+ request: Request
1415
+ ): Promise<Response> {
1416
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
1417
+ const accountId = decodeURIComponent(segments[2] ?? '');
1418
+ if (accountId !== account.accountId) {
1419
+ return this.problemResponse(404, 'about:blank', 'Unknown account');
873
1420
  }
874
- return user;
875
- }
876
-
877
- private createEmailFromSpec(user: IJmapServerUser, spec: Record<string, any>): Record<string, any> {
878
- const mailboxIds = spec.mailboxIds as Record<string, boolean> | undefined;
879
- if (!mailboxIds || !Object.keys(mailboxIds).length) {
880
- throw new Error('Email create requires mailboxIds.');
881
- }
882
- for (const mailboxId of Object.keys(mailboxIds)) {
883
- if (!user.mailboxes.has(mailboxId)) {
884
- throw new Error(`Mailbox "${mailboxId}" does not exist.`);
885
- }
886
- }
887
- const id = `email_${++user.idCounter}`;
888
- const receivedAt = new Date().toISOString();
889
- const bodyValues = (spec.bodyValues ?? {}) as Record<string, { value: string }>;
890
- const resolvePart = (part: Record<string, any>): string =>
891
- part?.partId != null ? bodyValues[part.partId]?.value ?? '' : '';
892
- const textBodyParts = ((spec.textBody ?? []) as Array<Record<string, any>>).map((part) => ({
893
- partId: part.partId ?? null,
894
- type: part.type ?? 'text/plain',
895
- size: resolvePart(part).length,
896
- }));
897
- const htmlBodyParts = ((spec.htmlBody ?? []) as Array<Record<string, any>>).map((part) => ({
898
- partId: part.partId ?? null,
899
- type: part.type ?? 'text/html',
900
- size: resolvePart(part).length,
901
- }));
902
- const textValue = textBodyParts.map((part) => bodyValues[part.partId ?? '']?.value ?? '').join('\n');
903
-
904
- const from = (spec.from ?? []) as IJmapEmailAddress[];
905
- const to = (spec.to ?? []) as IJmapEmailAddress[];
906
- const blobId = `blob_${id}`;
907
- const rawLines = [
908
- `From: ${from.map((address) => address.email).join(', ')}`,
909
- `To: ${to.map((address) => address.email).join(', ')}`,
910
- `Subject: ${spec.subject ?? ''}`,
911
- `Date: ${receivedAt}`,
912
- '',
913
- textValue,
914
- ];
915
- user.blobs.set(blobId, {
916
- data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
917
- type: 'message/rfc822',
918
- name: `${id}.eml`,
919
- });
1421
+ const data = new Uint8Array(await request.arrayBuffer());
1422
+ if (data.length > JMAP_SERVER_LIMITS.maxSizeUpload) {
1423
+ return this.limitResponse(
1424
+ 400,
1425
+ 'maxSizeUpload',
1426
+ `The upload is larger than ${JMAP_SERVER_LIMITS.maxSizeUpload} bytes.`
1427
+ );
1428
+ }
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 });
1432
+ }
920
1433
 
921
- const normalizedBodyValues: Record<string, { value: string; isTruncated: boolean }> = {};
922
- for (const [partId, bodyValue] of Object.entries(bodyValues)) {
923
- normalizedBodyValues[partId] = { value: bodyValue.value ?? '', isTruncated: false };
1434
+ private async handleDownload(
1435
+ account: IJmapAccountInfo,
1436
+ pathname: string,
1437
+ url: URL
1438
+ ): Promise<Response> {
1439
+ // /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');
1444
+ if (accountId !== account.accountId) {
1445
+ return this.problemResponse(404, 'about:blank', 'Unknown account');
1446
+ }
1447
+ const blob = await this.backend.getBlob(account.accountId, blobId);
1448
+ if (!blob) {
1449
+ return this.problemResponse(404, 'about:blank', 'Unknown blob');
924
1450
  }
1451
+ const type = url.searchParams.get('type') ?? blob.type;
1452
+ return new Response(asBodyBytes(blob.data), {
1453
+ status: 200,
1454
+ headers: {
1455
+ 'content-type': type,
1456
+ 'content-length': String(blob.data.length),
1457
+ 'content-disposition': `attachment; filename="${name.replaceAll('"', '')}"`,
1458
+ },
1459
+ });
1460
+ }
925
1461
 
926
- return {
927
- id,
928
- blobId,
929
- threadId: `thread_${id}`,
930
- mailboxIds: { ...mailboxIds },
931
- keywords: spec.keywords ?? {},
932
- size: textValue.length,
933
- receivedAt,
934
- sentAt: receivedAt,
935
- subject: spec.subject ?? null,
936
- from,
937
- to,
938
- cc: spec.cc ?? null,
939
- bcc: spec.bcc ?? null,
940
- replyTo: spec.replyTo ?? null,
941
- hasAttachment: false,
942
- preview: textValue.slice(0, 100),
943
- bodyValues: normalizedBodyValues,
944
- textBody: textBodyParts,
945
- htmlBody: htmlBodyParts,
946
- attachments: [],
1462
+ // ======================
1463
+ // event source (SSE)
1464
+ // ======================
1465
+
1466
+ 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;
1481
+
1482
+ const state: IEventStreamState = {
1483
+ accountId: account.accountId,
1484
+ types,
1485
+ closeAfterState,
1486
+ controller: null,
1487
+ pingTimer: null,
1488
+ lastSent: new Map(),
1489
+ closed: false,
947
1490
  };
1491
+ const stream = new ReadableStream<Uint8Array>({
1492
+ start: (controller) => {
1493
+ state.controller = controller;
1494
+ controller.enqueue(textEncoder.encode(': jmap event stream\n\n'));
1495
+ if (pingSeconds > 0) {
1496
+ state.pingTimer = setInterval(() => {
1497
+ this.writeToStream(state, `event: ping\ndata: {"interval":${pingSeconds}}\n\n`);
1498
+ }, pingSeconds * 1000);
1499
+ }
1500
+ try {
1501
+ this.registerStream(state);
1502
+ } catch (error) {
1503
+ // a throwing backend.subscribeToChanges must not strand the ping timer
1504
+ this.unregisterStream(state);
1505
+ throw error;
1506
+ }
1507
+ },
1508
+ cancel: () => {
1509
+ this.unregisterStream(state);
1510
+ },
1511
+ });
1512
+ return new Response(stream, {
1513
+ status: 200,
1514
+ headers: {
1515
+ 'content-type': 'text/event-stream',
1516
+ 'cache-control': 'no-cache',
1517
+ },
1518
+ });
1519
+ }
1520
+
1521
+ private registerStream(state: IEventStreamState): void {
1522
+ if (!this.changeFeedUnsubscribe) {
1523
+ this.changeFeedUnsubscribe = this.backend.subscribeToChanges((change) => {
1524
+ this.pushStateChange(change.accountId, change.changed);
1525
+ });
1526
+ }
1527
+ this.eventStreams.add(state);
1528
+ }
1529
+
1530
+ private unregisterStream(state: IEventStreamState): void {
1531
+ state.closed = true;
1532
+ if (state.pingTimer) {
1533
+ clearInterval(state.pingTimer);
1534
+ state.pingTimer = null;
1535
+ }
1536
+ this.eventStreams.delete(state);
1537
+ if (!this.eventStreams.size && this.changeFeedUnsubscribe) {
1538
+ this.changeFeedUnsubscribe();
1539
+ this.changeFeedUnsubscribe = null;
1540
+ }
1541
+ }
1542
+
1543
+ private closeStream(state: IEventStreamState): void {
1544
+ this.unregisterStream(state);
1545
+ try {
1546
+ state.controller?.close();
1547
+ } catch {
1548
+ // already closed or errored
1549
+ }
1550
+ }
1551
+
1552
+ private writeToStream(state: IEventStreamState, payload: string): void {
1553
+ if (state.closed || !state.controller) {
1554
+ return;
1555
+ }
1556
+ try {
1557
+ state.controller.enqueue(textEncoder.encode(payload));
1558
+ } catch {
1559
+ this.closeStream(state);
1560
+ }
948
1561
  }
949
1562
 
950
1563
  /**
951
- * Applies an Email/set update patch: either whole properties (e.g. keywords)
952
- * or JSON-pointer style paths like "keywords/$seen" (RFC 8620 §5.3).
1564
+ * Pushes a StateChange to matching event streams. Per-type states are
1565
+ * deduplicated per stream, so a mutation notified both by the backend
1566
+ * change feed and by the post-request push is delivered once.
953
1567
  */
954
- private applyPatch(email: Record<string, any>, patch: Record<string, any>): void {
955
- for (const [path, value] of Object.entries(patch)) {
956
- const segments = path.split('/');
957
- if (segments.length === 1) {
958
- if (value === null) {
959
- delete email[path];
960
- } else {
961
- email[path] = value;
962
- }
1568
+ private pushStateChange(accountId: string, changed: Record<string, string>): void {
1569
+ for (const state of Array.from(this.eventStreams)) {
1570
+ if (state.accountId !== accountId) {
963
1571
  continue;
964
1572
  }
965
- let target = email;
966
- for (const segment of segments.slice(0, -1)) {
967
- if (typeof target[segment] !== 'object' || target[segment] === null) {
968
- target[segment] = {};
1573
+ const filtered: Record<string, string> = {};
1574
+ for (const [type, typeState] of Object.entries(changed)) {
1575
+ if (state.types && !state.types.has(type)) {
1576
+ continue;
1577
+ }
1578
+ if (state.lastSent.get(type) === typeState) {
1579
+ continue;
969
1580
  }
970
- target = target[segment];
1581
+ filtered[type] = typeState;
971
1582
  }
972
- const leaf = segments[segments.length - 1];
973
- if (value === null || value === false) {
974
- delete target[leaf];
975
- } else {
976
- target[leaf] = value;
1583
+ if (!Object.keys(filtered).length) {
1584
+ continue;
1585
+ }
1586
+ for (const [type, typeState] of Object.entries(filtered)) {
1587
+ state.lastSent.set(type, typeState);
1588
+ }
1589
+ const stateChange = {
1590
+ '@type': 'StateChange',
1591
+ changed: { [accountId]: filtered },
1592
+ };
1593
+ this.writeToStream(state, `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`);
1594
+ if (state.closeAfterState) {
1595
+ this.closeStream(state);
977
1596
  }
978
1597
  }
979
1598
  }
980
1599
 
981
- /** Increments the account state, records the change, and pushes a StateChange to open event streams. */
982
- private bumpState(
983
- user: IJmapServerUser,
984
- change: { created?: string[]; updated?: string[]; destroyed?: string[] }
985
- ): void {
986
- user.state += 1;
987
- user.changes.push({
988
- state: user.state,
989
- created: change.created ?? [],
990
- updated: change.updated ?? [],
991
- destroyed: change.destroyed ?? [],
1600
+ /**
1601
+ * Emits a StateChange after a mutating JMAP request, covering backends
1602
+ * whose change feed only reports out-of-band mutations. Deduplicated
1603
+ * against feed-driven pushes via the per-stream state tracking.
1604
+ */
1605
+ private async pushAfterMutation(account: IJmapAccountInfo): Promise<void> {
1606
+ if (!this.eventStreams.size) {
1607
+ return;
1608
+ }
1609
+ const [emails, mailboxes, threads] = await Promise.all([
1610
+ this.backend.getEmails(account.accountId, []),
1611
+ this.backend.getMailboxes(account.accountId, []),
1612
+ this.backend.getThreads(account.accountId, []),
1613
+ ]);
1614
+ this.pushStateChange(account.accountId, {
1615
+ Email: emails.state,
1616
+ Thread: threads.state,
1617
+ Mailbox: mailboxes.state,
992
1618
  });
993
- this.notifyStateChange(user);
994
1619
  }
995
1620
 
996
- private notifyStateChange(user: IJmapServerUser): void {
997
- const stateChange = {
998
- '@type': 'StateChange',
999
- changed: {
1000
- [user.accountId]: {
1001
- Email: String(user.state),
1002
- Mailbox: String(user.state),
1003
- Thread: String(user.state),
1004
- },
1005
- },
1006
- };
1007
- const payload = `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`;
1008
- for (const [response, streamUser] of this.eventStreams) {
1009
- if (streamUser !== user) {
1010
- continue;
1621
+ // ======================
1622
+ // node req/res adapter
1623
+ // ======================
1624
+
1625
+ private async handleNodeRequest(
1626
+ req: plugins.http.IncomingMessage,
1627
+ res: plugins.http.ServerResponse
1628
+ ): 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);
1657
+ }
1658
+
1659
+ const headers = new Headers();
1660
+ const skippedHeaders = new Set([
1661
+ 'connection',
1662
+ 'content-length',
1663
+ 'expect',
1664
+ 'keep-alive',
1665
+ 'proxy-connection',
1666
+ 'transfer-encoding',
1667
+ 'upgrade',
1668
+ ]);
1669
+ for (const [key, value] of Object.entries(req.headers)) {
1670
+ if (value === undefined || skippedHeaders.has(key.toLowerCase())) {
1671
+ continue;
1672
+ }
1673
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
1674
+ }
1675
+ const host = req.headers.host ?? '127.0.0.1';
1676
+ const request = new Request(`http://${host}${req.url ?? '/'}`, {
1677
+ method,
1678
+ headers,
1679
+ body: body ? asBodyBytes(body) : undefined,
1680
+ });
1681
+
1682
+ const response = await this.fetchHandler(request);
1683
+
1684
+ const responseHeaders: Record<string, string> = {};
1685
+ response.headers.forEach((value, key) => {
1686
+ responseHeaders[key] = value;
1687
+ });
1688
+ res.writeHead(response.status, responseHeaders);
1689
+ if (!response.body) {
1690
+ res.end();
1691
+ return;
1011
1692
  }
1693
+ const reader = response.body.getReader();
1694
+ let clientClosed = false;
1695
+ res.on('close', () => {
1696
+ clientClosed = true;
1697
+ // Cancelling the reader triggers the stream's cancel handler, which
1698
+ // releases any per-stream resources (SSE registration, ping timer).
1699
+ void reader.cancel().catch(() => {});
1700
+ });
1012
1701
  try {
1013
- response.write(payload);
1702
+ while (true) {
1703
+ const { done, value } = await reader.read();
1704
+ if (done || clientClosed || res.destroyed) {
1705
+ break;
1706
+ }
1707
+ res.write(value);
1708
+ }
1014
1709
  } catch {
1015
- this.eventStreams.delete(response);
1710
+ // stream cancelled or connection gone
1711
+ }
1712
+ if (!res.destroyed) {
1713
+ res.end();
1016
1714
  }
1715
+ } catch (error) {
1716
+ const message = error instanceof Error ? error.message : String(error);
1717
+ if (!res.headersSent) {
1718
+ res.writeHead(500, { 'content-type': 'application/problem+json' });
1719
+ }
1720
+ res.end(JSON.stringify({ type: 'about:blank', status: 500, detail: message }));
1017
1721
  }
1018
1722
  }
1723
+
1724
+ // ======================
1725
+ // shared validators + responses
1726
+ // ======================
1727
+
1728
+ private validateProperties(
1729
+ requested: any,
1730
+ known: Set<string>
1731
+ ): string[] | null {
1732
+ if (requested === null || requested === undefined) {
1733
+ return null;
1734
+ }
1735
+ if (!Array.isArray(requested)) {
1736
+ throw new JmapServerMethodError('invalidArguments', 'properties must be an array or null.');
1737
+ }
1738
+ for (const property of requested) {
1739
+ if (typeof property !== 'string' || !known.has(property)) {
1740
+ throw new JmapServerMethodError(
1741
+ 'invalidArguments',
1742
+ `Unknown property "${String(property)}".`
1743
+ );
1744
+ }
1745
+ }
1746
+ return requested.includes('id') ? requested : ['id', ...requested];
1747
+ }
1748
+
1749
+ private projectObject(source: Record<string, any>, properties: string[]): Record<string, any> {
1750
+ const projected: Record<string, any> = {};
1751
+ for (const property of properties) {
1752
+ projected[property] = source[property] === undefined ? null : source[property];
1753
+ }
1754
+ return projected;
1755
+ }
1756
+
1757
+ private validatePosition(args: Record<string, any>): number {
1758
+ if (args.position === undefined || args.position === null) {
1759
+ return 0;
1760
+ }
1761
+ if (!Number.isInteger(args.position) || args.position < 0) {
1762
+ throw new JmapServerMethodError(
1763
+ 'invalidArguments',
1764
+ 'position must be a non-negative integer.'
1765
+ );
1766
+ }
1767
+ return args.position as number;
1768
+ }
1769
+
1770
+ private validateChangesArgs(args: Record<string, any>): {
1771
+ sinceState: string;
1772
+ maxChanges?: number;
1773
+ } {
1774
+ if (typeof args.sinceState !== 'string') {
1775
+ throw new JmapServerMethodError('invalidArguments', 'sinceState is required.');
1776
+ }
1777
+ let maxChanges: number | undefined;
1778
+ if (args.maxChanges !== undefined && args.maxChanges !== null) {
1779
+ if (!Number.isInteger(args.maxChanges) || args.maxChanges <= 0) {
1780
+ throw new JmapServerMethodError(
1781
+ 'invalidArguments',
1782
+ 'maxChanges must be a positive integer.'
1783
+ );
1784
+ }
1785
+ maxChanges = args.maxChanges as number;
1786
+ }
1787
+ return { sinceState: args.sinceState, maxChanges };
1788
+ }
1789
+
1790
+ private jsonResponse(status: number, body: unknown): Response {
1791
+ return new Response(JSON.stringify(body), {
1792
+ status,
1793
+ headers: { 'content-type': 'application/json' },
1794
+ });
1795
+ }
1796
+
1797
+ private problemResponse(
1798
+ status: number,
1799
+ type: string,
1800
+ detail: string,
1801
+ extraHeaders: Record<string, string> = {}
1802
+ ): Response {
1803
+ return new Response(JSON.stringify({ type, status, detail }), {
1804
+ status,
1805
+ headers: { 'content-type': 'application/problem+json', ...extraHeaders },
1806
+ });
1807
+ }
1808
+
1809
+ private limitResponse(status: number, limit: string, detail: string): Response {
1810
+ return new Response(
1811
+ JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit, status, detail }),
1812
+ { status, headers: { 'content-type': 'application/problem+json' } }
1813
+ );
1814
+ }
1019
1815
  }