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