@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.
@@ -0,0 +1,872 @@
1
+ import * as plugins from './smartjmap.plugins.js';
2
+
3
+ export const JMAP_CORE_CAPABILITY = 'urn:ietf:params:jmap:core';
4
+ export const JMAP_MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
5
+ export const JMAP_SUBMISSION_CAPABILITY = 'urn:ietf:params:jmap:submission';
6
+
7
+ export interface IJmapClientAuthBearer {
8
+ /** OAuth2 / API access token sent as `Authorization: Bearer <token>`. */
9
+ accessToken: string;
10
+ }
11
+
12
+ export interface IJmapClientAuthBasic {
13
+ user: string;
14
+ pass: string;
15
+ }
16
+
17
+ export type TJmapClientAuth = IJmapClientAuthBearer | IJmapClientAuthBasic;
18
+
19
+ export interface IJmapClientConfig {
20
+ /**
21
+ * URL of the JMAP session resource.
22
+ * Callers can pass the autodiscovery endpoint, e.g. `https://host/.well-known/jmap`;
23
+ * relative URLs inside the session object are resolved against this URL.
24
+ */
25
+ sessionUrl: string;
26
+ /** Either `{ accessToken }` (Bearer) or `{ user, pass }` (Basic) — exactly one shape. */
27
+ auth: TJmapClientAuth;
28
+ /**
29
+ * Mailbox to watch for new messages. Defaults to 'INBOX' semantics:
30
+ * resolved via the Mailbox with role 'inbox', falling back to a
31
+ * case-insensitive name match.
32
+ */
33
+ mailbox?: string;
34
+ /** Fallback polling cadence in ms, used when the event stream is unavailable. Default 30000. */
35
+ pollIntervalMs?: number;
36
+ }
37
+
38
+ /** A single JMAP method call: [methodName, arguments, methodCallId] (RFC 8620 §3.2). */
39
+ export type TJmapMethodCall = [string, Record<string, any>, string];
40
+ /** A single JMAP method response: [methodName, arguments, methodCallId] (RFC 8620 §3.4). */
41
+ export type TJmapMethodResponse = [string, Record<string, any>, string];
42
+
43
+ export interface IJmapSession {
44
+ capabilities: Record<string, any>;
45
+ accounts: Record<string, any>;
46
+ primaryAccounts: Record<string, string>;
47
+ username?: string;
48
+ apiUrl: string;
49
+ downloadUrl: string;
50
+ uploadUrl?: string;
51
+ eventSourceUrl?: string;
52
+ state?: string;
53
+ }
54
+
55
+ export interface IJmapMailbox {
56
+ id: string;
57
+ name: string;
58
+ role?: string | null;
59
+ parentId?: string | null;
60
+ sortOrder?: number;
61
+ totalEmails?: number;
62
+ unreadEmails?: number;
63
+ totalThreads?: number;
64
+ unreadThreads?: number;
65
+ }
66
+
67
+ export interface IJmapEmailAddress {
68
+ name?: string | null;
69
+ email: string;
70
+ }
71
+
72
+ export interface IJmapEmailBodyPart {
73
+ partId?: string | null;
74
+ blobId?: string;
75
+ size?: number;
76
+ name?: string | null;
77
+ type?: string;
78
+ charset?: string | null;
79
+ disposition?: string | null;
80
+ cid?: string | null;
81
+ }
82
+
83
+ export interface IJmapEmailBodyValue {
84
+ value: string;
85
+ isTruncated?: boolean;
86
+ isEncodingProblem?: boolean;
87
+ }
88
+
89
+ /** The raw JMAP Email object (RFC 8621 §4) — the subset of properties this client requests. */
90
+ export interface IJmapEmail {
91
+ id: string;
92
+ blobId: string;
93
+ threadId: string;
94
+ mailboxIds: Record<string, boolean>;
95
+ keywords: Record<string, boolean>;
96
+ size?: number;
97
+ receivedAt?: string;
98
+ sentAt?: string | null;
99
+ subject?: string | null;
100
+ from?: IJmapEmailAddress[] | null;
101
+ to?: IJmapEmailAddress[] | null;
102
+ cc?: IJmapEmailAddress[] | null;
103
+ bcc?: IJmapEmailAddress[] | null;
104
+ replyTo?: IJmapEmailAddress[] | null;
105
+ hasAttachment?: boolean;
106
+ preview?: string;
107
+ bodyValues?: Record<string, IJmapEmailBodyValue>;
108
+ textBody?: IJmapEmailBodyPart[];
109
+ htmlBody?: IJmapEmailBodyPart[];
110
+ attachments?: IJmapEmailBodyPart[];
111
+ }
112
+
113
+ export interface IJmapAttachment {
114
+ blobId: string;
115
+ name?: string;
116
+ type?: string;
117
+ size?: number;
118
+ }
119
+
120
+ /** The message shape emitted on 'message' events and returned by the query/get helpers. */
121
+ export interface ISmartJmapMessage {
122
+ id: string;
123
+ blobId: string;
124
+ threadId: string;
125
+ mailboxIds: Record<string, boolean>;
126
+ keywords: Record<string, boolean>;
127
+ from: IJmapEmailAddress[];
128
+ to: IJmapEmailAddress[];
129
+ cc: IJmapEmailAddress[];
130
+ bcc: IJmapEmailAddress[];
131
+ replyTo: IJmapEmailAddress[];
132
+ subject?: string;
133
+ receivedAt?: string;
134
+ /** Plain-text body, resolved from the JMAP bodyValues. */
135
+ textBody: string;
136
+ /** HTML body, resolved from the JMAP bodyValues; undefined when the email has no text/html part. */
137
+ htmlBody?: string;
138
+ attachments: IJmapAttachment[];
139
+ /** The full raw JMAP Email object as returned by Email/get. */
140
+ raw: IJmapEmail;
141
+ }
142
+
143
+ export interface IJmapOutgoingEmail {
144
+ /** Sender; defaults to the address of the account's first JMAP Identity. */
145
+ from?: IJmapEmailAddress;
146
+ to: IJmapEmailAddress[];
147
+ cc?: IJmapEmailAddress[];
148
+ bcc?: IJmapEmailAddress[];
149
+ subject?: string;
150
+ textBody?: string;
151
+ htmlBody?: string;
152
+ }
153
+
154
+ export interface IJmapSendResult {
155
+ emailId: string;
156
+ submissionId: string;
157
+ }
158
+
159
+ /** Error thrown for HTTP-level (RFC 7807 problem details) and JMAP method-level errors. */
160
+ export class JmapError extends Error {
161
+ public httpStatus?: number;
162
+ public problemType?: string;
163
+ public problemDetail?: string;
164
+ public jmapErrorType?: string;
165
+ public jmapErrorDescription?: string;
166
+ public jmapCallId?: string;
167
+
168
+ constructor(message: string, fields: Partial<JmapError> = {}) {
169
+ super(message);
170
+ this.name = 'JmapError';
171
+ Object.assign(this, fields);
172
+ }
173
+ }
174
+
175
+ const EMAIL_PROPERTIES = [
176
+ 'id',
177
+ 'blobId',
178
+ 'threadId',
179
+ 'mailboxIds',
180
+ 'keywords',
181
+ 'size',
182
+ 'receivedAt',
183
+ 'sentAt',
184
+ 'subject',
185
+ 'from',
186
+ 'to',
187
+ 'cc',
188
+ 'bcc',
189
+ 'replyTo',
190
+ 'hasAttachment',
191
+ 'preview',
192
+ 'bodyValues',
193
+ 'textBody',
194
+ 'htmlBody',
195
+ 'attachments',
196
+ ];
197
+
198
+ export class JmapClient extends plugins.events.EventEmitter {
199
+ private config: IJmapClientConfig;
200
+ private session: IJmapSession | null = null;
201
+ private accountId: string = '';
202
+ private apiUrl: string = '';
203
+ private mailbox: IJmapMailbox | null = null;
204
+ private connected: boolean = false;
205
+ private stopped: boolean = false;
206
+ private lastEmailState: string | null = null;
207
+ private seenEmailIds: Set<string> = new Set();
208
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
209
+ private sseAbortController: AbortController | null = null;
210
+ private fetchingDeltas: boolean = false;
211
+ private deltaQueued: boolean = false;
212
+
213
+ constructor(config: IJmapClientConfig) {
214
+ super();
215
+
216
+ const auth = config.auth as Partial<IJmapClientAuthBearer & IJmapClientAuthBasic>;
217
+ const hasBearer = typeof auth.accessToken === 'string';
218
+ const hasBasic = typeof auth.user === 'string' && typeof auth.pass === 'string';
219
+ if (!hasBearer && !hasBasic) {
220
+ throw new Error(
221
+ 'JmapClient requires auth as either { accessToken } (Bearer) or { user, pass } (Basic).'
222
+ );
223
+ }
224
+ if (hasBearer && (typeof auth.user === 'string' || typeof auth.pass === 'string')) {
225
+ throw new Error('JmapClient auth accepts either { accessToken } or { user, pass }, not both.');
226
+ }
227
+ if (typeof config.sessionUrl !== 'string' || !config.sessionUrl) {
228
+ throw new Error('JmapClient requires a sessionUrl, e.g. https://host/.well-known/jmap');
229
+ }
230
+
231
+ this.config = config;
232
+ }
233
+
234
+ /**
235
+ * Fetches and validates the JMAP session, resolves the target mailbox,
236
+ * emits 'connected', emits 'message' for every email currently in the
237
+ * mailbox, then watches for new mail via the JMAP event source (SSE),
238
+ * falling back to polling when the event stream is unavailable.
239
+ * Failures are emitted as 'error' events; connect() does not throw.
240
+ */
241
+ public async connect(): Promise<void> {
242
+ // Reconnecting on the same instance is supported; release any watchers
243
+ // from a previous connect() so no SSE stream or poll timer is stranded.
244
+ this.teardownWatchers();
245
+ this.stopped = false;
246
+ try {
247
+ const response = await fetch(this.config.sessionUrl, {
248
+ headers: {
249
+ authorization: this.getAuthHeader(),
250
+ accept: 'application/json',
251
+ },
252
+ });
253
+ if (!response.ok) {
254
+ throw await this.createHttpError(response, 'Failed to fetch JMAP session');
255
+ }
256
+ const session = (await response.json()) as IJmapSession;
257
+ if (!session.capabilities || !(JMAP_CORE_CAPABILITY in session.capabilities)) {
258
+ throw new Error(`JMAP session is missing the ${JMAP_CORE_CAPABILITY} capability.`);
259
+ }
260
+ if (!(JMAP_MAIL_CAPABILITY in session.capabilities)) {
261
+ throw new Error(`JMAP session is missing the ${JMAP_MAIL_CAPABILITY} capability.`);
262
+ }
263
+ const accountId =
264
+ session.primaryAccounts?.[JMAP_MAIL_CAPABILITY] ??
265
+ session.primaryAccounts?.[JMAP_CORE_CAPABILITY];
266
+ if (!accountId) {
267
+ throw new Error('JMAP session has no primary account for urn:ietf:params:jmap:mail.');
268
+ }
269
+ this.session = session;
270
+ this.accountId = accountId;
271
+ this.apiUrl = new URL(session.apiUrl, this.config.sessionUrl).toString();
272
+
273
+ this.mailbox = await this.resolveMailbox();
274
+ // Capture the Email state before the initial sweep so no change is lost
275
+ // between the sweep and the start of watching (dedupe handles overlap).
276
+ this.lastEmailState = await this.fetchEmailState();
277
+
278
+ if (this.stopped) {
279
+ // disconnect() was called while connect() was in flight.
280
+ return;
281
+ }
282
+ this.connected = true;
283
+ this.emit('connected');
284
+
285
+ await this.emitInitialEmails();
286
+ this.startWatching();
287
+ } catch (error) {
288
+ // Clean up anything that may have been started; this instance holds no
289
+ // sockets or timers after a failed connect.
290
+ this.teardownWatchers();
291
+ this.connected = false;
292
+ this.emit('error', error);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Stops watching (event stream and/or polling timer) and emits
298
+ * 'disconnected' when the client was connected. Leaves no open handles.
299
+ */
300
+ public async disconnect(): Promise<void> {
301
+ this.stopped = true;
302
+ this.teardownWatchers();
303
+ if (this.connected) {
304
+ this.connected = false;
305
+ this.emit('disconnected');
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Core JMAP method-call helper (RFC 8620 §3.2): POSTs { using, methodCalls }
311
+ * to the session's apiUrl and returns the methodResponses. HTTP problem
312
+ * details (RFC 7807) and JMAP `error` method responses are thrown as
313
+ * JmapError with `httpStatus`/`problemType`/`jmapErrorType` fields.
314
+ */
315
+ public async request(methodCalls: TJmapMethodCall[]): Promise<TJmapMethodResponse[]> {
316
+ if (!this.apiUrl) {
317
+ throw new Error('JmapClient is not connected yet — call connect() first.');
318
+ }
319
+ const response = await fetch(this.apiUrl, {
320
+ method: 'POST',
321
+ headers: {
322
+ authorization: this.getAuthHeader(),
323
+ 'content-type': 'application/json',
324
+ accept: 'application/json',
325
+ },
326
+ body: JSON.stringify({
327
+ using: [JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY, JMAP_SUBMISSION_CAPABILITY],
328
+ methodCalls,
329
+ }),
330
+ });
331
+ if (!response.ok) {
332
+ throw await this.createHttpError(response, 'JMAP API request failed');
333
+ }
334
+ const parsed = (await response.json()) as { methodResponses?: TJmapMethodResponse[] };
335
+ const methodResponses = parsed.methodResponses ?? [];
336
+ for (const [name, args, callId] of methodResponses) {
337
+ if (name === 'error') {
338
+ throw new JmapError(
339
+ `JMAP method error "${args?.type}"${args?.description ? `: ${args.description}` : ''} (call ${callId})`,
340
+ {
341
+ jmapErrorType: args?.type,
342
+ jmapErrorDescription: args?.description,
343
+ jmapCallId: callId,
344
+ }
345
+ );
346
+ }
347
+ }
348
+ return methodResponses;
349
+ }
350
+
351
+ /** Returns all mailboxes of the account (Mailbox/get with ids: null). */
352
+ public async getMailboxes(): Promise<IJmapMailbox[]> {
353
+ const responses = await this.request([
354
+ ['Mailbox/get', { accountId: this.accountId, ids: null }, 'mb0'],
355
+ ]);
356
+ return (responses[0][1].list ?? []) as IJmapMailbox[];
357
+ }
358
+
359
+ /**
360
+ * Runs Email/query (default filter: the watched mailbox) followed by a full
361
+ * Email/get and returns resolved messages, newest first.
362
+ */
363
+ public async queryEmails(
364
+ filter?: Record<string, any>,
365
+ limit?: number
366
+ ): Promise<ISmartJmapMessage[]> {
367
+ const effectiveFilter =
368
+ filter ?? (this.mailbox ? { inMailbox: this.mailbox.id } : {});
369
+ const queryArgs: Record<string, any> = {
370
+ accountId: this.accountId,
371
+ filter: effectiveFilter,
372
+ sort: [{ property: 'receivedAt', isAscending: false }],
373
+ };
374
+ if (typeof limit === 'number') {
375
+ queryArgs.limit = limit;
376
+ }
377
+ const responses = await this.request([['Email/query', queryArgs, 'q0']]);
378
+ const ids: string[] = responses[0][1].ids ?? [];
379
+ if (!ids.length) {
380
+ return [];
381
+ }
382
+ const emails = await this.getEmailObjects(ids);
383
+ return emails.map((email) => this.toMessage(email));
384
+ }
385
+
386
+ /** Fetches a single email with all body values resolved (RFC 8621 §4.2, fetchAllBodyValues). */
387
+ public async getEmail(id: string): Promise<ISmartJmapMessage> {
388
+ const [email] = await this.getEmailObjects([id]);
389
+ if (!email) {
390
+ throw new Error(`Email "${id}" not found.`);
391
+ }
392
+ return this.toMessage(email);
393
+ }
394
+
395
+ /** Replaces the full keywords object of an email via Email/set. */
396
+ public async setKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
397
+ await this.updateEmail(emailId, { keywords });
398
+ }
399
+
400
+ /** Convenience: sets the $seen keyword via an Email/set keywords patch. */
401
+ public async markSeen(emailId: string): Promise<void> {
402
+ await this.updateEmail(emailId, { 'keywords/$seen': true });
403
+ }
404
+
405
+ /**
406
+ * Sends an email: Identity/get lookup, Email/set create (into the mailbox
407
+ * with role 'sent', falling back to 'drafts', then the watched mailbox) and
408
+ * EmailSubmission/set referencing the created email.
409
+ */
410
+ public async sendEmail(outgoing: IJmapOutgoingEmail): Promise<IJmapSendResult> {
411
+ const identityResponses = await this.request([
412
+ ['Identity/get', { accountId: this.accountId }, 'i0'],
413
+ ]);
414
+ const identities: any[] = identityResponses[0][1].list ?? [];
415
+ const identity =
416
+ (outgoing.from
417
+ ? identities.find((candidate) => candidate.email === outgoing.from!.email)
418
+ : undefined) ?? identities[0];
419
+ if (!identity) {
420
+ throw new Error('No JMAP identity available for sending.');
421
+ }
422
+ const from: IJmapEmailAddress =
423
+ outgoing.from ?? { name: identity.name ?? null, email: identity.email };
424
+
425
+ const mailboxes = await this.getMailboxes();
426
+ const targetMailbox =
427
+ mailboxes.find((mb) => mb.role === 'sent') ??
428
+ mailboxes.find((mb) => mb.role === 'drafts') ??
429
+ this.mailbox;
430
+ if (!targetMailbox) {
431
+ throw new Error('No mailbox available to store the outgoing email in.');
432
+ }
433
+
434
+ const bodyValues: Record<string, { value: string }> = {};
435
+ const textBodyParts: IJmapEmailBodyPart[] = [];
436
+ const htmlBodyParts: IJmapEmailBodyPart[] = [];
437
+ if (typeof outgoing.textBody === 'string') {
438
+ bodyValues.text = { value: outgoing.textBody };
439
+ textBodyParts.push({ partId: 'text', type: 'text/plain' });
440
+ }
441
+ if (typeof outgoing.htmlBody === 'string') {
442
+ bodyValues.html = { value: outgoing.htmlBody };
443
+ htmlBodyParts.push({ partId: 'html', type: 'text/html' });
444
+ }
445
+ if (!textBodyParts.length && !htmlBodyParts.length) {
446
+ throw new Error('sendEmail requires textBody and/or htmlBody.');
447
+ }
448
+
449
+ const emailCreate: Record<string, any> = {
450
+ mailboxIds: { [targetMailbox.id]: true },
451
+ keywords: { $seen: true },
452
+ from: [from],
453
+ to: outgoing.to,
454
+ subject: outgoing.subject,
455
+ bodyValues,
456
+ };
457
+ if (outgoing.cc) {
458
+ emailCreate.cc = outgoing.cc;
459
+ }
460
+ if (outgoing.bcc) {
461
+ emailCreate.bcc = outgoing.bcc;
462
+ }
463
+ if (textBodyParts.length) {
464
+ emailCreate.textBody = textBodyParts;
465
+ }
466
+ if (htmlBodyParts.length) {
467
+ emailCreate.htmlBody = htmlBodyParts;
468
+ }
469
+
470
+ const responses = await this.request([
471
+ ['Email/set', { accountId: this.accountId, create: { newEmail: emailCreate } }, 'c0'],
472
+ [
473
+ 'EmailSubmission/set',
474
+ {
475
+ accountId: this.accountId,
476
+ create: { newSubmission: { emailId: '#newEmail', identityId: identity.id } },
477
+ },
478
+ 'c1',
479
+ ],
480
+ ]);
481
+
482
+ const emailSetResult = responses.find((entry) => entry[2] === 'c0')?.[1] ?? {};
483
+ const createdEmail = emailSetResult.created?.newEmail;
484
+ if (!createdEmail) {
485
+ const notCreated = emailSetResult.notCreated?.newEmail;
486
+ throw new JmapError(
487
+ `Email/set create failed${notCreated ? `: ${notCreated.type}${notCreated.description ? ` — ${notCreated.description}` : ''}` : ''}`,
488
+ { jmapErrorType: notCreated?.type, jmapErrorDescription: notCreated?.description }
489
+ );
490
+ }
491
+ const submissionSetResult = responses.find((entry) => entry[2] === 'c1')?.[1] ?? {};
492
+ const createdSubmission = submissionSetResult.created?.newSubmission;
493
+ if (!createdSubmission) {
494
+ const notCreated = submissionSetResult.notCreated?.newSubmission;
495
+ throw new JmapError(
496
+ `EmailSubmission/set create failed${notCreated ? `: ${notCreated.type}${notCreated.description ? ` — ${notCreated.description}` : ''}` : ''}`,
497
+ { jmapErrorType: notCreated?.type, jmapErrorDescription: notCreated?.description }
498
+ );
499
+ }
500
+ return { emailId: createdEmail.id, submissionId: createdSubmission.id };
501
+ }
502
+
503
+ /**
504
+ * Downloads a blob's raw bytes using the session downloadUrl template
505
+ * (RFC 8620 §6.2: {accountId}, {blobId}, {type}, {name}).
506
+ */
507
+ public async downloadBlob(
508
+ blobId: string,
509
+ type: string = 'application/octet-stream',
510
+ name: string = 'blob'
511
+ ): Promise<Uint8Array> {
512
+ const template = this.session?.downloadUrl;
513
+ if (!template) {
514
+ throw new Error('JMAP session provides no downloadUrl — call connect() first.');
515
+ }
516
+ const expanded = template
517
+ .replace('{accountId}', encodeURIComponent(this.accountId))
518
+ .replace('{blobId}', encodeURIComponent(blobId))
519
+ .replace('{type}', encodeURIComponent(type))
520
+ .replace('{name}', encodeURIComponent(name));
521
+ const url = new URL(expanded, this.config.sessionUrl).toString();
522
+ const response = await fetch(url, {
523
+ headers: { authorization: this.getAuthHeader() },
524
+ });
525
+ if (!response.ok) {
526
+ throw await this.createHttpError(response, `Failed to download blob "${blobId}"`);
527
+ }
528
+ return new Uint8Array(await response.arrayBuffer());
529
+ }
530
+
531
+ // ======================
532
+ // internal helpers
533
+ // ======================
534
+
535
+ private getAuthHeader(): string {
536
+ const auth = this.config.auth as Partial<IJmapClientAuthBearer & IJmapClientAuthBasic>;
537
+ if (typeof auth.accessToken === 'string') {
538
+ return `Bearer ${auth.accessToken}`;
539
+ }
540
+ return `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString('base64')}`;
541
+ }
542
+
543
+ private async createHttpError(response: Response, messagePrefix: string): Promise<JmapError> {
544
+ let problemType: string | undefined;
545
+ let problemDetail: string | undefined;
546
+ try {
547
+ const body = (await response.json()) as any;
548
+ if (body && typeof body === 'object') {
549
+ problemType = typeof body.type === 'string' ? body.type : undefined;
550
+ problemDetail = typeof body.detail === 'string' ? body.detail : undefined;
551
+ }
552
+ } catch {
553
+ // non-JSON error body
554
+ }
555
+ return new JmapError(
556
+ `${messagePrefix}: HTTP ${response.status}${problemDetail ? ` — ${problemDetail}` : ''}${problemType ? ` (${problemType})` : ''}`,
557
+ { httpStatus: response.status, problemType, problemDetail }
558
+ );
559
+ }
560
+
561
+ private async resolveMailbox(): Promise<IJmapMailbox> {
562
+ const responses = await this.request([
563
+ ['Mailbox/get', { accountId: this.accountId, ids: null }, 'mb0'],
564
+ ]);
565
+ const list = (responses[0][1].list ?? []) as IJmapMailbox[];
566
+ const wanted = this.config.mailbox ?? 'INBOX';
567
+ const wantedLower = wanted.toLowerCase();
568
+ const byRole = list.find((mb) => (mb.role ?? '').toLowerCase() === wantedLower);
569
+ const byName = list.find((mb) => mb.name.toLowerCase() === wantedLower);
570
+ const mailbox = byRole ?? byName;
571
+ if (!mailbox) {
572
+ throw new Error(`JMAP mailbox "${wanted}" not found (no role or name match).`);
573
+ }
574
+ return mailbox;
575
+ }
576
+
577
+ private async fetchEmailState(): Promise<string> {
578
+ const responses = await this.request([
579
+ ['Email/get', { accountId: this.accountId, ids: [] }, 'st0'],
580
+ ]);
581
+ return String(responses[0][1].state ?? '');
582
+ }
583
+
584
+ private async getEmailObjects(ids: string[]): Promise<IJmapEmail[]> {
585
+ const responses = await this.request([
586
+ [
587
+ 'Email/get',
588
+ {
589
+ accountId: this.accountId,
590
+ ids,
591
+ properties: EMAIL_PROPERTIES,
592
+ fetchAllBodyValues: true,
593
+ },
594
+ 'g0',
595
+ ],
596
+ ]);
597
+ return (responses[0][1].list ?? []) as IJmapEmail[];
598
+ }
599
+
600
+ private async updateEmail(emailId: string, patch: Record<string, any>): Promise<void> {
601
+ const responses = await this.request([
602
+ ['Email/set', { accountId: this.accountId, update: { [emailId]: patch } }, 'u0'],
603
+ ]);
604
+ const result = responses[0][1];
605
+ const notUpdated = result.notUpdated?.[emailId];
606
+ if (notUpdated) {
607
+ throw new JmapError(
608
+ `Email/set update of "${emailId}" failed: ${notUpdated.type}${notUpdated.description ? ` — ${notUpdated.description}` : ''}`,
609
+ { jmapErrorType: notUpdated.type, jmapErrorDescription: notUpdated.description }
610
+ );
611
+ }
612
+ }
613
+
614
+ private toMessage(email: IJmapEmail): ISmartJmapMessage {
615
+ const bodyValues = email.bodyValues ?? {};
616
+ const resolveParts = (parts: IJmapEmailBodyPart[]): string =>
617
+ parts
618
+ .map((part) => (part.partId != null ? bodyValues[part.partId]?.value : undefined))
619
+ .filter((value): value is string => typeof value === 'string')
620
+ .join('\n');
621
+ const htmlParts = (email.htmlBody ?? []).filter((part) => part.type === 'text/html');
622
+ const attachments: IJmapAttachment[] = (email.attachments ?? [])
623
+ .filter((part) => typeof part.blobId === 'string')
624
+ .map((part) => ({
625
+ blobId: part.blobId as string,
626
+ name: part.name ?? undefined,
627
+ type: part.type,
628
+ size: part.size,
629
+ }));
630
+ return {
631
+ id: email.id,
632
+ blobId: email.blobId,
633
+ threadId: email.threadId,
634
+ mailboxIds: email.mailboxIds ?? {},
635
+ keywords: email.keywords ?? {},
636
+ from: email.from ?? [],
637
+ to: email.to ?? [],
638
+ cc: email.cc ?? [],
639
+ bcc: email.bcc ?? [],
640
+ replyTo: email.replyTo ?? [],
641
+ subject: email.subject ?? undefined,
642
+ receivedAt: email.receivedAt,
643
+ textBody: resolveParts(email.textBody ?? []),
644
+ htmlBody: htmlParts.length ? resolveParts(htmlParts) : undefined,
645
+ attachments,
646
+ raw: email,
647
+ };
648
+ }
649
+
650
+ private emitMessage(message: ISmartJmapMessage): void {
651
+ if (this.seenEmailIds.has(message.id)) {
652
+ return;
653
+ }
654
+ this.seenEmailIds.add(message.id);
655
+ this.emit('message', message);
656
+ }
657
+
658
+ private async emitInitialEmails(): Promise<void> {
659
+ const messages = await this.queryEmails();
660
+ // queryEmails sorts newest first; emit oldest first for natural ordering
661
+ for (const message of messages.reverse()) {
662
+ this.emitMessage(message);
663
+ }
664
+ }
665
+
666
+ private startWatching(): void {
667
+ if (this.stopped) {
668
+ return;
669
+ }
670
+ const eventSourceUrl = this.session?.eventSourceUrl;
671
+ if (eventSourceUrl) {
672
+ this.startEventStream(eventSourceUrl);
673
+ } else {
674
+ this.startPolling();
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Watches the JMAP event source (RFC 8620 §7.3) by parsing SSE frames from
680
+ * a fetch body stream. A hand-rolled parser is used instead of the global
681
+ * EventSource because JMAP requires an Authorization header, which the
682
+ * WHATWG EventSource API cannot send.
683
+ */
684
+ private startEventStream(templateUrl: string): void {
685
+ const expanded = templateUrl
686
+ .replace('{types}', '*')
687
+ .replace('{closeafter}', 'no')
688
+ .replace('{ping}', '30');
689
+ const url = new URL(expanded, this.config.sessionUrl).toString();
690
+ // Last stream wins under any interleaving: abort a previous stream before
691
+ // installing the new controller.
692
+ if (this.sseAbortController) {
693
+ this.sseAbortController.abort();
694
+ }
695
+ const abortController = new AbortController();
696
+ this.sseAbortController = abortController;
697
+ void this.runEventStream(url, abortController);
698
+ }
699
+
700
+ private async runEventStream(url: string, abortController: AbortController): Promise<void> {
701
+ try {
702
+ const response = await fetch(url, {
703
+ headers: {
704
+ authorization: this.getAuthHeader(),
705
+ accept: 'text/event-stream',
706
+ },
707
+ signal: abortController.signal,
708
+ });
709
+ if (!response.ok || !response.body) {
710
+ throw new Error(`JMAP event source unavailable (HTTP ${response.status}).`);
711
+ }
712
+ // The stream is live; catch up on anything that changed between the
713
+ // initial state snapshot and the stream being established.
714
+ void this.fetchDeltas();
715
+
716
+ const reader = response.body.getReader();
717
+ const decoder = new TextDecoder();
718
+ let buffer = '';
719
+ let eventName = 'message';
720
+ let dataLines: string[] = [];
721
+ while (true) {
722
+ const { done, value } = await reader.read();
723
+ if (done) {
724
+ break;
725
+ }
726
+ buffer += decoder.decode(value, { stream: true });
727
+ let newlineIndex: number;
728
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
729
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
730
+ buffer = buffer.slice(newlineIndex + 1);
731
+ if (line === '') {
732
+ if (dataLines.length) {
733
+ this.handleEventStreamEvent(eventName, dataLines.join('\n'));
734
+ }
735
+ eventName = 'message';
736
+ dataLines = [];
737
+ } else if (line.startsWith(':')) {
738
+ // comment / keepalive
739
+ } else if (line.startsWith('event:')) {
740
+ eventName = line.slice(6).trim();
741
+ } else if (line.startsWith('data:')) {
742
+ dataLines.push(line.slice(5).replace(/^ /, ''));
743
+ }
744
+ // id: and retry: fields are irrelevant here
745
+ }
746
+ }
747
+ // Stream ended server-side: fall back to polling (mirroring the catch
748
+ // guards so a superseded stream's continuation stays inert).
749
+ if (!this.stopped && !abortController.signal.aborted) {
750
+ if (this.sseAbortController === abortController) {
751
+ this.sseAbortController = null;
752
+ }
753
+ this.startPolling();
754
+ }
755
+ } catch (error) {
756
+ if (this.stopped || abortController.signal.aborted) {
757
+ return;
758
+ }
759
+ // Event stream unavailable or broken: abort so any held response body
760
+ // (e.g. an unconsumed non-OK response) releases its connection, then
761
+ // fall back to polling.
762
+ abortController.abort();
763
+ if (this.sseAbortController === abortController) {
764
+ this.sseAbortController = null;
765
+ }
766
+ this.startPolling();
767
+ }
768
+ }
769
+
770
+ private handleEventStreamEvent(eventName: string, data: string): void {
771
+ let parsed: any;
772
+ try {
773
+ parsed = JSON.parse(data);
774
+ } catch {
775
+ return;
776
+ }
777
+ if (eventName !== 'state' && parsed?.['@type'] !== 'StateChange') {
778
+ return;
779
+ }
780
+ const changed = parsed?.changed?.[this.accountId];
781
+ const emailState = changed?.Email;
782
+ if (typeof emailState === 'string' && emailState !== this.lastEmailState) {
783
+ void this.fetchDeltas();
784
+ }
785
+ }
786
+
787
+ private startPolling(): void {
788
+ if (this.pollTimer || this.stopped) {
789
+ return;
790
+ }
791
+ const interval = this.config.pollIntervalMs ?? 30000;
792
+ this.pollTimer = setInterval(() => {
793
+ void this.fetchDeltas();
794
+ }, interval);
795
+ }
796
+
797
+ private async fetchDeltas(): Promise<void> {
798
+ if (this.stopped) {
799
+ return;
800
+ }
801
+ if (this.fetchingDeltas) {
802
+ this.deltaQueued = true;
803
+ return;
804
+ }
805
+ this.fetchingDeltas = true;
806
+ try {
807
+ do {
808
+ this.deltaQueued = false;
809
+ await this.fetchDeltasOnce();
810
+ } while (this.deltaQueued && !this.stopped);
811
+ } finally {
812
+ this.fetchingDeltas = false;
813
+ }
814
+ }
815
+
816
+ private async fetchDeltasOnce(): Promise<void> {
817
+ try {
818
+ let hasMore = true;
819
+ while (hasMore && !this.stopped) {
820
+ const sinceState = this.lastEmailState;
821
+ const responses = await this.request([
822
+ ['Email/changes', { accountId: this.accountId, sinceState }, 'ch0'],
823
+ ]);
824
+ const changes = responses[0][1];
825
+ this.lastEmailState = String(changes.newState ?? sinceState);
826
+ hasMore = changes.hasMoreChanges === true && this.lastEmailState !== sinceState;
827
+ const createdIds: string[] = changes.created ?? [];
828
+ if (createdIds.length) {
829
+ const emails = await this.getEmailObjects(createdIds);
830
+ for (const email of emails) {
831
+ if (this.mailbox && email.mailboxIds?.[this.mailbox.id]) {
832
+ this.emitMessage(this.toMessage(email));
833
+ }
834
+ }
835
+ }
836
+ }
837
+ } catch (error) {
838
+ if (this.stopped) {
839
+ return;
840
+ }
841
+ if ((error as JmapError)?.jmapErrorType === 'cannotCalculateChanges') {
842
+ try {
843
+ await this.resyncViaQuery();
844
+ } catch (resyncError) {
845
+ this.emit('error', resyncError);
846
+ }
847
+ } else {
848
+ this.emit('error', error);
849
+ }
850
+ }
851
+ }
852
+
853
+ /** Full resync fallback when the server cannot calculate changes. */
854
+ private async resyncViaQuery(): Promise<void> {
855
+ this.lastEmailState = await this.fetchEmailState();
856
+ const messages = await this.queryEmails();
857
+ for (const message of messages.reverse()) {
858
+ this.emitMessage(message);
859
+ }
860
+ }
861
+
862
+ private teardownWatchers(): void {
863
+ if (this.sseAbortController) {
864
+ this.sseAbortController.abort();
865
+ this.sseAbortController = null;
866
+ }
867
+ if (this.pollTimer) {
868
+ clearInterval(this.pollTimer);
869
+ this.pollTimer = null;
870
+ }
871
+ }
872
+ }