@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
package/src/body-sync.ts CHANGED
@@ -5,9 +5,10 @@ import {
5
5
  recordCalendarSuggestion,
6
6
  } from "@remit/calendar-service";
7
7
  import {
8
+ type BodyPartItem,
8
9
  type IAddressRepository,
10
+ type ICalendarStore,
9
11
  type ICalendarSuggestionRepository,
10
- type ICalendarUnitOfWork,
11
12
  type IEnvelopeRepository,
12
13
  type IFilterRepository,
13
14
  type IMailboxSpecialUseRepository,
@@ -18,8 +19,13 @@ import {
18
19
  type ThreadMessageItem,
19
20
  type UpdateMessageInput,
20
21
  } from "@remit/data-ports";
21
- import { NotFoundError } from "@remit/data-ports/errors";
22
+ import {
23
+ MessageGoneFromServerError,
24
+ MessagePlacementUnsettledError,
25
+ NotFoundError,
26
+ } from "@remit/data-ports/errors";
22
27
  import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
28
+ import { isEncapsulatedMessage } from "@remit/data-ports/message-parts";
23
29
  import { isBulkSender } from "@remit/data-ports/wellknown";
24
30
  import {
25
31
  FilterState,
@@ -28,6 +34,7 @@ import {
28
34
  PlacementConfidence,
29
35
  QuarantineFailureStage,
30
36
  SenderTrust,
37
+ SyncWarningStage,
31
38
  } from "@remit/domain-enums";
32
39
  import {
33
40
  isStorageNotFoundError,
@@ -37,12 +44,16 @@ import {
37
44
  import { type ParsedMail, simpleParser } from "mailparser";
38
45
  import pMap from "p-map";
39
46
  import { BodyParseError, parseMessageBody } from "./body-parse.js";
40
- import { mapBodyPartsToContent } from "./body-part-mapper.js";
47
+ import {
48
+ mapMessagePartsToContent,
49
+ parsePartSource,
50
+ } from "./body-part-mapper.js";
41
51
  import { calendarParts } from "./calendar-parts.js";
42
52
  import {
43
53
  classifyParsedMessage,
44
54
  extractPrimaryFromEmail,
45
55
  } from "./classify-message.js";
56
+
46
57
  import { extractListId } from "./filters/list-id.js";
47
58
  import type { FilterMessage } from "./filters/match.js";
48
59
  import {
@@ -62,9 +73,12 @@ import {
62
73
  denormalizeMessageCategory,
63
74
  hasDecidedCategory,
64
75
  } from "./message-category.js";
76
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
65
77
  import type { PlacementMoveService } from "./placement-move.js";
78
+ import { isPlacementUnsettled, uidMailboxIdOf } from "./placement-settled.js";
66
79
  import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
67
80
  import { extractSnippetFromEmail } from "./snippet.js";
81
+ import type { SyncWarningService } from "./sync-warning.js";
68
82
  import { type IImapConnection, MailConnectionError } from "./types.js";
69
83
 
70
84
  const BODY_PART_STORE_CONCURRENCY = 4;
@@ -112,12 +126,20 @@ export const isBodyPartDeferralEnabled = (): boolean => {
112
126
  * single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
113
127
  * and the per-leaf HEADs on every warm re-open.
114
128
  *
115
- * Deliberately an S3-only marker (no Message-row flag): a DDB write would
116
- * re-fire the redundant vector upsert that #607 removed. A leading dot keeps it
129
+ * Deliberately an S3-only marker (no Message-row flag). A leading dot keeps it
117
130
  * out of any real IMAP section path (those are dot-separated digits).
118
131
  */
119
132
  const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
120
133
 
134
+ /**
135
+ * The sentinel for a message carrying a forwarded message. Those parts were
136
+ * once stored under a layout in which the forwarded message's root overwrote
137
+ * its own row, so a `.materialized` written then vouches for objects at the
138
+ * wrong paths. Its own name makes every such message materialize once more,
139
+ * overwriting what is there; no other message is touched.
140
+ */
141
+ const MATERIALIZED_SENTINEL_V2_PART_PATH = ".materialized.2";
142
+
121
143
  /**
122
144
  * A mid-stream socket drop during the ranged body fetch surfaces as a typed
123
145
  * `MailConnectionError` (the connection layer classifies imapflow's
@@ -134,7 +156,7 @@ const isConnectionDrop = (error: unknown): boolean => {
134
156
 
135
157
  /**
136
158
  * Project a parsed message onto the fields a filter matches against (RFC 034) —
137
- * the literal-clause targets plus the text a semantic anchor embeds. Kept
159
+ * the literal-clause targets. Kept
138
160
  * separate from `ParsedMail` so {@link FilterPipeline} stays parser-agnostic.
139
161
  */
140
162
  const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
@@ -241,10 +263,15 @@ export interface StoreBodyPartContentsResult {
241
263
  export type ConnectionGetter = () => Promise<IImapConnection>;
242
264
 
243
265
  export interface BodySyncFolder {
266
+ mailboxId: string;
244
267
  fullPath: string;
245
268
  placement: FolderPlacement;
246
269
  }
247
270
 
271
+ export interface BodySyncRoundFolder extends BodySyncFolder {
272
+ uidValidity: number;
273
+ }
274
+
248
275
  export interface PlacementConfig {
249
276
  mailboxSpecialUseService: IMailboxSpecialUseRepository;
250
277
  /**
@@ -289,7 +316,7 @@ export interface UnsubscribeConfig {
289
316
  /**
290
317
  * What body sync needs to offer a mail's invitation as a card (issue #1033).
291
318
  *
292
- * The unit of work is here to provision the account's default collection on
319
+ * The calendar store is here to provision the account's default collection on
293
320
  * first use, through the one function every other caller provisions with. That
294
321
  * is not bookkeeping: an invitation whose DTSTART names no zone is RFC 5545
295
322
  * floating time, and reading it anywhere but where the user's calendar lives
@@ -301,7 +328,7 @@ export interface UnsubscribeConfig {
301
328
  */
302
329
  export interface CalendarSuggestionConfig {
303
330
  calendarSuggestionService: ICalendarSuggestionRepository;
304
- calendarUnitOfWork: ICalendarUnitOfWork;
331
+ calendarStore: ICalendarStore;
305
332
  filterService: Pick<IFilterRepository, "listByAccountAndState">;
306
333
  }
307
334
 
@@ -315,6 +342,7 @@ export class BodySyncService {
315
342
  private threadMessageService: IThreadMessageRepository,
316
343
  private addressService: IAddressRepository,
317
344
  private envelopeService: IEnvelopeRepository,
345
+ private readonly syncWarningService: SyncWarningService,
318
346
  logger?: BodySyncLogger,
319
347
  private readonly placementConfig?: PlacementConfig,
320
348
  private readonly filterConfig?: FilterConfig,
@@ -328,34 +356,16 @@ export class BodySyncService {
328
356
  : undefined;
329
357
  }
330
358
 
331
- /**
332
- * Sync message bodies for a batch of messages.
333
- *
334
- * Fail-fast on connection errors: if the IMAP connection is lost,
335
- * immediately stop processing and return all remaining messages as failed
336
- * so they can be requeued for later retry.
337
- *
338
- * @param messageIds - The message IDs to sync bodies for
339
- * @param accountId - The account ID (for storage path)
340
- * @param accountConfigId - The account config ID (for thread updates)
341
- * @param folder - The IMAP mailbox path and its folder placement
342
- * @param getConnection - Function to get a (lazy) IMAP connection
343
- * @param force - Bypass the "already stored" skip guard and re-fetch every
344
- * message in this batch even though `bodyStorageKey` is already set. Set
345
- * only by the read-miss re-arm cue (a `/content` read found the storage
346
- * object missing despite the DB row saying otherwise); bulk metadata-sync
347
- * never sets this, so it keeps skipping on `bodyStorageKey` with no
348
- * existence check.
349
- */
350
359
  async syncBodies(
351
360
  messageIds: string[],
352
361
  accountId: string,
353
362
  accountConfigId: string,
354
- folder: BodySyncFolder,
363
+ folder: BodySyncRoundFolder,
355
364
  getConnection: ConnectionGetter,
356
365
  force = false,
357
366
  ): Promise<SyncBodiesResult> {
358
367
  const syncedMessageIds: string[] = [];
368
+ const syncedUids: number[] = [];
359
369
  let skippedCount = 0;
360
370
  const location = {
361
371
  accountId,
@@ -385,8 +395,22 @@ export class BodySyncService {
385
395
  const quarantined =
386
396
  await this.quarantineConfig?.quarantineService.load(accountConfigId);
387
397
 
398
+ const rows = new Map(
399
+ (await this.messageService.get(messageIds)).map((row) => [
400
+ row.messageId,
401
+ row,
402
+ ]),
403
+ );
404
+
388
405
  for (const messageId of messageIds) {
389
- const message = await this.messageService.get(messageId);
406
+ const message = rows.get(messageId);
407
+
408
+ // A row deleted since the batch was cut, or moved out of this folder
409
+ // for good, has no uid here; its new uid would fetch another message.
410
+ if (!message || uidMailboxIdOf(message) !== folder.mailboxId) {
411
+ skippedCount++;
412
+ continue;
413
+ }
390
414
 
391
415
  // A quarantined message has no stored body, so nothing else here would
392
416
  // stop it being fetched and re-parsed on every redelivery, forever.
@@ -454,43 +478,31 @@ export class BodySyncService {
454
478
  reStoredMessageIds.has(messageId),
455
479
  );
456
480
  } catch (error) {
457
- // A per-message store failure (e.g. the parsed-body S3 write) is a
458
- // real fault, but it must not silently mark the message synced —
459
- // that would leave parsedBody null and the message unindexed. Leave
460
- // the UID in `pending` so it lands in failedMessageIds and the SQS
461
- // batch requeues just this message. A dropped connection is handled
462
- // by the outer catch (fail-fast on the whole remaining batch).
463
- if (isConnectionDrop(error)) throw error;
464
-
465
481
  // The one error in this frame that is the message's own fault. It
466
- // reaches here from the parse call and nowhere else, so the S3, DDB
467
- // and SQS work this same frame wraps cannot be mistaken for it and
468
- // keeps requeueing below. The row is durable before the UID leaves
469
- // `pending`, so the message is never let go of unrecorded.
470
- if (
471
- error instanceof BodyParseError &&
472
- (await this.quarantineBodyParse(messageId, location, error))
473
- ) {
482
+ // reaches here from the parse call and nowhere else, so the S3, DB
483
+ // and queue work this same frame wraps cannot be mistaken for it.
484
+ // The row is durable before the UID leaves `pending`, so the
485
+ // message is never let go of unrecorded.
486
+ if (error instanceof BodyParseError && this.quarantineConfig) {
487
+ await this.recordQuarantine(
488
+ this.quarantineConfig,
489
+ messageId,
490
+ location,
491
+ error,
492
+ );
474
493
  pending.delete(uid);
475
494
  skippedCount++;
476
495
  source.resume();
477
496
  continue;
478
497
  }
479
498
 
480
- this.log.error?.(
481
- {
482
- messageId,
483
- errorName: (error as { name?: string }).name,
484
- error: (error as Error).message,
485
- },
486
- "Body store failed for message; leaving for requeue",
487
- );
488
- // Drain the stream so the connection stays usable for the next UID.
489
- source.resume();
490
- continue;
499
+ // Anything else is our own storage, database or queue failing. It
500
+ // aborts the batch, which redelivers whole; what stored skips.
501
+ throw error;
491
502
  }
492
503
  pending.delete(uid);
493
504
  syncedMessageIds.push(messageId);
505
+ syncedUids.push(uid);
494
506
  }
495
507
  } catch (error) {
496
508
  this.log.error?.(
@@ -514,6 +526,21 @@ export class BodySyncService {
514
526
  // server silently omitted) — re-enqueue it.
515
527
  const failedMessageIds = [...pending.values()];
516
528
 
529
+ await this.syncWarningService.settle(
530
+ {
531
+ accountConfigId,
532
+ accountId,
533
+ mailboxId: folder.mailboxId,
534
+ uidValidity: folder.uidValidity,
535
+ },
536
+ SyncWarningStage.Body,
537
+ {
538
+ failedUids: [...pending.keys()],
539
+ syncedUids,
540
+ serverUids: { kind: "Unlisted" },
541
+ },
542
+ );
543
+
517
544
  this.log.info(
518
545
  {
519
546
  synced: syncedMessageIds.length,
@@ -529,53 +556,10 @@ export class BodySyncService {
529
556
  }
530
557
 
531
558
  /**
532
- * Set a message aside because its body will not parse (issue #72).
533
- *
534
- * Returns false when no record was written — either because this service was
535
- * built without a quarantine writer, or because writing it failed. The
536
- * caller then falls back to requeueing rather than dropping a message no
537
- * record survives. The failure the row carries is the parser's, and only the
538
- * parser's — see {@link BodyParseError}.
539
- *
540
- * Reading and writing the record is storage and database work, so its own
541
- * failure is contained per message the same way the save it replaces is: it
542
- * must not take down a batch that has other messages to fetch, and in the
543
- * stream loop a throw would also skip `source.resume()` and leave the
544
- * literal undrained on an otherwise usable connection.
545
- *
546
- * The fingerprint comes from the BodyPart rows metadata sync already wrote:
547
- * the body path streams raw bytes and has no FETCH result to read, and
548
- * re-reading headers the parser has just refused would be reading the very
549
- * thing that is broken.
559
+ * Set a message aside because its body will not parse (issue #72). The
560
+ * fingerprint comes from the BodyPart rows metadata sync already wrote: the
561
+ * body path streams raw bytes and has no FETCH result to read.
550
562
  */
551
- private async quarantineBodyParse(
552
- messageId: string,
553
- location: {
554
- accountId: string;
555
- accountConfigId: string;
556
- mailboxPath: string;
557
- },
558
- error: BodyParseError,
559
- ): Promise<boolean> {
560
- const config = this.quarantineConfig;
561
- if (!config) return false;
562
-
563
- return this.recordQuarantine(config, messageId, location, error).then(
564
- () => true,
565
- (writeError: unknown) => {
566
- this.log.error?.(
567
- {
568
- messageId,
569
- errorName: (writeError as { name?: string }).name,
570
- error: inspect(writeError),
571
- },
572
- "Could not record quarantine; leaving the message for requeue",
573
- );
574
- return false;
575
- },
576
- );
577
- }
578
-
579
563
  private async recordQuarantine(
580
564
  config: QuarantineConfig,
581
565
  messageId: string,
@@ -588,6 +572,10 @@ export class BodySyncService {
588
572
  ): Promise<void> {
589
573
  const message = await this.messageService.get(messageId);
590
574
  const messageData = await this.envelopeService.getMessageData(messageId);
575
+ const threadMessage = await this.threadMessageService.findByMessageId(
576
+ location.accountConfigId,
577
+ messageId,
578
+ );
591
579
 
592
580
  await config.quarantineService.record(
593
581
  {
@@ -604,7 +592,11 @@ export class BodySyncService {
604
592
  code: error.failureCode,
605
593
  message: error.message,
606
594
  },
607
- shapeFromMessageData(message, messageData),
595
+ shapeFromMessageData(
596
+ message,
597
+ messageData,
598
+ threadMessage?.messageIdHeader,
599
+ ),
608
600
  );
609
601
  }
610
602
 
@@ -761,7 +753,8 @@ export class BodySyncService {
761
753
  accountConfigId,
762
754
  accountId,
763
755
  messageId,
764
- parsed,
756
+ await this.envelopeService.listBodyParts(messageId),
757
+ body,
765
758
  );
766
759
  }
767
760
  await this.messageService.update(messageId, {
@@ -806,6 +799,7 @@ export class BodySyncService {
806
799
  // The DECISION is computed here; the applies below are unswallowed.
807
800
  const filterDecision = await this.evaluateFilters(
808
801
  accountConfigId,
802
+ accountId,
809
803
  messageId,
810
804
  parsed,
811
805
  );
@@ -813,10 +807,10 @@ export class BodySyncService {
813
807
  // Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
814
808
  // guard in syncBodies treats a stored bodyStorageKey as "fully synced",
815
809
  // so if we wrote it first and the parsed-cache write then failed, the
816
- // requeued retry would skip the message — leaving parsedBody null and the
817
- // search-index upsert bodyless forever. A parsed-cache failure here throws
818
- // and propagates before bodyStorageKey is written, so the requeued message
819
- // genuinely re-attempts the parsed write and gets indexed.
810
+ // requeued retry would skip the message — leaving parsedBody null forever.
811
+ // A parsed-cache failure here throws and propagates before bodyStorageKey
812
+ // is written, so the requeued message genuinely re-attempts the parsed
813
+ // write.
820
814
  await this.storeParsedBodyCache(
821
815
  accountConfigId,
822
816
  accountId,
@@ -833,7 +827,8 @@ export class BodySyncService {
833
827
  accountConfigId,
834
828
  accountId,
835
829
  messageId,
836
- parsed,
830
+ await this.envelopeService.listBodyParts(messageId),
831
+ body,
837
832
  );
838
833
  }
839
834
 
@@ -854,10 +849,13 @@ export class BodySyncService {
854
849
  // offer an undo back to it (issue #223). Snapshotted into the marker
855
850
  // rather than read from `originalMailboxId` at derive time, which a
856
851
  // later user move would overwrite — the filter's source must survive an
857
- // undo to stay derivable.
858
- const sourceMailboxId = (await this.messageService.get(messageId))
859
- .mailboxId;
860
- await this.filterConfig?.placementMoveService.moveMessage(
852
+ // undo to stay derivable. A redelivery after a failed enqueue finds
853
+ // the move already applied locally, so the source is the folder the
854
+ // uid still names, not the one the row now points at.
855
+ const sourceMailboxId = uidMailboxIdOf(
856
+ await this.messageService.get(messageId),
857
+ );
858
+ const filed = await this.filterConfig?.placementMoveService.moveMessage(
861
859
  accountConfigId,
862
860
  messageId,
863
861
  destinationMailboxId,
@@ -868,7 +866,7 @@ export class BodySyncService {
868
866
  // there matches but was not moved by Remit. `moveMessage` no-ops in that
869
867
  // case; record neither the marker nor `movedByRemit`, so the badge never
870
868
  // claims a move that did not happen and its Undo is never a no-op.
871
- if (sourceMailboxId !== destinationMailboxId) {
869
+ if (sourceMailboxId !== destinationMailboxId && filed !== "refused") {
872
870
  filterMoved = true;
873
871
  filterMove = {
874
872
  filterId: filterDecision.move.filterId,
@@ -928,9 +926,8 @@ export class BodySyncService {
928
926
  const moved = Boolean(resolved.move || filterMoved);
929
927
 
930
928
  // ONE Message UpdateItem per synced message: bodyStorageKey + every
931
- // classification/derived field + the move flag + the audit verdict. Each
932
- // extra Message mutation emits a DDB stream record that fans out to a
933
- // redundant S3-Vectors upsert, so we collapse them into a single write.
929
+ // classification/derived field + the move flag + the audit verdict,
930
+ // collapsed into a single write.
934
931
  // The flag is folded in only when a move WAS applied (by the classifier or
935
932
  // a filter); the verdict is folded in whenever Remit decided to act.
936
933
  // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
@@ -1047,7 +1044,9 @@ export class BodySyncService {
1047
1044
  );
1048
1045
  needsStore = true;
1049
1046
  body = await this.fetchFromImap(
1050
- message.uid,
1047
+ message,
1048
+ accountId,
1049
+ accountConfigId,
1051
1050
  folder.fullPath,
1052
1051
  getConnection,
1053
1052
  );
@@ -1056,7 +1055,9 @@ export class BodySyncService {
1056
1055
  // Fetch from IMAP and store
1057
1056
  needsStore = true;
1058
1057
  body = await this.fetchFromImap(
1059
- message.uid,
1058
+ message,
1059
+ accountId,
1060
+ accountConfigId,
1060
1061
  folder.fullPath,
1061
1062
  getConnection,
1062
1063
  );
@@ -1099,14 +1100,75 @@ export class BodySyncService {
1099
1100
  }
1100
1101
 
1101
1102
  private async fetchFromImap(
1102
- uid: number,
1103
+ message: MessageItem,
1104
+ accountId: string,
1105
+ accountConfigId: string,
1103
1106
  mailboxPath: string,
1104
1107
  getConnection: ConnectionGetter,
1105
1108
  ): Promise<Buffer> {
1109
+ const { uid } = message;
1106
1110
  this.log.debug?.({ uid }, "Fetching body from IMAP");
1107
1111
  const connection = await getConnection();
1108
1112
  await connection.openBox(mailboxPath);
1109
- return connection.fetchMessageBody(uid);
1113
+ const body = await connection.fetchMessageBody(uid);
1114
+ if (body) return body;
1115
+
1116
+ if (!(await isMessageGoneFromOpenMailbox(connection, uid))) {
1117
+ throw new Error(
1118
+ `IMAP FETCH returned no source for UID ${uid} in ${mailboxPath}, which the server still lists`,
1119
+ );
1120
+ }
1121
+
1122
+ return this.reconcileGoneMessage(message, accountId, mailboxPath);
1123
+ }
1124
+
1125
+ /**
1126
+ * The server no longer holds the message at the row's UID. A row with our
1127
+ * own move or delete in flight belongs to that mutation (409). A settled row
1128
+ * is removed with its thread entries, but only while its placement still
1129
+ * matches what was read (imap-mutations R3); losing that predicate is a 409
1130
+ * like any other lost placement transition.
1131
+ */
1132
+ private async reconcileGoneMessage(
1133
+ message: MessageItem,
1134
+ accountId: string,
1135
+ mailboxPath: string,
1136
+ ): Promise<never> {
1137
+ const { messageId, uid } = message;
1138
+ if (isPlacementUnsettled(message)) {
1139
+ throw new MessagePlacementUnsettledError(
1140
+ `Message ${messageId} is not at its UID while a move or delete is in flight`,
1141
+ accountId,
1142
+ messageId,
1143
+ "in_flight",
1144
+ );
1145
+ }
1146
+
1147
+ const reconciled = await this.messageService.deleteIfPlacement(messageId, {
1148
+ status: message.status,
1149
+ mailboxId: message.mailboxId,
1150
+ uid,
1151
+ });
1152
+
1153
+ if (!reconciled) {
1154
+ throw new MessagePlacementUnsettledError(
1155
+ `Message ${messageId} changed placement while its absence from ${mailboxPath} was being reconciled`,
1156
+ accountId,
1157
+ messageId,
1158
+ "in_flight",
1159
+ );
1160
+ }
1161
+
1162
+ this.log.info(
1163
+ {
1164
+ metric: "message_gone_stale_row_reconciled",
1165
+ messageId,
1166
+ uid,
1167
+ mailboxPath,
1168
+ },
1169
+ "Message no longer on the mail server (expunged or moved by another client); stale row reconciled",
1170
+ );
1171
+ throw new MessageGoneFromServerError(messageId);
1110
1172
  }
1111
1173
 
1112
1174
  /**
@@ -1298,7 +1360,7 @@ export class BodySyncService {
1298
1360
  const parts = calendarParts(parsed);
1299
1361
  if (parts.length === 0) return;
1300
1362
 
1301
- const { calendarUnitOfWork, calendarSuggestionService, filterService } =
1363
+ const { calendarStore, calendarSuggestionService, filterService } =
1302
1364
  this.calendarConfig;
1303
1365
 
1304
1366
  await Promise.resolve()
@@ -1328,7 +1390,7 @@ export class BodySyncService {
1328
1390
  // the organizer meant it. Idempotent: the id is derived, so a second
1329
1391
  // first use returns the collection the first one made.
1330
1392
  const collection = await provisionDefaultCalendar(
1331
- calendarUnitOfWork,
1393
+ calendarStore,
1332
1394
  accountConfigId,
1333
1395
  );
1334
1396
  for (const part of parts) {
@@ -1398,12 +1460,14 @@ export class BodySyncService {
1398
1460
  */
1399
1461
  private async evaluateFilters(
1400
1462
  accountConfigId: string,
1463
+ accountId: string,
1401
1464
  messageId: string,
1402
1465
  parsed: ParsedMail,
1403
1466
  ): Promise<FilterDecision> {
1404
1467
  if (!this.filterPipeline) return { labels: [] };
1405
1468
  return this.filterPipeline.evaluate(
1406
1469
  accountConfigId,
1470
+ accountId,
1407
1471
  messageId,
1408
1472
  toFilterMessage(parsed),
1409
1473
  );
@@ -1412,8 +1476,7 @@ export class BodySyncService {
1412
1476
  /**
1413
1477
  * Resolve the placement decision for a message, BEFORE the single Message
1414
1478
  * update — so both the `movedByRemit` flag and the audit verdict join that
1415
- * one UpdateItem instead of a second mutation that would fan out another
1416
- * redundant S3-Vectors upsert. Drives both directions (junk → inbox rescue,
1479
+ * one UpdateItem instead of a second mutation. Drives both directions (junk → inbox rescue,
1417
1480
  * inbox → junk demote) off the pure {@link classifyPlacement} verdict.
1418
1481
  *
1419
1482
  * Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
@@ -1667,10 +1730,10 @@ export class BodySyncService {
1667
1730
  accountConfigId: string,
1668
1731
  accountId: string,
1669
1732
  messageId: string,
1670
- parsed: ParsedMail,
1733
+ bodyParts: readonly BodyPartItem[],
1734
+ body: Buffer,
1671
1735
  options?: { skipExisting?: boolean },
1672
1736
  ): Promise<StoreBodyPartContentsResult> {
1673
- const bodyParts = await this.envelopeService.listBodyParts(messageId);
1674
1737
  if (bodyParts.length === 0) {
1675
1738
  this.log.debug?.(
1676
1739
  { messageId },
@@ -1680,31 +1743,32 @@ export class BodySyncService {
1680
1743
  }
1681
1744
 
1682
1745
  const log = this.log;
1683
- const pairs = mapBodyPartsToContent(bodyParts, parsed, {
1684
- messageId,
1685
- logger:
1686
- log.warn || log.debug
1687
- ? {
1688
- warn: (obj, msg) => {
1689
- log.warn?.(obj, msg);
1690
- },
1691
- debug: (obj, msg) => {
1692
- log.debug?.(obj, msg);
1693
- },
1694
- }
1695
- : undefined,
1696
- });
1746
+ // A parse of its own: classification and the snippet, which search reads,
1747
+ // need an inline forwarded message's text, which `ignoreEmbedded` keeps out.
1748
+ const pairs = await mapMessagePartsToContent(
1749
+ bodyParts,
1750
+ await parsePartSource(body),
1751
+ {
1752
+ messageId,
1753
+ logger:
1754
+ log.warn || log.debug
1755
+ ? {
1756
+ warn: (obj, msg) => {
1757
+ log.warn?.(obj, msg);
1758
+ },
1759
+ debug: (obj, msg) => {
1760
+ log.debug?.(obj, msg);
1761
+ },
1762
+ }
1763
+ : undefined,
1764
+ },
1765
+ );
1697
1766
 
1698
1767
  if (pairs.length === 0) {
1699
1768
  return { stored: 0 };
1700
1769
  }
1701
1770
 
1702
- const bodyPartIdByPath = new Map(
1703
- bodyParts.map((bp) => [bp.partPath, bp.bodyPartId]),
1704
- );
1705
-
1706
1771
  let stored = 0;
1707
- const textContents: { bodyPartId: string; content: string }[] = [];
1708
1772
  await pMap(
1709
1773
  pairs,
1710
1774
  async (entry) => {
@@ -1728,30 +1792,10 @@ export class BodySyncService {
1728
1792
  contentType: entry.contentType,
1729
1793
  });
1730
1794
  stored++;
1731
-
1732
- if (
1733
- entry.contentType.toLowerCase().startsWith("text/") &&
1734
- entry.content.length > 0
1735
- ) {
1736
- const bodyPartId = bodyPartIdByPath.get(entry.partPath);
1737
- if (bodyPartId) {
1738
- textContents.push({
1739
- bodyPartId,
1740
- content: entry.content.toString("utf8"),
1741
- });
1742
- }
1743
- }
1744
1795
  },
1745
1796
  { concurrency: BODY_PART_STORE_CONCURRENCY },
1746
1797
  );
1747
1798
 
1748
- if (textContents.length > 0) {
1749
- await this.envelopeService.upsertBodyPartContents(
1750
- messageId,
1751
- textContents,
1752
- );
1753
- }
1754
-
1755
1799
  this.log.info(
1756
1800
  { messageId, partCount: pairs.length, stored },
1757
1801
  "Body parts stored",
@@ -1787,12 +1831,17 @@ export class BodySyncService {
1787
1831
  messageId: string,
1788
1832
  bodyStorageKey: string,
1789
1833
  ): Promise<StoreBodyPartContentsResult> {
1834
+ const bodyParts = await this.envelopeService.listBodyParts(messageId);
1835
+ const carriesMessage = bodyParts.some(isEncapsulatedMessage);
1836
+ const sentinel = carriesMessage
1837
+ ? MATERIALIZED_SENTINEL_V2_PART_PATH
1838
+ : MATERIALIZED_SENTINEL_PART_PATH;
1790
1839
  if (
1791
1840
  await this.storageService.bodyPartExists(
1792
1841
  accountConfigId,
1793
1842
  accountId,
1794
1843
  messageId,
1795
- MATERIALIZED_SENTINEL_PART_PATH,
1844
+ sentinel,
1796
1845
  )
1797
1846
  ) {
1798
1847
  this.log.debug?.(
@@ -1803,13 +1852,13 @@ export class BodySyncService {
1803
1852
  }
1804
1853
 
1805
1854
  const body = await this.storageService.retrieve(bodyStorageKey);
1806
- const parsed = await simpleParser(body);
1807
1855
  const result = await this.storeBodyPartContents(
1808
1856
  accountConfigId,
1809
1857
  accountId,
1810
1858
  messageId,
1811
- parsed,
1812
- { skipExisting: true },
1859
+ bodyParts,
1860
+ body,
1861
+ { skipExisting: !carriesMessage },
1813
1862
  );
1814
1863
 
1815
1864
  // Reaching here means storeBodyPartContents resolved — `pMap` rejects on
@@ -1819,47 +1868,24 @@ export class BodySyncService {
1819
1868
  accountConfigId,
1820
1869
  accountId,
1821
1870
  messageId,
1822
- partPath: MATERIALIZED_SENTINEL_PART_PATH,
1871
+ partPath: sentinel,
1823
1872
  content: Buffer.alloc(0),
1824
1873
  });
1825
1874
 
1826
1875
  return result;
1827
1876
  }
1828
1877
 
1829
- /**
1830
- * Persist the pre-parsed body cache. A failure here MUST fail the
1831
- * surrounding body-sync: the parsed-body object is what the search-index
1832
- * pipeline reads, so swallowing a write failure leaves the message marked
1833
- * synced while `parsedBody` is null and it never gets indexed — a silent
1834
- * search gap. Propagate so the message lands in failedMessageIds and the
1835
- * SQS batch requeues it.
1836
- */
1837
1878
  private async storeParsedBodyCache(
1838
1879
  accountConfigId: string,
1839
1880
  accountId: string,
1840
1881
  messageId: string,
1841
1882
  parsed: ParsedMail,
1842
1883
  ): Promise<void> {
1843
- const parsedBody = toParsedBody(parsed);
1844
- try {
1845
- await this.storageService.storeParsedBody({
1846
- accountConfigId,
1847
- accountId,
1848
- messageId,
1849
- parsed: parsedBody,
1850
- });
1851
- this.log.debug?.({ messageId }, "Parsed body cache stored");
1852
- } catch (err: unknown) {
1853
- this.log.error?.(
1854
- {
1855
- messageId,
1856
- errorName: (err as { name?: string }).name,
1857
- errorCode: (err as { Code?: string }).Code,
1858
- error: inspect(err),
1859
- },
1860
- "Failed to store parsed body cache; failing sync to requeue",
1861
- );
1862
- throw err;
1863
- }
1884
+ await this.storageService.storeParsedBody({
1885
+ accountConfigId,
1886
+ accountId,
1887
+ messageId,
1888
+ parsed: toParsedBody(parsed),
1889
+ });
1864
1890
  }
1865
1891
  }