@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.
- package/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- 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 {
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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,
|
|
467
|
-
// and
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
932
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
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:
|
|
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
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
}
|