@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.
@@ -140,6 +140,13 @@ export interface ISmartJmapMessage {
140
140
  raw: IJmapEmail;
141
141
  }
142
142
 
143
+ /** An attachment reference for outgoing mail; upload the bytes first via `uploadBlob`. */
144
+ export interface IJmapOutgoingAttachment {
145
+ blobId: string;
146
+ type: string;
147
+ name?: string;
148
+ }
149
+
143
150
  export interface IJmapOutgoingEmail {
144
151
  /** Sender; defaults to the address of the account's first JMAP Identity. */
145
152
  from?: IJmapEmailAddress;
@@ -149,6 +156,8 @@ export interface IJmapOutgoingEmail {
149
156
  subject?: string;
150
157
  textBody?: string;
151
158
  htmlBody?: string;
159
+ /** Attachments referencing blobs previously uploaded with `uploadBlob`. */
160
+ attachments?: IJmapOutgoingAttachment[];
152
161
  }
153
162
 
154
163
  export interface IJmapSendResult {
@@ -156,6 +165,14 @@ export interface IJmapSendResult {
156
165
  submissionId: string;
157
166
  }
158
167
 
168
+ /** The upload endpoint's response (RFC 8620 §6.1). */
169
+ export interface IJmapUploadResult {
170
+ accountId: string;
171
+ blobId: string;
172
+ type: string;
173
+ size: number;
174
+ }
175
+
159
176
  /** Error thrown for HTTP-level (RFC 7807 problem details) and JMAP method-level errors. */
160
177
  export class JmapError extends Error {
161
178
  public httpStatus?: number;
@@ -195,20 +212,26 @@ const EMAIL_PROPERTIES = [
195
212
  'attachments',
196
213
  ];
197
214
 
215
+ interface IConnectionGeneration {
216
+ id: number;
217
+ abortController: AbortController;
218
+ session: IJmapSession | null;
219
+ accountId: string;
220
+ apiUrl: string;
221
+ mailbox: IJmapMailbox | null;
222
+ connected: boolean;
223
+ lastEmailState: string | null;
224
+ seenEmailIds: Set<string>;
225
+ pollTimer: ReturnType<typeof setInterval> | null;
226
+ sseAbortController: AbortController | null;
227
+ fetchingDeltas: boolean;
228
+ deltaQueued: boolean;
229
+ }
230
+
198
231
  export class JmapClient extends plugins.events.EventEmitter {
199
232
  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;
233
+ private generationCounter = 0;
234
+ private connectionGeneration: IConnectionGeneration | null = null;
212
235
 
213
236
  constructor(config: IJmapClientConfig) {
214
237
  super();
@@ -239,21 +262,31 @@ export class JmapClient extends plugins.events.EventEmitter {
239
262
  * Failures are emitted as 'error' events; connect() does not throw.
240
263
  */
241
264
  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;
265
+ const previousGeneration = this.connectionGeneration;
266
+ const generation = this.createConnectionGeneration();
267
+ this.connectionGeneration = generation;
268
+ if (previousGeneration) {
269
+ this.teardownGeneration(
270
+ previousGeneration,
271
+ new Error('JMAP connection was superseded by a newer connect().')
272
+ );
273
+ }
246
274
  try {
247
275
  const response = await fetch(this.config.sessionUrl, {
248
276
  headers: {
249
277
  authorization: this.getAuthHeader(),
250
278
  accept: 'application/json',
251
279
  },
280
+ signal: generation.abortController.signal,
252
281
  });
282
+ this.assertCurrentGeneration(generation);
253
283
  if (!response.ok) {
254
- throw await this.createHttpError(response, 'Failed to fetch JMAP session');
284
+ const error = await this.createHttpError(response, 'Failed to fetch JMAP session');
285
+ this.assertCurrentGeneration(generation);
286
+ throw error;
255
287
  }
256
288
  const session = (await response.json()) as IJmapSession;
289
+ this.assertCurrentGeneration(generation);
257
290
  if (!session.capabilities || !(JMAP_CORE_CAPABILITY in session.capabilities)) {
258
291
  throw new Error(`JMAP session is missing the ${JMAP_CORE_CAPABILITY} capability.`);
259
292
  }
@@ -266,29 +299,31 @@ export class JmapClient extends plugins.events.EventEmitter {
266
299
  if (!accountId) {
267
300
  throw new Error('JMAP session has no primary account for urn:ietf:params:jmap:mail.');
268
301
  }
269
- this.session = session;
270
- this.accountId = accountId;
271
- this.apiUrl = new URL(session.apiUrl, this.config.sessionUrl).toString();
302
+ generation.session = session;
303
+ generation.accountId = accountId;
304
+ generation.apiUrl = new URL(session.apiUrl, this.config.sessionUrl).toString();
272
305
 
273
- this.mailbox = await this.resolveMailbox();
306
+ generation.mailbox = await this.resolveMailbox(generation);
307
+ this.assertCurrentGeneration(generation);
274
308
  // Capture the Email state before the initial sweep so no change is lost
275
309
  // 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;
310
+ generation.lastEmailState = await this.fetchEmailState(generation);
311
+ this.assertCurrentGeneration(generation);
312
+ generation.connected = true;
283
313
  this.emit('connected');
284
314
 
285
- await this.emitInitialEmails();
286
- this.startWatching();
315
+ await this.emitInitialEmails(generation);
316
+ this.assertCurrentGeneration(generation);
317
+ this.startWatching(generation);
287
318
  } 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;
319
+ if (!this.isCurrentGeneration(generation)) {
320
+ return;
321
+ }
322
+ const shouldEmitError = !generation.abortController.signal.aborted;
323
+ this.connectionGeneration = null;
324
+ generation.connected = false;
325
+ this.teardownGeneration(generation, error);
326
+ if (!shouldEmitError) return;
292
327
  this.emit('error', error);
293
328
  }
294
329
  }
@@ -298,10 +333,13 @@ export class JmapClient extends plugins.events.EventEmitter {
298
333
  * 'disconnected' when the client was connected. Leaves no open handles.
299
334
  */
300
335
  public async disconnect(): Promise<void> {
301
- this.stopped = true;
302
- this.teardownWatchers();
303
- if (this.connected) {
304
- this.connected = false;
336
+ const generation = this.connectionGeneration;
337
+ if (!generation) return;
338
+ this.connectionGeneration = null;
339
+ const wasConnected = generation.connected;
340
+ generation.connected = false;
341
+ this.teardownGeneration(generation, new Error('JMAP client disconnected.'));
342
+ if (wasConnected) {
305
343
  this.emit('disconnected');
306
344
  }
307
345
  }
@@ -313,10 +351,18 @@ export class JmapClient extends plugins.events.EventEmitter {
313
351
  * JmapError with `httpStatus`/`problemType`/`jmapErrorType` fields.
314
352
  */
315
353
  public async request(methodCalls: TJmapMethodCall[]): Promise<TJmapMethodResponse[]> {
316
- if (!this.apiUrl) {
354
+ return await this.requestWithGeneration(this.requireConnectedGeneration(), methodCalls);
355
+ }
356
+
357
+ private async requestWithGeneration(
358
+ generation: IConnectionGeneration,
359
+ methodCalls: TJmapMethodCall[]
360
+ ): Promise<TJmapMethodResponse[]> {
361
+ this.assertCurrentGeneration(generation);
362
+ if (!generation.apiUrl) {
317
363
  throw new Error('JmapClient is not connected yet — call connect() first.');
318
364
  }
319
- const response = await fetch(this.apiUrl, {
365
+ const response = await fetch(generation.apiUrl, {
320
366
  method: 'POST',
321
367
  headers: {
322
368
  authorization: this.getAuthHeader(),
@@ -327,11 +373,16 @@ export class JmapClient extends plugins.events.EventEmitter {
327
373
  using: [JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY, JMAP_SUBMISSION_CAPABILITY],
328
374
  methodCalls,
329
375
  }),
376
+ signal: generation.abortController.signal,
330
377
  });
378
+ this.assertCurrentGeneration(generation);
331
379
  if (!response.ok) {
332
- throw await this.createHttpError(response, 'JMAP API request failed');
380
+ const error = await this.createHttpError(response, 'JMAP API request failed');
381
+ this.assertCurrentGeneration(generation);
382
+ throw error;
333
383
  }
334
384
  const parsed = (await response.json()) as { methodResponses?: TJmapMethodResponse[] };
385
+ this.assertCurrentGeneration(generation);
335
386
  const methodResponses = parsed.methodResponses ?? [];
336
387
  for (const [name, args, callId] of methodResponses) {
337
388
  if (name === 'error') {
@@ -350,8 +401,14 @@ export class JmapClient extends plugins.events.EventEmitter {
350
401
 
351
402
  /** Returns all mailboxes of the account (Mailbox/get with ids: null). */
352
403
  public async getMailboxes(): Promise<IJmapMailbox[]> {
353
- const responses = await this.request([
354
- ['Mailbox/get', { accountId: this.accountId, ids: null }, 'mb0'],
404
+ return await this.getMailboxesWithGeneration(this.requireConnectedGeneration());
405
+ }
406
+
407
+ private async getMailboxesWithGeneration(
408
+ generation: IConnectionGeneration
409
+ ): Promise<IJmapMailbox[]> {
410
+ const responses = await this.requestWithGeneration(generation, [
411
+ ['Mailbox/get', { accountId: generation.accountId, ids: null }, 'mb0'],
355
412
  ]);
356
413
  return (responses[0][1].list ?? []) as IJmapMailbox[];
357
414
  }
@@ -363,29 +420,44 @@ export class JmapClient extends plugins.events.EventEmitter {
363
420
  public async queryEmails(
364
421
  filter?: Record<string, any>,
365
422
  limit?: number
423
+ ): Promise<ISmartJmapMessage[]> {
424
+ return await this.queryEmailsWithGeneration(
425
+ this.requireConnectedGeneration(),
426
+ filter,
427
+ limit
428
+ );
429
+ }
430
+
431
+ private async queryEmailsWithGeneration(
432
+ generation: IConnectionGeneration,
433
+ filter?: Record<string, any>,
434
+ limit?: number
366
435
  ): Promise<ISmartJmapMessage[]> {
367
436
  const effectiveFilter =
368
- filter ?? (this.mailbox ? { inMailbox: this.mailbox.id } : {});
437
+ filter ?? (generation.mailbox ? { inMailbox: generation.mailbox.id } : {});
369
438
  const queryArgs: Record<string, any> = {
370
- accountId: this.accountId,
439
+ accountId: generation.accountId,
371
440
  filter: effectiveFilter,
372
441
  sort: [{ property: 'receivedAt', isAscending: false }],
373
442
  };
374
443
  if (typeof limit === 'number') {
375
444
  queryArgs.limit = limit;
376
445
  }
377
- const responses = await this.request([['Email/query', queryArgs, 'q0']]);
446
+ const responses = await this.requestWithGeneration(generation, [
447
+ ['Email/query', queryArgs, 'q0'],
448
+ ]);
378
449
  const ids: string[] = responses[0][1].ids ?? [];
379
450
  if (!ids.length) {
380
451
  return [];
381
452
  }
382
- const emails = await this.getEmailObjects(ids);
453
+ const emails = await this.getEmailObjects(generation, ids);
383
454
  return emails.map((email) => this.toMessage(email));
384
455
  }
385
456
 
386
457
  /** Fetches a single email with all body values resolved (RFC 8621 §4.2, fetchAllBodyValues). */
387
458
  public async getEmail(id: string): Promise<ISmartJmapMessage> {
388
- const [email] = await this.getEmailObjects([id]);
459
+ const generation = this.requireConnectedGeneration();
460
+ const [email] = await this.getEmailObjects(generation, [id]);
389
461
  if (!email) {
390
462
  throw new Error(`Email "${id}" not found.`);
391
463
  }
@@ -394,12 +466,59 @@ export class JmapClient extends plugins.events.EventEmitter {
394
466
 
395
467
  /** Replaces the full keywords object of an email via Email/set. */
396
468
  public async setKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
397
- await this.updateEmail(emailId, { keywords });
469
+ await this.updateEmail(this.requireConnectedGeneration(), emailId, { keywords });
398
470
  }
399
471
 
400
472
  /** Convenience: sets the $seen keyword via an Email/set keywords patch. */
401
473
  public async markSeen(emailId: string): Promise<void> {
402
- await this.updateEmail(emailId, { 'keywords/$seen': true });
474
+ await this.updateEmail(this.requireConnectedGeneration(), emailId, { 'keywords/$seen': true });
475
+ }
476
+
477
+ /** Convenience: removes the $seen keyword via an Email/set keywords patch. */
478
+ public async markUnseen(emailId: string): Promise<void> {
479
+ await this.updateEmail(this.requireConnectedGeneration(), emailId, { 'keywords/$seen': null });
480
+ }
481
+
482
+ /** Convenience: adds or removes the $flagged keyword. */
483
+ public async setFlagged(emailId: string, flagged: boolean): Promise<void> {
484
+ await this.updateEmail(this.requireConnectedGeneration(), emailId, {
485
+ 'keywords/$flagged': flagged ? true : null,
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Replaces the email's mailbox membership. Pass one mailbox id for a
491
+ * provider-style move, or multiple ids when the remote JMAP account
492
+ * supports multi-mailbox membership.
493
+ */
494
+ public async setMailboxes(emailId: string, mailboxIds: string[]): Promise<void> {
495
+ const generation = this.requireConnectedGeneration();
496
+ const uniqueMailboxIds = [...new Set(mailboxIds.filter(Boolean))];
497
+ if (!uniqueMailboxIds.length) {
498
+ throw new Error('setMailboxes requires at least one mailbox id.');
499
+ }
500
+ await this.updateEmail(generation, emailId, {
501
+ mailboxIds: Object.fromEntries(uniqueMailboxIds.map((mailboxId) => [mailboxId, true])),
502
+ });
503
+ }
504
+
505
+ /** Permanently destroys an email via Email/set. */
506
+ public async destroyEmail(emailId: string): Promise<void> {
507
+ const generation = this.requireConnectedGeneration();
508
+ const responses = await this.requestWithGeneration(generation, [
509
+ ['Email/set', { accountId: generation.accountId, destroy: [emailId] }, 'd0'],
510
+ ]);
511
+ const result = responses[0][1];
512
+ const notDestroyed = result.notDestroyed?.[emailId];
513
+ if (notDestroyed) {
514
+ throw new JmapError(
515
+ `Email/set destroy of "${emailId}" failed: ${notDestroyed.type}${notDestroyed.description ? ` — ${notDestroyed.description}` : ''}`,
516
+ {
517
+ jmapErrorType: notDestroyed.type,
518
+ jmapErrorDescription: notDestroyed.description,
519
+ }
520
+ );
521
+ }
403
522
  }
404
523
 
405
524
  /**
@@ -408,8 +527,9 @@ export class JmapClient extends plugins.events.EventEmitter {
408
527
  * EmailSubmission/set referencing the created email.
409
528
  */
410
529
  public async sendEmail(outgoing: IJmapOutgoingEmail): Promise<IJmapSendResult> {
411
- const identityResponses = await this.request([
412
- ['Identity/get', { accountId: this.accountId }, 'i0'],
530
+ const generation = this.requireConnectedGeneration();
531
+ const identityResponses = await this.requestWithGeneration(generation, [
532
+ ['Identity/get', { accountId: generation.accountId }, 'i0'],
413
533
  ]);
414
534
  const identities: any[] = identityResponses[0][1].list ?? [];
415
535
  const identity =
@@ -422,11 +542,11 @@ export class JmapClient extends plugins.events.EventEmitter {
422
542
  const from: IJmapEmailAddress =
423
543
  outgoing.from ?? { name: identity.name ?? null, email: identity.email };
424
544
 
425
- const mailboxes = await this.getMailboxes();
545
+ const mailboxes = await this.getMailboxesWithGeneration(generation);
426
546
  const targetMailbox =
427
547
  mailboxes.find((mb) => mb.role === 'sent') ??
428
548
  mailboxes.find((mb) => mb.role === 'drafts') ??
429
- this.mailbox;
549
+ generation.mailbox;
430
550
  if (!targetMailbox) {
431
551
  throw new Error('No mailbox available to store the outgoing email in.');
432
552
  }
@@ -466,13 +586,21 @@ export class JmapClient extends plugins.events.EventEmitter {
466
586
  if (htmlBodyParts.length) {
467
587
  emailCreate.htmlBody = htmlBodyParts;
468
588
  }
589
+ if (outgoing.attachments?.length) {
590
+ emailCreate.attachments = outgoing.attachments.map((attachment) => ({
591
+ blobId: attachment.blobId,
592
+ type: attachment.type,
593
+ name: attachment.name ?? null,
594
+ disposition: 'attachment',
595
+ }));
596
+ }
469
597
 
470
- const responses = await this.request([
471
- ['Email/set', { accountId: this.accountId, create: { newEmail: emailCreate } }, 'c0'],
598
+ const responses = await this.requestWithGeneration(generation, [
599
+ ['Email/set', { accountId: generation.accountId, create: { newEmail: emailCreate } }, 'c0'],
472
600
  [
473
601
  'EmailSubmission/set',
474
602
  {
475
- accountId: this.accountId,
603
+ accountId: generation.accountId,
476
604
  create: { newSubmission: { emailId: '#newEmail', identityId: identity.id } },
477
605
  },
478
606
  'c1',
@@ -500,6 +628,46 @@ export class JmapClient extends plugins.events.EventEmitter {
500
628
  return { emailId: createdEmail.id, submissionId: createdSubmission.id };
501
629
  }
502
630
 
631
+ /**
632
+ * Uploads raw bytes to the session's uploadUrl (RFC 8620 §6.1) and returns
633
+ * the stored blob's id, type, and size. Use the blobId in
634
+ * `sendEmail({ attachments: [...] })` or any other blob reference.
635
+ */
636
+ public async uploadBlob(
637
+ data: Uint8Array,
638
+ type: string = 'application/octet-stream'
639
+ ): Promise<IJmapUploadResult> {
640
+ const generation = this.requireConnectedGeneration();
641
+ const template = generation.session?.uploadUrl;
642
+ if (!template) {
643
+ throw new Error('JMAP session provides no uploadUrl — call connect() first.');
644
+ }
645
+ const expanded = template.replace('{accountId}', encodeURIComponent(generation.accountId));
646
+ const url = new URL(expanded, this.config.sessionUrl).toString();
647
+ // Re-wrap so the bytes are backed by a plain ArrayBuffer (BodyInit-safe).
648
+ const body =
649
+ data.buffer instanceof ArrayBuffer ? (data as Uint8Array<ArrayBuffer>) : new Uint8Array(data);
650
+ const response = await fetch(url, {
651
+ method: 'POST',
652
+ headers: {
653
+ authorization: this.getAuthHeader(),
654
+ 'content-type': type,
655
+ accept: 'application/json',
656
+ },
657
+ body,
658
+ signal: generation.abortController.signal,
659
+ });
660
+ this.assertCurrentGeneration(generation);
661
+ if (!response.ok) {
662
+ const error = await this.createHttpError(response, 'Failed to upload blob');
663
+ this.assertCurrentGeneration(generation);
664
+ throw error;
665
+ }
666
+ const result = (await response.json()) as IJmapUploadResult;
667
+ this.assertCurrentGeneration(generation);
668
+ return result;
669
+ }
670
+
503
671
  /**
504
672
  * Downloads a blob's raw bytes using the session downloadUrl template
505
673
  * (RFC 8620 §6.2: {accountId}, {blobId}, {type}, {name}).
@@ -509,29 +677,93 @@ export class JmapClient extends plugins.events.EventEmitter {
509
677
  type: string = 'application/octet-stream',
510
678
  name: string = 'blob'
511
679
  ): Promise<Uint8Array> {
512
- const template = this.session?.downloadUrl;
680
+ const generation = this.requireConnectedGeneration();
681
+ const template = generation.session?.downloadUrl;
513
682
  if (!template) {
514
683
  throw new Error('JMAP session provides no downloadUrl — call connect() first.');
515
684
  }
516
685
  const expanded = template
517
- .replace('{accountId}', encodeURIComponent(this.accountId))
686
+ .replace('{accountId}', encodeURIComponent(generation.accountId))
518
687
  .replace('{blobId}', encodeURIComponent(blobId))
519
688
  .replace('{type}', encodeURIComponent(type))
520
689
  .replace('{name}', encodeURIComponent(name));
521
690
  const url = new URL(expanded, this.config.sessionUrl).toString();
522
691
  const response = await fetch(url, {
523
692
  headers: { authorization: this.getAuthHeader() },
693
+ signal: generation.abortController.signal,
524
694
  });
695
+ this.assertCurrentGeneration(generation);
525
696
  if (!response.ok) {
526
- throw await this.createHttpError(response, `Failed to download blob "${blobId}"`);
697
+ const error = await this.createHttpError(response, `Failed to download blob "${blobId}"`);
698
+ this.assertCurrentGeneration(generation);
699
+ throw error;
527
700
  }
528
- return new Uint8Array(await response.arrayBuffer());
701
+ const data = new Uint8Array(await response.arrayBuffer());
702
+ this.assertCurrentGeneration(generation);
703
+ return data;
529
704
  }
530
705
 
531
706
  // ======================
532
707
  // internal helpers
533
708
  // ======================
534
709
 
710
+ private createConnectionGeneration(): IConnectionGeneration {
711
+ return {
712
+ id: ++this.generationCounter,
713
+ abortController: new AbortController(),
714
+ session: null,
715
+ accountId: '',
716
+ apiUrl: '',
717
+ mailbox: null,
718
+ connected: false,
719
+ lastEmailState: null,
720
+ seenEmailIds: new Set(),
721
+ pollTimer: null,
722
+ sseAbortController: null,
723
+ fetchingDeltas: false,
724
+ deltaQueued: false,
725
+ };
726
+ }
727
+
728
+ private isCurrentGeneration(generation: IConnectionGeneration): boolean {
729
+ return (
730
+ this.connectionGeneration === generation &&
731
+ !generation.abortController.signal.aborted
732
+ );
733
+ }
734
+
735
+ private assertCurrentGeneration(generation: IConnectionGeneration): void {
736
+ if (this.isCurrentGeneration(generation)) return;
737
+ const reason = generation.abortController.signal.reason;
738
+ throw reason instanceof Error
739
+ ? reason
740
+ : new Error(`JMAP connection generation ${generation.id} is no longer active.`);
741
+ }
742
+
743
+ private requireConnectedGeneration(): IConnectionGeneration {
744
+ const generation = this.connectionGeneration;
745
+ if (!generation || !generation.connected) {
746
+ throw new Error('JmapClient is not connected yet — call connect() first.');
747
+ }
748
+ this.assertCurrentGeneration(generation);
749
+ return generation;
750
+ }
751
+
752
+ private teardownGeneration(generation: IConnectionGeneration, reason: unknown): void {
753
+ if (!generation.abortController.signal.aborted) {
754
+ generation.abortController.abort(reason);
755
+ }
756
+ if (generation.sseAbortController) {
757
+ generation.sseAbortController.abort(reason);
758
+ generation.sseAbortController = null;
759
+ }
760
+ if (generation.pollTimer) {
761
+ clearInterval(generation.pollTimer);
762
+ generation.pollTimer = null;
763
+ }
764
+ generation.deltaQueued = false;
765
+ }
766
+
535
767
  private getAuthHeader(): string {
536
768
  const auth = this.config.auth as Partial<IJmapClientAuthBearer & IJmapClientAuthBasic>;
537
769
  if (typeof auth.accessToken === 'string') {
@@ -558,9 +790,9 @@ export class JmapClient extends plugins.events.EventEmitter {
558
790
  );
559
791
  }
560
792
 
561
- private async resolveMailbox(): Promise<IJmapMailbox> {
562
- const responses = await this.request([
563
- ['Mailbox/get', { accountId: this.accountId, ids: null }, 'mb0'],
793
+ private async resolveMailbox(generation: IConnectionGeneration): Promise<IJmapMailbox> {
794
+ const responses = await this.requestWithGeneration(generation, [
795
+ ['Mailbox/get', { accountId: generation.accountId, ids: null }, 'mb0'],
564
796
  ]);
565
797
  const list = (responses[0][1].list ?? []) as IJmapMailbox[];
566
798
  const wanted = this.config.mailbox ?? 'INBOX';
@@ -574,19 +806,22 @@ export class JmapClient extends plugins.events.EventEmitter {
574
806
  return mailbox;
575
807
  }
576
808
 
577
- private async fetchEmailState(): Promise<string> {
578
- const responses = await this.request([
579
- ['Email/get', { accountId: this.accountId, ids: [] }, 'st0'],
809
+ private async fetchEmailState(generation: IConnectionGeneration): Promise<string> {
810
+ const responses = await this.requestWithGeneration(generation, [
811
+ ['Email/get', { accountId: generation.accountId, ids: [] }, 'st0'],
580
812
  ]);
581
813
  return String(responses[0][1].state ?? '');
582
814
  }
583
815
 
584
- private async getEmailObjects(ids: string[]): Promise<IJmapEmail[]> {
585
- const responses = await this.request([
816
+ private async getEmailObjects(
817
+ generation: IConnectionGeneration,
818
+ ids: string[]
819
+ ): Promise<IJmapEmail[]> {
820
+ const responses = await this.requestWithGeneration(generation, [
586
821
  [
587
822
  'Email/get',
588
823
  {
589
- accountId: this.accountId,
824
+ accountId: generation.accountId,
590
825
  ids,
591
826
  properties: EMAIL_PROPERTIES,
592
827
  fetchAllBodyValues: true,
@@ -597,9 +832,13 @@ export class JmapClient extends plugins.events.EventEmitter {
597
832
  return (responses[0][1].list ?? []) as IJmapEmail[];
598
833
  }
599
834
 
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'],
835
+ private async updateEmail(
836
+ generation: IConnectionGeneration,
837
+ emailId: string,
838
+ patch: Record<string, any>
839
+ ): Promise<void> {
840
+ const responses = await this.requestWithGeneration(generation, [
841
+ ['Email/set', { accountId: generation.accountId, update: { [emailId]: patch } }, 'u0'],
603
842
  ]);
604
843
  const result = responses[0][1];
605
844
  const notUpdated = result.notUpdated?.[emailId];
@@ -647,31 +886,33 @@ export class JmapClient extends plugins.events.EventEmitter {
647
886
  };
648
887
  }
649
888
 
650
- private emitMessage(message: ISmartJmapMessage): void {
651
- if (this.seenEmailIds.has(message.id)) {
889
+ private emitMessage(
890
+ generation: IConnectionGeneration,
891
+ message: ISmartJmapMessage
892
+ ): void {
893
+ if (!this.isCurrentGeneration(generation) || generation.seenEmailIds.has(message.id)) {
652
894
  return;
653
895
  }
654
- this.seenEmailIds.add(message.id);
896
+ generation.seenEmailIds.add(message.id);
655
897
  this.emit('message', message);
656
898
  }
657
899
 
658
- private async emitInitialEmails(): Promise<void> {
659
- const messages = await this.queryEmails();
900
+ private async emitInitialEmails(generation: IConnectionGeneration): Promise<void> {
901
+ const messages = await this.queryEmailsWithGeneration(generation);
902
+ this.assertCurrentGeneration(generation);
660
903
  // queryEmails sorts newest first; emit oldest first for natural ordering
661
904
  for (const message of messages.reverse()) {
662
- this.emitMessage(message);
905
+ this.emitMessage(generation, message);
663
906
  }
664
907
  }
665
908
 
666
- private startWatching(): void {
667
- if (this.stopped) {
668
- return;
669
- }
670
- const eventSourceUrl = this.session?.eventSourceUrl;
909
+ private startWatching(generation: IConnectionGeneration): void {
910
+ if (!this.isCurrentGeneration(generation)) return;
911
+ const eventSourceUrl = generation.session?.eventSourceUrl;
671
912
  if (eventSourceUrl) {
672
- this.startEventStream(eventSourceUrl);
913
+ this.startEventStream(generation, eventSourceUrl);
673
914
  } else {
674
- this.startPolling();
915
+ this.startPolling(generation);
675
916
  }
676
917
  }
677
918
 
@@ -681,7 +922,11 @@ export class JmapClient extends plugins.events.EventEmitter {
681
922
  * EventSource because JMAP requires an Authorization header, which the
682
923
  * WHATWG EventSource API cannot send.
683
924
  */
684
- private startEventStream(templateUrl: string): void {
925
+ private startEventStream(
926
+ generation: IConnectionGeneration,
927
+ templateUrl: string
928
+ ): void {
929
+ if (!this.isCurrentGeneration(generation)) return;
685
930
  const expanded = templateUrl
686
931
  .replace('{types}', '*')
687
932
  .replace('{closeafter}', 'no')
@@ -689,29 +934,41 @@ export class JmapClient extends plugins.events.EventEmitter {
689
934
  const url = new URL(expanded, this.config.sessionUrl).toString();
690
935
  // Last stream wins under any interleaving: abort a previous stream before
691
936
  // installing the new controller.
692
- if (this.sseAbortController) {
693
- this.sseAbortController.abort();
937
+ if (generation.sseAbortController) {
938
+ generation.sseAbortController.abort(
939
+ new Error('JMAP event stream was superseded.')
940
+ );
694
941
  }
695
942
  const abortController = new AbortController();
696
- this.sseAbortController = abortController;
697
- void this.runEventStream(url, abortController);
943
+ generation.sseAbortController = abortController;
944
+ void this.runEventStream(generation, url, abortController);
698
945
  }
699
946
 
700
- private async runEventStream(url: string, abortController: AbortController): Promise<void> {
947
+ private async runEventStream(
948
+ generation: IConnectionGeneration,
949
+ url: string,
950
+ abortController: AbortController
951
+ ): Promise<void> {
701
952
  try {
953
+ const signal = AbortSignal.any([
954
+ generation.abortController.signal,
955
+ abortController.signal,
956
+ ]);
702
957
  const response = await fetch(url, {
703
958
  headers: {
704
959
  authorization: this.getAuthHeader(),
705
960
  accept: 'text/event-stream',
706
961
  },
707
- signal: abortController.signal,
962
+ signal,
708
963
  });
964
+ this.assertCurrentGeneration(generation);
965
+ if (abortController.signal.aborted) return;
709
966
  if (!response.ok || !response.body) {
710
967
  throw new Error(`JMAP event source unavailable (HTTP ${response.status}).`);
711
968
  }
712
969
  // The stream is live; catch up on anything that changed between the
713
970
  // initial state snapshot and the stream being established.
714
- void this.fetchDeltas();
971
+ void this.fetchDeltas(generation);
715
972
 
716
973
  const reader = response.body.getReader();
717
974
  const decoder = new TextDecoder();
@@ -720,6 +977,8 @@ export class JmapClient extends plugins.events.EventEmitter {
720
977
  let dataLines: string[] = [];
721
978
  while (true) {
722
979
  const { done, value } = await reader.read();
980
+ this.assertCurrentGeneration(generation);
981
+ if (abortController.signal.aborted) return;
723
982
  if (done) {
724
983
  break;
725
984
  }
@@ -730,7 +989,7 @@ export class JmapClient extends plugins.events.EventEmitter {
730
989
  buffer = buffer.slice(newlineIndex + 1);
731
990
  if (line === '') {
732
991
  if (dataLines.length) {
733
- this.handleEventStreamEvent(eventName, dataLines.join('\n'));
992
+ this.handleEventStreamEvent(generation, eventName, dataLines.join('\n'));
734
993
  }
735
994
  eventName = 'message';
736
995
  dataLines = [];
@@ -746,28 +1005,37 @@ export class JmapClient extends plugins.events.EventEmitter {
746
1005
  }
747
1006
  // Stream ended server-side: fall back to polling (mirroring the catch
748
1007
  // 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;
1008
+ if (this.isCurrentGeneration(generation) && !abortController.signal.aborted) {
1009
+ if (generation.sseAbortController === abortController) {
1010
+ generation.sseAbortController = null;
752
1011
  }
753
- this.startPolling();
1012
+ this.startPolling(generation);
754
1013
  }
755
1014
  } catch (error) {
756
- if (this.stopped || abortController.signal.aborted) {
1015
+ if (
1016
+ !this.isCurrentGeneration(generation) ||
1017
+ generation.abortController.signal.aborted ||
1018
+ abortController.signal.aborted
1019
+ ) {
757
1020
  return;
758
1021
  }
759
1022
  // Event stream unavailable or broken: abort so any held response body
760
1023
  // (e.g. an unconsumed non-OK response) releases its connection, then
761
1024
  // fall back to polling.
762
- abortController.abort();
763
- if (this.sseAbortController === abortController) {
764
- this.sseAbortController = null;
1025
+ abortController.abort(error);
1026
+ if (generation.sseAbortController === abortController) {
1027
+ generation.sseAbortController = null;
765
1028
  }
766
- this.startPolling();
1029
+ this.startPolling(generation);
767
1030
  }
768
1031
  }
769
1032
 
770
- private handleEventStreamEvent(eventName: string, data: string): void {
1033
+ private handleEventStreamEvent(
1034
+ generation: IConnectionGeneration,
1035
+ eventName: string,
1036
+ data: string
1037
+ ): void {
1038
+ if (!this.isCurrentGeneration(generation)) return;
771
1039
  let parsed: any;
772
1040
  try {
773
1041
  parsed = JSON.parse(data);
@@ -777,72 +1045,73 @@ export class JmapClient extends plugins.events.EventEmitter {
777
1045
  if (eventName !== 'state' && parsed?.['@type'] !== 'StateChange') {
778
1046
  return;
779
1047
  }
780
- const changed = parsed?.changed?.[this.accountId];
1048
+ const changed = parsed?.changed?.[generation.accountId];
781
1049
  const emailState = changed?.Email;
782
- if (typeof emailState === 'string' && emailState !== this.lastEmailState) {
783
- void this.fetchDeltas();
1050
+ if (typeof emailState === 'string' && emailState !== generation.lastEmailState) {
1051
+ void this.fetchDeltas(generation);
784
1052
  }
785
1053
  }
786
1054
 
787
- private startPolling(): void {
788
- if (this.pollTimer || this.stopped) {
1055
+ private startPolling(generation: IConnectionGeneration): void {
1056
+ if (generation.pollTimer || !this.isCurrentGeneration(generation)) {
789
1057
  return;
790
1058
  }
791
1059
  const interval = this.config.pollIntervalMs ?? 30000;
792
- this.pollTimer = setInterval(() => {
793
- void this.fetchDeltas();
1060
+ generation.pollTimer = setInterval(() => {
1061
+ void this.fetchDeltas(generation);
794
1062
  }, interval);
795
1063
  }
796
1064
 
797
- private async fetchDeltas(): Promise<void> {
798
- if (this.stopped) {
1065
+ private async fetchDeltas(generation: IConnectionGeneration): Promise<void> {
1066
+ if (!this.isCurrentGeneration(generation)) return;
1067
+ if (generation.fetchingDeltas) {
1068
+ generation.deltaQueued = true;
799
1069
  return;
800
1070
  }
801
- if (this.fetchingDeltas) {
802
- this.deltaQueued = true;
803
- return;
804
- }
805
- this.fetchingDeltas = true;
1071
+ generation.fetchingDeltas = true;
806
1072
  try {
807
1073
  do {
808
- this.deltaQueued = false;
809
- await this.fetchDeltasOnce();
810
- } while (this.deltaQueued && !this.stopped);
1074
+ generation.deltaQueued = false;
1075
+ await this.fetchDeltasOnce(generation);
1076
+ } while (generation.deltaQueued && this.isCurrentGeneration(generation));
811
1077
  } finally {
812
- this.fetchingDeltas = false;
1078
+ generation.fetchingDeltas = false;
813
1079
  }
814
1080
  }
815
1081
 
816
- private async fetchDeltasOnce(): Promise<void> {
1082
+ private async fetchDeltasOnce(generation: IConnectionGeneration): Promise<void> {
817
1083
  try {
818
1084
  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'],
1085
+ while (hasMore && this.isCurrentGeneration(generation)) {
1086
+ const sinceState = generation.lastEmailState;
1087
+ const responses = await this.requestWithGeneration(generation, [
1088
+ ['Email/changes', { accountId: generation.accountId, sinceState }, 'ch0'],
823
1089
  ]);
1090
+ this.assertCurrentGeneration(generation);
824
1091
  const changes = responses[0][1];
825
- this.lastEmailState = String(changes.newState ?? sinceState);
826
- hasMore = changes.hasMoreChanges === true && this.lastEmailState !== sinceState;
1092
+ generation.lastEmailState = String(changes.newState ?? sinceState);
1093
+ hasMore =
1094
+ changes.hasMoreChanges === true && generation.lastEmailState !== sinceState;
827
1095
  const createdIds: string[] = changes.created ?? [];
828
1096
  if (createdIds.length) {
829
- const emails = await this.getEmailObjects(createdIds);
1097
+ const emails = await this.getEmailObjects(generation, createdIds);
1098
+ this.assertCurrentGeneration(generation);
830
1099
  for (const email of emails) {
831
- if (this.mailbox && email.mailboxIds?.[this.mailbox.id]) {
832
- this.emitMessage(this.toMessage(email));
1100
+ if (generation.mailbox && email.mailboxIds?.[generation.mailbox.id]) {
1101
+ this.emitMessage(generation, this.toMessage(email));
833
1102
  }
834
1103
  }
835
1104
  }
836
1105
  }
837
1106
  } catch (error) {
838
- if (this.stopped) {
839
- return;
840
- }
1107
+ if (!this.isCurrentGeneration(generation)) return;
841
1108
  if ((error as JmapError)?.jmapErrorType === 'cannotCalculateChanges') {
842
1109
  try {
843
- await this.resyncViaQuery();
1110
+ await this.resyncViaQuery(generation);
844
1111
  } catch (resyncError) {
845
- this.emit('error', resyncError);
1112
+ if (this.isCurrentGeneration(generation)) {
1113
+ this.emit('error', resyncError);
1114
+ }
846
1115
  }
847
1116
  } else {
848
1117
  this.emit('error', error);
@@ -851,22 +1120,14 @@ export class JmapClient extends plugins.events.EventEmitter {
851
1120
  }
852
1121
 
853
1122
  /** 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();
1123
+ private async resyncViaQuery(generation: IConnectionGeneration): Promise<void> {
1124
+ const state = await this.fetchEmailState(generation);
1125
+ this.assertCurrentGeneration(generation);
1126
+ generation.lastEmailState = state;
1127
+ const messages = await this.queryEmailsWithGeneration(generation);
1128
+ this.assertCurrentGeneration(generation);
857
1129
  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;
1130
+ this.emitMessage(generation, message);
870
1131
  }
871
1132
  }
872
1133
  }