@push.rocks/smartjmap 1.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 +56 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.jmapclient.d.ts +236 -0
- package/dist_ts/classes.jmapclient.js +650 -0
- package/dist_ts/classes.jmapserver.d.ts +110 -0
- package/dist_ts/classes.jmapserver.js +873 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -0
- package/dist_ts/smartjmap.plugins.d.ts +4 -0
- package/dist_ts/smartjmap.plugins.js +6 -0
- package/license +19 -0
- package/npmextra.json +39 -0
- package/package.json +68 -0
- package/readme.hints.md +34 -0
- package/readme.md +287 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.jmapclient.ts +872 -0
- package/ts/classes.jmapserver.ts +1019 -0
- package/ts/index.ts +2 -0
- package/ts/smartjmap.plugins.ts +10 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import * as plugins from './smartjmap.plugins.js';
|
|
2
|
+
import type { IJmapEmailAddress } from './classes.jmapclient.js';
|
|
3
|
+
|
|
4
|
+
export interface IJmapServerMailbox {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
role: string | null;
|
|
8
|
+
parentId: string | null;
|
|
9
|
+
sortOrder: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IJmapServerBlob {
|
|
13
|
+
data: Buffer;
|
|
14
|
+
type: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IJmapServerChangeEntry {
|
|
19
|
+
state: number;
|
|
20
|
+
created: string[];
|
|
21
|
+
updated: string[];
|
|
22
|
+
destroyed: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IJmapServerIdentity {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
email: string;
|
|
29
|
+
mayDelete: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IJmapServerSubmission {
|
|
33
|
+
id: string;
|
|
34
|
+
emailId: string;
|
|
35
|
+
identityId: string;
|
|
36
|
+
undoStatus: string;
|
|
37
|
+
sendAt: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IJmapServerUser {
|
|
41
|
+
username: string;
|
|
42
|
+
password: string;
|
|
43
|
+
bearerTokens: Set<string>;
|
|
44
|
+
accountId: string;
|
|
45
|
+
mailboxes: Map<string, IJmapServerMailbox>;
|
|
46
|
+
emails: Map<string, Record<string, any>>;
|
|
47
|
+
blobs: Map<string, IJmapServerBlob>;
|
|
48
|
+
submissions: Map<string, IJmapServerSubmission>;
|
|
49
|
+
identities: IJmapServerIdentity[];
|
|
50
|
+
state: number;
|
|
51
|
+
changes: IJmapServerChangeEntry[];
|
|
52
|
+
idCounter: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IJmapServerEmailInput {
|
|
56
|
+
from: IJmapEmailAddress | IJmapEmailAddress[];
|
|
57
|
+
to: IJmapEmailAddress | IJmapEmailAddress[];
|
|
58
|
+
cc?: IJmapEmailAddress[];
|
|
59
|
+
bcc?: IJmapEmailAddress[];
|
|
60
|
+
subject?: string;
|
|
61
|
+
textBody?: string;
|
|
62
|
+
htmlBody?: string;
|
|
63
|
+
keywords?: Record<string, boolean>;
|
|
64
|
+
receivedAt?: string;
|
|
65
|
+
attachments?: Array<{ name: string; type: string; content: string | Uint8Array }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Error type used inside method dispatch; rendered as a JMAP `error` method response. */
|
|
69
|
+
class JmapServerMethodError extends Error {
|
|
70
|
+
constructor(public type: string, description?: string) {
|
|
71
|
+
super(description ?? type);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A minimal in-memory JMAP server (RFC 8620 core + RFC 8621 mail subset)
|
|
77
|
+
* for offline testing. Serves the session resource at /.well-known/jmap and
|
|
78
|
+
* /jmap/session, the API at /jmap/api, blob downloads at /jmap/download/...,
|
|
79
|
+
* and StateChange pushes via SSE at /jmap/eventsource.
|
|
80
|
+
*/
|
|
81
|
+
export class JmapServer {
|
|
82
|
+
public users: Map<string, IJmapServerUser> = new Map();
|
|
83
|
+
private server: plugins.http.Server;
|
|
84
|
+
private sockets: Set<plugins.net.Socket> = new Set();
|
|
85
|
+
private eventStreams: Map<plugins.http.ServerResponse, IJmapServerUser> = new Map();
|
|
86
|
+
|
|
87
|
+
constructor() {
|
|
88
|
+
this.server = plugins.http.createServer((req, res) => {
|
|
89
|
+
this.handleRequest(req, res);
|
|
90
|
+
});
|
|
91
|
+
this.server.on('connection', (socket) => {
|
|
92
|
+
this.sockets.add(socket);
|
|
93
|
+
socket.on('close', () => {
|
|
94
|
+
this.sockets.delete(socket);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ======================
|
|
100
|
+
// store management
|
|
101
|
+
// ======================
|
|
102
|
+
|
|
103
|
+
public addUser(username: string, password: string): void {
|
|
104
|
+
if (this.users.has(username)) {
|
|
105
|
+
throw new Error(`User "${username}" already exists.`);
|
|
106
|
+
}
|
|
107
|
+
const email = username.includes('@') ? username : `${username}@example.com`;
|
|
108
|
+
this.users.set(username, {
|
|
109
|
+
username,
|
|
110
|
+
password,
|
|
111
|
+
bearerTokens: new Set(),
|
|
112
|
+
accountId: `account_${username}`,
|
|
113
|
+
mailboxes: new Map(),
|
|
114
|
+
emails: new Map(),
|
|
115
|
+
blobs: new Map(),
|
|
116
|
+
submissions: new Map(),
|
|
117
|
+
identities: [{ id: 'identity_1', name: username, email, mayDelete: false }],
|
|
118
|
+
state: 0,
|
|
119
|
+
changes: [],
|
|
120
|
+
idCounter: 0,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Registers a valid Bearer token for a user. */
|
|
125
|
+
public addBearerToken(username: string, token: string): void {
|
|
126
|
+
const user = this.requireUser(username);
|
|
127
|
+
user.bearerTokens.add(token);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public createMailbox(username: string, name: string, role?: string): string {
|
|
131
|
+
const user = this.requireUser(username);
|
|
132
|
+
for (const mailbox of user.mailboxes.values()) {
|
|
133
|
+
if (mailbox.name === name) {
|
|
134
|
+
throw new Error(`Mailbox "${name}" already exists for user "${username}".`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const id = `mailbox_${++user.idCounter}`;
|
|
138
|
+
user.mailboxes.set(id, {
|
|
139
|
+
id,
|
|
140
|
+
name,
|
|
141
|
+
role: role ?? null,
|
|
142
|
+
parentId: null,
|
|
143
|
+
sortOrder: user.mailboxes.size,
|
|
144
|
+
});
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Seeds an email into a user's mailbox; bumps state and pushes a StateChange. Returns the email id. */
|
|
149
|
+
public addEmail(username: string, mailboxName: string, input: IJmapServerEmailInput): string {
|
|
150
|
+
const user = this.requireUser(username);
|
|
151
|
+
const mailbox = Array.from(user.mailboxes.values()).find(
|
|
152
|
+
(candidate) => candidate.name === mailboxName || candidate.role === mailboxName
|
|
153
|
+
);
|
|
154
|
+
if (!mailbox) {
|
|
155
|
+
throw new Error(`Mailbox "${mailboxName}" does not exist for user "${username}".`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const from = Array.isArray(input.from) ? input.from : [input.from];
|
|
159
|
+
const to = Array.isArray(input.to) ? input.to : [input.to];
|
|
160
|
+
const id = `email_${++user.idCounter}`;
|
|
161
|
+
const receivedAt = input.receivedAt ?? new Date().toISOString();
|
|
162
|
+
const textBody = input.textBody ?? '';
|
|
163
|
+
|
|
164
|
+
// raw message blob for Email/get blobId + downloads
|
|
165
|
+
const blobId = `blob_${id}`;
|
|
166
|
+
const rawLines = [
|
|
167
|
+
`From: ${from.map((address) => address.email).join(', ')}`,
|
|
168
|
+
`To: ${to.map((address) => address.email).join(', ')}`,
|
|
169
|
+
`Subject: ${input.subject ?? ''}`,
|
|
170
|
+
`Date: ${receivedAt}`,
|
|
171
|
+
'',
|
|
172
|
+
textBody,
|
|
173
|
+
];
|
|
174
|
+
user.blobs.set(blobId, {
|
|
175
|
+
data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
|
|
176
|
+
type: 'message/rfc822',
|
|
177
|
+
name: `${id}.eml`,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const bodyValues: Record<string, { value: string; isTruncated: boolean }> = {
|
|
181
|
+
text: { value: textBody, isTruncated: false },
|
|
182
|
+
};
|
|
183
|
+
const textBodyParts = [{ partId: 'text', type: 'text/plain', size: textBody.length }];
|
|
184
|
+
const htmlBodyParts: Array<Record<string, any>> = [];
|
|
185
|
+
if (typeof input.htmlBody === 'string') {
|
|
186
|
+
bodyValues.html = { value: input.htmlBody, isTruncated: false };
|
|
187
|
+
htmlBodyParts.push({ partId: 'html', type: 'text/html', size: input.htmlBody.length });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const attachmentParts: Array<Record<string, any>> = [];
|
|
191
|
+
for (const [index, attachment] of (input.attachments ?? []).entries()) {
|
|
192
|
+
const attachmentBlobId = `blob_${id}_att${index + 1}`;
|
|
193
|
+
const data = Buffer.from(attachment.content as any);
|
|
194
|
+
user.blobs.set(attachmentBlobId, {
|
|
195
|
+
data,
|
|
196
|
+
type: attachment.type,
|
|
197
|
+
name: attachment.name,
|
|
198
|
+
});
|
|
199
|
+
attachmentParts.push({
|
|
200
|
+
partId: null,
|
|
201
|
+
blobId: attachmentBlobId,
|
|
202
|
+
name: attachment.name,
|
|
203
|
+
type: attachment.type,
|
|
204
|
+
size: data.length,
|
|
205
|
+
disposition: 'attachment',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
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;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ======================
|
|
237
|
+
// lifecycle
|
|
238
|
+
// ======================
|
|
239
|
+
|
|
240
|
+
/** Starts the server. Resolves with the bound port (pass 0 for an ephemeral port). */
|
|
241
|
+
public start(port: number): Promise<number> {
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
this.server.once('error', reject);
|
|
244
|
+
this.server.listen(port, () => {
|
|
245
|
+
const address = this.server.address();
|
|
246
|
+
const boundPort =
|
|
247
|
+
typeof address === 'object' && address ? address.port : port;
|
|
248
|
+
resolve(boundPort);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Stops the server, ending open event streams and destroying open connections. */
|
|
254
|
+
public stop(): Promise<void> {
|
|
255
|
+
for (const response of this.eventStreams.keys()) {
|
|
256
|
+
try {
|
|
257
|
+
response.end();
|
|
258
|
+
} catch {
|
|
259
|
+
// response already gone
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this.eventStreams.clear();
|
|
263
|
+
for (const socket of this.sockets) {
|
|
264
|
+
socket.destroy();
|
|
265
|
+
}
|
|
266
|
+
this.sockets.clear();
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
this.server.close(() => {
|
|
269
|
+
resolve();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ======================
|
|
275
|
+
// http handling
|
|
276
|
+
// ======================
|
|
277
|
+
|
|
278
|
+
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
279
|
+
try {
|
|
280
|
+
const user = this.authenticate(req);
|
|
281
|
+
if (!user) {
|
|
282
|
+
res.writeHead(401, {
|
|
283
|
+
'www-authenticate': 'Bearer realm="jmap", Basic realm="jmap"',
|
|
284
|
+
'content-type': 'application/problem+json',
|
|
285
|
+
});
|
|
286
|
+
res.end(
|
|
287
|
+
JSON.stringify({ type: 'about:blank', status: 401, detail: 'Authentication required' })
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
293
|
+
const pathname = url.pathname;
|
|
294
|
+
|
|
295
|
+
if (req.method === 'GET' && (pathname === '/.well-known/jmap' || pathname === '/jmap/session')) {
|
|
296
|
+
this.handleSession(user, res);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (req.method === 'POST' && pathname === '/jmap/api') {
|
|
300
|
+
let body = '';
|
|
301
|
+
req.on('data', (chunk) => {
|
|
302
|
+
body += chunk;
|
|
303
|
+
});
|
|
304
|
+
req.on('end', () => {
|
|
305
|
+
this.handleApi(user, body, res);
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (req.method === 'GET' && pathname === '/jmap/eventsource') {
|
|
310
|
+
this.handleEventSource(user, req, res);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (req.method === 'GET' && pathname.startsWith('/jmap/download/')) {
|
|
314
|
+
this.handleDownload(user, pathname, url, res);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
res.writeHead(404, { 'content-type': 'application/problem+json' });
|
|
319
|
+
res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Not found' }));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
322
|
+
if (!res.headersSent) {
|
|
323
|
+
res.writeHead(500, { 'content-type': 'application/problem+json' });
|
|
324
|
+
}
|
|
325
|
+
res.end(JSON.stringify({ type: 'about:blank', status: 500, detail: message }));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private authenticate(req: plugins.http.IncomingMessage): IJmapServerUser | null {
|
|
330
|
+
const header = req.headers.authorization;
|
|
331
|
+
if (!header) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const spaceIndex = header.indexOf(' ');
|
|
335
|
+
if (spaceIndex === -1) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const scheme = header.slice(0, spaceIndex).toLowerCase();
|
|
339
|
+
const value = header.slice(spaceIndex + 1).trim();
|
|
340
|
+
if (scheme === 'bearer') {
|
|
341
|
+
for (const user of this.users.values()) {
|
|
342
|
+
if (user.bearerTokens.has(value)) {
|
|
343
|
+
return user;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (scheme === 'basic') {
|
|
349
|
+
const decoded = Buffer.from(value, 'base64').toString('utf8');
|
|
350
|
+
const colonIndex = decoded.indexOf(':');
|
|
351
|
+
if (colonIndex === -1) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
const username = decoded.slice(0, colonIndex);
|
|
355
|
+
const password = decoded.slice(colonIndex + 1);
|
|
356
|
+
const user = this.users.get(username);
|
|
357
|
+
if (user && user.password === password) {
|
|
358
|
+
return user;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private handleSession(user: IJmapServerUser, res: plugins.http.ServerResponse): void {
|
|
366
|
+
const session = {
|
|
367
|
+
capabilities: {
|
|
368
|
+
'urn:ietf:params:jmap:core': {
|
|
369
|
+
maxSizeUpload: 50_000_000,
|
|
370
|
+
maxConcurrentUpload: 4,
|
|
371
|
+
maxSizeRequest: 10_000_000,
|
|
372
|
+
maxConcurrentRequests: 4,
|
|
373
|
+
maxCallsInRequest: 16,
|
|
374
|
+
maxObjectsInGet: 500,
|
|
375
|
+
maxObjectsInSet: 500,
|
|
376
|
+
collationAlgorithms: ['i;ascii-numeric', 'i;ascii-casemap'],
|
|
377
|
+
},
|
|
378
|
+
'urn:ietf:params:jmap:mail': {},
|
|
379
|
+
'urn:ietf:params:jmap:submission': {},
|
|
380
|
+
},
|
|
381
|
+
accounts: {
|
|
382
|
+
[user.accountId]: {
|
|
383
|
+
name: user.username,
|
|
384
|
+
isPersonal: true,
|
|
385
|
+
isReadOnly: false,
|
|
386
|
+
accountCapabilities: {
|
|
387
|
+
'urn:ietf:params:jmap:mail': {},
|
|
388
|
+
'urn:ietf:params:jmap:submission': {},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
primaryAccounts: {
|
|
393
|
+
'urn:ietf:params:jmap:core': user.accountId,
|
|
394
|
+
'urn:ietf:params:jmap:mail': user.accountId,
|
|
395
|
+
'urn:ietf:params:jmap:submission': user.accountId,
|
|
396
|
+
},
|
|
397
|
+
username: user.username,
|
|
398
|
+
apiUrl: '/jmap/api',
|
|
399
|
+
downloadUrl: '/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
|
400
|
+
uploadUrl: '/jmap/upload/{accountId}',
|
|
401
|
+
eventSourceUrl: '/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}',
|
|
402
|
+
state: String(user.state),
|
|
403
|
+
};
|
|
404
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
405
|
+
res.end(JSON.stringify(session));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private handleApi(user: IJmapServerUser, body: string, res: plugins.http.ServerResponse): void {
|
|
409
|
+
let parsed: any;
|
|
410
|
+
try {
|
|
411
|
+
parsed = JSON.parse(body);
|
|
412
|
+
} catch {
|
|
413
|
+
res.writeHead(400, { 'content-type': 'application/problem+json' });
|
|
414
|
+
res.end(
|
|
415
|
+
JSON.stringify({
|
|
416
|
+
type: 'urn:ietf:params:jmap:error:notJSON',
|
|
417
|
+
status: 400,
|
|
418
|
+
detail: 'The request body is not valid JSON.',
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!Array.isArray(parsed?.methodCalls)) {
|
|
424
|
+
res.writeHead(400, { 'content-type': 'application/problem+json' });
|
|
425
|
+
res.end(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
type: 'urn:ietf:params:jmap:error:notRequest',
|
|
428
|
+
status: 400,
|
|
429
|
+
detail: 'The request body is not a valid JMAP request object.',
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const createdIds = new Map<string, string>();
|
|
436
|
+
const methodResponses: Array<[string, Record<string, any>, string]> = [];
|
|
437
|
+
for (const call of parsed.methodCalls) {
|
|
438
|
+
const [methodName, args, callId] = call as [string, Record<string, any>, string];
|
|
439
|
+
try {
|
|
440
|
+
const result = this.dispatchMethod(user, methodName, args ?? {}, createdIds);
|
|
441
|
+
methodResponses.push([methodName, result, callId]);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error instanceof JmapServerMethodError) {
|
|
444
|
+
methodResponses.push([
|
|
445
|
+
'error',
|
|
446
|
+
{ type: error.type, description: error.message },
|
|
447
|
+
callId,
|
|
448
|
+
]);
|
|
449
|
+
} else {
|
|
450
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
451
|
+
methodResponses.push(['error', { type: 'serverFail', description: message }, callId]);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
457
|
+
res.end(JSON.stringify({ methodResponses, sessionState: String(user.state) }));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private handleEventSource(
|
|
461
|
+
user: IJmapServerUser,
|
|
462
|
+
req: plugins.http.IncomingMessage,
|
|
463
|
+
res: plugins.http.ServerResponse
|
|
464
|
+
): void {
|
|
465
|
+
res.writeHead(200, {
|
|
466
|
+
'content-type': 'text/event-stream',
|
|
467
|
+
'cache-control': 'no-cache',
|
|
468
|
+
connection: 'keep-alive',
|
|
469
|
+
});
|
|
470
|
+
res.write(': jmap event stream\n\n');
|
|
471
|
+
this.eventStreams.set(res, user);
|
|
472
|
+
req.on('close', () => {
|
|
473
|
+
this.eventStreams.delete(res);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private handleDownload(
|
|
478
|
+
user: IJmapServerUser,
|
|
479
|
+
pathname: string,
|
|
480
|
+
url: URL,
|
|
481
|
+
res: plugins.http.ServerResponse
|
|
482
|
+
): void {
|
|
483
|
+
// /jmap/download/{accountId}/{blobId}/{name}
|
|
484
|
+
const segments = pathname.split('/').filter((segment) => segment.length > 0);
|
|
485
|
+
const accountId = decodeURIComponent(segments[2] ?? '');
|
|
486
|
+
const blobId = decodeURIComponent(segments[3] ?? '');
|
|
487
|
+
if (accountId !== user.accountId) {
|
|
488
|
+
res.writeHead(404, { 'content-type': 'application/problem+json' });
|
|
489
|
+
res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown account' }));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const blob = user.blobs.get(blobId);
|
|
493
|
+
if (!blob) {
|
|
494
|
+
res.writeHead(404, { 'content-type': 'application/problem+json' });
|
|
495
|
+
res.end(JSON.stringify({ type: 'about:blank', status: 404, detail: 'Unknown blob' }));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const type = url.searchParams.get('type') ?? blob.type;
|
|
499
|
+
res.writeHead(200, { 'content-type': type, 'content-length': blob.data.length });
|
|
500
|
+
res.end(blob.data);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ======================
|
|
504
|
+
// jmap method dispatch
|
|
505
|
+
// ======================
|
|
506
|
+
|
|
507
|
+
private dispatchMethod(
|
|
508
|
+
user: IJmapServerUser,
|
|
509
|
+
methodName: string,
|
|
510
|
+
args: Record<string, any>,
|
|
511
|
+
createdIds: Map<string, string>
|
|
512
|
+
): Record<string, any> {
|
|
513
|
+
switch (methodName) {
|
|
514
|
+
case 'Core/echo':
|
|
515
|
+
return args;
|
|
516
|
+
case 'Mailbox/get':
|
|
517
|
+
return this.methodMailboxGet(user, args);
|
|
518
|
+
case 'Mailbox/query':
|
|
519
|
+
return this.methodMailboxQuery(user, args);
|
|
520
|
+
case 'Email/get':
|
|
521
|
+
return this.methodEmailGet(user, args);
|
|
522
|
+
case 'Email/query':
|
|
523
|
+
return this.methodEmailQuery(user, args);
|
|
524
|
+
case 'Email/changes':
|
|
525
|
+
return this.methodEmailChanges(user, args);
|
|
526
|
+
case 'Email/set':
|
|
527
|
+
return this.methodEmailSet(user, args, createdIds);
|
|
528
|
+
case 'Identity/get':
|
|
529
|
+
return this.methodIdentityGet(user);
|
|
530
|
+
case 'EmailSubmission/set':
|
|
531
|
+
return this.methodEmailSubmissionSet(user, args, createdIds);
|
|
532
|
+
default:
|
|
533
|
+
throw new JmapServerMethodError('unknownMethod', `Unknown method "${methodName}".`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private methodMailboxGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
|
|
538
|
+
const all = Array.from(user.mailboxes.values());
|
|
539
|
+
let list: IJmapServerMailbox[];
|
|
540
|
+
const notFound: string[] = [];
|
|
541
|
+
if (args.ids == null) {
|
|
542
|
+
list = all;
|
|
543
|
+
} else {
|
|
544
|
+
list = [];
|
|
545
|
+
for (const id of args.ids as string[]) {
|
|
546
|
+
const mailbox = user.mailboxes.get(id);
|
|
547
|
+
if (mailbox) {
|
|
548
|
+
list.push(mailbox);
|
|
549
|
+
} else {
|
|
550
|
+
notFound.push(id);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const augmented = list.map((mailbox) => {
|
|
555
|
+
const emails = Array.from(user.emails.values()).filter(
|
|
556
|
+
(email) => email.mailboxIds?.[mailbox.id]
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
...mailbox,
|
|
560
|
+
totalEmails: emails.length,
|
|
561
|
+
unreadEmails: emails.filter((email) => !email.keywords?.$seen).length,
|
|
562
|
+
totalThreads: emails.length,
|
|
563
|
+
unreadThreads: emails.filter((email) => !email.keywords?.$seen).length,
|
|
564
|
+
myRights: {
|
|
565
|
+
mayReadItems: true,
|
|
566
|
+
mayAddItems: true,
|
|
567
|
+
mayRemoveItems: true,
|
|
568
|
+
maySetSeen: true,
|
|
569
|
+
maySetKeywords: true,
|
|
570
|
+
mayCreateChild: true,
|
|
571
|
+
mayRename: true,
|
|
572
|
+
mayDelete: true,
|
|
573
|
+
maySubmit: true,
|
|
574
|
+
},
|
|
575
|
+
isSubscribed: true,
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
accountId: user.accountId,
|
|
580
|
+
state: String(user.state),
|
|
581
|
+
list: augmented,
|
|
582
|
+
notFound,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private methodMailboxQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
|
|
587
|
+
const filter = args.filter ?? {};
|
|
588
|
+
let mailboxes = Array.from(user.mailboxes.values());
|
|
589
|
+
if (typeof filter.role === 'string') {
|
|
590
|
+
mailboxes = mailboxes.filter((mailbox) => mailbox.role === filter.role);
|
|
591
|
+
}
|
|
592
|
+
if (typeof filter.name === 'string') {
|
|
593
|
+
mailboxes = mailboxes.filter((mailbox) => mailbox.name === filter.name);
|
|
594
|
+
}
|
|
595
|
+
const ids = mailboxes.map((mailbox) => mailbox.id);
|
|
596
|
+
return {
|
|
597
|
+
accountId: user.accountId,
|
|
598
|
+
queryState: String(user.state),
|
|
599
|
+
canCalculateChanges: false,
|
|
600
|
+
position: 0,
|
|
601
|
+
ids,
|
|
602
|
+
total: ids.length,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private methodEmailGet(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
|
|
607
|
+
if (!Array.isArray(args.ids)) {
|
|
608
|
+
throw new JmapServerMethodError('invalidArguments', 'Email/get requires an ids array.');
|
|
609
|
+
}
|
|
610
|
+
const list: Array<Record<string, any>> = [];
|
|
611
|
+
const notFound: string[] = [];
|
|
612
|
+
for (const id of args.ids as string[]) {
|
|
613
|
+
const email = user.emails.get(id);
|
|
614
|
+
if (email) {
|
|
615
|
+
// Returned as a full superset of the requestable properties;
|
|
616
|
+
// property projection is intentionally not implemented.
|
|
617
|
+
list.push({ ...email });
|
|
618
|
+
} else {
|
|
619
|
+
notFound.push(id);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
accountId: user.accountId,
|
|
624
|
+
state: String(user.state),
|
|
625
|
+
list,
|
|
626
|
+
notFound,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private methodEmailQuery(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
|
|
631
|
+
const filter = args.filter ?? {};
|
|
632
|
+
let emails = Array.from(user.emails.values());
|
|
633
|
+
if (typeof filter.inMailbox === 'string') {
|
|
634
|
+
emails = emails.filter((email) => email.mailboxIds?.[filter.inMailbox]);
|
|
635
|
+
}
|
|
636
|
+
if (typeof filter.subject === 'string') {
|
|
637
|
+
emails = emails.filter((email) =>
|
|
638
|
+
String(email.subject ?? '').toLowerCase().includes(filter.subject.toLowerCase())
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
if (typeof filter.text === 'string') {
|
|
642
|
+
const needle = filter.text.toLowerCase();
|
|
643
|
+
emails = emails.filter(
|
|
644
|
+
(email) =>
|
|
645
|
+
String(email.subject ?? '').toLowerCase().includes(needle) ||
|
|
646
|
+
String(email.bodyValues?.text?.value ?? '').toLowerCase().includes(needle)
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const sort = Array.isArray(args.sort) && args.sort.length ? args.sort[0] : null;
|
|
650
|
+
if (sort?.property === 'receivedAt') {
|
|
651
|
+
emails.sort((a, b) => {
|
|
652
|
+
const comparison = String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? ''));
|
|
653
|
+
return sort.isAscending === false ? -comparison : comparison;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const position = typeof args.position === 'number' ? args.position : 0;
|
|
657
|
+
let ids = emails.map((email) => email.id as string).slice(position);
|
|
658
|
+
const total = emails.length;
|
|
659
|
+
if (typeof args.limit === 'number') {
|
|
660
|
+
ids = ids.slice(0, args.limit);
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
accountId: user.accountId,
|
|
664
|
+
queryState: String(user.state),
|
|
665
|
+
canCalculateChanges: false,
|
|
666
|
+
position,
|
|
667
|
+
ids,
|
|
668
|
+
total,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private methodEmailChanges(user: IJmapServerUser, args: Record<string, any>): Record<string, any> {
|
|
673
|
+
const since = Number(args.sinceState);
|
|
674
|
+
if (!Number.isFinite(since) || since < 0 || since > user.state) {
|
|
675
|
+
throw new JmapServerMethodError(
|
|
676
|
+
'cannotCalculateChanges',
|
|
677
|
+
`Cannot calculate changes since state "${args.sinceState}".`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const created = new Set<string>();
|
|
681
|
+
const updated = new Set<string>();
|
|
682
|
+
const destroyed = new Set<string>();
|
|
683
|
+
for (const entry of user.changes) {
|
|
684
|
+
if (entry.state <= since) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
for (const id of entry.created) {
|
|
688
|
+
created.add(id);
|
|
689
|
+
}
|
|
690
|
+
for (const id of entry.updated) {
|
|
691
|
+
updated.add(id);
|
|
692
|
+
}
|
|
693
|
+
for (const id of entry.destroyed) {
|
|
694
|
+
if (created.has(id)) {
|
|
695
|
+
// created and destroyed within the window: omit entirely
|
|
696
|
+
created.delete(id);
|
|
697
|
+
} else {
|
|
698
|
+
destroyed.add(id);
|
|
699
|
+
}
|
|
700
|
+
updated.delete(id);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
for (const id of created) {
|
|
704
|
+
updated.delete(id);
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
accountId: user.accountId,
|
|
708
|
+
oldState: String(since),
|
|
709
|
+
newState: String(user.state),
|
|
710
|
+
hasMoreChanges: false,
|
|
711
|
+
created: Array.from(created),
|
|
712
|
+
updated: Array.from(updated),
|
|
713
|
+
destroyed: Array.from(destroyed),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private methodEmailSet(
|
|
718
|
+
user: IJmapServerUser,
|
|
719
|
+
args: Record<string, any>,
|
|
720
|
+
createdIds: Map<string, string>
|
|
721
|
+
): Record<string, any> {
|
|
722
|
+
const oldState = String(user.state);
|
|
723
|
+
const created: Record<string, any> = {};
|
|
724
|
+
const notCreated: Record<string, any> = {};
|
|
725
|
+
const updated: Record<string, any> = {};
|
|
726
|
+
const notUpdated: Record<string, any> = {};
|
|
727
|
+
const destroyed: string[] = [];
|
|
728
|
+
const notDestroyed: Record<string, any> = {};
|
|
729
|
+
const changedCreated: string[] = [];
|
|
730
|
+
const changedUpdated: string[] = [];
|
|
731
|
+
const changedDestroyed: string[] = [];
|
|
732
|
+
|
|
733
|
+
for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
|
|
734
|
+
try {
|
|
735
|
+
const email = this.createEmailFromSpec(user, spec);
|
|
736
|
+
user.emails.set(email.id, email);
|
|
737
|
+
createdIds.set(creationId, email.id);
|
|
738
|
+
changedCreated.push(email.id);
|
|
739
|
+
created[creationId] = {
|
|
740
|
+
id: email.id,
|
|
741
|
+
blobId: email.blobId,
|
|
742
|
+
threadId: email.threadId,
|
|
743
|
+
size: email.size,
|
|
744
|
+
};
|
|
745
|
+
} catch (error) {
|
|
746
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
747
|
+
notCreated[creationId] = { type: 'invalidProperties', description: message };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, any>)) {
|
|
752
|
+
const email = user.emails.get(id);
|
|
753
|
+
if (!email) {
|
|
754
|
+
notUpdated[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
this.applyPatch(email, patch);
|
|
759
|
+
changedUpdated.push(id);
|
|
760
|
+
updated[id] = null;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
763
|
+
notUpdated[id] = { type: 'invalidPatch', description: message };
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const id of (args.destroy ?? []) as string[]) {
|
|
768
|
+
if (user.emails.delete(id)) {
|
|
769
|
+
changedDestroyed.push(id);
|
|
770
|
+
destroyed.push(id);
|
|
771
|
+
} else {
|
|
772
|
+
notDestroyed[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (changedCreated.length || changedUpdated.length || changedDestroyed.length) {
|
|
777
|
+
this.bumpState(user, {
|
|
778
|
+
created: changedCreated,
|
|
779
|
+
updated: changedUpdated,
|
|
780
|
+
destroyed: changedDestroyed,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
accountId: user.accountId,
|
|
786
|
+
oldState,
|
|
787
|
+
newState: String(user.state),
|
|
788
|
+
created,
|
|
789
|
+
notCreated,
|
|
790
|
+
updated,
|
|
791
|
+
notUpdated,
|
|
792
|
+
destroyed,
|
|
793
|
+
notDestroyed,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private methodIdentityGet(user: IJmapServerUser): Record<string, any> {
|
|
798
|
+
return {
|
|
799
|
+
accountId: user.accountId,
|
|
800
|
+
state: '0',
|
|
801
|
+
list: user.identities,
|
|
802
|
+
notFound: [],
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private methodEmailSubmissionSet(
|
|
807
|
+
user: IJmapServerUser,
|
|
808
|
+
args: Record<string, any>,
|
|
809
|
+
createdIds: Map<string, string>
|
|
810
|
+
): Record<string, any> {
|
|
811
|
+
const created: Record<string, any> = {};
|
|
812
|
+
const notCreated: Record<string, any> = {};
|
|
813
|
+
for (const [creationId, spec] of Object.entries((args.create ?? {}) as Record<string, any>)) {
|
|
814
|
+
let emailId: string = spec.emailId;
|
|
815
|
+
if (typeof emailId === 'string' && emailId.startsWith('#')) {
|
|
816
|
+
const resolved = createdIds.get(emailId.slice(1));
|
|
817
|
+
if (!resolved) {
|
|
818
|
+
notCreated[creationId] = {
|
|
819
|
+
type: 'invalidProperties',
|
|
820
|
+
description: `Unknown creation id reference "${emailId}".`,
|
|
821
|
+
};
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
emailId = resolved;
|
|
825
|
+
}
|
|
826
|
+
if (!user.emails.has(emailId)) {
|
|
827
|
+
notCreated[creationId] = {
|
|
828
|
+
type: 'invalidProperties',
|
|
829
|
+
description: `Email "${emailId}" does not exist.`,
|
|
830
|
+
};
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const identity = user.identities.find((candidate) => candidate.id === spec.identityId);
|
|
834
|
+
if (!identity) {
|
|
835
|
+
notCreated[creationId] = {
|
|
836
|
+
type: 'invalidProperties',
|
|
837
|
+
description: `Identity "${spec.identityId}" does not exist.`,
|
|
838
|
+
};
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const id = `submission_${++user.idCounter}`;
|
|
842
|
+
const submission: IJmapServerSubmission = {
|
|
843
|
+
id,
|
|
844
|
+
emailId,
|
|
845
|
+
identityId: identity.id,
|
|
846
|
+
undoStatus: 'final',
|
|
847
|
+
sendAt: new Date().toISOString(),
|
|
848
|
+
};
|
|
849
|
+
user.submissions.set(id, submission);
|
|
850
|
+
created[creationId] = { id, undoStatus: submission.undoStatus, sendAt: submission.sendAt };
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
accountId: user.accountId,
|
|
854
|
+
oldState: String(user.state),
|
|
855
|
+
newState: String(user.state),
|
|
856
|
+
created,
|
|
857
|
+
notCreated,
|
|
858
|
+
updated: {},
|
|
859
|
+
notUpdated: {},
|
|
860
|
+
destroyed: [],
|
|
861
|
+
notDestroyed: {},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ======================
|
|
866
|
+
// internal helpers
|
|
867
|
+
// ======================
|
|
868
|
+
|
|
869
|
+
private requireUser(username: string): IJmapServerUser {
|
|
870
|
+
const user = this.users.get(username);
|
|
871
|
+
if (!user) {
|
|
872
|
+
throw new Error(`User "${username}" does not exist.`);
|
|
873
|
+
}
|
|
874
|
+
return user;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private createEmailFromSpec(user: IJmapServerUser, spec: Record<string, any>): Record<string, any> {
|
|
878
|
+
const mailboxIds = spec.mailboxIds as Record<string, boolean> | undefined;
|
|
879
|
+
if (!mailboxIds || !Object.keys(mailboxIds).length) {
|
|
880
|
+
throw new Error('Email create requires mailboxIds.');
|
|
881
|
+
}
|
|
882
|
+
for (const mailboxId of Object.keys(mailboxIds)) {
|
|
883
|
+
if (!user.mailboxes.has(mailboxId)) {
|
|
884
|
+
throw new Error(`Mailbox "${mailboxId}" does not exist.`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const id = `email_${++user.idCounter}`;
|
|
888
|
+
const receivedAt = new Date().toISOString();
|
|
889
|
+
const bodyValues = (spec.bodyValues ?? {}) as Record<string, { value: string }>;
|
|
890
|
+
const resolvePart = (part: Record<string, any>): string =>
|
|
891
|
+
part?.partId != null ? bodyValues[part.partId]?.value ?? '' : '';
|
|
892
|
+
const textBodyParts = ((spec.textBody ?? []) as Array<Record<string, any>>).map((part) => ({
|
|
893
|
+
partId: part.partId ?? null,
|
|
894
|
+
type: part.type ?? 'text/plain',
|
|
895
|
+
size: resolvePart(part).length,
|
|
896
|
+
}));
|
|
897
|
+
const htmlBodyParts = ((spec.htmlBody ?? []) as Array<Record<string, any>>).map((part) => ({
|
|
898
|
+
partId: part.partId ?? null,
|
|
899
|
+
type: part.type ?? 'text/html',
|
|
900
|
+
size: resolvePart(part).length,
|
|
901
|
+
}));
|
|
902
|
+
const textValue = textBodyParts.map((part) => bodyValues[part.partId ?? '']?.value ?? '').join('\n');
|
|
903
|
+
|
|
904
|
+
const from = (spec.from ?? []) as IJmapEmailAddress[];
|
|
905
|
+
const to = (spec.to ?? []) as IJmapEmailAddress[];
|
|
906
|
+
const blobId = `blob_${id}`;
|
|
907
|
+
const rawLines = [
|
|
908
|
+
`From: ${from.map((address) => address.email).join(', ')}`,
|
|
909
|
+
`To: ${to.map((address) => address.email).join(', ')}`,
|
|
910
|
+
`Subject: ${spec.subject ?? ''}`,
|
|
911
|
+
`Date: ${receivedAt}`,
|
|
912
|
+
'',
|
|
913
|
+
textValue,
|
|
914
|
+
];
|
|
915
|
+
user.blobs.set(blobId, {
|
|
916
|
+
data: Buffer.from(rawLines.join('\r\n'), 'utf8'),
|
|
917
|
+
type: 'message/rfc822',
|
|
918
|
+
name: `${id}.eml`,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const normalizedBodyValues: Record<string, { value: string; isTruncated: boolean }> = {};
|
|
922
|
+
for (const [partId, bodyValue] of Object.entries(bodyValues)) {
|
|
923
|
+
normalizedBodyValues[partId] = { value: bodyValue.value ?? '', isTruncated: false };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
id,
|
|
928
|
+
blobId,
|
|
929
|
+
threadId: `thread_${id}`,
|
|
930
|
+
mailboxIds: { ...mailboxIds },
|
|
931
|
+
keywords: spec.keywords ?? {},
|
|
932
|
+
size: textValue.length,
|
|
933
|
+
receivedAt,
|
|
934
|
+
sentAt: receivedAt,
|
|
935
|
+
subject: spec.subject ?? null,
|
|
936
|
+
from,
|
|
937
|
+
to,
|
|
938
|
+
cc: spec.cc ?? null,
|
|
939
|
+
bcc: spec.bcc ?? null,
|
|
940
|
+
replyTo: spec.replyTo ?? null,
|
|
941
|
+
hasAttachment: false,
|
|
942
|
+
preview: textValue.slice(0, 100),
|
|
943
|
+
bodyValues: normalizedBodyValues,
|
|
944
|
+
textBody: textBodyParts,
|
|
945
|
+
htmlBody: htmlBodyParts,
|
|
946
|
+
attachments: [],
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Applies an Email/set update patch: either whole properties (e.g. keywords)
|
|
952
|
+
* or JSON-pointer style paths like "keywords/$seen" (RFC 8620 §5.3).
|
|
953
|
+
*/
|
|
954
|
+
private applyPatch(email: Record<string, any>, patch: Record<string, any>): void {
|
|
955
|
+
for (const [path, value] of Object.entries(patch)) {
|
|
956
|
+
const segments = path.split('/');
|
|
957
|
+
if (segments.length === 1) {
|
|
958
|
+
if (value === null) {
|
|
959
|
+
delete email[path];
|
|
960
|
+
} else {
|
|
961
|
+
email[path] = value;
|
|
962
|
+
}
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
let target = email;
|
|
966
|
+
for (const segment of segments.slice(0, -1)) {
|
|
967
|
+
if (typeof target[segment] !== 'object' || target[segment] === null) {
|
|
968
|
+
target[segment] = {};
|
|
969
|
+
}
|
|
970
|
+
target = target[segment];
|
|
971
|
+
}
|
|
972
|
+
const leaf = segments[segments.length - 1];
|
|
973
|
+
if (value === null || value === false) {
|
|
974
|
+
delete target[leaf];
|
|
975
|
+
} else {
|
|
976
|
+
target[leaf] = value;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Increments the account state, records the change, and pushes a StateChange to open event streams. */
|
|
982
|
+
private bumpState(
|
|
983
|
+
user: IJmapServerUser,
|
|
984
|
+
change: { created?: string[]; updated?: string[]; destroyed?: string[] }
|
|
985
|
+
): void {
|
|
986
|
+
user.state += 1;
|
|
987
|
+
user.changes.push({
|
|
988
|
+
state: user.state,
|
|
989
|
+
created: change.created ?? [],
|
|
990
|
+
updated: change.updated ?? [],
|
|
991
|
+
destroyed: change.destroyed ?? [],
|
|
992
|
+
});
|
|
993
|
+
this.notifyStateChange(user);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private notifyStateChange(user: IJmapServerUser): void {
|
|
997
|
+
const stateChange = {
|
|
998
|
+
'@type': 'StateChange',
|
|
999
|
+
changed: {
|
|
1000
|
+
[user.accountId]: {
|
|
1001
|
+
Email: String(user.state),
|
|
1002
|
+
Mailbox: String(user.state),
|
|
1003
|
+
Thread: String(user.state),
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
const payload = `event: state\ndata: ${JSON.stringify(stateChange)}\n\n`;
|
|
1008
|
+
for (const [response, streamUser] of this.eventStreams) {
|
|
1009
|
+
if (streamUser !== user) {
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
response.write(payload);
|
|
1014
|
+
} catch {
|
|
1015
|
+
this.eventStreams.delete(response);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|