@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.
- package/.smartconfig.json +5 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/classes.jmapclient.d.ts +43 -13
- package/dist_ts/classes.jmapclient.js +326 -149
- package/dist_ts/classes.jmapserver.d.ts +154 -84
- package/dist_ts/classes.jmapserver.js +1788 -660
- package/dist_ts/classes.memorymailbackend.d.ts +109 -0
- package/dist_ts/classes.memorymailbackend.js +670 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/interfaces.backend.d.ts +349 -0
- package/dist_ts/interfaces.backend.js +13 -0
- package/npmextra.json +4 -2
- package/package.json +8 -7
- package/readme.hints.md +25 -14
- package/readme.md +149 -18
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/classes.jmapclient.ts +414 -153
- package/ts/classes.jmapserver.ts +2389 -761
- package/ts/classes.memorymailbackend.ts +850 -0
- package/ts/index.ts +2 -0
- package/ts/interfaces.backend.ts +413 -0
package/ts/classes.jmapclient.ts
CHANGED
|
@@ -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
|
|
201
|
-
private
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
this.
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
302
|
+
generation.session = session;
|
|
303
|
+
generation.accountId = accountId;
|
|
304
|
+
generation.apiUrl = new URL(session.apiUrl, this.config.sessionUrl).toString();
|
|
272
305
|
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
315
|
+
await this.emitInitialEmails(generation);
|
|
316
|
+
this.assertCurrentGeneration(generation);
|
|
317
|
+
this.startWatching(generation);
|
|
287
318
|
} catch (error) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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 ?? (
|
|
437
|
+
filter ?? (generation.mailbox ? { inMailbox: generation.mailbox.id } : {});
|
|
369
438
|
const queryArgs: Record<string, any> = {
|
|
370
|
-
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.
|
|
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
|
|
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
|
|
412
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
471
|
-
['Email/set', { accountId:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
697
|
+
const error = await this.createHttpError(response, `Failed to download blob "${blobId}"`);
|
|
698
|
+
this.assertCurrentGeneration(generation);
|
|
699
|
+
throw error;
|
|
527
700
|
}
|
|
528
|
-
|
|
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.
|
|
563
|
-
['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'],
|
|
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.
|
|
579
|
-
['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'],
|
|
580
812
|
]);
|
|
581
813
|
return String(responses[0][1].state ?? '');
|
|
582
814
|
}
|
|
583
815
|
|
|
584
|
-
private async getEmailObjects(
|
|
585
|
-
|
|
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:
|
|
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(
|
|
601
|
-
|
|
602
|
-
|
|
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(
|
|
651
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
668
|
-
|
|
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(
|
|
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 (
|
|
693
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
750
|
-
if (
|
|
751
|
-
|
|
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 (
|
|
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 (
|
|
764
|
-
|
|
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(
|
|
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?.[
|
|
1048
|
+
const changed = parsed?.changed?.[generation.accountId];
|
|
781
1049
|
const emailState = changed?.Email;
|
|
782
|
-
if (typeof emailState === 'string' && emailState !==
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
802
|
-
this.deltaQueued = true;
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
this.fetchingDeltas = true;
|
|
1071
|
+
generation.fetchingDeltas = true;
|
|
806
1072
|
try {
|
|
807
1073
|
do {
|
|
808
|
-
|
|
809
|
-
await this.fetchDeltasOnce();
|
|
810
|
-
} while (
|
|
1074
|
+
generation.deltaQueued = false;
|
|
1075
|
+
await this.fetchDeltasOnce(generation);
|
|
1076
|
+
} while (generation.deltaQueued && this.isCurrentGeneration(generation));
|
|
811
1077
|
} finally {
|
|
812
|
-
|
|
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 &&
|
|
820
|
-
const sinceState =
|
|
821
|
-
const responses = await this.
|
|
822
|
-
['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'],
|
|
823
1089
|
]);
|
|
1090
|
+
this.assertCurrentGeneration(generation);
|
|
824
1091
|
const changes = responses[0][1];
|
|
825
|
-
|
|
826
|
-
hasMore =
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
856
|
-
|
|
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
|
}
|