@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
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
import type { IJmapEmailAddress, IJmapEmailBodyPart, IJmapEmailBodyValue } from './classes.jmapclient.js';
|
|
2
|
+
import {
|
|
3
|
+
JmapBackendError,
|
|
4
|
+
type IJmapAccountInfo,
|
|
5
|
+
type IJmapBlobRecord,
|
|
6
|
+
type IJmapChanges,
|
|
7
|
+
type IJmapEmailCreate,
|
|
8
|
+
type IJmapEmailFilter,
|
|
9
|
+
type IJmapEmailQueryOptions,
|
|
10
|
+
type IJmapEmailQueryResult,
|
|
11
|
+
type IJmapEmailRecord,
|
|
12
|
+
type IJmapEmailSetRequest,
|
|
13
|
+
type IJmapEmailSetResult,
|
|
14
|
+
type IJmapEmailUpdate,
|
|
15
|
+
type IJmapEnvelope,
|
|
16
|
+
type IJmapGetResult,
|
|
17
|
+
type IJmapIdentityRecord,
|
|
18
|
+
type IJmapMailBackend,
|
|
19
|
+
type IJmapMailboxRecord,
|
|
20
|
+
type IJmapStateChange,
|
|
21
|
+
type IJmapSubmissionInput,
|
|
22
|
+
type IJmapSubmissionRecord,
|
|
23
|
+
type IJmapThreadRecord,
|
|
24
|
+
type TJmapChangeListener,
|
|
25
|
+
type TJmapPrincipal,
|
|
26
|
+
} from './interfaces.backend.js';
|
|
27
|
+
|
|
28
|
+
/** A change-log entry: which ids changed at which numeric state. */
|
|
29
|
+
export interface IMemoryChangeEntry {
|
|
30
|
+
state: number;
|
|
31
|
+
created: string[];
|
|
32
|
+
updated: string[];
|
|
33
|
+
destroyed: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Base mailbox fields stored in memory; counters are computed on read. */
|
|
37
|
+
export interface IMemoryMailbox {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
role: string | null;
|
|
41
|
+
parentId: string | null;
|
|
42
|
+
sortOrder: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A stored submission, including the envelope and raw payload for inspection. */
|
|
46
|
+
export interface IMemorySubmission extends IJmapSubmissionRecord {
|
|
47
|
+
envelope: IJmapEnvelope;
|
|
48
|
+
message: Uint8Array;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The in-memory per-account store, exposed publicly for test assertions. */
|
|
52
|
+
export interface IMemoryMailAccount {
|
|
53
|
+
username: string;
|
|
54
|
+
accountId: string;
|
|
55
|
+
mailboxes: Map<string, IMemoryMailbox>;
|
|
56
|
+
emails: Map<string, IJmapEmailRecord>;
|
|
57
|
+
blobs: Map<string, IJmapBlobRecord>;
|
|
58
|
+
submissions: Map<string, IMemorySubmission>;
|
|
59
|
+
identities: IJmapIdentityRecord[];
|
|
60
|
+
emailState: number;
|
|
61
|
+
emailChanges: IMemoryChangeEntry[];
|
|
62
|
+
mailboxState: number;
|
|
63
|
+
mailboxChanges: IMemoryChangeEntry[];
|
|
64
|
+
threadIdsBySubject: Map<string, string>;
|
|
65
|
+
idCounter: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Seeding input for `addEmail`: a convenience superset of an Email create. */
|
|
69
|
+
export interface IMemoryEmailInput {
|
|
70
|
+
from: IJmapEmailAddress | IJmapEmailAddress[];
|
|
71
|
+
to: IJmapEmailAddress | IJmapEmailAddress[];
|
|
72
|
+
cc?: IJmapEmailAddress[];
|
|
73
|
+
bcc?: IJmapEmailAddress[];
|
|
74
|
+
subject?: string;
|
|
75
|
+
textBody?: string;
|
|
76
|
+
htmlBody?: string;
|
|
77
|
+
keywords?: Record<string, boolean>;
|
|
78
|
+
receivedAt?: string;
|
|
79
|
+
attachments?: Array<{ name: string; type: string; content: string | Uint8Array }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const textEncoder = new TextEncoder();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Strips reply/forward prefixes and collapses whitespace so replies group
|
|
86
|
+
* into the same thread. This normalized-subject heuristic is a documented
|
|
87
|
+
* simplification of real References-header threading.
|
|
88
|
+
*/
|
|
89
|
+
const normalizeSubject = (subject: string | null | undefined): string =>
|
|
90
|
+
(subject ?? '')
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/^(\s*(re|fwd|fw|aw|wg)(\[\d+\])?\s*:\s*)+/, '')
|
|
93
|
+
.replace(/\s+/g, ' ')
|
|
94
|
+
.trim();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The in-memory reference implementation of `IJmapMailBackend`, used as the
|
|
98
|
+
* default backend of `JmapServer` for offline testing. Accounts are keyed by
|
|
99
|
+
* username (accountId = `account_<username>`) and auto-created on first
|
|
100
|
+
* reference. Seeding helpers: `addAccount`, `createMailbox`, `addEmail`.
|
|
101
|
+
*
|
|
102
|
+
* Documented simplifications: thread ids derive from normalized subjects (not
|
|
103
|
+
* References headers), raw RFC 5322 blobs render headers + the text body only
|
|
104
|
+
* (attachments are separate blobs, not MIME-encoded into the raw message),
|
|
105
|
+
* the change log grows unboundedly (one entry per mutation), and destroyed
|
|
106
|
+
* emails leave their blobs in the blob store.
|
|
107
|
+
*/
|
|
108
|
+
export class MemoryMailBackend implements IJmapMailBackend {
|
|
109
|
+
public accounts: Map<string, IMemoryMailAccount> = new Map();
|
|
110
|
+
private changeListeners: Set<TJmapChangeListener> = new Set();
|
|
111
|
+
|
|
112
|
+
// ======================
|
|
113
|
+
// seeding helpers
|
|
114
|
+
// ======================
|
|
115
|
+
|
|
116
|
+
/** Ensures an account exists for the username and returns it. */
|
|
117
|
+
public addAccount(username: string): IMemoryMailAccount {
|
|
118
|
+
return this.ensureAccount(username);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Creates a mailbox (name must be unique per account). Returns the mailbox id. */
|
|
122
|
+
public createMailbox(username: string, name: string, role?: string): string {
|
|
123
|
+
const account = this.ensureAccount(username);
|
|
124
|
+
for (const mailbox of account.mailboxes.values()) {
|
|
125
|
+
if (mailbox.name === name) {
|
|
126
|
+
throw new Error(`Mailbox "${name}" already exists for user "${username}".`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const id = `mailbox_${++account.idCounter}`;
|
|
130
|
+
account.mailboxes.set(id, {
|
|
131
|
+
id,
|
|
132
|
+
name,
|
|
133
|
+
role: role ?? null,
|
|
134
|
+
parentId: null,
|
|
135
|
+
sortOrder: account.mailboxes.size,
|
|
136
|
+
});
|
|
137
|
+
account.mailboxState += 1;
|
|
138
|
+
account.mailboxChanges.push({
|
|
139
|
+
state: account.mailboxState,
|
|
140
|
+
created: [id],
|
|
141
|
+
updated: [],
|
|
142
|
+
destroyed: [],
|
|
143
|
+
});
|
|
144
|
+
this.emitChange({
|
|
145
|
+
accountId: account.accountId,
|
|
146
|
+
changed: { Mailbox: String(account.mailboxState) },
|
|
147
|
+
});
|
|
148
|
+
return id;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Seeds an email into a mailbox (matched by name or role), bumps the Email
|
|
153
|
+
* state, and notifies change listeners — connected JmapServers turn this
|
|
154
|
+
* into a StateChange push. Returns the email id.
|
|
155
|
+
*/
|
|
156
|
+
public addEmail(username: string, mailboxName: string, input: IMemoryEmailInput): string {
|
|
157
|
+
const account = this.ensureAccount(username);
|
|
158
|
+
const mailbox = Array.from(account.mailboxes.values()).find(
|
|
159
|
+
(candidate) => candidate.name === mailboxName || candidate.role === mailboxName
|
|
160
|
+
);
|
|
161
|
+
if (!mailbox) {
|
|
162
|
+
throw new Error(`Mailbox "${mailboxName}" does not exist for user "${username}".`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const from = Array.isArray(input.from) ? input.from : [input.from];
|
|
166
|
+
const to = Array.isArray(input.to) ? input.to : [input.to];
|
|
167
|
+
const textBody = input.textBody ?? '';
|
|
168
|
+
|
|
169
|
+
const bodyValues: Record<string, { value: string }> = { text: { value: textBody } };
|
|
170
|
+
const textBodyParts: IJmapEmailBodyPart[] = [{ partId: 'text', type: 'text/plain' }];
|
|
171
|
+
const htmlBodyParts: IJmapEmailBodyPart[] = [];
|
|
172
|
+
if (typeof input.htmlBody === 'string') {
|
|
173
|
+
bodyValues.html = { value: input.htmlBody };
|
|
174
|
+
htmlBodyParts.push({ partId: 'html', type: 'text/html' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const attachmentParts: IJmapEmailBodyPart[] = [];
|
|
178
|
+
for (const attachment of input.attachments ?? []) {
|
|
179
|
+
const data =
|
|
180
|
+
typeof attachment.content === 'string'
|
|
181
|
+
? textEncoder.encode(attachment.content)
|
|
182
|
+
: attachment.content;
|
|
183
|
+
const blobId = `blob_seed_${++account.idCounter}`;
|
|
184
|
+
account.blobs.set(blobId, { data, type: attachment.type, name: attachment.name });
|
|
185
|
+
attachmentParts.push({
|
|
186
|
+
partId: null,
|
|
187
|
+
blobId,
|
|
188
|
+
name: attachment.name,
|
|
189
|
+
type: attachment.type,
|
|
190
|
+
disposition: 'attachment',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const record = this.buildEmailRecord(account, {
|
|
195
|
+
mailboxIds: { [mailbox.id]: true },
|
|
196
|
+
keywords: input.keywords ?? {},
|
|
197
|
+
from,
|
|
198
|
+
to,
|
|
199
|
+
cc: input.cc ?? null,
|
|
200
|
+
bcc: input.bcc ?? null,
|
|
201
|
+
subject: input.subject ?? null,
|
|
202
|
+
receivedAt: input.receivedAt,
|
|
203
|
+
bodyValues,
|
|
204
|
+
textBody: textBodyParts,
|
|
205
|
+
htmlBody: htmlBodyParts,
|
|
206
|
+
attachments: attachmentParts,
|
|
207
|
+
});
|
|
208
|
+
account.emails.set(record.id, record);
|
|
209
|
+
this.bumpEmailState(account, { created: [record.id] }, new Set([mailbox.id]));
|
|
210
|
+
return record.id;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ======================
|
|
214
|
+
// IJmapMailBackend
|
|
215
|
+
// ======================
|
|
216
|
+
|
|
217
|
+
public async resolveAccount(principal: TJmapPrincipal): Promise<IJmapAccountInfo | null> {
|
|
218
|
+
const account = this.ensureAccount(principal.username);
|
|
219
|
+
return {
|
|
220
|
+
accountId: account.accountId,
|
|
221
|
+
name: account.username,
|
|
222
|
+
isPersonal: true,
|
|
223
|
+
isReadOnly: false,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
public async getMailboxes(
|
|
228
|
+
accountId: string,
|
|
229
|
+
ids: string[] | null
|
|
230
|
+
): Promise<IJmapGetResult<IJmapMailboxRecord>> {
|
|
231
|
+
const account = this.requireAccountById(accountId);
|
|
232
|
+
const notFound: string[] = [];
|
|
233
|
+
let mailboxes: IMemoryMailbox[];
|
|
234
|
+
if (ids === null) {
|
|
235
|
+
mailboxes = Array.from(account.mailboxes.values());
|
|
236
|
+
} else {
|
|
237
|
+
mailboxes = [];
|
|
238
|
+
for (const id of ids) {
|
|
239
|
+
const mailbox = account.mailboxes.get(id);
|
|
240
|
+
if (mailbox) {
|
|
241
|
+
mailboxes.push(mailbox);
|
|
242
|
+
} else {
|
|
243
|
+
notFound.push(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const list = mailboxes.map((mailbox) => {
|
|
248
|
+
const emails = Array.from(account.emails.values()).filter(
|
|
249
|
+
(email) => email.mailboxIds[mailbox.id]
|
|
250
|
+
);
|
|
251
|
+
const unread = emails.filter((email) => !email.keywords.$seen);
|
|
252
|
+
const threadIds = new Set(emails.map((email) => email.threadId));
|
|
253
|
+
const unreadThreadIds = new Set(unread.map((email) => email.threadId));
|
|
254
|
+
return {
|
|
255
|
+
...mailbox,
|
|
256
|
+
totalEmails: emails.length,
|
|
257
|
+
unreadEmails: unread.length,
|
|
258
|
+
totalThreads: threadIds.size,
|
|
259
|
+
unreadThreads: unreadThreadIds.size,
|
|
260
|
+
isSubscribed: true,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
return { state: String(account.mailboxState), list, notFound };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public async getMailboxChanges(
|
|
267
|
+
accountId: string,
|
|
268
|
+
sinceState: string,
|
|
269
|
+
maxChanges?: number
|
|
270
|
+
): Promise<IJmapChanges | null> {
|
|
271
|
+
const account = this.requireAccountById(accountId);
|
|
272
|
+
return this.collectChanges(account.mailboxChanges, account.mailboxState, sinceState, maxChanges);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
public async getEmails(
|
|
276
|
+
accountId: string,
|
|
277
|
+
ids: string[],
|
|
278
|
+
_properties?: string[] | null
|
|
279
|
+
): Promise<IJmapGetResult<IJmapEmailRecord>> {
|
|
280
|
+
// The properties hint is ignored: records live in memory, so projecting
|
|
281
|
+
// early saves nothing. The server applies the final projection.
|
|
282
|
+
const account = this.requireAccountById(accountId);
|
|
283
|
+
const list: IJmapEmailRecord[] = [];
|
|
284
|
+
const notFound: string[] = [];
|
|
285
|
+
for (const id of ids) {
|
|
286
|
+
const email = account.emails.get(id);
|
|
287
|
+
if (email) {
|
|
288
|
+
list.push(email);
|
|
289
|
+
} else {
|
|
290
|
+
notFound.push(id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { state: String(account.emailState), list, notFound };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
public async queryEmails(
|
|
297
|
+
accountId: string,
|
|
298
|
+
options: IJmapEmailQueryOptions
|
|
299
|
+
): Promise<IJmapEmailQueryResult> {
|
|
300
|
+
const account = this.requireAccountById(accountId);
|
|
301
|
+
const filter = options.filter ?? {};
|
|
302
|
+
let emails = Array.from(account.emails.values()).filter((email) =>
|
|
303
|
+
this.matchesFilter(email, filter)
|
|
304
|
+
);
|
|
305
|
+
const comparator = (options.sort ?? []).find((entry) => entry.property === 'receivedAt');
|
|
306
|
+
if (comparator) {
|
|
307
|
+
emails.sort((a, b) => {
|
|
308
|
+
const comparison = a.receivedAt.localeCompare(b.receivedAt);
|
|
309
|
+
return comparator.isAscending === false ? -comparison : comparison;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const total = emails.length;
|
|
313
|
+
const position = options.position ?? 0;
|
|
314
|
+
let ids = emails.map((email) => email.id).slice(position);
|
|
315
|
+
if (typeof options.limit === 'number') {
|
|
316
|
+
ids = ids.slice(0, options.limit);
|
|
317
|
+
}
|
|
318
|
+
const result: IJmapEmailQueryResult = {
|
|
319
|
+
queryState: String(account.emailState),
|
|
320
|
+
ids,
|
|
321
|
+
position,
|
|
322
|
+
};
|
|
323
|
+
if (options.calculateTotal) {
|
|
324
|
+
result.total = total;
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
public async getEmailChanges(
|
|
330
|
+
accountId: string,
|
|
331
|
+
sinceState: string,
|
|
332
|
+
maxChanges?: number
|
|
333
|
+
): Promise<IJmapChanges | null> {
|
|
334
|
+
const account = this.requireAccountById(accountId);
|
|
335
|
+
return this.collectChanges(account.emailChanges, account.emailState, sinceState, maxChanges);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public async setEmails(
|
|
339
|
+
accountId: string,
|
|
340
|
+
request: IJmapEmailSetRequest
|
|
341
|
+
): Promise<IJmapEmailSetResult> {
|
|
342
|
+
const account = this.requireAccountById(accountId);
|
|
343
|
+
const oldState = String(account.emailState);
|
|
344
|
+
const created: Record<string, IJmapEmailRecord> = {};
|
|
345
|
+
const notCreated: Record<string, { type: string; description?: string }> = {};
|
|
346
|
+
const updated: string[] = [];
|
|
347
|
+
const notUpdated: Record<string, { type: string; description?: string }> = {};
|
|
348
|
+
const destroyed: string[] = [];
|
|
349
|
+
const notDestroyed: Record<string, { type: string; description?: string }> = {};
|
|
350
|
+
const affectedMailboxIds = new Set<string>();
|
|
351
|
+
|
|
352
|
+
for (const [creationId, spec] of Object.entries(request.create ?? {})) {
|
|
353
|
+
try {
|
|
354
|
+
const record = this.buildEmailRecord(account, spec);
|
|
355
|
+
account.emails.set(record.id, record);
|
|
356
|
+
created[creationId] = record;
|
|
357
|
+
for (const mailboxId of Object.keys(record.mailboxIds)) {
|
|
358
|
+
affectedMailboxIds.add(mailboxId);
|
|
359
|
+
}
|
|
360
|
+
} catch (error) {
|
|
361
|
+
notCreated[creationId] = this.toSetError(error);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const [id, update] of Object.entries(request.update ?? {})) {
|
|
366
|
+
const email = account.emails.get(id);
|
|
367
|
+
if (!email) {
|
|
368
|
+
notUpdated[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const previousMailboxIds = Object.keys(email.mailboxIds);
|
|
373
|
+
this.applyEmailUpdate(account, email, update);
|
|
374
|
+
for (const mailboxId of [...previousMailboxIds, ...Object.keys(email.mailboxIds)]) {
|
|
375
|
+
affectedMailboxIds.add(mailboxId);
|
|
376
|
+
}
|
|
377
|
+
updated.push(id);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
notUpdated[id] = this.toSetError(error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const id of request.destroy ?? []) {
|
|
384
|
+
const email = account.emails.get(id);
|
|
385
|
+
if (email) {
|
|
386
|
+
for (const mailboxId of Object.keys(email.mailboxIds)) {
|
|
387
|
+
affectedMailboxIds.add(mailboxId);
|
|
388
|
+
}
|
|
389
|
+
account.emails.delete(id);
|
|
390
|
+
destroyed.push(id);
|
|
391
|
+
} else {
|
|
392
|
+
notDestroyed[id] = { type: 'notFound', description: `Email "${id}" does not exist.` };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const createdIds = Object.values(created).map((record) => record.id);
|
|
397
|
+
if (createdIds.length || updated.length || destroyed.length) {
|
|
398
|
+
this.bumpEmailState(
|
|
399
|
+
account,
|
|
400
|
+
{ created: createdIds, updated, destroyed },
|
|
401
|
+
affectedMailboxIds
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
oldState,
|
|
407
|
+
newState: String(account.emailState),
|
|
408
|
+
created,
|
|
409
|
+
notCreated,
|
|
410
|
+
updated,
|
|
411
|
+
notUpdated,
|
|
412
|
+
destroyed,
|
|
413
|
+
notDestroyed,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public async getThreads(
|
|
418
|
+
accountId: string,
|
|
419
|
+
ids: string[]
|
|
420
|
+
): Promise<IJmapGetResult<IJmapThreadRecord>> {
|
|
421
|
+
const account = this.requireAccountById(accountId);
|
|
422
|
+
const byThread = new Map<string, IJmapEmailRecord[]>();
|
|
423
|
+
for (const email of account.emails.values()) {
|
|
424
|
+
const bucket = byThread.get(email.threadId);
|
|
425
|
+
if (bucket) {
|
|
426
|
+
bucket.push(email);
|
|
427
|
+
} else {
|
|
428
|
+
byThread.set(email.threadId, [email]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const list: IJmapThreadRecord[] = [];
|
|
432
|
+
const notFound: string[] = [];
|
|
433
|
+
for (const id of ids) {
|
|
434
|
+
const emails = byThread.get(id);
|
|
435
|
+
if (emails) {
|
|
436
|
+
emails.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
|
437
|
+
list.push({ id, emailIds: emails.map((email) => email.id) });
|
|
438
|
+
} else {
|
|
439
|
+
notFound.push(id);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { state: String(account.emailState), list, notFound };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public async uploadBlob(
|
|
446
|
+
accountId: string,
|
|
447
|
+
data: Uint8Array,
|
|
448
|
+
type: string
|
|
449
|
+
): Promise<{ blobId: string; size: number }> {
|
|
450
|
+
const account = this.requireAccountById(accountId);
|
|
451
|
+
const blobId = `blob_upload_${++account.idCounter}`;
|
|
452
|
+
account.blobs.set(blobId, { data, type });
|
|
453
|
+
return { blobId, size: data.length };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public async getBlob(accountId: string, blobId: string): Promise<IJmapBlobRecord | null> {
|
|
457
|
+
const account = this.requireAccountById(accountId);
|
|
458
|
+
return account.blobs.get(blobId) ?? null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
public async getIdentities(accountId: string): Promise<IJmapGetResult<IJmapIdentityRecord>> {
|
|
462
|
+
const account = this.requireAccountById(accountId);
|
|
463
|
+
return { state: '0', list: account.identities, notFound: [] };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
public async submitEmail(
|
|
467
|
+
accountId: string,
|
|
468
|
+
submission: IJmapSubmissionInput
|
|
469
|
+
): Promise<IJmapSubmissionRecord> {
|
|
470
|
+
const account = this.requireAccountById(accountId);
|
|
471
|
+
const id = `submission_${++account.idCounter}`;
|
|
472
|
+
const record: IMemorySubmission = {
|
|
473
|
+
id,
|
|
474
|
+
emailId: submission.emailId,
|
|
475
|
+
identityId: submission.identityId,
|
|
476
|
+
undoStatus: 'final',
|
|
477
|
+
sendAt: new Date().toISOString(),
|
|
478
|
+
envelope: submission.envelope,
|
|
479
|
+
message: submission.message,
|
|
480
|
+
};
|
|
481
|
+
account.submissions.set(id, record);
|
|
482
|
+
return {
|
|
483
|
+
id: record.id,
|
|
484
|
+
emailId: record.emailId,
|
|
485
|
+
identityId: record.identityId,
|
|
486
|
+
undoStatus: record.undoStatus,
|
|
487
|
+
sendAt: record.sendAt,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
public subscribeToChanges(listener: TJmapChangeListener): () => void {
|
|
492
|
+
this.changeListeners.add(listener);
|
|
493
|
+
return () => {
|
|
494
|
+
this.changeListeners.delete(listener);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ======================
|
|
499
|
+
// internal helpers
|
|
500
|
+
// ======================
|
|
501
|
+
|
|
502
|
+
private ensureAccount(username: string): IMemoryMailAccount {
|
|
503
|
+
const existing = this.accounts.get(username);
|
|
504
|
+
if (existing) {
|
|
505
|
+
return existing;
|
|
506
|
+
}
|
|
507
|
+
const email = username.includes('@') ? username : `${username}@example.com`;
|
|
508
|
+
const account: IMemoryMailAccount = {
|
|
509
|
+
username,
|
|
510
|
+
accountId: `account_${username}`,
|
|
511
|
+
mailboxes: new Map(),
|
|
512
|
+
emails: new Map(),
|
|
513
|
+
blobs: new Map(),
|
|
514
|
+
submissions: new Map(),
|
|
515
|
+
identities: [{ id: 'identity_1', name: username, email, mayDelete: false }],
|
|
516
|
+
emailState: 0,
|
|
517
|
+
emailChanges: [],
|
|
518
|
+
mailboxState: 0,
|
|
519
|
+
mailboxChanges: [],
|
|
520
|
+
threadIdsBySubject: new Map(),
|
|
521
|
+
idCounter: 0,
|
|
522
|
+
};
|
|
523
|
+
this.accounts.set(username, account);
|
|
524
|
+
return account;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private requireAccountById(accountId: string): IMemoryMailAccount {
|
|
528
|
+
for (const account of this.accounts.values()) {
|
|
529
|
+
if (account.accountId === accountId) {
|
|
530
|
+
return account;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
throw new JmapBackendError('accountNotFound', `Account "${accountId}" does not exist.`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private matchesFilter(email: IJmapEmailRecord, filter: IJmapEmailFilter): boolean {
|
|
537
|
+
const addressMatch = (
|
|
538
|
+
addresses: IJmapEmailAddress[] | null | undefined,
|
|
539
|
+
needle: string
|
|
540
|
+
): boolean =>
|
|
541
|
+
(addresses ?? []).some(
|
|
542
|
+
(address) =>
|
|
543
|
+
address.email.toLowerCase().includes(needle) ||
|
|
544
|
+
(address.name ?? '').toLowerCase().includes(needle)
|
|
545
|
+
);
|
|
546
|
+
if (typeof filter.inMailbox === 'string' && !email.mailboxIds[filter.inMailbox]) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
if (typeof filter.subject === 'string') {
|
|
550
|
+
if (!(email.subject ?? '').toLowerCase().includes(filter.subject.toLowerCase())) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (typeof filter.from === 'string' && !addressMatch(email.from, filter.from.toLowerCase())) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
if (typeof filter.to === 'string' && !addressMatch(email.to, filter.to.toLowerCase())) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
if (typeof filter.text === 'string') {
|
|
561
|
+
const needle = filter.text.toLowerCase();
|
|
562
|
+
const inBodies = Object.values(email.bodyValues).some((bodyValue) =>
|
|
563
|
+
bodyValue.value.toLowerCase().includes(needle)
|
|
564
|
+
);
|
|
565
|
+
const inAddresses =
|
|
566
|
+
addressMatch(email.from, needle) ||
|
|
567
|
+
addressMatch(email.to, needle) ||
|
|
568
|
+
addressMatch(email.cc, needle) ||
|
|
569
|
+
addressMatch(email.bcc, needle);
|
|
570
|
+
if (!(email.subject ?? '').toLowerCase().includes(needle) && !inBodies && !inAddresses) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (typeof filter.before === 'string' && !(email.receivedAt < filter.before)) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
if (typeof filter.after === 'string' && !(email.receivedAt >= filter.after)) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
if (typeof filter.hasKeyword === 'string' && !email.keywords[filter.hasKeyword]) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
if (typeof filter.notKeyword === 'string' && email.keywords[filter.notKeyword]) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Builds a full email record from a validated create spec. Throws JmapBackendError. */
|
|
590
|
+
private buildEmailRecord(account: IMemoryMailAccount, spec: IJmapEmailCreate): IJmapEmailRecord {
|
|
591
|
+
const mailboxIds = spec.mailboxIds;
|
|
592
|
+
if (!mailboxIds || !Object.keys(mailboxIds).length) {
|
|
593
|
+
throw new JmapBackendError('invalidProperties', 'Email create requires mailboxIds.');
|
|
594
|
+
}
|
|
595
|
+
for (const mailboxId of Object.keys(mailboxIds)) {
|
|
596
|
+
if (!account.mailboxes.has(mailboxId)) {
|
|
597
|
+
throw new JmapBackendError('invalidProperties', `Mailbox "${mailboxId}" does not exist.`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const id = `email_${++account.idCounter}`;
|
|
602
|
+
const receivedAt = spec.receivedAt ?? new Date().toISOString();
|
|
603
|
+
const bodyValues = spec.bodyValues ?? {};
|
|
604
|
+
const resolveValue = (part: IJmapEmailBodyPart): string =>
|
|
605
|
+
part.partId != null ? bodyValues[part.partId]?.value ?? '' : '';
|
|
606
|
+
const textBodyParts = (spec.textBody ?? []).map((part) => ({
|
|
607
|
+
partId: part.partId ?? null,
|
|
608
|
+
type: part.type ?? 'text/plain',
|
|
609
|
+
size: resolveValue(part).length,
|
|
610
|
+
}));
|
|
611
|
+
const htmlBodyParts = (spec.htmlBody ?? []).map((part) => ({
|
|
612
|
+
partId: part.partId ?? null,
|
|
613
|
+
type: part.type ?? 'text/html',
|
|
614
|
+
size: resolveValue(part).length,
|
|
615
|
+
}));
|
|
616
|
+
const textValue = textBodyParts
|
|
617
|
+
.map((part) => (part.partId != null ? bodyValues[part.partId]?.value ?? '' : ''))
|
|
618
|
+
.join('\n');
|
|
619
|
+
|
|
620
|
+
const attachmentParts: IJmapEmailBodyPart[] = [];
|
|
621
|
+
for (const part of spec.attachments ?? []) {
|
|
622
|
+
if (typeof part.blobId !== 'string') {
|
|
623
|
+
throw new JmapBackendError('invalidProperties', 'Attachment parts require a blobId.');
|
|
624
|
+
}
|
|
625
|
+
const blob = account.blobs.get(part.blobId);
|
|
626
|
+
if (!blob) {
|
|
627
|
+
throw new JmapBackendError('invalidProperties', `Blob "${part.blobId}" does not exist.`);
|
|
628
|
+
}
|
|
629
|
+
attachmentParts.push({
|
|
630
|
+
partId: null,
|
|
631
|
+
blobId: part.blobId,
|
|
632
|
+
name: part.name ?? blob.name ?? null,
|
|
633
|
+
type: part.type ?? blob.type,
|
|
634
|
+
size: blob.data.length,
|
|
635
|
+
disposition: part.disposition ?? 'attachment',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const from = spec.from ?? [];
|
|
640
|
+
const to = spec.to ?? [];
|
|
641
|
+
// Simplified raw RFC 5322 rendering: headers plus the text body. Attachments
|
|
642
|
+
// remain separate blobs and are not MIME-encoded into the raw message.
|
|
643
|
+
const rawLines = [
|
|
644
|
+
`From: ${from.map((address) => address.email).join(', ')}`,
|
|
645
|
+
`To: ${to.map((address) => address.email).join(', ')}`,
|
|
646
|
+
`Subject: ${spec.subject ?? ''}`,
|
|
647
|
+
`Date: ${receivedAt}`,
|
|
648
|
+
'',
|
|
649
|
+
textValue,
|
|
650
|
+
];
|
|
651
|
+
const rawData = textEncoder.encode(rawLines.join('\r\n'));
|
|
652
|
+
const blobId = `blob_${id}`;
|
|
653
|
+
account.blobs.set(blobId, { data: rawData, type: 'message/rfc822', name: `${id}.eml` });
|
|
654
|
+
|
|
655
|
+
const normalizedBodyValues: Record<string, IJmapEmailBodyValue> = {};
|
|
656
|
+
for (const [partId, bodyValue] of Object.entries(bodyValues)) {
|
|
657
|
+
normalizedBodyValues[partId] = { value: bodyValue.value ?? '', isTruncated: false };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const attachmentBytes = attachmentParts.reduce((sum, part) => sum + (part.size ?? 0), 0);
|
|
661
|
+
return {
|
|
662
|
+
id,
|
|
663
|
+
blobId,
|
|
664
|
+
threadId: this.resolveThreadId(account, spec.subject ?? null),
|
|
665
|
+
mailboxIds: { ...mailboxIds },
|
|
666
|
+
keywords: { ...(spec.keywords ?? {}) },
|
|
667
|
+
size: rawData.length + attachmentBytes,
|
|
668
|
+
receivedAt,
|
|
669
|
+
sentAt: spec.sentAt ?? receivedAt,
|
|
670
|
+
messageId: spec.messageId ?? null,
|
|
671
|
+
inReplyTo: spec.inReplyTo ?? null,
|
|
672
|
+
references: spec.references ?? null,
|
|
673
|
+
sender: spec.sender ?? null,
|
|
674
|
+
subject: spec.subject ?? null,
|
|
675
|
+
from: spec.from ?? null,
|
|
676
|
+
to: spec.to ?? null,
|
|
677
|
+
cc: spec.cc ?? null,
|
|
678
|
+
bcc: spec.bcc ?? null,
|
|
679
|
+
replyTo: spec.replyTo ?? null,
|
|
680
|
+
hasAttachment: attachmentParts.length > 0,
|
|
681
|
+
preview: textValue.slice(0, 100),
|
|
682
|
+
bodyValues: normalizedBodyValues,
|
|
683
|
+
textBody: textBodyParts,
|
|
684
|
+
htmlBody: htmlBodyParts,
|
|
685
|
+
attachments: attachmentParts,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Applies a normalized email update in place. Throws JmapBackendError. */
|
|
690
|
+
private applyEmailUpdate(
|
|
691
|
+
account: IMemoryMailAccount,
|
|
692
|
+
email: IJmapEmailRecord,
|
|
693
|
+
update: IJmapEmailUpdate
|
|
694
|
+
): void {
|
|
695
|
+
let keywords = update.keywords ? { ...update.keywords } : { ...email.keywords };
|
|
696
|
+
for (const [keyword, value] of Object.entries(update.keywordsPatch ?? {})) {
|
|
697
|
+
if (value === true) {
|
|
698
|
+
keywords[keyword] = true;
|
|
699
|
+
} else {
|
|
700
|
+
delete keywords[keyword];
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
let mailboxIds = update.mailboxIds ? { ...update.mailboxIds } : { ...email.mailboxIds };
|
|
704
|
+
for (const [mailboxId, value] of Object.entries(update.mailboxIdsPatch ?? {})) {
|
|
705
|
+
if (value === true) {
|
|
706
|
+
mailboxIds[mailboxId] = true;
|
|
707
|
+
} else {
|
|
708
|
+
delete mailboxIds[mailboxId];
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const mailboxId of Object.keys(mailboxIds)) {
|
|
712
|
+
if (!account.mailboxes.has(mailboxId)) {
|
|
713
|
+
throw new JmapBackendError('invalidProperties', `Mailbox "${mailboxId}" does not exist.`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (!Object.keys(mailboxIds).length) {
|
|
717
|
+
throw new JmapBackendError(
|
|
718
|
+
'invalidProperties',
|
|
719
|
+
'An email must belong to at least one mailbox; destroy it instead.'
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
email.keywords = keywords;
|
|
723
|
+
email.mailboxIds = mailboxIds;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private resolveThreadId(account: IMemoryMailAccount, subject: string | null): string {
|
|
727
|
+
const normalized = normalizeSubject(subject);
|
|
728
|
+
if (!normalized) {
|
|
729
|
+
return `thread_${++account.idCounter}`;
|
|
730
|
+
}
|
|
731
|
+
const existing = account.threadIdsBySubject.get(normalized);
|
|
732
|
+
if (existing) {
|
|
733
|
+
return existing;
|
|
734
|
+
}
|
|
735
|
+
const threadId = `thread_${++account.idCounter}`;
|
|
736
|
+
account.threadIdsBySubject.set(normalized, threadId);
|
|
737
|
+
return threadId;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private collectChanges(
|
|
741
|
+
entries: IMemoryChangeEntry[],
|
|
742
|
+
currentState: number,
|
|
743
|
+
sinceState: string,
|
|
744
|
+
maxChanges?: number
|
|
745
|
+
): IJmapChanges | null {
|
|
746
|
+
const since = Number(sinceState);
|
|
747
|
+
if (!Number.isFinite(since) || since < 0 || since > currentState) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
const pending = entries.filter((entry) => entry.state > since);
|
|
751
|
+
let included = pending;
|
|
752
|
+
let hasMoreChanges = false;
|
|
753
|
+
if (typeof maxChanges === 'number' && maxChanges > 0) {
|
|
754
|
+
included = [];
|
|
755
|
+
let count = 0;
|
|
756
|
+
for (const entry of pending) {
|
|
757
|
+
const entryCount = entry.created.length + entry.updated.length + entry.destroyed.length;
|
|
758
|
+
if (included.length && count + entryCount > maxChanges) {
|
|
759
|
+
hasMoreChanges = true;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
included.push(entry);
|
|
763
|
+
count += entryCount;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const created = new Set<string>();
|
|
767
|
+
const updated = new Set<string>();
|
|
768
|
+
const destroyed = new Set<string>();
|
|
769
|
+
for (const entry of included) {
|
|
770
|
+
for (const id of entry.created) {
|
|
771
|
+
created.add(id);
|
|
772
|
+
}
|
|
773
|
+
for (const id of entry.updated) {
|
|
774
|
+
updated.add(id);
|
|
775
|
+
}
|
|
776
|
+
for (const id of entry.destroyed) {
|
|
777
|
+
if (created.has(id)) {
|
|
778
|
+
// created and destroyed within the window: omit entirely
|
|
779
|
+
created.delete(id);
|
|
780
|
+
} else {
|
|
781
|
+
destroyed.add(id);
|
|
782
|
+
}
|
|
783
|
+
updated.delete(id);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for (const id of created) {
|
|
787
|
+
updated.delete(id);
|
|
788
|
+
}
|
|
789
|
+
const newState = hasMoreChanges
|
|
790
|
+
? String(included[included.length - 1]!.state)
|
|
791
|
+
: String(currentState);
|
|
792
|
+
return {
|
|
793
|
+
oldState: String(since),
|
|
794
|
+
newState,
|
|
795
|
+
hasMoreChanges,
|
|
796
|
+
created: Array.from(created),
|
|
797
|
+
updated: Array.from(updated),
|
|
798
|
+
destroyed: Array.from(destroyed),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private bumpEmailState(
|
|
803
|
+
account: IMemoryMailAccount,
|
|
804
|
+
change: { created?: string[]; updated?: string[]; destroyed?: string[] },
|
|
805
|
+
affectedMailboxIds: Set<string>
|
|
806
|
+
): void {
|
|
807
|
+
account.emailState += 1;
|
|
808
|
+
account.emailChanges.push({
|
|
809
|
+
state: account.emailState,
|
|
810
|
+
created: change.created ?? [],
|
|
811
|
+
updated: change.updated ?? [],
|
|
812
|
+
destroyed: change.destroyed ?? [],
|
|
813
|
+
});
|
|
814
|
+
const changed: Record<string, string> = {
|
|
815
|
+
Email: String(account.emailState),
|
|
816
|
+
Thread: String(account.emailState),
|
|
817
|
+
};
|
|
818
|
+
if (affectedMailboxIds.size) {
|
|
819
|
+
account.mailboxState += 1;
|
|
820
|
+
account.mailboxChanges.push({
|
|
821
|
+
state: account.mailboxState,
|
|
822
|
+
created: [],
|
|
823
|
+
updated: Array.from(affectedMailboxIds),
|
|
824
|
+
destroyed: [],
|
|
825
|
+
});
|
|
826
|
+
changed.Mailbox = String(account.mailboxState);
|
|
827
|
+
}
|
|
828
|
+
this.emitChange({ accountId: account.accountId, changed });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private emitChange(change: IJmapStateChange): void {
|
|
832
|
+
for (const listener of Array.from(this.changeListeners)) {
|
|
833
|
+
try {
|
|
834
|
+
listener(change);
|
|
835
|
+
} catch {
|
|
836
|
+
// listeners must not break mutation paths
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private toSetError(error: unknown): { type: string; description?: string } {
|
|
842
|
+
if (error instanceof JmapBackendError) {
|
|
843
|
+
return { type: error.type, description: error.message };
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
type: 'invalidProperties',
|
|
847
|
+
description: error instanceof Error ? error.message : String(error),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|