@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.
@@ -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 { box, unseenCount, deletedCount, uids } = await this.fetchUidsToSync(
208
- mailboxPath,
209
- lastSyncUid,
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 — the watermarks just used to filter `uids` may
216
- // already be meaningless on the new axis, so nothing below may be acted
217
- // on this round (epic #1281 invariants 3 and 5).
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
- syncedCount: 0,
231
- syncedMessageIds: [],
232
- syncedMessages: [],
233
- hasMore: false,
234
- remainingCount: 0,
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
- // Still update counts even if no new messages to sync
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: box.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: box.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. `fetchUidsToSync` reselects work purely by UID
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
- // Failures are different: the watermark range [batchMin, batchMax] jumps
324
- // over any interior UID, so a failed UID inside the range would be lost.
325
- // We therefore advance the forward watermark only past the top contiguous
326
- // run of successes, and the backfill watermark only past the bottom
327
- // contiguous run — clamping at the first failure from each end so every
328
- // failed UID stays selectable next cycle.
329
- const ascendingUids = [...batchUids].sort((a, b) => a - b);
330
-
331
- // Top contiguous run of successes → the highest UID safe to mark "seen".
332
- let forwardMax = highWaterMarkUid;
333
- for (let i = ascendingUids.length - 1; i >= 0; i--) {
334
- const uid = ascendingUids[i];
335
- if (failedUids.has(uid)) break;
336
- forwardMax = Math.max(forwardMax, uid);
337
- }
338
- const newHighWaterMark = forwardMax;
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: box.messageCount,
424
+ messageCount: status.messages,
425
+ ...(enumerationComplete ? { highestModseq: status.highestModseq } : {}),
362
426
  unseenCount,
363
427
  deletedCount,
364
428
  });
365
429
 
366
- const remainingCount = uids.length - batchUids.length;
367
- const hasMore = remainingCount > 0;
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
- const status = await connection.getMailboxStatus(mailboxPath);
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: serverUids.length > 0 ? Math.max(...serverUids) : 0,
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
- * Fetch UIDs to sync using dual-watermark strategy.
715
+ * One CHANGEDSINCE round (issue #20).
614
716
  *
615
- * Returns UIDs sorted descending (newest first):
616
- * 1. New messages: UIDs > highWaterMarkUid
617
- * 2. Backfill: UIDs < lastSyncUid (if lastSyncUid > 1)
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 fetchUidsToSync(
620
- mailboxPath: string,
621
- lastSyncUid: number,
622
- highWaterMarkUid: number,
623
- ): Promise<{
624
- box: { uidvalidity: number; uidnext: number; messageCount: number };
625
- unseenCount: number;
626
- deletedCount: number;
627
- uids: number[];
628
- }> {
629
- const connection = this.connectionFactory.getConnection();
630
- const box = await connection.openBox(mailboxPath);
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
- // Get mailbox status including unseen count
633
- const status = await connection.getMailboxStatus(mailboxPath);
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 allUids = await connection.search(["ALL"]);
756
+ const ordered = dropAppliedPrefix(orderByModseq(changed), cursor);
757
+ const batch = ordered.slice(0, batchSize);
636
758
 
637
- // New messages: UIDs greater than what we've seen
638
- const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
759
+ const outcomes = await pMap(
760
+ batch,
761
+ (msg) => this.tryApplyChange(mailboxId, accountId, accountConfigId, msg),
762
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
763
+ );
639
764
 
640
- // Backfill: UIDs below our lowest synced point (if sync started)
641
- const backfillUids =
642
- lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
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
- // Fresh sync: if no watermarks, sync everything
645
- const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
646
- const uidsToSync = isFreshSync ? allUids : [...newUids, ...backfillUids];
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
- // Sort descending (newest first)
649
- uidsToSync.sort((a, b) => b - a);
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
- return {
652
- box: {
653
- uidvalidity: box.uidvalidity,
654
- uidnext: box.uidnext,
655
- messageCount: status.messages,
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
- uids: uidsToSync,
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
- // Check if message is read based on IMAP flags
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. Compared as a literal for the same
945
- // reason \Seen is above: the generated MessageSystemFlag members drop the
946
- // leading backslash, so they do not match a wire flag.
947
- const hasStars = flags.includes("\\Flagged");
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