@remit/mailbox-service 0.0.111 → 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 +156 -235
  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 +5 -4
  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/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  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
@@ -39,6 +39,7 @@ import {
39
39
  placementBindingOf,
40
40
  waitForPlacementToSettle,
41
41
  } from "./placement-settled.js";
42
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
42
43
 
43
44
  /**
44
45
  * What a delete batch did not claim. A row whose placement changed between the
@@ -109,6 +110,40 @@ interface MessageCopyEvent {
109
110
  uid: number;
110
111
  }
111
112
 
113
+ /** An event, and the undo of the local write it was enqueued for. */
114
+ interface UndoableEvent<T> {
115
+ event: T;
116
+ handBack: () => Promise<void>;
117
+ }
118
+
119
+ type BatchEnqueueOutcome =
120
+ | { outcome: "sent" }
121
+ | {
122
+ outcome: "unsent";
123
+ unsent: UndoableEvent<MessageDeleteEvent>[];
124
+ fault: unknown;
125
+ };
126
+
127
+ /** Undo every write whose event never left, then fail with the queue's error. */
128
+ const handBackThenThrow = async (
129
+ fault: unknown,
130
+ handBacks: ReadonlyArray<() => Promise<void>>,
131
+ ): Promise<never> => {
132
+ const undoFaults: unknown[] = [];
133
+ for (const handBack of handBacks) {
134
+ await handBack().catch((undoFault: unknown) => {
135
+ undoFaults.push(undoFault);
136
+ });
137
+ }
138
+ if (undoFaults.length > 0) {
139
+ throw new AggregateError(
140
+ [fault, ...undoFaults],
141
+ "The queue refused the event, and handing the local write back failed too",
142
+ );
143
+ }
144
+ throw fault;
145
+ };
146
+
112
147
  type MessageMoveQueueEvent =
113
148
  | MessageDeleteEvent
114
149
  | MessageMoveEvent
@@ -443,7 +478,7 @@ export class MessageMoveService {
443
478
  }
444
479
 
445
480
  // Collect all events for batch SQS send
446
- const events: MessageDeleteEvent[] = [];
481
+ const events: UndoableEvent<MessageDeleteEvent>[] = [];
447
482
 
448
483
  // Process move to trash
449
484
  if (trashMailbox && moveToTrashMessages.length > 0) {
@@ -504,18 +539,27 @@ export class MessageMoveService {
504
539
  );
505
540
 
506
541
  events.push({
507
- type: "MESSAGE_DELETE",
508
- schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
509
- eventId: randomUUID(),
510
- timestamp: Date.now(),
511
- accountId,
512
- messageId,
513
- mailboxId: sourceMailbox.mailboxId,
514
- mailboxPath: sourceMailbox.fullPath,
515
- uid: message.uid,
516
- operation: "move_to_trash",
517
- destinationMailboxId: trashMailbox.mailboxId,
518
- destinationMailboxPath: trashMailbox.fullPath,
542
+ event: {
543
+ type: "MESSAGE_DELETE",
544
+ schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
545
+ eventId: randomUUID(),
546
+ timestamp: Date.now(),
547
+ accountId,
548
+ messageId,
549
+ mailboxId: sourceMailbox.mailboxId,
550
+ mailboxPath: sourceMailbox.fullPath,
551
+ uid: message.uid,
552
+ operation: "move_to_trash",
553
+ destinationMailboxId: trashMailbox.mailboxId,
554
+ destinationMailboxPath: trashMailbox.fullPath,
555
+ },
556
+ handBack: () =>
557
+ this.handBackUnsentMove(
558
+ accountConfigId,
559
+ messageId,
560
+ sourceMailbox.mailboxId,
561
+ message.uid,
562
+ ),
519
563
  });
520
564
  }
521
565
 
@@ -573,16 +617,32 @@ export class MessageMoveService {
573
617
  await this.deleteThreadMessagesForMessage(accountConfigId, messageId);
574
618
 
575
619
  events.push({
576
- type: "MESSAGE_DELETE",
577
- schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
578
- eventId: randomUUID(),
579
- timestamp: Date.now(),
580
- accountId,
581
- messageId,
582
- mailboxId: sourceMailbox.mailboxId,
583
- mailboxPath: sourceMailbox.fullPath,
584
- uid: message.uid,
585
- operation: "permanent_delete",
620
+ event: {
621
+ type: "MESSAGE_DELETE",
622
+ schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
623
+ eventId: randomUUID(),
624
+ timestamp: Date.now(),
625
+ accountId,
626
+ messageId,
627
+ mailboxId: sourceMailbox.mailboxId,
628
+ mailboxPath: sourceMailbox.fullPath,
629
+ uid: message.uid,
630
+ operation: "permanent_delete",
631
+ },
632
+ // The listing rows went above, so the local removal finishes, as the
633
+ // worker's `handBackDelete` does; the folder's next sync re-projects it.
634
+ handBack: async () => {
635
+ const claimed = await this.messageService.transitionPlacement(
636
+ messageId,
637
+ {
638
+ status: MessageStatus.deleting,
639
+ mailboxId: message.mailboxId,
640
+ uid: message.uid,
641
+ },
642
+ { status: MessageStatus.deleted },
643
+ );
644
+ if (claimed) await this.messageService.delete(messageId);
645
+ },
586
646
  });
587
647
  }
588
648
 
@@ -593,7 +653,6 @@ export class MessageMoveService {
593
653
  );
594
654
  }
595
655
 
596
- // Batch send events to SQS
597
656
  await this.enqueueEventsBatch(events);
598
657
 
599
658
  return { refusedMessageIds: refused };
@@ -750,7 +809,14 @@ export class MessageMoveService {
750
809
  uid: message.uid,
751
810
  };
752
811
 
753
- await this.enqueueEvent(event);
812
+ await this.enqueueEvent(event, () =>
813
+ this.handBackUnsentMove(
814
+ accountConfigId,
815
+ messageId,
816
+ sourceMailbox.mailboxId,
817
+ message.uid,
818
+ ),
819
+ );
754
820
  };
755
821
 
756
822
  /**
@@ -839,14 +905,13 @@ export class MessageMoveService {
839
905
  // the copy at uid=0 with an undelivered event; upsert no-ops on the row
840
906
  // and lets the event be re-enqueued below, keeping the operation
841
907
  // idempotent and the copy re-drivable (issue #75).
842
- await this.messageService.upsert({
908
+ const copyRow = await this.messageService.upsertWithStatus({
843
909
  messageId: newMessageId,
844
910
  mailboxId: destinationMailboxId,
845
911
  uid: 0, // Will be updated by worker after IMAP COPY
846
912
  sequenceNumber: 0, // Will be updated by worker
847
913
  rfc822Size: sourceMessage.rfc822Size,
848
914
  internalDate: sourceMessage.internalDate,
849
- messageIdHeader: sourceMessage.messageIdHeader,
850
915
  envelopeId: sourceMessage.envelopeId, // Share envelope with source
851
916
  rootBodyPartId: sourceMessage.rootBodyPartId, // Share body parts with source
852
917
  status: MessageStatus.moving,
@@ -871,7 +936,7 @@ export class MessageMoveService {
871
936
  messageId,
872
937
  );
873
938
 
874
- await this.threadMessageService.create({
939
+ const copyListing = await this.threadMessageService.create({
875
940
  accountConfigId: sourceThreadMessage.accountConfigId,
876
941
  threadId: sourceThreadMessage.threadId,
877
942
  messageId: newMessageId,
@@ -923,7 +988,20 @@ export class MessageMoveService {
923
988
  uid: sourceMessage.uid,
924
989
  };
925
990
 
926
- await this.enqueueEvent(event);
991
+ await this.enqueueEvent(event, async () => {
992
+ await this.threadMessageService.deleteMany([
993
+ {
994
+ accountConfigId: copyListing.accountConfigId,
995
+ threadMessageId: copyListing.threadMessageId,
996
+ },
997
+ ]);
998
+ if (!copyRow.created) return;
999
+ await this.messageService.deleteIfPlacement(newMessageId, {
1000
+ status: MessageStatus.moving,
1001
+ mailboxId: destinationMailboxId,
1002
+ uid: 0,
1003
+ });
1004
+ });
927
1005
 
928
1006
  return newMessageId;
929
1007
  };
@@ -1053,6 +1131,7 @@ export class MessageMoveService {
1053
1131
  // another lane won: the row is left alone and not counted, rather than
1054
1132
  // marked `deleting` for an expunge that would bind somebody else's uid.
1055
1133
  const messages: MessageItem[] = [];
1134
+ const undo: Array<() => Promise<void>> = [];
1056
1135
  for (const message of candidates) {
1057
1136
  const marked = await this.messageService.transitionPlacement(
1058
1137
  message.messageId,
@@ -1074,11 +1153,29 @@ export class MessageMoveService {
1074
1153
  continue;
1075
1154
  }
1076
1155
  messages.push(marked);
1077
- await this.updateThreadMessageDeleted(
1156
+ const wasDeleted = await this.updateThreadMessageDeleted(
1078
1157
  accountConfigId,
1079
1158
  message.messageId,
1080
1159
  true,
1081
1160
  );
1161
+ undo.push(async () => {
1162
+ const restored = await this.messageService.transitionPlacement(
1163
+ message.messageId,
1164
+ {
1165
+ status: MessageStatus.deleting,
1166
+ mailboxId: message.mailboxId,
1167
+ uid: message.uid,
1168
+ },
1169
+ { status: message.status, syncStatus: message.syncStatus },
1170
+ );
1171
+ if (restored && !wasDeleted) {
1172
+ await this.updateThreadMessageDeleted(
1173
+ accountConfigId,
1174
+ message.messageId,
1175
+ false,
1176
+ );
1177
+ }
1178
+ });
1082
1179
  }
1083
1180
 
1084
1181
  this.log.info(
@@ -1103,7 +1200,9 @@ export class MessageMoveService {
1103
1200
  trashUidValidity,
1104
1201
  };
1105
1202
 
1106
- await this.enqueueEvent(event);
1203
+ await this.enqueueEvent(event, async () => {
1204
+ for (const restore of undo) await restore();
1205
+ });
1107
1206
 
1108
1207
  return { deletedCount: messages.length };
1109
1208
  };
@@ -1250,7 +1349,7 @@ export class MessageMoveService {
1250
1349
  accountConfigId: string,
1251
1350
  messageId: string,
1252
1351
  isDeleted: boolean,
1253
- ): Promise<void> => {
1352
+ ): Promise<boolean> => {
1254
1353
  const threadMessage = await this.threadMessageService.getByMessageId(
1255
1354
  accountConfigId,
1256
1355
  messageId,
@@ -1281,6 +1380,7 @@ export class MessageMoveService {
1281
1380
  },
1282
1381
  "Updated ThreadMessage.isDeleted",
1283
1382
  );
1383
+ return threadMessage.isDeleted;
1284
1384
  };
1285
1385
 
1286
1386
  /**
@@ -1292,18 +1392,21 @@ export class MessageMoveService {
1292
1392
  */
1293
1393
  private enqueueEvent = async (
1294
1394
  event: MessageMoveQueueEvent,
1395
+ handBack: () => Promise<void>,
1295
1396
  ): Promise<void> => {
1296
1397
  const useFifo = this.queueUrl.endsWith(".fifo");
1297
- await this.sqs.send(
1298
- new SendMessageCommand({
1299
- QueueUrl: this.queueUrl,
1300
- MessageBody: JSON.stringify(event),
1301
- ...(useFifo && {
1302
- MessageGroupId: event.accountId,
1303
- MessageDeduplicationId: event.eventId,
1398
+ await this.sqs
1399
+ .send(
1400
+ new SendMessageCommand({
1401
+ QueueUrl: this.queueUrl,
1402
+ MessageBody: JSON.stringify(event),
1403
+ ...(useFifo && {
1404
+ MessageGroupId: event.accountId,
1405
+ MessageDeduplicationId: event.eventId,
1406
+ }),
1304
1407
  }),
1305
- }),
1306
- );
1408
+ )
1409
+ .catch((fault: unknown) => handBackThenThrow(fault, [handBack]));
1307
1410
 
1308
1411
  this.log.info(
1309
1412
  { eventId: event.eventId, type: event.type },
@@ -1319,30 +1422,97 @@ export class MessageMoveService {
1319
1422
  * reject it. We detect FIFO queues by the `.fifo` suffix.
1320
1423
  */
1321
1424
  private enqueueEventsBatch = async (
1322
- events: MessageMoveQueueEvent[],
1425
+ events: UndoableEvent<MessageDeleteEvent>[],
1323
1426
  ): Promise<void> => {
1324
- if (events.length === 0) return;
1427
+ const enqueued = await this.sendEventsBatch(events);
1428
+ if (enqueued.outcome === "sent") return;
1429
+ await handBackThenThrow(
1430
+ enqueued.fault,
1431
+ enqueued.unsent.map(({ handBack }) => handBack),
1432
+ );
1433
+ };
1325
1434
 
1435
+ // A batch send answers 200 with per-entry refusals in `Failed`.
1436
+ private sendEventsBatch = async (
1437
+ events: UndoableEvent<MessageDeleteEvent>[],
1438
+ ): Promise<BatchEnqueueOutcome> => {
1326
1439
  const SQS_BATCH_SIZE = 10;
1327
1440
  const useFifo = this.queueUrl.endsWith(".fifo");
1328
1441
 
1329
1442
  for (let i = 0; i < events.length; i += SQS_BATCH_SIZE) {
1330
1443
  const batch = events.slice(i, i + SQS_BATCH_SIZE);
1331
- await this.sqs.send(
1332
- new SendMessageBatchCommand({
1333
- QueueUrl: this.queueUrl,
1334
- Entries: batch.map((event, idx) => ({
1335
- Id: `${i + idx}`,
1336
- MessageBody: JSON.stringify(event),
1337
- ...(useFifo && {
1338
- MessageGroupId: event.accountId,
1339
- MessageDeduplicationId: event.eventId,
1340
- }),
1341
- })),
1342
- }),
1343
- );
1444
+ const sent = await this.sqs
1445
+ .send(
1446
+ new SendMessageBatchCommand({
1447
+ QueueUrl: this.queueUrl,
1448
+ Entries: batch.map(({ event }, idx) => ({
1449
+ Id: `${i + idx}`,
1450
+ MessageBody: JSON.stringify(event),
1451
+ ...(useFifo && {
1452
+ MessageGroupId: event.accountId,
1453
+ MessageDeduplicationId: event.eventId,
1454
+ }),
1455
+ })),
1456
+ }),
1457
+ )
1458
+ .then(
1459
+ (result) => ({
1460
+ refusedIds: new Set((result.Failed ?? []).map((entry) => entry.Id)),
1461
+ }),
1462
+ (fault: unknown) => ({ fault }),
1463
+ );
1464
+ if ("fault" in sent) {
1465
+ return {
1466
+ outcome: "unsent",
1467
+ unsent: events.slice(i),
1468
+ fault: sent.fault,
1469
+ };
1470
+ }
1471
+ if (sent.refusedIds.size > 0) {
1472
+ const refused = batch.filter((_, idx) =>
1473
+ sent.refusedIds.has(`${i + idx}`),
1474
+ );
1475
+ return {
1476
+ outcome: "unsent",
1477
+ unsent: [...refused, ...events.slice(i + SQS_BATCH_SIZE)],
1478
+ fault: new Error(
1479
+ `The queue refused ${refused.length} of ${events.length} delete event(s)`,
1480
+ ),
1481
+ };
1482
+ }
1483
+ }
1484
+
1485
+ if (events.length > 0) {
1486
+ this.log.info({ count: events.length }, "Enqueued message events batch");
1344
1487
  }
1488
+ return { outcome: "sent" };
1489
+ };
1345
1490
 
1346
- this.log.info({ count: events.length }, "Enqueued message events batch");
1491
+ private handBackUnsentMove = async (
1492
+ accountConfigId: string,
1493
+ messageId: string,
1494
+ sourceMailboxId: string,
1495
+ uid: number,
1496
+ ): Promise<void> => {
1497
+ await restoreSourcePlacement(
1498
+ {
1499
+ messageService: this.messageService,
1500
+ threadMessageService: this.threadMessageService,
1501
+ },
1502
+ {
1503
+ accountConfigId,
1504
+ messageId,
1505
+ sourceMailboxId,
1506
+ uid,
1507
+ syncStatus: MessageSyncStatus.synced,
1508
+ abandonedMutation: MessageMutation.none,
1509
+ },
1510
+ );
1511
+ await this.addressService.reconcileJunkOnlyForMessage(
1512
+ messageId,
1513
+ await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
1514
+ accountConfigId,
1515
+ ),
1516
+ );
1347
1517
  };
1348
1518
  }
@@ -0,0 +1,87 @@
1
+ import { isStorageNotFoundError } from "@remit/storage-service";
2
+
3
+ /**
4
+ * The slice of `BodySyncService` the read path needs to lazily materialize
5
+ * deferred per-part objects.
6
+ */
7
+ export interface BodyPartMaterializer {
8
+ ensureBodyPartsStored(
9
+ accountConfigId: string,
10
+ accountId: string,
11
+ messageId: string,
12
+ bodyStorageKey: string,
13
+ ): Promise<unknown>;
14
+ }
15
+
16
+ export interface MaterializeLogger {
17
+ warn(obj: Record<string, unknown>, msg: string): void;
18
+ error(obj: Record<string, unknown>, msg: string): void;
19
+ }
20
+
21
+ /**
22
+ * Re-arms the body-sync cue for one message (the read-path slice of
23
+ * `BodySyncQueueService`).
24
+ */
25
+ export interface BodySyncCue {
26
+ requestBodySync(input: {
27
+ accountId: string;
28
+ mailboxId: string;
29
+ messageId: string;
30
+ uid?: number;
31
+ }): Promise<void>;
32
+ }
33
+
34
+ export interface MaterializeResult {
35
+ /** `false` when the body object was missing — the caller must not serve the body. */
36
+ ready: boolean;
37
+ }
38
+
39
+ /**
40
+ * Materialize the deferred per-part storage objects for a message before any
41
+ * part is read. Distinguishes the two failure modes the body contract cares
42
+ * about:
43
+ *
44
+ * - Missing storage object (never synced or lost) — re-arm the SYNC_MESSAGE_BODY
45
+ * cue so the worker (re)stores the body, and report `ready: false`. Not a
46
+ * 500: a retry succeeds once the worker catches up.
47
+ * - Any other error — rethrow so the request 500s and the bug is observable.
48
+ * Never swallowed, never "best-effort".
49
+ *
50
+ * No-op (`ready: true`) when the body isn't stored yet (no `bodyStorageKey`) or
51
+ * when the parts were written eagerly.
52
+ */
53
+ export const materializeBodyParts = async (
54
+ materializer: BodyPartMaterializer,
55
+ args: {
56
+ accountConfigId: string;
57
+ accountId: string;
58
+ mailboxId: string;
59
+ messageId: string;
60
+ uid?: number;
61
+ bodyStorageKey: string | undefined;
62
+ cue: BodySyncCue | undefined;
63
+ logger: MaterializeLogger;
64
+ },
65
+ ): Promise<MaterializeResult> => {
66
+ const { accountConfigId, accountId, mailboxId, messageId, uid } = args;
67
+ if (!args.bodyStorageKey) return { ready: true };
68
+
69
+ try {
70
+ await materializer.ensureBodyPartsStored(
71
+ accountConfigId,
72
+ accountId,
73
+ messageId,
74
+ args.bodyStorageKey,
75
+ );
76
+ return { ready: true };
77
+ } catch (error: unknown) {
78
+ if (!isStorageNotFoundError(error)) throw error;
79
+
80
+ args.logger.warn(
81
+ { messageId, accountId, mailboxId },
82
+ "Body object missing on read; re-arming body-sync cue and signalling retry",
83
+ );
84
+ await args.cue?.requestBodySync({ accountId, mailboxId, messageId, uid });
85
+ return { ready: false };
86
+ }
87
+ };
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type {
4
+ AccountItem,
5
+ IThreadMessageRepository,
6
+ MailboxItem,
7
+ } from "@remit/data-ports";
8
+ import { MessageSyncService } from "./message-sync.js";
9
+ import { SyncWarningService } from "./sync-warning.js";
10
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
11
+ import {
12
+ openStore,
13
+ type Store,
14
+ seedAccount,
15
+ seedMailbox,
16
+ silentLog,
17
+ } from "./test-helpers/test-store.js";
18
+
19
+ let store: Store;
20
+ let imap: FakeImapServer;
21
+ let account: AccountItem;
22
+ let inbox: MailboxItem;
23
+
24
+ beforeEach(async () => {
25
+ store = await openStore();
26
+ imap = new FakeImapServer();
27
+ account = await seedAccount(store);
28
+ inbox = await seedMailbox(store, account, "INBOX");
29
+ imap.addFolder("INBOX");
30
+ });
31
+
32
+ afterEach(() => store.close());
33
+
34
+ /** The thread store, refusing its first create: the last write of a save. */
35
+ const failingFirstCreate = (
36
+ inner: IThreadMessageRepository,
37
+ ): IThreadMessageRepository => {
38
+ let refused = false;
39
+ return Object.assign(Object.create(inner), {
40
+ create: (...args: Parameters<IThreadMessageRepository["create"]>) => {
41
+ if (!refused) {
42
+ refused = true;
43
+ return Promise.reject(new Error("the thread write failed"));
44
+ }
45
+ return inner.create(...args);
46
+ },
47
+ });
48
+ };
49
+
50
+ const syncOnce = (threadMessage: IThreadMessageRepository) => {
51
+ const connection = imap.connection();
52
+ return new MessageSyncService(
53
+ { getConnection: () => connection, close: async () => {} },
54
+ store.mailbox,
55
+ store.mailboxSpecialUse,
56
+ store.message,
57
+ store.envelope,
58
+ store.address,
59
+ threadMessage,
60
+ new SyncWarningService(store.syncWarning),
61
+ silentLog,
62
+ ).syncMessages(inbox.mailboxId, account.accountId, account.accountConfigId);
63
+ };
64
+
65
+ describe("a message save that fails at its last write", () => {
66
+ it("is completed by the next round, with one row of each", async () => {
67
+ imap.deliver(
68
+ "INBOX",
69
+ eml({ Subject: "Halfway", "Message-ID": "<halfway@example.test>" }),
70
+ );
71
+ const threadMessage = failingFirstCreate(store.threadMessage);
72
+
73
+ await syncOnce(threadMessage);
74
+ await syncOnce(threadMessage);
75
+
76
+ const messages = await store.message.listAllByMailbox(inbox.mailboxId);
77
+ assert.equal(messages.length, 1);
78
+ const [message] = messages;
79
+ assert.ok(message);
80
+ assert.equal(
81
+ (
82
+ await store.threadMessage.findAllByMessageId(
83
+ account.accountConfigId,
84
+ message.messageId,
85
+ )
86
+ ).length,
87
+ 1,
88
+ );
89
+ });
90
+ });
@@ -6,12 +6,14 @@ import type {
6
6
  IEnvelopeRepository,
7
7
  IMailboxRepository,
8
8
  IMessageRepository,
9
+ ISyncWarningRepository,
9
10
  IThreadMessageRepository,
10
11
  ThreadMessageItem,
11
12
  } from "@remit/data-ports";
12
13
  import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
13
14
  import type { ManagedConnectionFactory } from "./connection-factory.js";
14
15
  import { MessageSyncService } from "./message-sync.js";
16
+ import { SyncWarningService } from "./sync-warning.js";
15
17
  import { noFolderRoles } from "./test-helpers/folder-roles.js";
16
18
  import type { ImapEnvelope } from "./types.js";
17
19
 
@@ -46,6 +48,8 @@ const captureCreate = (): {
46
48
  } => {
47
49
  const inputs: CreateThreadMessageInput[] = [];
48
50
  const repo = {
51
+ findByMessageId: async () => null,
52
+ rekeyThread: async () => ({ moved: 0, dropped: 0 }),
49
53
  create: async (input: CreateThreadMessageInput) => {
50
54
  inputs.push(input);
51
55
  return input as unknown as ThreadMessageItem;
@@ -65,6 +69,7 @@ type CreateThreadForMessage = (
65
69
  threadMessageService: IThreadMessageRepository,
66
70
  messageId: string,
67
71
  mailboxId: string,
72
+ accountId: string,
68
73
  accountConfigId: string,
69
74
  uid: number,
70
75
  internalDate: number,
@@ -84,9 +89,12 @@ const createThreadWithFlags = async (
84
89
  stub<IMailboxRepository>(),
85
90
  noFolderRoles,
86
91
  stub<IMessageRepository>(),
87
- stub<IEnvelopeRepository>(),
92
+ {
93
+ listQueuedStructureRefetches: async () => [],
94
+ } as unknown as IEnvelopeRepository,
88
95
  stub<IAddressRepository>(),
89
96
  stub<IThreadMessageRepository>(),
97
+ new SyncWarningService(stub<ISyncWarningRepository>()),
90
98
  );
91
99
  const { repo, inputs } = captureCreate();
92
100
  const now = Date.now();
@@ -97,6 +105,7 @@ const createThreadWithFlags = async (
97
105
  repo,
98
106
  MESSAGE_ID,
99
107
  MAILBOX_ID,
108
+ "acct-1",
100
109
  ACCOUNT_CONFIG_ID,
101
110
  42,
102
111
  now,