@push.rocks/smartjmap 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }