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