@remit/mailbox-service 0.0.9 → 0.0.11
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/package.json +1 -1
- package/src/imapflow-connection.test.ts +85 -0
- package/src/imapflow-connection.ts +59 -12
- package/src/mailbox-sync.test.ts +31 -2
- package/src/mailbox-sync.ts +11 -11
- package/src/message-move.ts +8 -4
- package/src/message-sync-changedsince.test.ts +742 -0
- package/src/message-sync.ts +494 -108
- package/src/sync-watermarks.test.ts +418 -0
- package/src/sync-watermarks.ts +322 -0
- package/src/types.ts +22 -0
package/src/message-sync.ts
CHANGED
|
@@ -3,10 +3,13 @@ import type {
|
|
|
3
3
|
IAddressRepository,
|
|
4
4
|
IEnvelopeRepository,
|
|
5
5
|
IMailboxRepository,
|
|
6
|
+
IMessageFlagPushRepository,
|
|
7
|
+
IMessageFlagRepository,
|
|
6
8
|
IMessageRepository,
|
|
7
9
|
IThreadMessageRepository,
|
|
8
10
|
IUnitOfWork,
|
|
9
11
|
MailboxItem,
|
|
12
|
+
ThreadMessageItem,
|
|
10
13
|
} from "@remit/data-ports";
|
|
11
14
|
import {
|
|
12
15
|
deriveAddressId,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
import {
|
|
21
24
|
AddressRole,
|
|
22
25
|
MailboxCursorState,
|
|
26
|
+
MessageSystemFlag,
|
|
23
27
|
StarColor,
|
|
24
28
|
} from "@remit/domain-enums";
|
|
25
29
|
import pMap from "p-map";
|
|
@@ -33,10 +37,22 @@ import {
|
|
|
33
37
|
import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
|
|
34
38
|
import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
|
|
35
39
|
import { reconcileStaleMessage } from "./stale-message-reconcile.js";
|
|
40
|
+
import {
|
|
41
|
+
advanceChangeCursor,
|
|
42
|
+
advanceUidWatermarks,
|
|
43
|
+
type ChangeCursor,
|
|
44
|
+
dropAppliedPrefix,
|
|
45
|
+
formatChangeCursor,
|
|
46
|
+
hasChangeCursor,
|
|
47
|
+
orderByModseq,
|
|
48
|
+
parseChangeCursor,
|
|
49
|
+
parseModseq,
|
|
50
|
+
} from "./sync-watermarks.js";
|
|
36
51
|
import type {
|
|
37
52
|
ImapAddress,
|
|
38
53
|
ImapBodyStructure,
|
|
39
54
|
ImapEnvelope,
|
|
55
|
+
ImapMailboxStatus,
|
|
40
56
|
ImapMessage,
|
|
41
57
|
} from "./types.js";
|
|
42
58
|
|
|
@@ -104,11 +120,13 @@ export type ImapConnectionFactory = () => {
|
|
|
104
120
|
export interface SyncLogger {
|
|
105
121
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
106
122
|
warn(obj: Record<string, unknown>, msg: string): void;
|
|
123
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
const noopLogger: SyncLogger = {
|
|
110
127
|
info: () => {},
|
|
111
128
|
warn: () => {},
|
|
129
|
+
error: () => {},
|
|
112
130
|
};
|
|
113
131
|
|
|
114
132
|
export interface SyncedMessage {
|
|
@@ -141,8 +159,46 @@ export interface SyncMessagesResult {
|
|
|
141
159
|
syncedMessages: SyncedMessage[];
|
|
142
160
|
hasMore: boolean;
|
|
143
161
|
remainingCount: number;
|
|
162
|
+
/**
|
|
163
|
+
* The round had work to do and moved no cursor. Nothing throws on this
|
|
164
|
+
* path — a message that cannot be applied is caught and held back — so
|
|
165
|
+
* without this flag a mailbox that stops syncing looks exactly like one
|
|
166
|
+
* with nothing to sync. Callers must surface it.
|
|
167
|
+
*/
|
|
168
|
+
cursorStalled: boolean;
|
|
144
169
|
}
|
|
145
170
|
|
|
171
|
+
const emptySyncResult = (): SyncMessagesResult => ({
|
|
172
|
+
syncedCount: 0,
|
|
173
|
+
syncedMessageIds: [],
|
|
174
|
+
syncedMessages: [],
|
|
175
|
+
hasMore: false,
|
|
176
|
+
remainingCount: 0,
|
|
177
|
+
cursorStalled: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Pick the UIDs a full-enumeration round should sync, newest first.
|
|
182
|
+
*
|
|
183
|
+
* 1. New messages: UIDs above the forward watermark.
|
|
184
|
+
* 2. Backfill: UIDs below the lowest one synced so far.
|
|
185
|
+
* 3. A mailbox with no watermarks at all syncs everything.
|
|
186
|
+
*/
|
|
187
|
+
export const selectUidsToSync = (
|
|
188
|
+
allUids: number[],
|
|
189
|
+
lastSyncUid: number,
|
|
190
|
+
highWaterMarkUid: number,
|
|
191
|
+
): number[] => {
|
|
192
|
+
const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
|
|
193
|
+
const backfillUids =
|
|
194
|
+
lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
|
|
195
|
+
|
|
196
|
+
const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
|
|
197
|
+
const uidsToSync = isFreshSync ? [...allUids] : [...newUids, ...backfillUids];
|
|
198
|
+
|
|
199
|
+
return uidsToSync.sort((a, b) => b - a);
|
|
200
|
+
};
|
|
201
|
+
|
|
146
202
|
export class MessageSyncService {
|
|
147
203
|
private log: SyncLogger;
|
|
148
204
|
private unitOfWork: IUnitOfWork;
|
|
@@ -156,6 +212,18 @@ export class MessageSyncService {
|
|
|
156
212
|
private threadMessageService: IThreadMessageRepository,
|
|
157
213
|
logger?: SyncLogger,
|
|
158
214
|
unitOfWork?: IUnitOfWork,
|
|
215
|
+
/**
|
|
216
|
+
* Pending outbound flag-push markers (#1273). Supplied, an inbound
|
|
217
|
+
* metadata change never overwrites a local flip that IMAP has not been
|
|
218
|
+
* told about yet; omitted, the server always wins.
|
|
219
|
+
*/
|
|
220
|
+
private flagPushMarkerService?: IMessageFlagPushRepository,
|
|
221
|
+
/**
|
|
222
|
+
* The canonical flag record. Supplied, an inbound metadata change lands
|
|
223
|
+
* on the same record the outbound flip path reads, so a user's next
|
|
224
|
+
* flip is never dismissed as redundant.
|
|
225
|
+
*/
|
|
226
|
+
private messageFlagService?: IMessageFlagRepository,
|
|
159
227
|
) {
|
|
160
228
|
this.log = logger ?? noopLogger;
|
|
161
229
|
this.unitOfWork =
|
|
@@ -204,17 +272,16 @@ export class MessageSyncService {
|
|
|
204
272
|
const lastSyncUid = mailbox.lastSyncUid || 0;
|
|
205
273
|
const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
|
|
206
274
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
highWaterMarkUid,
|
|
211
|
-
);
|
|
275
|
+
const connection = this.connectionFactory.getConnection();
|
|
276
|
+
const box = await connection.openBox(mailboxPath);
|
|
277
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
212
278
|
|
|
213
279
|
// Detection: the served UIDVALIDITY may have changed since it was last
|
|
214
280
|
// stored, even though this mailbox was `normal` a moment ago. Trip the
|
|
215
|
-
// cursor and pause —
|
|
216
|
-
//
|
|
217
|
-
//
|
|
281
|
+
// cursor and pause — every stored watermark, the mod-sequence included,
|
|
282
|
+
// is meaningless on the new axis, so nothing below may be acted on this
|
|
283
|
+
// round (epic #1281 invariants 3 and 5). The rebuild that follows is
|
|
284
|
+
// where the mod-sequence is reseeded from the new axis.
|
|
218
285
|
const cursorCheck = await guardMailboxCursor(
|
|
219
286
|
{ mailboxService: this.mailboxService },
|
|
220
287
|
accountId,
|
|
@@ -226,21 +293,37 @@ export class MessageSyncService {
|
|
|
226
293
|
{ mailboxId, mailboxPath, cursorState: cursorCheck.state },
|
|
227
294
|
"UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round",
|
|
228
295
|
);
|
|
229
|
-
return
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
296
|
+
return emptySyncResult();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const cursor = parseChangeCursor(mailbox.highestModseq);
|
|
300
|
+
if (hasChangeCursor(cursor) && connection.supportsCondstore()) {
|
|
301
|
+
return this.syncChangedSince({
|
|
302
|
+
mailbox,
|
|
303
|
+
accountId,
|
|
304
|
+
accountConfigId,
|
|
305
|
+
cursor,
|
|
306
|
+
box,
|
|
307
|
+
status,
|
|
308
|
+
batchSize,
|
|
309
|
+
});
|
|
236
310
|
}
|
|
237
311
|
|
|
312
|
+
const allUids = await connection.search(["ALL"]);
|
|
313
|
+
const uids = selectUidsToSync(allUids, lastSyncUid, highWaterMarkUid);
|
|
314
|
+
const unseenCount = status.unseen;
|
|
315
|
+
const deletedCount = status.deletedCount;
|
|
316
|
+
|
|
238
317
|
if (uids.length === 0) {
|
|
239
|
-
//
|
|
318
|
+
// Nothing left to enumerate: the folder is fully covered on this
|
|
319
|
+
// UIDVALIDITY axis, which is the one moment a mod-sequence watermark
|
|
320
|
+
// can be seeded without hiding unsynced history behind it. From the
|
|
321
|
+
// next round on, this mailbox takes the CHANGEDSINCE path above.
|
|
240
322
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
241
323
|
lastMessageSyncAt: Date.now(),
|
|
242
324
|
uidValidity: box.uidvalidity,
|
|
243
|
-
messageCount:
|
|
325
|
+
messageCount: status.messages,
|
|
326
|
+
highestModseq: status.highestModseq,
|
|
244
327
|
unseenCount,
|
|
245
328
|
deletedCount,
|
|
246
329
|
});
|
|
@@ -250,18 +333,13 @@ export class MessageSyncService {
|
|
|
250
333
|
mailboxId,
|
|
251
334
|
mailboxPath,
|
|
252
335
|
total: 0,
|
|
253
|
-
messageCount:
|
|
336
|
+
messageCount: status.messages,
|
|
254
337
|
unseenCount,
|
|
338
|
+
highestModseq: status.highestModseq,
|
|
255
339
|
},
|
|
256
340
|
"No new messages to sync",
|
|
257
341
|
);
|
|
258
|
-
return
|
|
259
|
-
syncedCount: 0,
|
|
260
|
-
syncedMessageIds: [],
|
|
261
|
-
syncedMessages: [],
|
|
262
|
-
hasMore: false,
|
|
263
|
-
remainingCount: 0,
|
|
264
|
-
};
|
|
342
|
+
return emptySyncResult();
|
|
265
343
|
}
|
|
266
344
|
|
|
267
345
|
const totalBatches = Math.ceil(uids.length / batchSize);
|
|
@@ -312,59 +390,62 @@ export class MessageSyncService {
|
|
|
312
390
|
}
|
|
313
391
|
|
|
314
392
|
// Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
|
|
315
|
-
// independent of ownership. `
|
|
393
|
+
// independent of ownership. `selectUidsToSync` reselects work purely by UID
|
|
316
394
|
// vs watermark (there is no per-UID processed set), so a foreign-owned UID
|
|
317
395
|
// that did not advance the watermark would be re-fetched every cycle
|
|
318
396
|
// forever. The same Message-ID legitimately appears in several of one
|
|
319
397
|
// account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
|
|
320
398
|
// conflicts are routine; excluding them from body-sync is correct, stalling
|
|
321
|
-
// forward sync is not.
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// Bottom contiguous run of successes → the lowest UID safe to backfill
|
|
341
|
-
// past. The first (lowest) UID that succeeded defines it; if the very
|
|
342
|
-
// lowest UID failed there is nothing safe to backfill past.
|
|
343
|
-
const backfillMin: number | undefined = failedUids.has(ascendingUids[0])
|
|
344
|
-
? undefined
|
|
345
|
-
: ascendingUids[0];
|
|
346
|
-
|
|
347
|
-
// Update lastSyncUid only for backfill UIDs (below current lastSyncUid or
|
|
348
|
-
// fresh sync). When the lowest UID failed there is nothing safe to backfill
|
|
349
|
-
// past, so leave lastSyncUid untouched.
|
|
350
|
-
const newLastSyncUid =
|
|
351
|
-
backfillMin !== undefined &&
|
|
352
|
-
(lastSyncUid === 0 || backfillMin < lastSyncUid)
|
|
353
|
-
? backfillMin
|
|
354
|
-
: lastSyncUid;
|
|
399
|
+
// forward sync is not. Failures are what a watermark may never pass —
|
|
400
|
+
// see `advanceUidWatermarks`.
|
|
401
|
+
const { highWaterMarkUid: newHighWaterMark, lastSyncUid: newLastSyncUid } =
|
|
402
|
+
advanceUidWatermarks({
|
|
403
|
+
batchUids,
|
|
404
|
+
failedUids,
|
|
405
|
+
lastSyncUid,
|
|
406
|
+
highWaterMarkUid,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const remainingCount = uids.length - batchUids.length;
|
|
410
|
+
const hasMore = remainingCount > 0;
|
|
411
|
+
|
|
412
|
+
// The mod-sequence watermark is seeded only by a round that leaves no
|
|
413
|
+
// enumeration work behind and lost no message to a failed save. Seeding
|
|
414
|
+
// it earlier would switch the mailbox to CHANGEDSINCE while UIDs it has
|
|
415
|
+
// never fetched still sit below the watermark, and those messages would
|
|
416
|
+
// never be discovered.
|
|
417
|
+
const enumerationComplete = !hasMore && failedUids.size === 0;
|
|
355
418
|
|
|
356
419
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
357
420
|
lastSyncUid: newLastSyncUid,
|
|
358
421
|
highWaterMarkUid: newHighWaterMark,
|
|
359
422
|
lastMessageSyncAt: Date.now(),
|
|
360
423
|
uidValidity: box.uidvalidity,
|
|
361
|
-
messageCount:
|
|
424
|
+
messageCount: status.messages,
|
|
425
|
+
...(enumerationComplete ? { highestModseq: status.highestModseq } : {}),
|
|
362
426
|
unseenCount,
|
|
363
427
|
deletedCount,
|
|
364
428
|
});
|
|
365
429
|
|
|
366
|
-
|
|
367
|
-
|
|
430
|
+
// Same stall condition as the CHANGEDSINCE round, on the UID axis: work
|
|
431
|
+
// selected, nothing moved, and the next round will select exactly the
|
|
432
|
+
// same work. No error surfaces on its own — every failure here is caught
|
|
433
|
+
// and held back — so this is the only signal that the mailbox is stuck.
|
|
434
|
+
const stalled =
|
|
435
|
+
newHighWaterMark === highWaterMarkUid && newLastSyncUid === lastSyncUid;
|
|
436
|
+
if (stalled) {
|
|
437
|
+
this.log.error(
|
|
438
|
+
{
|
|
439
|
+
alert: "message_sync_cursor_stalled",
|
|
440
|
+
mailboxId,
|
|
441
|
+
mailboxPath,
|
|
442
|
+
accountId,
|
|
443
|
+
pendingUids: uids.length,
|
|
444
|
+
failedUids: [...failedUids],
|
|
445
|
+
},
|
|
446
|
+
"Message sync watermarks did not advance while UIDs were pending; this mailbox stops syncing until they do",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
368
449
|
|
|
369
450
|
this.log.info(
|
|
370
451
|
{
|
|
@@ -387,6 +468,7 @@ export class MessageSyncService {
|
|
|
387
468
|
syncedMessages,
|
|
388
469
|
hasMore,
|
|
389
470
|
remainingCount,
|
|
471
|
+
cursorStalled: stalled,
|
|
390
472
|
};
|
|
391
473
|
}
|
|
392
474
|
|
|
@@ -425,6 +507,11 @@ export class MessageSyncService {
|
|
|
425
507
|
|
|
426
508
|
const connection = this.connectionFactory.getConnection();
|
|
427
509
|
const box = await connection.openBox(mailboxPath);
|
|
510
|
+
// Read before the pass, never after: a message arriving while the
|
|
511
|
+
// rebuild runs is absent from the snapshot below, and a HIGHESTMODSEQ
|
|
512
|
+
// read afterwards would already sit above that arrival's mod-sequence —
|
|
513
|
+
// seeding it would close the mailbox over a message it never stored.
|
|
514
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
428
515
|
const allUids = await connection.search(["ALL"]);
|
|
429
516
|
const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
|
|
430
517
|
const serverSnapshots: CursorRebuildSnapshot[] = snapshots.map((s) => ({
|
|
@@ -512,14 +599,28 @@ export class MessageSyncService {
|
|
|
512
599
|
);
|
|
513
600
|
|
|
514
601
|
const serverUids = serverSnapshots.map((s) => s.uid);
|
|
515
|
-
|
|
602
|
+
|
|
603
|
+
// A new message whose save threw must stay selectable, so the forward
|
|
604
|
+
// watermark stops below it and every UID above it is re-enumerated next
|
|
605
|
+
// round. The mod-sequence seed is withheld entirely in that case: the
|
|
606
|
+
// old value is meaningless on this axis (RFC 7162 requires discarding
|
|
607
|
+
// it on a UIDVALIDITY change) and the new one would sit above the
|
|
608
|
+
// message that failed, so the mailbox goes back to enumeration until a
|
|
609
|
+
// clean round seeds it.
|
|
610
|
+
const failedUids = new Set(
|
|
611
|
+
outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
|
|
612
|
+
);
|
|
613
|
+
const lowestFailure = failedUids.size
|
|
614
|
+
? Math.min(...failedUids)
|
|
615
|
+
: Number.POSITIVE_INFINITY;
|
|
616
|
+
const coveredUids = serverUids.filter((uid) => uid < lowestFailure);
|
|
516
617
|
|
|
517
618
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
518
619
|
cursorState: MailboxCursorState.normal,
|
|
519
620
|
uidValidity: box.uidvalidity,
|
|
520
|
-
highWaterMarkUid:
|
|
621
|
+
highWaterMarkUid: coveredUids.length > 0 ? Math.max(...coveredUids) : 0,
|
|
521
622
|
lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
|
|
522
|
-
highestModseq: status.highestModseq,
|
|
623
|
+
highestModseq: failedUids.size === 0 ? status.highestModseq : "0",
|
|
523
624
|
lastMessageSyncAt: Date.now(),
|
|
524
625
|
messageCount: status.messages,
|
|
525
626
|
unseenCount: status.unseen,
|
|
@@ -543,6 +644,7 @@ export class MessageSyncService {
|
|
|
543
644
|
syncedMessages,
|
|
544
645
|
hasMore: false,
|
|
545
646
|
remainingCount: 0,
|
|
647
|
+
cursorStalled: false,
|
|
546
648
|
};
|
|
547
649
|
}
|
|
548
650
|
|
|
@@ -610,56 +712,340 @@ export class MessageSyncService {
|
|
|
610
712
|
}
|
|
611
713
|
|
|
612
714
|
/**
|
|
613
|
-
*
|
|
715
|
+
* One CHANGEDSINCE round (issue #20).
|
|
614
716
|
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
717
|
+
* A single `FETCH ... (CHANGEDSINCE <modseq>)` over the whole UID space
|
|
718
|
+
* returns both the messages that arrived and the messages whose metadata
|
|
719
|
+
* changed since the stored watermark, so a flag flipped on another client
|
|
720
|
+
* is picked up without enumerating the folder. Rows already present take
|
|
721
|
+
* the metadata path — the envelope, body structure and addresses are
|
|
722
|
+
* immutable, so re-writing them for a read-state change would be pure
|
|
723
|
+
* waste; everything else is a new message and takes the normal save
|
|
724
|
+
* pipeline.
|
|
725
|
+
*
|
|
726
|
+
* Expunges are invisible here (CONDSTORE without QRESYNC never reports
|
|
727
|
+
* them, RFC 7162 Section 3.1.2.1) — they remain the reconcile path's job.
|
|
618
728
|
*/
|
|
619
|
-
private async
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
box: { uidvalidity: number
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
729
|
+
private async syncChangedSince(params: {
|
|
730
|
+
mailbox: MailboxItem;
|
|
731
|
+
accountId: string;
|
|
732
|
+
accountConfigId: string;
|
|
733
|
+
cursor: ChangeCursor;
|
|
734
|
+
box: { uidvalidity: number };
|
|
735
|
+
status: ImapMailboxStatus;
|
|
736
|
+
batchSize: number;
|
|
737
|
+
}): Promise<SyncMessagesResult> {
|
|
738
|
+
const {
|
|
739
|
+
mailbox,
|
|
740
|
+
accountId,
|
|
741
|
+
accountConfigId,
|
|
742
|
+
cursor,
|
|
743
|
+
box,
|
|
744
|
+
status,
|
|
745
|
+
batchSize,
|
|
746
|
+
} = params;
|
|
747
|
+
const mailboxId = mailbox.mailboxId;
|
|
748
|
+
const mailboxPath = mailbox.fullPath;
|
|
631
749
|
|
|
632
|
-
|
|
633
|
-
|
|
750
|
+
const connection = this.connectionFactory.getConnection();
|
|
751
|
+
// Ask from the last COMPLETE mod-sequence, so a group left part-applied
|
|
752
|
+
// by an earlier round is served again in full; its applied members are
|
|
753
|
+
// then dropped by position, without a lookup.
|
|
754
|
+
const changed = await connection.fetchMessagesChangedSince(cursor.modseq);
|
|
634
755
|
|
|
635
|
-
const
|
|
756
|
+
const ordered = dropAppliedPrefix(orderByModseq(changed), cursor);
|
|
757
|
+
const batch = ordered.slice(0, batchSize);
|
|
636
758
|
|
|
637
|
-
|
|
638
|
-
|
|
759
|
+
const outcomes = await pMap(
|
|
760
|
+
batch,
|
|
761
|
+
(msg) => this.tryApplyChange(mailboxId, accountId, accountConfigId, msg),
|
|
762
|
+
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
763
|
+
);
|
|
639
764
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
765
|
+
const failedUids = new Set(
|
|
766
|
+
outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
|
|
767
|
+
);
|
|
768
|
+
if (failedUids.size > 0) {
|
|
769
|
+
this.log.warn(
|
|
770
|
+
{ mailboxId, mailboxPath, failedUids: [...failedUids] },
|
|
771
|
+
"Some changes failed to apply; holding the sync cursor below them for retry",
|
|
772
|
+
);
|
|
773
|
+
}
|
|
643
774
|
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
const
|
|
775
|
+
// Body sync only concerns messages this round created — a metadata
|
|
776
|
+
// change has no new body to fetch.
|
|
777
|
+
const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
|
|
778
|
+
o.kind === "saved" && o.result !== null && o.result.owned
|
|
779
|
+
? [{ messageId: o.result.messageId, uid: o.result.uid }]
|
|
780
|
+
: [],
|
|
781
|
+
);
|
|
782
|
+
const syncedMessageIds = syncedMessages.map((m) => m.messageId);
|
|
647
783
|
|
|
648
|
-
|
|
649
|
-
|
|
784
|
+
const { cursor: nextCursor, hasMore } = advanceChangeCursor({
|
|
785
|
+
cursor,
|
|
786
|
+
serverModseq: parseModseq(status.highestModseq),
|
|
787
|
+
ordered,
|
|
788
|
+
batch,
|
|
789
|
+
failedUids,
|
|
790
|
+
});
|
|
791
|
+
const highestModseq = formatChangeCursor(nextCursor);
|
|
792
|
+
|
|
793
|
+
// The UID watermark obeys the same clamp as the enumeration path even
|
|
794
|
+
// though the cursor governs retries here: a mailbox that later falls
|
|
795
|
+
// back to enumeration must not find a failed UID already behind its
|
|
796
|
+
// forward watermark.
|
|
797
|
+
const { highWaterMarkUid: newHighWaterMark } = advanceUidWatermarks({
|
|
798
|
+
batchUids: batch.map((msg) => msg.uid),
|
|
799
|
+
failedUids,
|
|
800
|
+
lastSyncUid: mailbox.lastSyncUid || 0,
|
|
801
|
+
highWaterMarkUid: mailbox.highWaterMarkUid || 0,
|
|
802
|
+
});
|
|
650
803
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
804
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
805
|
+
highestModseq,
|
|
806
|
+
highWaterMarkUid: newHighWaterMark,
|
|
807
|
+
lastMessageSyncAt: Date.now(),
|
|
808
|
+
uidValidity: box.uidvalidity,
|
|
809
|
+
messageCount: status.messages,
|
|
657
810
|
unseenCount: status.unseen,
|
|
658
811
|
deletedCount: status.deletedCount,
|
|
659
|
-
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// A round that had work to do and moved nothing is stalled: the same
|
|
815
|
+
// fetch will return the same set forever, and the set only grows as the
|
|
816
|
+
// mailbox keeps changing. Nothing above this call fails, so nothing else
|
|
817
|
+
// would ever notice — the queue message is acked either way.
|
|
818
|
+
const stalled =
|
|
819
|
+
ordered.length > 0 && highestModseq === mailbox.highestModseq;
|
|
820
|
+
if (stalled) {
|
|
821
|
+
this.log.error(
|
|
822
|
+
{
|
|
823
|
+
alert: "message_sync_cursor_stalled",
|
|
824
|
+
mailboxId,
|
|
825
|
+
mailboxPath,
|
|
826
|
+
accountId,
|
|
827
|
+
cursor: highestModseq,
|
|
828
|
+
pendingChanges: ordered.length,
|
|
829
|
+
failedUids: [...failedUids],
|
|
830
|
+
},
|
|
831
|
+
"Message sync cursor did not advance while changes were pending; this mailbox stops seeing changes until it does",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
this.log.info(
|
|
836
|
+
{
|
|
837
|
+
mailboxId,
|
|
838
|
+
mailboxPath,
|
|
839
|
+
changed: ordered.length,
|
|
840
|
+
applied: batch.length,
|
|
841
|
+
created: syncedMessageIds.length,
|
|
842
|
+
fromCursor: formatChangeCursor(cursor),
|
|
843
|
+
cursor: highestModseq,
|
|
844
|
+
hasMore,
|
|
845
|
+
},
|
|
846
|
+
"CHANGEDSINCE round complete",
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
syncedCount: syncedMessageIds.length,
|
|
851
|
+
syncedMessageIds,
|
|
852
|
+
syncedMessages,
|
|
853
|
+
hasMore,
|
|
854
|
+
remainingCount: ordered.length - batch.length,
|
|
855
|
+
cursorStalled: stalled,
|
|
660
856
|
};
|
|
661
857
|
}
|
|
662
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Apply one message from a CHANGEDSINCE result without ever rejecting —
|
|
861
|
+
* same contract as {@link trySaveMessage}, so a single unapplicable change
|
|
862
|
+
* holds the watermark back instead of failing the round.
|
|
863
|
+
*/
|
|
864
|
+
private async tryApplyChange(
|
|
865
|
+
mailboxId: string,
|
|
866
|
+
accountId: string,
|
|
867
|
+
accountConfigId: string,
|
|
868
|
+
msg: ImapMessage,
|
|
869
|
+
): Promise<BatchOutcome> {
|
|
870
|
+
return this.applyChange(mailboxId, accountId, accountConfigId, msg)
|
|
871
|
+
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
872
|
+
.catch((error): BatchOutcome => {
|
|
873
|
+
this.log.warn(
|
|
874
|
+
{
|
|
875
|
+
mailboxId,
|
|
876
|
+
uid: msg.uid,
|
|
877
|
+
messageId: msg.envelope?.messageId,
|
|
878
|
+
error: error instanceof Error ? error.message : String(error),
|
|
879
|
+
},
|
|
880
|
+
"Failed to apply message change; will retry on next sync",
|
|
881
|
+
);
|
|
882
|
+
return { kind: "failed", uid: msg.uid };
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private async applyChange(
|
|
887
|
+
mailboxId: string,
|
|
888
|
+
accountId: string,
|
|
889
|
+
accountConfigId: string,
|
|
890
|
+
msg: ImapMessage,
|
|
891
|
+
): Promise<SaveMessageResult | null> {
|
|
892
|
+
if (!msg.envelope) return null;
|
|
893
|
+
|
|
894
|
+
const messageId = deriveMessageIdFromSource(accountId, {
|
|
895
|
+
messageId: msg.envelope.messageId,
|
|
896
|
+
uid: msg.uid,
|
|
897
|
+
mailboxId,
|
|
898
|
+
date: msg.envelope.date,
|
|
899
|
+
subject: msg.envelope.subject,
|
|
900
|
+
fromMailbox: msg.envelope.from?.[0]?.mailbox,
|
|
901
|
+
fromHost: msg.envelope.from?.[0]?.host,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const existing = await this.threadMessageService.findByMessageId(
|
|
905
|
+
accountConfigId,
|
|
906
|
+
messageId,
|
|
907
|
+
);
|
|
908
|
+
if (!existing) {
|
|
909
|
+
return this.saveMessage(mailboxId, accountId, accountConfigId, msg);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
await this.applyServerFlags(existing, msg.flags);
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Bring a stored row's read and star state in line with the server's
|
|
918
|
+
* flags.
|
|
919
|
+
*
|
|
920
|
+
* A field with a pending outbound push is left alone: the user flipped it
|
|
921
|
+
* locally, IMAP has not been told yet, and the server's answer is
|
|
922
|
+
* therefore known-stale for that field. Writing it back would revert the
|
|
923
|
+
* flip in front of the user and then push the reverted value.
|
|
924
|
+
*/
|
|
925
|
+
private async applyServerFlags(
|
|
926
|
+
existing: ThreadMessageItem,
|
|
927
|
+
flags: string[],
|
|
928
|
+
): Promise<void> {
|
|
929
|
+
const isRead = flags.includes(MessageSystemFlag.Seen);
|
|
930
|
+
const hasStars = flags.includes(MessageSystemFlag.Flagged);
|
|
931
|
+
|
|
932
|
+
const updates: {
|
|
933
|
+
isRead?: boolean;
|
|
934
|
+
hasStars?: boolean;
|
|
935
|
+
star?: (typeof StarColor)[keyof typeof StarColor];
|
|
936
|
+
} = {};
|
|
937
|
+
if (
|
|
938
|
+
existing.isRead !== isRead &&
|
|
939
|
+
!(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Seen))
|
|
940
|
+
) {
|
|
941
|
+
updates.isRead = isRead;
|
|
942
|
+
}
|
|
943
|
+
if (
|
|
944
|
+
existing.hasStars !== hasStars &&
|
|
945
|
+
!(await this.hasPendingPush(
|
|
946
|
+
existing.messageId,
|
|
947
|
+
MessageSystemFlag.Flagged,
|
|
948
|
+
))
|
|
949
|
+
) {
|
|
950
|
+
updates.hasStars = hasStars;
|
|
951
|
+
// `hasStars` is the boolean of record and `star` its presentation
|
|
952
|
+
// colour; the two may never disagree (#58). A star cleared upstream
|
|
953
|
+
// loses its colour; one set upstream takes the standard colour unless
|
|
954
|
+
// the row already carries a real one the user chose.
|
|
955
|
+
if (!hasStars) {
|
|
956
|
+
updates.star = StarColor.None;
|
|
957
|
+
} else if (
|
|
958
|
+
existing.star === undefined ||
|
|
959
|
+
existing.star === StarColor.None
|
|
960
|
+
) {
|
|
961
|
+
updates.star = StarColor.Yellow;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (Object.keys(updates).length === 0) return;
|
|
966
|
+
|
|
967
|
+
// MessageFlag is the canonical flag record — `FlagQueueService` reads it
|
|
968
|
+
// to decide whether a user's flip is redundant, and the API answers
|
|
969
|
+
// read/starred from it. Writing only the denormalized row would leave
|
|
970
|
+
// the two disagreeing, and the next local flip would be dismissed as
|
|
971
|
+
// already-in-state: a click that does nothing.
|
|
972
|
+
//
|
|
973
|
+
// Canonical record first, projection second — the same order the local
|
|
974
|
+
// flip path uses. A crash between the two leaves the pair inconsistent
|
|
975
|
+
// exactly as a crashed local flip would, and the round's watermark has
|
|
976
|
+
// not moved, so the next round re-fetches the message and re-applies
|
|
977
|
+
// both writes (each is idempotent).
|
|
978
|
+
if (updates.isRead !== undefined) {
|
|
979
|
+
await this.setMessageFlag(
|
|
980
|
+
existing.messageId,
|
|
981
|
+
MessageSystemFlag.Seen,
|
|
982
|
+
updates.isRead,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
if (updates.hasStars !== undefined) {
|
|
986
|
+
await this.setMessageFlag(
|
|
987
|
+
existing.messageId,
|
|
988
|
+
MessageSystemFlag.Flagged,
|
|
989
|
+
updates.hasStars,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
await this.threadMessageService.update(
|
|
994
|
+
existing.accountConfigId,
|
|
995
|
+
existing.threadMessageId,
|
|
996
|
+
updates,
|
|
997
|
+
{
|
|
998
|
+
// The CURRENT values of every sort-key attribute: ElectroDB uses
|
|
999
|
+
// them for the conditional check on the existing row and to
|
|
1000
|
+
// recompute the new keys. Passing the new value here would fail
|
|
1001
|
+
// that check and silently drop the update (see FlagQueueService).
|
|
1002
|
+
composites: {
|
|
1003
|
+
sentDate: existing.sentDate,
|
|
1004
|
+
mailboxId: existing.mailboxId,
|
|
1005
|
+
isRead: existing.isRead,
|
|
1006
|
+
isDeleted: existing.isDeleted,
|
|
1007
|
+
hasStars: existing.hasStars,
|
|
1008
|
+
hasAttachment: existing.hasAttachment,
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
this.log.info(
|
|
1014
|
+
{
|
|
1015
|
+
messageId: existing.messageId,
|
|
1016
|
+
threadMessageId: existing.threadMessageId,
|
|
1017
|
+
...updates,
|
|
1018
|
+
},
|
|
1019
|
+
"Applied server flag state from CHANGEDSINCE",
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Set or clear one flag on the canonical record. Both repository calls are
|
|
1025
|
+
* idempotent, so a re-applied change is a no-op rather than a conflict.
|
|
1026
|
+
*/
|
|
1027
|
+
private async setMessageFlag(
|
|
1028
|
+
messageId: string,
|
|
1029
|
+
flagName: string,
|
|
1030
|
+
present: boolean,
|
|
1031
|
+
): Promise<void> {
|
|
1032
|
+
if (!this.messageFlagService) return;
|
|
1033
|
+
if (present) {
|
|
1034
|
+
await this.messageFlagService.addFlag(messageId, flagName);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
await this.messageFlagService.removeFlag(messageId, flagName);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private async hasPendingPush(
|
|
1041
|
+
messageId: string,
|
|
1042
|
+
flagName: string,
|
|
1043
|
+
): Promise<boolean> {
|
|
1044
|
+
if (!this.flagPushMarkerService) return false;
|
|
1045
|
+
const marker = await this.flagPushMarkerService.find(messageId, flagName);
|
|
1046
|
+
return marker !== null;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
663
1049
|
/**
|
|
664
1050
|
* Fetch a batch of messages using the managed connection.
|
|
665
1051
|
* Assumes mailbox is already open from fetchUidsToSync.
|
|
@@ -936,15 +1322,15 @@ export class MessageSyncService {
|
|
|
936
1322
|
// Derive threadId from the root Message-ID (deterministic)
|
|
937
1323
|
const threadId = deriveThreadId(accountId, rootMessageIdHeader);
|
|
938
1324
|
|
|
939
|
-
|
|
940
|
-
const isRead = flags.includes("\\Seen");
|
|
1325
|
+
const isRead = flags.includes(MessageSystemFlag.Seen);
|
|
941
1326
|
|
|
942
1327
|
// The server's \Flagged keyword is the star. Mail flagged in another
|
|
943
1328
|
// client must arrive starred, so carry it through on create rather than
|
|
944
|
-
// defaulting every row to unstarred.
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
1329
|
+
// defaulting every row to unstarred. Both comparisons go through the
|
|
1330
|
+
// generated members, which carry the wire spelling (reader#65) and are
|
|
1331
|
+
// the same values the flag-push markers are keyed by — one source of
|
|
1332
|
+
// truth for the wire flag and the record of it.
|
|
1333
|
+
const hasStars = flags.includes(MessageSystemFlag.Flagged);
|
|
948
1334
|
|
|
949
1335
|
// Extract sender info. When the server could not parse the From address,
|
|
950
1336
|
// omit fromEmail rather than persist a fabricated string — a display name
|