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