@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.
- package/.smartconfig.json +1 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.jmapclient.d.ts +22 -13
- package/dist_ts/classes.jmapclient.js +294 -153
- package/dist_ts/classes.jmapserver.d.ts +49 -4
- package/dist_ts/classes.jmapserver.js +852 -330
- package/dist_ts/interfaces.backend.d.ts +19 -0
- package/dist_ts/interfaces.backend.js +1 -1
- package/package.json +5 -6
- package/readme.md +36 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.jmapclient.ts +360 -157
- package/ts/classes.jmapserver.ts +1271 -439
- package/ts/interfaces.backend.ts +21 -0
package/ts/classes.jmapclient.ts
CHANGED
|
@@ -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
|
|
218
|
-
private
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
this.
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
302
|
+
generation.session = session;
|
|
303
|
+
generation.accountId = accountId;
|
|
304
|
+
generation.apiUrl = new URL(session.apiUrl, this.config.sessionUrl).toString();
|
|
289
305
|
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
315
|
+
await this.emitInitialEmails(generation);
|
|
316
|
+
this.assertCurrentGeneration(generation);
|
|
317
|
+
this.startWatching(generation);
|
|
304
318
|
} catch (error) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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 ?? (
|
|
437
|
+
filter ?? (generation.mailbox ? { inMailbox: generation.mailbox.id } : {});
|
|
386
438
|
const queryArgs: Record<string, any> = {
|
|
387
|
-
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.
|
|
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
|
|
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
|
|
429
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
496
|
-
['Email/set', { accountId:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
662
|
+
const error = await this.createHttpError(response, 'Failed to upload blob');
|
|
663
|
+
this.assertCurrentGeneration(generation);
|
|
664
|
+
throw error;
|
|
557
665
|
}
|
|
558
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
697
|
+
const error = await this.createHttpError(response, `Failed to download blob "${blobId}"`);
|
|
698
|
+
this.assertCurrentGeneration(generation);
|
|
699
|
+
throw error;
|
|
585
700
|
}
|
|
586
|
-
|
|
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.
|
|
621
|
-
['Mailbox/get', { accountId:
|
|
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.
|
|
637
|
-
['Email/get', { accountId:
|
|
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(
|
|
643
|
-
|
|
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:
|
|
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(
|
|
659
|
-
|
|
660
|
-
|
|
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(
|
|
709
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
726
|
-
|
|
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(
|
|
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 (
|
|
751
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
808
|
-
if (
|
|
809
|
-
|
|
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 (
|
|
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 (
|
|
822
|
-
|
|
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(
|
|
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?.[
|
|
1048
|
+
const changed = parsed?.changed?.[generation.accountId];
|
|
839
1049
|
const emailState = changed?.Email;
|
|
840
|
-
if (typeof emailState === 'string' && emailState !==
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
860
|
-
this.deltaQueued = true;
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
this.fetchingDeltas = true;
|
|
1071
|
+
generation.fetchingDeltas = true;
|
|
864
1072
|
try {
|
|
865
1073
|
do {
|
|
866
|
-
|
|
867
|
-
await this.fetchDeltasOnce();
|
|
868
|
-
} while (
|
|
1074
|
+
generation.deltaQueued = false;
|
|
1075
|
+
await this.fetchDeltasOnce(generation);
|
|
1076
|
+
} while (generation.deltaQueued && this.isCurrentGeneration(generation));
|
|
869
1077
|
} finally {
|
|
870
|
-
|
|
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 &&
|
|
878
|
-
const sinceState =
|
|
879
|
-
const responses = await this.
|
|
880
|
-
['Email/changes', { accountId:
|
|
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
|
-
|
|
884
|
-
hasMore =
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
914
|
-
|
|
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
|
}
|