@push.rocks/smartjmap 2.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.
@@ -212,20 +212,26 @@ const EMAIL_PROPERTIES = [
212
212
  'attachments',
213
213
  ];
214
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
+
215
231
  export class JmapClient extends plugins.events.EventEmitter {
216
232
  private config: IJmapClientConfig;
217
- private session: IJmapSession | null = null;
218
- private accountId: string = '';
219
- private apiUrl: string = '';
220
- private mailbox: IJmapMailbox | null = null;
221
- private connected: boolean = false;
222
- private stopped: boolean = false;
223
- private lastEmailState: string | null = null;
224
- private seenEmailIds: Set<string> = new Set();
225
- private pollTimer: ReturnType<typeof setInterval> | null = null;
226
- private sseAbortController: AbortController | null = null;
227
- private fetchingDeltas: boolean = false;
228
- private deltaQueued: boolean = false;
233
+ private generationCounter = 0;
234
+ private connectionGeneration: IConnectionGeneration | null = null;
229
235
 
230
236
  constructor(config: IJmapClientConfig) {
231
237
  super();
@@ -256,21 +262,31 @@ export class JmapClient extends plugins.events.EventEmitter {
256
262
  * Failures are emitted as 'error' events; connect() does not throw.
257
263
  */
258
264
  public async connect(): Promise<void> {
259
- // Reconnecting on the same instance is supported; release any watchers
260
- // from a previous connect() so no SSE stream or poll timer is stranded.
261
- this.teardownWatchers();
262
- 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
+ }
263
274
  try {
264
275
  const response = await fetch(this.config.sessionUrl, {
265
276
  headers: {
266
277
  authorization: this.getAuthHeader(),
267
278
  accept: 'application/json',
268
279
  },
280
+ signal: generation.abortController.signal,
269
281
  });
282
+ this.assertCurrentGeneration(generation);
270
283
  if (!response.ok) {
271
- 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;
272
287
  }
273
288
  const session = (await response.json()) as IJmapSession;
289
+ this.assertCurrentGeneration(generation);
274
290
  if (!session.capabilities || !(JMAP_CORE_CAPABILITY in session.capabilities)) {
275
291
  throw new Error(`JMAP session is missing the ${JMAP_CORE_CAPABILITY} capability.`);
276
292
  }
@@ -283,29 +299,31 @@ export class JmapClient extends plugins.events.EventEmitter {
283
299
  if (!accountId) {
284
300
  throw new Error('JMAP session has no primary account for urn:ietf:params:jmap:mail.');
285
301
  }
286
- this.session = session;
287
- this.accountId = accountId;
288
- 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();
289
305
 
290
- this.mailbox = await this.resolveMailbox();
306
+ generation.mailbox = await this.resolveMailbox(generation);
307
+ this.assertCurrentGeneration(generation);
291
308
  // Capture the Email state before the initial sweep so no change is lost
292
309
  // between the sweep and the start of watching (dedupe handles overlap).
293
- this.lastEmailState = await this.fetchEmailState();
294
-
295
- if (this.stopped) {
296
- // disconnect() was called while connect() was in flight.
297
- return;
298
- }
299
- this.connected = true;
310
+ generation.lastEmailState = await this.fetchEmailState(generation);
311
+ this.assertCurrentGeneration(generation);
312
+ generation.connected = true;
300
313
  this.emit('connected');
301
314
 
302
- await this.emitInitialEmails();
303
- this.startWatching();
315
+ await this.emitInitialEmails(generation);
316
+ this.assertCurrentGeneration(generation);
317
+ this.startWatching(generation);
304
318
  } catch (error) {
305
- // Clean up anything that may have been started; this instance holds no
306
- // sockets or timers after a failed connect.
307
- this.teardownWatchers();
308
- 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;
309
327
  this.emit('error', error);
310
328
  }
311
329
  }
@@ -315,10 +333,13 @@ export class JmapClient extends plugins.events.EventEmitter {
315
333
  * 'disconnected' when the client was connected. Leaves no open handles.
316
334
  */
317
335
  public async disconnect(): Promise<void> {
318
- this.stopped = true;
319
- this.teardownWatchers();
320
- if (this.connected) {
321
- 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) {
322
343
  this.emit('disconnected');
323
344
  }
324
345
  }
@@ -330,10 +351,18 @@ export class JmapClient extends plugins.events.EventEmitter {
330
351
  * JmapError with `httpStatus`/`problemType`/`jmapErrorType` fields.
331
352
  */
332
353
  public async request(methodCalls: TJmapMethodCall[]): Promise<TJmapMethodResponse[]> {
333
- 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) {
334
363
  throw new Error('JmapClient is not connected yet — call connect() first.');
335
364
  }
336
- const response = await fetch(this.apiUrl, {
365
+ const response = await fetch(generation.apiUrl, {
337
366
  method: 'POST',
338
367
  headers: {
339
368
  authorization: this.getAuthHeader(),
@@ -344,11 +373,16 @@ export class JmapClient extends plugins.events.EventEmitter {
344
373
  using: [JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY, JMAP_SUBMISSION_CAPABILITY],
345
374
  methodCalls,
346
375
  }),
376
+ signal: generation.abortController.signal,
347
377
  });
378
+ this.assertCurrentGeneration(generation);
348
379
  if (!response.ok) {
349
- 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;
350
383
  }
351
384
  const parsed = (await response.json()) as { methodResponses?: TJmapMethodResponse[] };
385
+ this.assertCurrentGeneration(generation);
352
386
  const methodResponses = parsed.methodResponses ?? [];
353
387
  for (const [name, args, callId] of methodResponses) {
354
388
  if (name === 'error') {
@@ -367,8 +401,14 @@ export class JmapClient extends plugins.events.EventEmitter {
367
401
 
368
402
  /** Returns all mailboxes of the account (Mailbox/get with ids: null). */
369
403
  public async getMailboxes(): Promise<IJmapMailbox[]> {
370
- const responses = await this.request([
371
- ['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'],
372
412
  ]);
373
413
  return (responses[0][1].list ?? []) as IJmapMailbox[];
374
414
  }
@@ -380,29 +420,44 @@ export class JmapClient extends plugins.events.EventEmitter {
380
420
  public async queryEmails(
381
421
  filter?: Record<string, any>,
382
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
383
435
  ): Promise<ISmartJmapMessage[]> {
384
436
  const effectiveFilter =
385
- filter ?? (this.mailbox ? { inMailbox: this.mailbox.id } : {});
437
+ filter ?? (generation.mailbox ? { inMailbox: generation.mailbox.id } : {});
386
438
  const queryArgs: Record<string, any> = {
387
- accountId: this.accountId,
439
+ accountId: generation.accountId,
388
440
  filter: effectiveFilter,
389
441
  sort: [{ property: 'receivedAt', isAscending: false }],
390
442
  };
391
443
  if (typeof limit === 'number') {
392
444
  queryArgs.limit = limit;
393
445
  }
394
- const responses = await this.request([['Email/query', queryArgs, 'q0']]);
446
+ const responses = await this.requestWithGeneration(generation, [
447
+ ['Email/query', queryArgs, 'q0'],
448
+ ]);
395
449
  const ids: string[] = responses[0][1].ids ?? [];
396
450
  if (!ids.length) {
397
451
  return [];
398
452
  }
399
- const emails = await this.getEmailObjects(ids);
453
+ const emails = await this.getEmailObjects(generation, ids);
400
454
  return emails.map((email) => this.toMessage(email));
401
455
  }
402
456
 
403
457
  /** Fetches a single email with all body values resolved (RFC 8621 §4.2, fetchAllBodyValues). */
404
458
  public async getEmail(id: string): Promise<ISmartJmapMessage> {
405
- const [email] = await this.getEmailObjects([id]);
459
+ const generation = this.requireConnectedGeneration();
460
+ const [email] = await this.getEmailObjects(generation, [id]);
406
461
  if (!email) {
407
462
  throw new Error(`Email "${id}" not found.`);
408
463
  }
@@ -411,12 +466,59 @@ export class JmapClient extends plugins.events.EventEmitter {
411
466
 
412
467
  /** Replaces the full keywords object of an email via Email/set. */
413
468
  public async setKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
414
- await this.updateEmail(emailId, { keywords });
469
+ await this.updateEmail(this.requireConnectedGeneration(), emailId, { keywords });
415
470
  }
416
471
 
417
472
  /** Convenience: sets the $seen keyword via an Email/set keywords patch. */
418
473
  public async markSeen(emailId: string): Promise<void> {
419
- 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
+ }
420
522
  }
421
523
 
422
524
  /**
@@ -425,8 +527,9 @@ export class JmapClient extends plugins.events.EventEmitter {
425
527
  * EmailSubmission/set referencing the created email.
426
528
  */
427
529
  public async sendEmail(outgoing: IJmapOutgoingEmail): Promise<IJmapSendResult> {
428
- const identityResponses = await this.request([
429
- ['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'],
430
533
  ]);
431
534
  const identities: any[] = identityResponses[0][1].list ?? [];
432
535
  const identity =
@@ -439,11 +542,11 @@ export class JmapClient extends plugins.events.EventEmitter {
439
542
  const from: IJmapEmailAddress =
440
543
  outgoing.from ?? { name: identity.name ?? null, email: identity.email };
441
544
 
442
- const mailboxes = await this.getMailboxes();
545
+ const mailboxes = await this.getMailboxesWithGeneration(generation);
443
546
  const targetMailbox =
444
547
  mailboxes.find((mb) => mb.role === 'sent') ??
445
548
  mailboxes.find((mb) => mb.role === 'drafts') ??
446
- this.mailbox;
549
+ generation.mailbox;
447
550
  if (!targetMailbox) {
448
551
  throw new Error('No mailbox available to store the outgoing email in.');
449
552
  }
@@ -492,12 +595,12 @@ export class JmapClient extends plugins.events.EventEmitter {
492
595
  }));
493
596
  }
494
597
 
495
- const responses = await this.request([
496
- ['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'],
497
600
  [
498
601
  'EmailSubmission/set',
499
602
  {
500
- accountId: this.accountId,
603
+ accountId: generation.accountId,
501
604
  create: { newSubmission: { emailId: '#newEmail', identityId: identity.id } },
502
605
  },
503
606
  'c1',
@@ -534,11 +637,12 @@ export class JmapClient extends plugins.events.EventEmitter {
534
637
  data: Uint8Array,
535
638
  type: string = 'application/octet-stream'
536
639
  ): Promise<IJmapUploadResult> {
537
- const template = this.session?.uploadUrl;
640
+ const generation = this.requireConnectedGeneration();
641
+ const template = generation.session?.uploadUrl;
538
642
  if (!template) {
539
643
  throw new Error('JMAP session provides no uploadUrl — call connect() first.');
540
644
  }
541
- const expanded = template.replace('{accountId}', encodeURIComponent(this.accountId));
645
+ const expanded = template.replace('{accountId}', encodeURIComponent(generation.accountId));
542
646
  const url = new URL(expanded, this.config.sessionUrl).toString();
543
647
  // Re-wrap so the bytes are backed by a plain ArrayBuffer (BodyInit-safe).
544
648
  const body =
@@ -551,11 +655,17 @@ export class JmapClient extends plugins.events.EventEmitter {
551
655
  accept: 'application/json',
552
656
  },
553
657
  body,
658
+ signal: generation.abortController.signal,
554
659
  });
660
+ this.assertCurrentGeneration(generation);
555
661
  if (!response.ok) {
556
- throw await this.createHttpError(response, 'Failed to upload blob');
662
+ const error = await this.createHttpError(response, 'Failed to upload blob');
663
+ this.assertCurrentGeneration(generation);
664
+ throw error;
557
665
  }
558
- return (await response.json()) as IJmapUploadResult;
666
+ const result = (await response.json()) as IJmapUploadResult;
667
+ this.assertCurrentGeneration(generation);
668
+ return result;
559
669
  }
560
670
 
561
671
  /**
@@ -567,29 +677,93 @@ export class JmapClient extends plugins.events.EventEmitter {
567
677
  type: string = 'application/octet-stream',
568
678
  name: string = 'blob'
569
679
  ): Promise<Uint8Array> {
570
- const template = this.session?.downloadUrl;
680
+ const generation = this.requireConnectedGeneration();
681
+ const template = generation.session?.downloadUrl;
571
682
  if (!template) {
572
683
  throw new Error('JMAP session provides no downloadUrl — call connect() first.');
573
684
  }
574
685
  const expanded = template
575
- .replace('{accountId}', encodeURIComponent(this.accountId))
686
+ .replace('{accountId}', encodeURIComponent(generation.accountId))
576
687
  .replace('{blobId}', encodeURIComponent(blobId))
577
688
  .replace('{type}', encodeURIComponent(type))
578
689
  .replace('{name}', encodeURIComponent(name));
579
690
  const url = new URL(expanded, this.config.sessionUrl).toString();
580
691
  const response = await fetch(url, {
581
692
  headers: { authorization: this.getAuthHeader() },
693
+ signal: generation.abortController.signal,
582
694
  });
695
+ this.assertCurrentGeneration(generation);
583
696
  if (!response.ok) {
584
- 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;
585
700
  }
586
- return new Uint8Array(await response.arrayBuffer());
701
+ const data = new Uint8Array(await response.arrayBuffer());
702
+ this.assertCurrentGeneration(generation);
703
+ return data;
587
704
  }
588
705
 
589
706
  // ======================
590
707
  // internal helpers
591
708
  // ======================
592
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
+
593
767
  private getAuthHeader(): string {
594
768
  const auth = this.config.auth as Partial<IJmapClientAuthBearer & IJmapClientAuthBasic>;
595
769
  if (typeof auth.accessToken === 'string') {
@@ -616,9 +790,9 @@ export class JmapClient extends plugins.events.EventEmitter {
616
790
  );
617
791
  }
618
792
 
619
- private async resolveMailbox(): Promise<IJmapMailbox> {
620
- const responses = await this.request([
621
- ['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'],
622
796
  ]);
623
797
  const list = (responses[0][1].list ?? []) as IJmapMailbox[];
624
798
  const wanted = this.config.mailbox ?? 'INBOX';
@@ -632,19 +806,22 @@ export class JmapClient extends plugins.events.EventEmitter {
632
806
  return mailbox;
633
807
  }
634
808
 
635
- private async fetchEmailState(): Promise<string> {
636
- const responses = await this.request([
637
- ['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'],
638
812
  ]);
639
813
  return String(responses[0][1].state ?? '');
640
814
  }
641
815
 
642
- private async getEmailObjects(ids: string[]): Promise<IJmapEmail[]> {
643
- 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, [
644
821
  [
645
822
  'Email/get',
646
823
  {
647
- accountId: this.accountId,
824
+ accountId: generation.accountId,
648
825
  ids,
649
826
  properties: EMAIL_PROPERTIES,
650
827
  fetchAllBodyValues: true,
@@ -655,9 +832,13 @@ export class JmapClient extends plugins.events.EventEmitter {
655
832
  return (responses[0][1].list ?? []) as IJmapEmail[];
656
833
  }
657
834
 
658
- private async updateEmail(emailId: string, patch: Record<string, any>): Promise<void> {
659
- const responses = await this.request([
660
- ['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'],
661
842
  ]);
662
843
  const result = responses[0][1];
663
844
  const notUpdated = result.notUpdated?.[emailId];
@@ -705,31 +886,33 @@ export class JmapClient extends plugins.events.EventEmitter {
705
886
  };
706
887
  }
707
888
 
708
- private emitMessage(message: ISmartJmapMessage): void {
709
- 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)) {
710
894
  return;
711
895
  }
712
- this.seenEmailIds.add(message.id);
896
+ generation.seenEmailIds.add(message.id);
713
897
  this.emit('message', message);
714
898
  }
715
899
 
716
- private async emitInitialEmails(): Promise<void> {
717
- const messages = await this.queryEmails();
900
+ private async emitInitialEmails(generation: IConnectionGeneration): Promise<void> {
901
+ const messages = await this.queryEmailsWithGeneration(generation);
902
+ this.assertCurrentGeneration(generation);
718
903
  // queryEmails sorts newest first; emit oldest first for natural ordering
719
904
  for (const message of messages.reverse()) {
720
- this.emitMessage(message);
905
+ this.emitMessage(generation, message);
721
906
  }
722
907
  }
723
908
 
724
- private startWatching(): void {
725
- if (this.stopped) {
726
- return;
727
- }
728
- const eventSourceUrl = this.session?.eventSourceUrl;
909
+ private startWatching(generation: IConnectionGeneration): void {
910
+ if (!this.isCurrentGeneration(generation)) return;
911
+ const eventSourceUrl = generation.session?.eventSourceUrl;
729
912
  if (eventSourceUrl) {
730
- this.startEventStream(eventSourceUrl);
913
+ this.startEventStream(generation, eventSourceUrl);
731
914
  } else {
732
- this.startPolling();
915
+ this.startPolling(generation);
733
916
  }
734
917
  }
735
918
 
@@ -739,7 +922,11 @@ export class JmapClient extends plugins.events.EventEmitter {
739
922
  * EventSource because JMAP requires an Authorization header, which the
740
923
  * WHATWG EventSource API cannot send.
741
924
  */
742
- private startEventStream(templateUrl: string): void {
925
+ private startEventStream(
926
+ generation: IConnectionGeneration,
927
+ templateUrl: string
928
+ ): void {
929
+ if (!this.isCurrentGeneration(generation)) return;
743
930
  const expanded = templateUrl
744
931
  .replace('{types}', '*')
745
932
  .replace('{closeafter}', 'no')
@@ -747,29 +934,41 @@ export class JmapClient extends plugins.events.EventEmitter {
747
934
  const url = new URL(expanded, this.config.sessionUrl).toString();
748
935
  // Last stream wins under any interleaving: abort a previous stream before
749
936
  // installing the new controller.
750
- if (this.sseAbortController) {
751
- this.sseAbortController.abort();
937
+ if (generation.sseAbortController) {
938
+ generation.sseAbortController.abort(
939
+ new Error('JMAP event stream was superseded.')
940
+ );
752
941
  }
753
942
  const abortController = new AbortController();
754
- this.sseAbortController = abortController;
755
- void this.runEventStream(url, abortController);
943
+ generation.sseAbortController = abortController;
944
+ void this.runEventStream(generation, url, abortController);
756
945
  }
757
946
 
758
- 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> {
759
952
  try {
953
+ const signal = AbortSignal.any([
954
+ generation.abortController.signal,
955
+ abortController.signal,
956
+ ]);
760
957
  const response = await fetch(url, {
761
958
  headers: {
762
959
  authorization: this.getAuthHeader(),
763
960
  accept: 'text/event-stream',
764
961
  },
765
- signal: abortController.signal,
962
+ signal,
766
963
  });
964
+ this.assertCurrentGeneration(generation);
965
+ if (abortController.signal.aborted) return;
767
966
  if (!response.ok || !response.body) {
768
967
  throw new Error(`JMAP event source unavailable (HTTP ${response.status}).`);
769
968
  }
770
969
  // The stream is live; catch up on anything that changed between the
771
970
  // initial state snapshot and the stream being established.
772
- void this.fetchDeltas();
971
+ void this.fetchDeltas(generation);
773
972
 
774
973
  const reader = response.body.getReader();
775
974
  const decoder = new TextDecoder();
@@ -778,6 +977,8 @@ export class JmapClient extends plugins.events.EventEmitter {
778
977
  let dataLines: string[] = [];
779
978
  while (true) {
780
979
  const { done, value } = await reader.read();
980
+ this.assertCurrentGeneration(generation);
981
+ if (abortController.signal.aborted) return;
781
982
  if (done) {
782
983
  break;
783
984
  }
@@ -788,7 +989,7 @@ export class JmapClient extends plugins.events.EventEmitter {
788
989
  buffer = buffer.slice(newlineIndex + 1);
789
990
  if (line === '') {
790
991
  if (dataLines.length) {
791
- this.handleEventStreamEvent(eventName, dataLines.join('\n'));
992
+ this.handleEventStreamEvent(generation, eventName, dataLines.join('\n'));
792
993
  }
793
994
  eventName = 'message';
794
995
  dataLines = [];
@@ -804,28 +1005,37 @@ export class JmapClient extends plugins.events.EventEmitter {
804
1005
  }
805
1006
  // Stream ended server-side: fall back to polling (mirroring the catch
806
1007
  // guards so a superseded stream's continuation stays inert).
807
- if (!this.stopped && !abortController.signal.aborted) {
808
- if (this.sseAbortController === abortController) {
809
- this.sseAbortController = null;
1008
+ if (this.isCurrentGeneration(generation) && !abortController.signal.aborted) {
1009
+ if (generation.sseAbortController === abortController) {
1010
+ generation.sseAbortController = null;
810
1011
  }
811
- this.startPolling();
1012
+ this.startPolling(generation);
812
1013
  }
813
1014
  } catch (error) {
814
- if (this.stopped || abortController.signal.aborted) {
1015
+ if (
1016
+ !this.isCurrentGeneration(generation) ||
1017
+ generation.abortController.signal.aborted ||
1018
+ abortController.signal.aborted
1019
+ ) {
815
1020
  return;
816
1021
  }
817
1022
  // Event stream unavailable or broken: abort so any held response body
818
1023
  // (e.g. an unconsumed non-OK response) releases its connection, then
819
1024
  // fall back to polling.
820
- abortController.abort();
821
- if (this.sseAbortController === abortController) {
822
- this.sseAbortController = null;
1025
+ abortController.abort(error);
1026
+ if (generation.sseAbortController === abortController) {
1027
+ generation.sseAbortController = null;
823
1028
  }
824
- this.startPolling();
1029
+ this.startPolling(generation);
825
1030
  }
826
1031
  }
827
1032
 
828
- 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;
829
1039
  let parsed: any;
830
1040
  try {
831
1041
  parsed = JSON.parse(data);
@@ -835,72 +1045,73 @@ export class JmapClient extends plugins.events.EventEmitter {
835
1045
  if (eventName !== 'state' && parsed?.['@type'] !== 'StateChange') {
836
1046
  return;
837
1047
  }
838
- const changed = parsed?.changed?.[this.accountId];
1048
+ const changed = parsed?.changed?.[generation.accountId];
839
1049
  const emailState = changed?.Email;
840
- if (typeof emailState === 'string' && emailState !== this.lastEmailState) {
841
- void this.fetchDeltas();
1050
+ if (typeof emailState === 'string' && emailState !== generation.lastEmailState) {
1051
+ void this.fetchDeltas(generation);
842
1052
  }
843
1053
  }
844
1054
 
845
- private startPolling(): void {
846
- if (this.pollTimer || this.stopped) {
1055
+ private startPolling(generation: IConnectionGeneration): void {
1056
+ if (generation.pollTimer || !this.isCurrentGeneration(generation)) {
847
1057
  return;
848
1058
  }
849
1059
  const interval = this.config.pollIntervalMs ?? 30000;
850
- this.pollTimer = setInterval(() => {
851
- void this.fetchDeltas();
1060
+ generation.pollTimer = setInterval(() => {
1061
+ void this.fetchDeltas(generation);
852
1062
  }, interval);
853
1063
  }
854
1064
 
855
- private async fetchDeltas(): Promise<void> {
856
- 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;
857
1069
  return;
858
1070
  }
859
- if (this.fetchingDeltas) {
860
- this.deltaQueued = true;
861
- return;
862
- }
863
- this.fetchingDeltas = true;
1071
+ generation.fetchingDeltas = true;
864
1072
  try {
865
1073
  do {
866
- this.deltaQueued = false;
867
- await this.fetchDeltasOnce();
868
- } while (this.deltaQueued && !this.stopped);
1074
+ generation.deltaQueued = false;
1075
+ await this.fetchDeltasOnce(generation);
1076
+ } while (generation.deltaQueued && this.isCurrentGeneration(generation));
869
1077
  } finally {
870
- this.fetchingDeltas = false;
1078
+ generation.fetchingDeltas = false;
871
1079
  }
872
1080
  }
873
1081
 
874
- private async fetchDeltasOnce(): Promise<void> {
1082
+ private async fetchDeltasOnce(generation: IConnectionGeneration): Promise<void> {
875
1083
  try {
876
1084
  let hasMore = true;
877
- while (hasMore && !this.stopped) {
878
- const sinceState = this.lastEmailState;
879
- const responses = await this.request([
880
- ['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'],
881
1089
  ]);
1090
+ this.assertCurrentGeneration(generation);
882
1091
  const changes = responses[0][1];
883
- this.lastEmailState = String(changes.newState ?? sinceState);
884
- hasMore = changes.hasMoreChanges === true && this.lastEmailState !== sinceState;
1092
+ generation.lastEmailState = String(changes.newState ?? sinceState);
1093
+ hasMore =
1094
+ changes.hasMoreChanges === true && generation.lastEmailState !== sinceState;
885
1095
  const createdIds: string[] = changes.created ?? [];
886
1096
  if (createdIds.length) {
887
- const emails = await this.getEmailObjects(createdIds);
1097
+ const emails = await this.getEmailObjects(generation, createdIds);
1098
+ this.assertCurrentGeneration(generation);
888
1099
  for (const email of emails) {
889
- if (this.mailbox && email.mailboxIds?.[this.mailbox.id]) {
890
- this.emitMessage(this.toMessage(email));
1100
+ if (generation.mailbox && email.mailboxIds?.[generation.mailbox.id]) {
1101
+ this.emitMessage(generation, this.toMessage(email));
891
1102
  }
892
1103
  }
893
1104
  }
894
1105
  }
895
1106
  } catch (error) {
896
- if (this.stopped) {
897
- return;
898
- }
1107
+ if (!this.isCurrentGeneration(generation)) return;
899
1108
  if ((error as JmapError)?.jmapErrorType === 'cannotCalculateChanges') {
900
1109
  try {
901
- await this.resyncViaQuery();
1110
+ await this.resyncViaQuery(generation);
902
1111
  } catch (resyncError) {
903
- this.emit('error', resyncError);
1112
+ if (this.isCurrentGeneration(generation)) {
1113
+ this.emit('error', resyncError);
1114
+ }
904
1115
  }
905
1116
  } else {
906
1117
  this.emit('error', error);
@@ -909,22 +1120,14 @@ export class JmapClient extends plugins.events.EventEmitter {
909
1120
  }
910
1121
 
911
1122
  /** Full resync fallback when the server cannot calculate changes. */
912
- private async resyncViaQuery(): Promise<void> {
913
- this.lastEmailState = await this.fetchEmailState();
914
- 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);
915
1129
  for (const message of messages.reverse()) {
916
- this.emitMessage(message);
917
- }
918
- }
919
-
920
- private teardownWatchers(): void {
921
- if (this.sseAbortController) {
922
- this.sseAbortController.abort();
923
- this.sseAbortController = null;
924
- }
925
- if (this.pollTimer) {
926
- clearInterval(this.pollTimer);
927
- this.pollTimer = null;
1130
+ this.emitMessage(generation, message);
928
1131
  }
929
1132
  }
930
1133
  }