@remit/mailbox-service 0.0.110 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -8,7 +8,6 @@ import type {
8
8
  IMessageFlagRepository,
9
9
  IMessageRepository,
10
10
  IThreadMessageRepository,
11
- IUnitOfWork,
12
11
  MailboxItem,
13
12
  ThreadMessageItem,
14
13
  } from "@remit/data-ports";
@@ -19,9 +18,11 @@ import {
19
18
  deriveBodyPartId,
20
19
  deriveCopyMessageId,
21
20
  deriveEnvelopeId,
21
+ deriveMessageId,
22
22
  deriveMessageIdFromSource,
23
23
  deriveThreadId,
24
24
  isValidMessageId,
25
+ wireMessageId,
25
26
  } from "@remit/data-ports/id";
26
27
  import {
27
28
  AddressRole,
@@ -29,6 +30,7 @@ import {
29
30
  MessageKeywordFlag,
30
31
  MessageSystemFlag,
31
32
  StarColor,
33
+ SyncWarningStage,
32
34
  } from "@remit/domain-enums";
33
35
  import pMap from "p-map";
34
36
  import type { ManagedConnectionFactory } from "./connection-factory.js";
@@ -45,9 +47,8 @@ import {
45
47
  matchCursorRebuild,
46
48
  } from "./mailbox-cursor-rebuild.js";
47
49
  import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
48
- import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
49
50
  import type { QuarantineService } from "./quarantine.js";
50
- import { reconcileStaleMessage } from "./stale-message-reconcile.js";
51
+ import type { SyncWarningRound, SyncWarningService } from "./sync-warning.js";
51
52
  import {
52
53
  advanceChangeCursor,
53
54
  advanceUidWatermarks,
@@ -59,6 +60,7 @@ import {
59
60
  parseChangeCursor,
60
61
  parseModseq,
61
62
  } from "./sync-watermarks.js";
63
+ import { messageReferencesOf, parentOf, threadRootOf } from "./thread-root.js";
62
64
  import type {
63
65
  ImapAddress,
64
66
  ImapBodyStructure,
@@ -262,6 +264,21 @@ const unresolvedSightingUids = (outcomes: BatchOutcome[]): number[] =>
262
264
  : [],
263
265
  );
264
266
 
267
+ const syncedUidsOf = (outcomes: BatchOutcome[]): number[] =>
268
+ outcomes.flatMap((outcome) =>
269
+ outcome.kind === "saved" && !outcome.result?.unresolvedSighting
270
+ ? [outcome.uid]
271
+ : [],
272
+ );
273
+
274
+ const saveFailedUidsOf = (outcomes: BatchOutcome[]): number[] =>
275
+ outcomes.flatMap((outcome) =>
276
+ outcome.kind === "failed" ? [outcome.uid] : [],
277
+ );
278
+
279
+ const envelopeLessUidsOf = (messages: ImapMessage[]): number[] =>
280
+ messages.flatMap((msg) => (hasEnvelope(msg) ? [] : [msg.uid]));
281
+
265
282
  const emptySyncResult = (): SyncMessagesResult => ({
266
283
  syncedCount: 0,
267
284
  syncedMessageIds: [],
@@ -321,7 +338,6 @@ const messageIdForSighting = (
321
338
 
322
339
  export class MessageSyncService {
323
340
  private log: SyncLogger;
324
- private unitOfWork: IUnitOfWork;
325
341
 
326
342
  constructor(
327
343
  private connectionFactory: ManagedConnectionFactory,
@@ -334,11 +350,11 @@ export class MessageSyncService {
334
350
  */
335
351
  private mailboxSpecialUseService: IMailboxSpecialUseRepository,
336
352
  private messageService: IMessageRepository,
337
- envelopeService: IEnvelopeRepository,
338
- addressService: IAddressRepository,
353
+ private envelopeService: IEnvelopeRepository,
354
+ private addressService: IAddressRepository,
339
355
  private threadMessageService: IThreadMessageRepository,
356
+ private syncWarningService: SyncWarningService,
340
357
  logger?: SyncLogger,
341
- unitOfWork?: IUnitOfWork,
342
358
  /**
343
359
  * Pending outbound flag-push markers (#1273). Supplied, an inbound
344
360
  * metadata change never overwrites a local flip that IMAP has not been
@@ -360,14 +376,6 @@ export class MessageSyncService {
360
376
  private quarantineService?: QuarantineService,
361
377
  ) {
362
378
  this.log = logger ?? noopLogger;
363
- this.unitOfWork =
364
- unitOfWork ??
365
- new PassThroughUnitOfWork({
366
- message: messageService,
367
- envelope: envelopeService,
368
- address: addressService,
369
- threadMessage: threadMessageService,
370
- });
371
379
  }
372
380
 
373
381
  /**
@@ -430,6 +438,17 @@ export class MessageSyncService {
430
438
  return emptySyncResult();
431
439
  }
432
440
 
441
+ if (
442
+ await this.restoreMissingStructure(
443
+ mailbox,
444
+ accountId,
445
+ accountConfigId,
446
+ batchSize,
447
+ )
448
+ ) {
449
+ await connection.openBox(mailboxPath);
450
+ }
451
+
433
452
  const cursor = parseChangeCursor(mailbox.highestModseq);
434
453
  if (hasChangeCursor(cursor) && connection.supportsCondstore()) {
435
454
  return this.syncChangedSince({
@@ -453,6 +472,17 @@ export class MessageSyncService {
453
472
  const deletedCount = status.deletedCount;
454
473
 
455
474
  if (uids.length === 0) {
475
+ await this.settleHeaderWarnings(
476
+ accountConfigId,
477
+ mailbox,
478
+ box.uidvalidity,
479
+ {
480
+ failedUids: [],
481
+ syncedUids: [],
482
+ serverUids: { kind: "Listed", uids: allUids },
483
+ },
484
+ );
485
+
456
486
  // Nothing left to enumerate: the folder is fully covered on this
457
487
  // UIDVALIDITY axis, which is the one moment a mod-sequence watermark
458
488
  // can be seeded without hiding unsynced history behind it. From the
@@ -616,6 +646,12 @@ export class MessageSyncService {
616
646
  // `failedUids` too: the round did not finish with it either.
617
647
  const enumerationComplete = !hasMore && failedUids.size === 0;
618
648
 
649
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
650
+ failedUids: [...saveFailedUids, ...envelopeLessUidsOf(messages)],
651
+ syncedUids: syncedUidsOf(outcomes),
652
+ serverUids: { kind: "Listed", uids: allUids },
653
+ });
654
+
619
655
  await this.mailboxService.update(accountId, mailboxId, {
620
656
  lastSyncUid: newLastSyncUid,
621
657
  highWaterMarkUid: newHighWaterMark,
@@ -682,9 +718,10 @@ export class MessageSyncService {
682
718
  * untouched.
683
719
  * - Server message with no row → normal new-message sync (the same
684
720
  * `trySaveMessage` pipeline the regular batch sync uses).
685
- * - Row with no counterpart → expunged; reconcile via {@link
686
- * reconcileStaleMessage} (#1283 — the exact same "gone upstream" outcome
687
- * as a body-sync retry exhaustion finding a stale row).
721
+ * - Row with no counterpart → expunged; removed through `deleteIfPlacement`
722
+ * on the uid the rebuild read (#1283 — the exact same "gone upstream"
723
+ * outcome as a body-sync retry exhaustion finding a stale row), so a row
724
+ * a move re-pointed in the meantime is left alone.
688
725
  *
689
726
  * Idempotent by construction: re-entering mid-rebuild (crash recovery —
690
727
  * the mailbox was left `rebuilding`) simply redoes the same match/rewrite
@@ -737,9 +774,7 @@ export class MessageSyncService {
737
774
  await pMap(
738
775
  matched,
739
776
  async ({ messageId, newUid, threadMessage }) => {
740
- await this.unitOfWork.transaction((repos) =>
741
- repos.message.updateUid(messageId, newUid, mailboxId),
742
- );
777
+ await this.messageService.updateUid(messageId, newUid, mailboxId);
743
778
  // Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
744
779
  // a normal move keeps both in sync (see buildThreadMessageMoveUpdate
745
780
  // in message-move.ts), and the rebuild must too, or a resumed
@@ -747,41 +782,34 @@ export class MessageSyncService {
747
782
  // reads uid from ThreadMessage) and any reader of the list
748
783
  // projection sees a stale UID.
749
784
  if (threadMessage) {
750
- await this.unitOfWork.transaction((repos) =>
751
- repos.threadMessage.update(
752
- threadMessage.accountConfigId,
753
- threadMessage.threadMessageId,
754
- { uid: newUid },
755
- {
756
- composites: {
757
- sentDate: threadMessage.sentDate,
758
- mailboxId: threadMessage.mailboxId,
759
- isRead: threadMessage.isRead,
760
- isDeleted: threadMessage.isDeleted,
761
- hasStars: threadMessage.hasStars,
762
- hasAttachment: threadMessage.hasAttachment,
763
- },
785
+ await this.threadMessageService.update(
786
+ threadMessage.accountConfigId,
787
+ threadMessage.threadMessageId,
788
+ { uid: newUid },
789
+ {
790
+ composites: {
791
+ sentDate: threadMessage.sentDate,
792
+ mailboxId: threadMessage.mailboxId,
793
+ isRead: threadMessage.isRead,
794
+ isDeleted: threadMessage.isDeleted,
795
+ hasStars: threadMessage.hasStars,
796
+ hasAttachment: threadMessage.hasAttachment,
764
797
  },
765
- ),
798
+ },
766
799
  );
767
800
  }
768
801
  },
769
802
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
770
803
  );
771
804
 
805
+ const stale = new Set(staleMessageIds);
772
806
  await pMap(
773
- staleMessageIds,
774
- (messageId) =>
775
- this.unitOfWork.transaction((repos) =>
776
- reconcileStaleMessage(
777
- {
778
- messageService: repos.message,
779
- threadMessageService: repos.threadMessage,
780
- },
781
- accountConfigId,
782
- messageId,
783
- ),
784
- ),
807
+ existingRows.filter((row) => stale.has(row.messageId)),
808
+ (row) =>
809
+ this.messageService.deleteIfPlacement(row.messageId, {
810
+ mailboxId,
811
+ uid: row.uid,
812
+ }),
785
813
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
786
814
  );
787
815
 
@@ -857,6 +885,15 @@ export class MessageSyncService {
857
885
  : Number.POSITIVE_INFINITY;
858
886
  const coveredUids = serverUids.filter((uid) => uid < lowestFailure);
859
887
 
888
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
889
+ failedUids: [
890
+ ...saveFailedUidsOf(outcomes),
891
+ ...envelopeLessUidsOf(newMessages),
892
+ ],
893
+ syncedUids: syncedUidsOf(outcomes),
894
+ serverUids: { kind: "Listed", uids: allUids },
895
+ });
896
+
860
897
  await this.mailboxService.update(accountId, mailboxId, {
861
898
  cursorState: MailboxCursorState.normal,
862
899
  uidValidity: box.uidvalidity,
@@ -959,11 +996,11 @@ export class MessageSyncService {
959
996
  * A single `FETCH ... (CHANGEDSINCE <modseq>)` over the whole UID space
960
997
  * returns both the messages that arrived and the messages whose metadata
961
998
  * changed since the stored watermark, so a flag flipped on another client
962
- * is picked up without enumerating the folder. Rows already present take
963
- * the metadata path — the envelope, body structure and addresses are
964
- * immutable, so re-writing them for a read-state change would be pure
965
- * waste; everything else is a new message and takes the normal save
966
- * pipeline.
999
+ * is picked up without enumerating the folder. Rows already present in
1000
+ * this folder take the metadata path — the envelope, body structure and
1001
+ * addresses are immutable, so re-writing them for a read-state change would
1002
+ * be pure waste; everything else, a row another client moved here from a
1003
+ * folder it points at included, takes the normal save pipeline (#1386).
967
1004
  *
968
1005
  * Expunges are invisible here (CONDSTORE without QRESYNC never reports
969
1006
  * them, RFC 7162 Section 3.1.2.1) — they remain the reconcile path's job.
@@ -1087,6 +1124,15 @@ export class MessageSyncService {
1087
1124
  });
1088
1125
  const highestModseq = formatChangeCursor(nextCursor);
1089
1126
 
1127
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
1128
+ failedUids: [...saveFailedUids, ...unusableUids],
1129
+ syncedUids: syncedUidsOf(outcomes),
1130
+ serverUids: {
1131
+ kind: "OnRequest",
1132
+ list: () => connection.search(["ALL"]),
1133
+ },
1134
+ });
1135
+
1090
1136
  // The UID watermark obeys the same clamp as the enumeration path even
1091
1137
  // though the cursor governs retries here: a mailbox that later falls
1092
1138
  // back to enumeration must not find a failed UID already behind its
@@ -1153,6 +1199,24 @@ export class MessageSyncService {
1153
1199
  };
1154
1200
  }
1155
1201
 
1202
+ private async settleHeaderWarnings(
1203
+ accountConfigId: string,
1204
+ mailbox: MailboxItem,
1205
+ uidValidity: number,
1206
+ round: SyncWarningRound,
1207
+ ): Promise<void> {
1208
+ await this.syncWarningService.settle(
1209
+ {
1210
+ accountConfigId,
1211
+ accountId: mailbox.accountId,
1212
+ mailboxId: mailbox.mailboxId,
1213
+ uidValidity,
1214
+ },
1215
+ SyncWarningStage.Header,
1216
+ round,
1217
+ );
1218
+ }
1219
+
1156
1220
  /**
1157
1221
  * Apply one message from a CHANGEDSINCE result without ever rejecting —
1158
1222
  * same contract as {@link trySaveMessage}, so a single unapplicable change
@@ -1218,7 +1282,16 @@ export class MessageSyncService {
1218
1282
  }
1219
1283
 
1220
1284
  await this.applyServerFlags(existing, msg.flags);
1221
- return null;
1285
+ if (!departures.needsSave.has(messageId)) return null;
1286
+
1287
+ return this.saveMessage(
1288
+ mailbox,
1289
+ accountId,
1290
+ accountConfigId,
1291
+ msg,
1292
+ roles,
1293
+ departures,
1294
+ );
1222
1295
  }
1223
1296
 
1224
1297
  /**
@@ -1392,6 +1465,72 @@ export class MessageSyncService {
1392
1465
  await this.setMessageFlag(messageId, flagName, present);
1393
1466
  }
1394
1467
 
1468
+ /**
1469
+ * Messages the forwarded-message repair queued — their part rows dropped,
1470
+ * the lost row only on the mail server — are fetched again by UID and saved
1471
+ * through the same path as any other sighting, which writes their parts. No
1472
+ * watermark moves: these UIDs are already behind them. Each leaves the queue
1473
+ * once saved or once the server returns no usable row for it, whatever
1474
+ * parts came back; only a failed save keeps it for the next round. Resolves
1475
+ * `true` when it touched the connection, which the probe of departures may
1476
+ * have left on another folder.
1477
+ */
1478
+ private async restoreMissingStructure(
1479
+ mailbox: MailboxItem,
1480
+ accountId: string,
1481
+ accountConfigId: string,
1482
+ batchSize: number,
1483
+ ): Promise<boolean> {
1484
+ const queued = await this.envelopeService.listQueuedStructureRefetches(
1485
+ mailbox.mailboxId,
1486
+ batchSize,
1487
+ );
1488
+ if (queued.length === 0) return false;
1489
+
1490
+ const applicable = (
1491
+ await this.fetchMessageBatch(queued.map(({ uid }) => uid))
1492
+ ).filter(hasEnvelope);
1493
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
1494
+ const departures = await this.probeDepartures(
1495
+ mailbox,
1496
+ accountId,
1497
+ applicable,
1498
+ );
1499
+ const outcomes = await pMap(
1500
+ applicable,
1501
+ (msg) =>
1502
+ this.trySaveMessage(
1503
+ mailbox,
1504
+ accountId,
1505
+ accountConfigId,
1506
+ msg,
1507
+ roles,
1508
+ departures,
1509
+ ),
1510
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
1511
+ );
1512
+ const failedUids = new Set(
1513
+ outcomes.flatMap((outcome) =>
1514
+ outcome.kind === "failed" ? [outcome.uid] : [],
1515
+ ),
1516
+ );
1517
+ await this.envelopeService.clearStructureRefetches(
1518
+ queued
1519
+ .filter(({ uid }) => !failedUids.has(uid))
1520
+ .map(({ messageId }) => messageId),
1521
+ );
1522
+ this.log.info(
1523
+ {
1524
+ mailboxId: mailbox.mailboxId,
1525
+ requested: queued.length,
1526
+ saved: outcomes.length - failedUids.size,
1527
+ failed: failedUids.size,
1528
+ },
1529
+ "Re-read the structure of messages the forwarded-message repair queued",
1530
+ );
1531
+ return true;
1532
+ }
1533
+
1395
1534
  /**
1396
1535
  * Fetch a batch of messages using the managed connection.
1397
1536
  * Assumes mailbox is already open from fetchUidsToSync.
@@ -1555,123 +1694,129 @@ export class MessageSyncService {
1555
1694
  let owned = false;
1556
1695
  let unresolvedSighting = false;
1557
1696
 
1558
- // One unit of work for the whole message: on Postgres these repos are
1559
- // transaction-bound, so the Envelope, addresses, Message, BodyParts and
1560
- // ThreadMessage — and the transactional-outbox rows the Message write
1561
- // appends — all commit together. A throw anywhere rolls the whole set
1562
- // back, so a failed save never strands a Message without its Envelope
1563
- // (#1072). Writes run in sequence: a single transaction serialises on one
1564
- // connection, and it lets the Envelope land before the Message, with the
1565
- // ThreadMessage written last so the list path never anchors on a
1566
- // ThreadMessage whose Message does not yet exist (#1209).
1567
- await this.unitOfWork.transaction(async (repos) => {
1568
- await repos.envelope.upsertEnvelope({
1569
- envelopeId,
1697
+ // Every write below is an idempotent upsert keyed on a derived id, so a
1698
+ // save that fails part-way is repaired by the next round repeating it: the
1699
+ // UID's watermark only moves once the save returns. The order is the
1700
+ // guarantee a reader gets in between: the Envelope lands before the
1701
+ // Message (#1072), and the ThreadMessage is written last so the list path
1702
+ // never anchors on a ThreadMessage whose Message does not yet exist
1703
+ // (#1209).
1704
+ await this.envelopeService.upsertEnvelope({
1705
+ envelopeId,
1706
+ messageId,
1707
+ dateValue: sentDate,
1708
+ dateRaw: envelope.date,
1709
+ subject: envelope.subject,
1710
+ messageIdValue: envelope.messageId,
1711
+ });
1712
+
1713
+ const messageReferences = messageReferencesOf(envelope, msg.references);
1714
+ if (messageReferences.length > 0) {
1715
+ await this.envelopeService.replaceMessageReferences(
1570
1716
  messageId,
1571
- dateValue: sentDate,
1572
- dateRaw: envelope.date,
1573
- subject: envelope.subject,
1574
- messageIdValue: envelope.messageId,
1575
- });
1717
+ messageReferences,
1718
+ );
1719
+ }
1576
1720
 
1577
- for (const { addresses, role } of addressOps) {
1578
- await this.saveAddresses(
1579
- repos.address,
1580
- messageId,
1581
- accountConfigId,
1582
- addresses,
1583
- role,
1584
- sighting,
1585
- );
1586
- }
1721
+ for (const { addresses, role } of addressOps) {
1722
+ await this.saveAddresses(
1723
+ this.addressService,
1724
+ messageId,
1725
+ accountConfigId,
1726
+ addresses,
1727
+ role,
1728
+ sighting,
1729
+ );
1730
+ }
1587
1731
 
1588
- // IMAP returns BODYSTRUCTURE in the same FETCH that returns the
1589
- // envelope, so persisting BodyParts here is "free" — no extra round-trip.
1590
- if (bodyParts.length > 0) {
1591
- await repos.envelope.upsertBodyParts(messageId, bodyParts);
1592
- }
1732
+ // IMAP returns BODYSTRUCTURE in the same FETCH that returns the
1733
+ // envelope, so persisting BodyParts here is "free" — no extra round-trip.
1734
+ if (bodyParts.length > 0) {
1735
+ await this.envelopeService.upsertBodyParts(messageId, bodyParts);
1736
+ }
1593
1737
 
1594
- const { item, created } = await repos.message.upsertWithStatus({
1595
- messageId,
1596
- mailboxId,
1597
- uid: msg.uid,
1598
- sequenceNumber: msg.seq,
1599
- rfc822Size: msg.size ?? 0,
1600
- internalDate: msg.internalDate.getTime(),
1601
- envelopeId,
1602
- rootBodyPartId,
1603
- });
1738
+ const { item, created } = await this.messageService.upsertWithStatus({
1739
+ messageId,
1740
+ mailboxId,
1741
+ uid: msg.uid,
1742
+ sequenceNumber: msg.seq,
1743
+ rfc822Size: msg.size ?? 0,
1744
+ internalDate: msg.internalDate.getTime(),
1745
+ envelopeId,
1746
+ rootBodyPartId,
1747
+ });
1604
1748
 
1605
- // The server has just named this folder and this UID for a message the
1606
- // database holds under another folder. `upsertWithStatus` left the row
1607
- // alone, so the pointer is repaired here — mailbox and UID together,
1608
- // because a UID only means anything inside the folder that issued it.
1609
- //
1610
- // Only when the message has really left the folder the row points at
1611
- // (#1146). A sighting alone does not say that: a Gmail label, a Sieve
1612
- // `fileinto` beside a `keep` and an echoed Sent copy all put the same
1613
- // message in a second real folder while the first still holds it, and
1614
- // following those took the mail out of the Inbox. `confirmDepartures`
1615
- // asked the source before this batch was saved, and its verdict names
1616
- // the placement it was reached for — so a row that has moved since
1617
- // matches nothing, and `unresolvedSighting` holds this UID back for
1618
- // the next round rather than repairing the row against a stale answer.
1619
- const contested = !created && sightingContestsPlacement(mailbox, item);
1620
- const verdict = placementKey(messageId, item.mailboxId, item.uid);
1621
- unresolvedSighting = contested && !departures.settled.has(verdict);
1622
- const repointed =
1623
- contested &&
1624
- departures.departed.has(verdict) &&
1625
- !(await this.holdsCopyOf(repos.message, messageId, mailboxId));
1626
- if (repointed) {
1627
- await repos.message.updateUid(messageId, msg.uid, mailboxId);
1628
- await this.repointThreadMessage(
1629
- repos.threadMessage,
1630
- accountConfigId,
1631
- messageId,
1632
- mailboxId,
1633
- msg.uid,
1634
- sighting === "discarded",
1635
- );
1636
- }
1749
+ // The server has just named this folder and this UID for a message the
1750
+ // database holds under another folder. `upsertWithStatus` left the row
1751
+ // alone, so the pointer is repaired here — mailbox and UID together,
1752
+ // because a UID only means anything inside the folder that issued it.
1753
+ //
1754
+ // Only when the message has really left the folder the row points at
1755
+ // (#1146). A sighting alone does not say that: a Gmail label, a Sieve
1756
+ // `fileinto` beside a `keep` and an echoed Sent copy all put the same
1757
+ // message in a second real folder while the first still holds it, and
1758
+ // following those took the mail out of the Inbox. `confirmDepartures`
1759
+ // asked the source before this batch was saved, and its verdict names
1760
+ // the placement it was reached for — so a row that has moved since
1761
+ // matches nothing, and `unresolvedSighting` holds this UID back for
1762
+ // the next round rather than repairing the row against a stale answer.
1763
+ const contested = !created && sightingContestsPlacement(mailbox, item);
1764
+ const verdict = placementKey(messageId, item.mailboxId, item.uid);
1765
+ unresolvedSighting = contested && !departures.settled.has(verdict);
1766
+ const repointable =
1767
+ contested &&
1768
+ departures.departed.has(verdict) &&
1769
+ !(await this.holdsCopyOf(this.messageService, messageId, mailboxId));
1770
+ // A user move landing between the read above and this write wins: the
1771
+ // re-point names the placement it read, writes nothing when the row has
1772
+ // moved since, and the UID waits for the next round.
1773
+ const repointed =
1774
+ repointable &&
1775
+ (await this.messageService.repointIfPlacement(
1776
+ messageId,
1777
+ { status: item.status, mailboxId: item.mailboxId, uid: item.uid },
1778
+ { mailboxId, uid: msg.uid, isDeleted: sighting === "discarded" },
1779
+ ));
1637
1780
 
1638
- owned = created || item.mailboxId === mailboxId || repointed;
1781
+ if (repointable && !repointed) unresolvedSighting = true;
1639
1782
 
1640
- // Withholding reads the pointer, so it is re-derived whenever the
1641
- // pointer could have changed the answer: a save into Junk, and a
1642
- // re-point in either direction. The reconcile both applies and lifts,
1643
- // so a sender whose only mail was moved out of Junk is restored by the
1644
- // same call that would have withheld them (#859).
1645
- if (sighting === "junk" || repointed) {
1646
- await repos.address.reconcileJunkOnlyForMessage(
1647
- messageId,
1648
- roles.configJunkRoles,
1649
- );
1650
- }
1783
+ owned = created || item.mailboxId === mailboxId || repointed;
1651
1784
 
1652
- await this.createThreadForMessage(
1653
- repos.threadMessage,
1785
+ // Withholding reads the pointer, so it is re-derived whenever the
1786
+ // pointer could have changed the answer: a save into Junk, and a
1787
+ // re-point in either direction. The reconcile both applies and lifts,
1788
+ // so a sender whose only mail was moved out of Junk is restored by the
1789
+ // same call that would have withheld them (#859).
1790
+ if (sighting === "junk" || repointed) {
1791
+ await this.addressService.reconcileJunkOnlyForMessage(
1654
1792
  messageId,
1655
- mailboxId,
1656
- accountConfigId,
1657
- msg.uid,
1658
- msg.internalDate.getTime(),
1659
- sentDate,
1660
- envelope,
1661
- msg.flags,
1662
- // The category of the Message this same save just wrote. A new
1663
- // message is `uncategorized` until body-sync classifies it; a row
1664
- // created for a Message that is ALREADY classified takes the decided
1665
- // value rather than sitting stale forever behind a body-sync pass that
1666
- // will skip it. That happens on thread-root drift — the same message
1667
- // re-saved under different `References` mints a second threadId and so
1668
- // a second row — not on a second mailbox, which resolves to the row
1669
- // that already exists (issue #320).
1670
- item.category,
1671
- msg.references,
1672
- hasAttachment,
1793
+ roles.configJunkRoles,
1673
1794
  );
1674
- });
1795
+ }
1796
+
1797
+ await this.createThreadForMessage(
1798
+ this.threadMessageService,
1799
+ messageId,
1800
+ mailboxId,
1801
+ accountId,
1802
+ accountConfigId,
1803
+ msg.uid,
1804
+ msg.internalDate.getTime(),
1805
+ sentDate,
1806
+ envelope,
1807
+ msg.flags,
1808
+ // The category of the Message this same save just wrote. A new
1809
+ // message is `uncategorized` until body-sync classifies it; a row
1810
+ // created for a Message that is ALREADY classified takes the decided
1811
+ // value rather than sitting stale forever behind a body-sync pass that
1812
+ // will skip it. That happens on thread-root drift — the same message
1813
+ // re-saved under different `References` mints a second threadId and so
1814
+ // a second row — not on a second mailbox, which resolves to the row
1815
+ // that already exists (issue #320).
1816
+ item.category,
1817
+ msg.references,
1818
+ hasAttachment,
1819
+ );
1675
1820
 
1676
1821
  return { messageId, uid: msg.uid, owned, unresolvedSighting };
1677
1822
  }
@@ -1693,52 +1838,6 @@ export class MessageSyncService {
1693
1838
  return rows.length > 0;
1694
1839
  }
1695
1840
 
1696
- /**
1697
- * Follow a re-pointed Message with the ThreadMessage row that renders it.
1698
- * The listing and every folder count read that row, so leaving it behind
1699
- * would show the message in the folder it just left and hide it from the one
1700
- * it landed in. `isDeleted` follows the same rule the move path uses — true
1701
- * in Trash, false anywhere else — so a message another client rescues out of
1702
- * Trash stops being filtered out of listings.
1703
- */
1704
- private async repointThreadMessage(
1705
- threadMessageService: IThreadMessageRepository,
1706
- accountConfigId: string,
1707
- messageId: string,
1708
- mailboxId: string,
1709
- uid: number,
1710
- isDeleted: boolean,
1711
- ): Promise<void> {
1712
- const row = await threadMessageService.findByMessageId(
1713
- accountConfigId,
1714
- messageId,
1715
- );
1716
- if (row === null) return;
1717
- if (
1718
- row.mailboxId === mailboxId &&
1719
- row.uid === uid &&
1720
- row.isDeleted === isDeleted
1721
- ) {
1722
- return;
1723
- }
1724
-
1725
- await threadMessageService.update(
1726
- row.accountConfigId,
1727
- row.threadMessageId,
1728
- { mailboxId, uid, isDeleted },
1729
- {
1730
- composites: {
1731
- mailboxId: row.mailboxId,
1732
- sentDate: row.sentDate,
1733
- isRead: row.isRead,
1734
- isDeleted: row.isDeleted,
1735
- hasStars: row.hasStars,
1736
- hasAttachment: row.hasAttachment,
1737
- },
1738
- },
1739
- );
1740
- }
1741
-
1742
1841
  private async saveAddresses(
1743
1842
  addressService: IAddressRepository,
1744
1843
  messageId: string,
@@ -1810,15 +1909,91 @@ export class MessageSyncService {
1810
1909
  }
1811
1910
 
1812
1911
  /**
1813
- * Create or update Thread and ThreadMessage for a synced message.
1814
- *
1815
- * Thread ID derivation (RFC 2822 compliant):
1816
- * 1. If References header exists, use the FIRST entry as thread root
1817
- * (References format: <root> <parent1> ... <direct-parent>)
1818
- * 2. Fall back to In-Reply-To if no References
1819
- * 3. Fall back to Message-ID (this message is a thread root)
1912
+ * The thread a synced message files under. A message keeps the thread it is
1913
+ * already stored in; a reply joins the thread its parent is stored in;
1914
+ * otherwise the thread is derived from the root `threadRootOf` names.
1915
+ */
1916
+ private async threadFor(
1917
+ threadMessageService: IThreadMessageRepository,
1918
+ messageId: string,
1919
+ accountId: string,
1920
+ accountConfigId: string,
1921
+ envelope: ImapEnvelope,
1922
+ references: string[] | undefined,
1923
+ ): Promise<string> {
1924
+ const own = await threadMessageService.findByMessageId(
1925
+ accountConfigId,
1926
+ messageId,
1927
+ );
1928
+ if (own) return own.threadId;
1929
+
1930
+ const parent = parentOf(envelope, references);
1931
+ const parentRow = parent
1932
+ ? await threadMessageService.findByMessageId(
1933
+ accountConfigId,
1934
+ deriveMessageId(accountId, parent),
1935
+ )
1936
+ : null;
1937
+ return (
1938
+ parentRow?.threadId ??
1939
+ deriveThreadId(
1940
+ accountConfigId,
1941
+ threadRootOf(envelope, references, messageId),
1942
+ )
1943
+ );
1944
+ }
1945
+
1946
+ /**
1947
+ * Joins the rows written on either side of a parent and its reply, after
1948
+ * this message's own row exists. Replies that arrived before this message,
1949
+ * and so were keyed on its own id, follow it into its thread; and a reply
1950
+ * whose parent was saved while it was being saved follows the parent, but
1951
+ * only from the thread it derived for itself — a thread it joined or was
1952
+ * already stored in is left as it is.
1820
1953
  *
1821
- * This ensures proper threading even when messages arrive out of order.
1954
+ * Both checks run after the create, so of a parent and a reply saved
1955
+ * concurrently at least one sees the other's row: neither is left in a
1956
+ * thread of its own.
1957
+ */
1958
+ private async joinThreads(
1959
+ threadMessageService: IThreadMessageRepository,
1960
+ messageId: string,
1961
+ accountId: string,
1962
+ accountConfigId: string,
1963
+ envelope: ImapEnvelope,
1964
+ references: string[] | undefined,
1965
+ threadId: string,
1966
+ ): Promise<void> {
1967
+ if (isValidMessageId(envelope.messageId)) {
1968
+ await threadMessageService.rekeyThread(
1969
+ accountConfigId,
1970
+ deriveThreadId(accountConfigId, wireMessageId(envelope.messageId)),
1971
+ threadId,
1972
+ );
1973
+ }
1974
+
1975
+ const parent = parentOf(envelope, references);
1976
+ if (!parent) return;
1977
+ const derived = deriveThreadId(
1978
+ accountConfigId,
1979
+ threadRootOf(envelope, references, messageId),
1980
+ );
1981
+ if (threadId !== derived) return;
1982
+ const parentRow = await threadMessageService.findByMessageId(
1983
+ accountConfigId,
1984
+ deriveMessageId(accountId, parent),
1985
+ );
1986
+ if (!parentRow || parentRow.threadId === threadId) return;
1987
+ await threadMessageService.rekeyThread(
1988
+ accountConfigId,
1989
+ threadId,
1990
+ parentRow.threadId,
1991
+ );
1992
+ }
1993
+
1994
+ /**
1995
+ * Create the ThreadMessage for a synced message, then join it to its
1996
+ * parent and replies.
1822
1997
  *
1823
1998
  * `category` is the value the caller already holds from the Message it just
1824
1999
  * wrote, so the row starts out agreeing with it rather than defaulting to
@@ -1828,6 +2003,7 @@ export class MessageSyncService {
1828
2003
  threadMessageService: IThreadMessageRepository,
1829
2004
  messageId: string,
1830
2005
  mailboxId: string,
2006
+ accountId: string,
1831
2007
  accountConfigId: string,
1832
2008
  uid: number,
1833
2009
  internalDate: number,
@@ -1838,30 +2014,14 @@ export class MessageSyncService {
1838
2014
  references?: string[],
1839
2015
  hasAttachment = false,
1840
2016
  ): Promise<void> {
1841
- // Determine the thread root Message-ID
1842
- let rootMessageIdHeader: string;
1843
-
1844
- if (references && references.length > 0) {
1845
- // References header exists - first entry is the thread root (RFC 2822)
1846
- rootMessageIdHeader = references[0];
1847
- } else if (envelope.inReplyTo) {
1848
- // No References, but has In-Reply-To - use as thread root
1849
- // (This is a reply to a single message, which becomes the root)
1850
- rootMessageIdHeader = envelope.inReplyTo;
1851
- } else if (isValidMessageId(envelope.messageId)) {
1852
- // No References, no In-Reply-To - this message is a thread root
1853
- rootMessageIdHeader = envelope.messageId;
1854
- } else {
1855
- // No usable header (missing, empty, or a "<>" delivery-failure
1856
- // placeholder). Fall back to the always-present internal messageId so
1857
- // this message becomes a standalone thread-of-one. Distinct headerless
1858
- // messages keep distinct ids, so they never collide into one bogus
1859
- // thread, and every persisted Message gets exactly one ThreadMessage.
1860
- rootMessageIdHeader = messageId;
1861
- }
1862
-
1863
- // Derive threadId from the root Message-ID (deterministic)
1864
- const threadId = deriveThreadId(accountConfigId, rootMessageIdHeader);
2017
+ const threadId = await this.threadFor(
2018
+ threadMessageService,
2019
+ messageId,
2020
+ accountId,
2021
+ accountConfigId,
2022
+ envelope,
2023
+ references,
2024
+ );
1865
2025
 
1866
2026
  const isRead = flags.includes(MessageSystemFlag.Seen);
1867
2027
 
@@ -1920,6 +2080,16 @@ export class MessageSyncService {
1920
2080
  }
1921
2081
  throw error;
1922
2082
  });
2083
+
2084
+ await this.joinThreads(
2085
+ threadMessageService,
2086
+ messageId,
2087
+ accountId,
2088
+ accountConfigId,
2089
+ envelope,
2090
+ references,
2091
+ threadId,
2092
+ );
1923
2093
  }
1924
2094
  }
1925
2095