@remit/mailbox-service 0.0.118 → 0.0.119

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.
@@ -32,6 +32,11 @@ export interface BodySyncLogger {
32
32
  warn?(obj: Record<string, unknown>, msg: string): void;
33
33
  error?(obj: Record<string, unknown>, msg: string): void;
34
34
  }
35
+ export interface SyncBodiesOptions {
36
+ force: boolean;
37
+ concurrency: number;
38
+ bytesInFlight: number;
39
+ }
35
40
  export interface SyncBodiesResult {
36
41
  syncedCount: number;
37
42
  syncedMessageIds: string[];
@@ -127,7 +132,7 @@ export declare class BodySyncService {
127
132
  private log;
128
133
  private readonly filterPipeline?;
129
134
  constructor(messageService: IMessageRepository, storageService: StorageService, threadMessageService: IThreadMessageRepository, addressService: IAddressRepository, envelopeService: IEnvelopeRepository, syncWarningService: SyncWarningService, logger?: BodySyncLogger, placementConfig?: PlacementConfig | undefined, filterConfig?: FilterConfig | undefined, quarantineConfig?: QuarantineConfig | undefined, unsubscribeConfig?: UnsubscribeConfig | undefined, calendarConfig?: CalendarSuggestionConfig | undefined);
130
- syncBodies(messageIds: string[], accountId: string, accountConfigId: string, folder: BodySyncRoundFolder, getConnection: ConnectionGetter, force?: boolean): Promise<SyncBodiesResult>;
135
+ syncBodies(messageIds: string[], accountId: string, accountConfigId: string, folder: BodySyncRoundFolder, getConnection: ConnectionGetter, { force, concurrency, bytesInFlight }: SyncBodiesOptions): Promise<SyncBodiesResult>;
131
136
  /**
132
137
  * Set a message aside because its body will not parse (issue #72). The
133
138
  * fingerprint comes from the BodyPart rows metadata sync already wrote: the
@@ -135,22 +140,10 @@ export declare class BodySyncService {
135
140
  */
136
141
  private recordQuarantine;
137
142
  private buildResult;
138
- /**
139
- * Stream one message body straight to storage while teeing the bytes into a
140
- * buffer for the parse-dependent steps (snippet, classification, parsed-body
141
- * cache, per-part objects). The S3 upload never sees a whole-body concat —
142
- * the storage service streams it — but mailparser still needs the full bytes,
143
- * so we collect them in parallel. Later issues move parsing off the hot path.
144
- *
145
- * `isReStore` marks a re-fetch of a message whose row already carried
146
- * `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
147
- * post-store steps then re-materialize the body artifacts without
148
- * re-running any decision (issue #1011).
149
- */
150
- private storeStreamedBody;
143
+ private storeFetchedBody;
151
144
  /**
152
145
  * The steps every synced body must go through regardless of which path
153
- * fetched it first — the sync path ({@link storeStreamedBody}) and the
146
+ * fetched it first — the sync path ({@link storeFetchedBody}) and the
154
147
  * read-path backfill ({@link fetchAndGetBody}) both call this instead of
155
148
  * duplicating (and drifting on) their own subset. Before issue #1271,
156
149
  * `fetchAndGetBody` skipped classification/placement entirely — a message
@@ -257,11 +250,12 @@ export declare class BodySyncService {
257
250
  * move); the caller applies them. A no-op returning no actions when no
258
251
  * {@link FilterConfig} is wired.
259
252
  *
260
- * Isolation lives in {@link FilterPipeline.evaluate}: this read/decision phase
261
- * never fails the body store (the #1246 placement precedent). The applies of
262
- * the returned decision are the caller's and are deliberately unswallowed.
253
+ * A failed filter read never fails the body store (the #1246 placement
254
+ * precedent). The applies of the returned decision are the caller's and are
255
+ * deliberately unswallowed.
263
256
  */
264
257
  private evaluateFilters;
258
+ private batchLookups;
265
259
  /**
266
260
  * Resolve the placement decision for a message, BEFORE the single Message
267
261
  * update — so both the `movedByRemit` flag and the audit verdict join that
package/dist/body-sync.js CHANGED
@@ -1,4 +1,3 @@
1
- import { PassThrough } from "node:stream";
2
1
  import { inspect } from "node:util";
3
2
  import { provisionDefaultCalendar, recordCalendarSuggestion, } from "@remit/calendar-service";
4
3
  import { isSenderMuted, } from "@remit/data-ports";
@@ -8,6 +7,7 @@ import { isEncapsulatedMessage } from "@remit/data-ports/message-parts";
8
7
  import { isBulkSender } from "@remit/data-ports/wellknown";
9
8
  import { FilterState, MessageClassificationState, PlacementAction, PlacementConfidence, QuarantineFailureStage, SenderTrust, SyncWarningStage, } from "@remit/domain-enums";
10
9
  import { isStorageNotFoundError, } from "@remit/storage-service";
10
+ import { Semaphore } from "async-mutex";
11
11
  import { simpleParser } from "mailparser";
12
12
  import pMap from "p-map";
13
13
  import { BodyParseError, parseMessageBody } from "./body-parse.js";
@@ -16,7 +16,7 @@ import { calendarParts } from "./calendar-parts.js";
16
16
  import { classifyParsedMessage, extractPrimaryFromEmail, } from "./classify-message.js";
17
17
  import { extractListId } from "./filters/list-id.js";
18
18
  import { FilterPipeline, } from "./filters/pipeline.js";
19
- import { placementOf, resolveFolderRoleMailboxes } from "./folder-placement.js";
19
+ import { placementOf, resolveFolderRoleMailboxes, } from "./folder-placement.js";
20
20
  import { classifyPlacement, resolveSenderPlacement, } from "./heuristics/classifyPlacement.js";
21
21
  import { denormalizeMessageCategory, hasDecidedCategory, } from "./message-category.js";
22
22
  import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
@@ -137,6 +137,22 @@ export const toParsedBody = (parsed) => ({
137
137
  const noopLogger = {
138
138
  info: () => { },
139
139
  };
140
+ // A failed load is forgotten, so the next message retries it rather than the
141
+ // rest of the batch inheriting the failure.
142
+ const once = (load) => {
143
+ let loaded;
144
+ return () => {
145
+ if (loaded)
146
+ return loaded;
147
+ const attempt = load();
148
+ loaded = attempt;
149
+ attempt.then(undefined, () => {
150
+ if (loaded === attempt)
151
+ loaded = undefined;
152
+ });
153
+ return attempt;
154
+ };
155
+ };
140
156
  export class BodySyncService {
141
157
  messageService;
142
158
  storageService;
@@ -168,9 +184,7 @@ export class BodySyncService {
168
184
  ? new FilterPipeline(filterConfig, this.log)
169
185
  : undefined;
170
186
  }
171
- async syncBodies(messageIds, accountId, accountConfigId, folder, getConnection, force = false) {
172
- const syncedMessageIds = [];
173
- const syncedUids = [];
187
+ async syncBodies(messageIds, accountId, accountConfigId, folder, getConnection, { force, concurrency, bytesInFlight }) {
174
188
  let skippedCount = 0;
175
189
  const location = {
176
190
  accountId,
@@ -234,63 +248,81 @@ export class BodySyncService {
234
248
  pending.set(message.uid, messageId);
235
249
  }
236
250
  if (pending.size === 0) {
237
- return this.buildResult(syncedMessageIds, skippedCount, []);
251
+ return this.buildResult([], skippedCount, []);
238
252
  }
239
253
  const connection = await getConnection();
240
254
  // Single SELECT for the whole batch. openBox is idempotent, so a warm
241
255
  // connection already on this mailbox skips the SELECT entirely.
242
256
  await connection.openBox(folder.fullPath);
243
- let connectionLost = false;
244
- try {
245
- for await (const { uid, source } of connection.fetchMessageBodies([
246
- ...pending.keys(),
247
- ])) {
257
+ const lookups = this.batchLookups(accountConfigId, accountId);
258
+ const log = this.log;
259
+ const stream = { lost: false, fault: null, failed: false };
260
+ const budget = new Semaphore(bytesInFlight);
261
+ const endStream = (error) => {
262
+ log.error?.({ error: error.message }, "Body fetch stream failed");
263
+ // A dropped connection leaves every not-yet-yielded UID in `pending`;
264
+ // they fall through to failedMessageIds and re-enqueue. Any other error
265
+ // is a real fault, raised once the stores already running have finished.
266
+ if (isConnectionDrop(error)) {
267
+ stream.lost = true;
268
+ log.info?.({ remainingCount: pending.size }, "Connection lost, aborting batch");
269
+ return null;
270
+ }
271
+ stream.fault = { error };
272
+ return null;
273
+ };
274
+ // The FETCH stays one command on the one connection, read in order; no
275
+ // row is pulled after a store has failed.
276
+ async function* fetchedBodies() {
277
+ const rows = connection.fetchMessageBodies([...pending.keys()]);
278
+ for (;;) {
279
+ const step = await rows.next().catch(endStream);
280
+ if (!step || step.done)
281
+ return;
282
+ const { uid, source } = step.value;
248
283
  const messageId = pending.get(uid);
249
- if (!messageId) {
250
- // A UID we didn't ask for — drain the stream so the connection
251
- // stays usable, then ignore it.
252
- source.resume();
284
+ if (!messageId)
253
285
  continue;
286
+ if (stream.failed) {
287
+ await rows.return(undefined);
288
+ return;
254
289
  }
255
- try {
256
- await this.storeStreamedBody(messageId, accountId, accountConfigId, source, folder.placement, reStoredMessageIds.has(messageId));
257
- }
258
- catch (error) {
259
- // The one error in this frame that is the message's own fault. It
260
- // reaches here from the parse call and nowhere else, so the S3, DB
261
- // and queue work this same frame wraps cannot be mistaken for it.
262
- // The row is durable before the UID leaves `pending`, so the
263
- // message is never let go of unrecorded.
264
- if (error instanceof BodyParseError && this.quarantineConfig) {
265
- await this.recordQuarantine(this.quarantineConfig, messageId, location, error);
266
- pending.delete(uid);
267
- skippedCount++;
268
- source.resume();
269
- continue;
270
- }
271
- // Anything else is our own storage, database or queue failing. It
272
- // aborts the batch, which redelivers whole; what stored skips.
273
- throw error;
274
- }
290
+ yield { uid, messageId, body: source };
291
+ }
292
+ }
293
+ const stored = await pMap(fetchedBodies(), async ({ uid, messageId, body }) => {
294
+ // A body larger than the whole budget takes all of it and goes alone.
295
+ const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body, folder.placement, reStoredMessageIds.has(messageId), lookups, location), Math.min(body.length, bytesInFlight));
296
+ if (outcome.kind === "Failed")
297
+ stream.failed = true;
298
+ return { uid, messageId, outcome };
299
+ }, { concurrency });
300
+ const syncedMessageIds = [];
301
+ const syncedUids = [];
302
+ const faults = [];
303
+ for (const { uid, messageId, outcome } of stored) {
304
+ if (outcome.kind === "Stored") {
275
305
  pending.delete(uid);
276
306
  syncedMessageIds.push(messageId);
277
307
  syncedUids.push(uid);
278
308
  }
279
- }
280
- catch (error) {
281
- this.log.error?.({ error: error.message }, "Body fetch stream failed");
282
- // Fail-fast: a dropped connection mid-stream leaves every not-yet-yielded
283
- // UID in `pending`; they fall through to failedMessageIds and re-enqueue.
284
- // Any other error is a real fault — let it crash.
285
- if (!isConnectionDrop(error)) {
286
- throw error;
309
+ if (outcome.kind === "Quarantined") {
310
+ pending.delete(uid);
311
+ skippedCount++;
287
312
  }
288
- connectionLost = true;
289
- this.log.info?.({ remainingCount: pending.size }, "Connection lost, aborting batch");
313
+ if (outcome.kind === "Failed")
314
+ faults.push(outcome.error);
290
315
  }
316
+ // A store failing on our own storage, database or queue fails the batch,
317
+ // which redelivers whole; what stored skips on the redelivery.
318
+ if (faults.length > 0)
319
+ throw faults[0];
320
+ if (stream.fault)
321
+ throw stream.fault.error;
291
322
  // Anything still pending was never yielded (mid-stream drop or a UID the
292
323
  // server silently omitted) — re-enqueue it.
293
324
  const failedMessageIds = [...pending.values()];
325
+ const connectionLost = stream.lost;
294
326
  await this.syncWarningService.settle({
295
327
  accountConfigId,
296
328
  accountId,
@@ -341,56 +373,36 @@ export class BodySyncService {
341
373
  failedMessageIds,
342
374
  };
343
375
  }
344
- /**
345
- * Stream one message body straight to storage while teeing the bytes into a
346
- * buffer for the parse-dependent steps (snippet, classification, parsed-body
347
- * cache, per-part objects). The S3 upload never sees a whole-body concat —
348
- * the storage service streams it — but mailparser still needs the full bytes,
349
- * so we collect them in parallel. Later issues move parsing off the hot path.
350
- *
351
- * `isReStore` marks a re-fetch of a message whose row already carried
352
- * `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
353
- * post-store steps then re-materialize the body artifacts without
354
- * re-running any decision (issue #1011).
355
- */
356
- async storeStreamedBody(messageId, accountId, accountConfigId, source, placement, isReStore) {
357
- const toStorage = new PassThrough();
358
- const chunks = [];
359
- // Tee the source: bytes flow to storage as a stream (no whole-body concat
360
- // on the upload path) while we also collect them for the parse-dependent
361
- // steps below, which still need the full body for mailparser. The store
362
- // and the tee are awaited together so neither rejection is orphaned.
363
- const tee = new Promise((resolve, reject) => {
364
- source.on("data", (chunk) => {
365
- chunks.push(chunk);
366
- toStorage.write(chunk);
367
- });
368
- source.on("end", () => {
369
- toStorage.end();
370
- resolve();
371
- });
372
- source.on("error", (err) => {
373
- toStorage.destroy(err);
374
- reject(err);
375
- });
376
- });
377
- const [ref] = await Promise.all([
378
- this.storageService.storeMessageBodyStream({
379
- accountConfigId,
380
- accountId,
381
- messageId,
382
- content: toStorage,
383
- }),
384
- tee,
385
- ]);
386
- const body = Buffer.concat(chunks);
387
- await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, {
388
- uri: ref.uri,
389
- }, placement, isReStore);
376
+ async storeFetchedBody(messageId, accountId, accountConfigId, body, placement, isReStore, lookups, location) {
377
+ const stored = await this.storageService
378
+ .storeMessageBody({
379
+ accountConfigId,
380
+ accountId,
381
+ messageId,
382
+ content: body,
383
+ })
384
+ .then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, placement, isReStore, lookups))
385
+ .then(() => ({ kind: "Stored" }), (error) => ({ kind: "Failed", error }));
386
+ if (stored.kind !== "Failed")
387
+ return stored;
388
+ const { error } = stored;
389
+ // The one error in this frame that is the message's own fault. It
390
+ // reaches here from the parse call and nowhere else, so the S3, DB and
391
+ // queue work this same frame wraps cannot be mistaken for it. The row is
392
+ // durable before the UID leaves `pending`, so the message is never let go
393
+ // of unrecorded.
394
+ if (!(error instanceof BodyParseError) || !this.quarantineConfig) {
395
+ this.log.error?.({ messageId, accountId, error: inspect(error) }, "Body store failed");
396
+ return stored;
397
+ }
398
+ return this.recordQuarantine(this.quarantineConfig, messageId, location, error).then(() => ({ kind: "Quarantined" }), (recordError) => ({
399
+ kind: "Failed",
400
+ error: recordError,
401
+ }));
390
402
  }
391
403
  /**
392
404
  * The steps every synced body must go through regardless of which path
393
- * fetched it first — the sync path ({@link storeStreamedBody}) and the
405
+ * fetched it first — the sync path ({@link storeFetchedBody}) and the
394
406
  * read-path backfill ({@link fetchAndGetBody}) both call this instead of
395
407
  * duplicating (and drifting on) their own subset. Before issue #1271,
396
408
  * `fetchAndGetBody` skipped classification/placement entirely — a message
@@ -420,7 +432,7 @@ export class BodySyncService {
420
432
  * placement/filter/category machinery is skipped entirely and the
421
433
  * message's existing category is carried forward unchanged.
422
434
  */
423
- isReStore) {
435
+ isReStore, lookups) {
424
436
  // The one operation on this path that can fail because of how the message
425
437
  // is built. Its own try block lives in `parseMessageBody`, so this frame —
426
438
  // which also wraps the S3 body write, the parsed-body cache, the placement
@@ -465,12 +477,8 @@ export class BodySyncService {
465
477
  // UpdateItem. The verdict is recorded whenever Remit decided to act
466
478
  // (action != leave); the move is enqueued only for a confident,
467
479
  // actionable verdict.
468
- const resolved = await this.resolvePlacement(messageId, accountId, accountConfigId, parsed, classification);
469
- // Index-time filter pass (RFC 034). Isolated the same way placement is —
470
- // FilterPipeline.evaluate swallows any read/decision failure and returns
471
- // no actions, so a schema-drifted filter query never fails the body store.
472
- // The DECISION is computed here; the applies below are unswallowed.
473
- const filterDecision = await this.evaluateFilters(accountConfigId, accountId, messageId, parsed);
480
+ const resolved = await this.resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification);
481
+ const filterDecision = await this.evaluateFilters(lookups, messageId, parsed);
474
482
  // Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
475
483
  // guard in syncBodies treats a stored bodyStorageKey as "fully synced",
476
484
  // so if we wrote it first and the parsed-cache write then failed, the
@@ -549,7 +557,7 @@ export class BodySyncService {
549
557
  // the message is already \Seen), so a failure here safely re-fires on the
550
558
  // next attempt rather than being lost behind the bodyStorageKey skip guard.
551
559
  await this.applyUnsubscribedAutoRead(messageId, accountId, accountConfigId, parsed, existingMessage.bodyStorageKey);
552
- await this.deriveCalendarSuggestions(messageId, accountConfigId, parsed, existingMessage.bodyStorageKey);
560
+ await this.deriveCalendarSuggestions(lookups, messageId, accountConfigId, parsed, existingMessage.bodyStorageKey);
553
561
  const moved = Boolean(resolved.move || filterMoved);
554
562
  // ONE Message UpdateItem per synced message: bodyStorageKey + every
555
563
  // classification/derived field + the move flag + the audit verdict,
@@ -657,13 +665,11 @@ export class BodySyncService {
657
665
  messageId,
658
666
  content: body,
659
667
  });
660
- // Same shared step the sync path (storeStreamedBody) runs — issue
661
- // #1271 unified the two body paths so classification/placement no
662
- // longer depends on which one fetched the body first. A store from
663
- // here is a re-store exactly when the row already claimed a stored
664
- // body — the NoSuchKey fallback above; a row with no bodyStorageKey is
665
- // a genuine first store and takes the full decision pass.
666
- parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null);
668
+ // A store from here is a re-store exactly when the row already claimed
669
+ // a stored body — the NoSuchKey fallback above; a row with no
670
+ // bodyStorageKey is a genuine first store and takes the full decision
671
+ // pass.
672
+ parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
667
673
  }
668
674
  else {
669
675
  parsed = await simpleParser(body);
@@ -839,7 +845,7 @@ export class BodySyncService {
839
845
  * repository failure here is logged loudly with the messageId rather than
840
846
  * failing a body store that otherwise succeeded.
841
847
  */
842
- async deriveCalendarSuggestions(messageId, accountConfigId, parsed, storedBodyKey) {
848
+ async deriveCalendarSuggestions(lookups, messageId, accountConfigId, parsed, storedBodyKey) {
843
849
  if (!this.calendarConfig)
844
850
  return;
845
851
  if (hasStoredBody(storedBodyKey))
@@ -847,7 +853,7 @@ export class BodySyncService {
847
853
  const parts = calendarParts(parsed);
848
854
  if (parts.length === 0)
849
855
  return;
850
- const { calendarStore, calendarSuggestionService, filterService } = this.calendarConfig;
856
+ const { calendarStore, calendarSuggestionService } = this.calendarConfig;
851
857
  await Promise.resolve()
852
858
  .then(async () => {
853
859
  // `dismiss{muteSender:true}` wrote a standing rule naming this
@@ -856,7 +862,7 @@ export class BodySyncService {
856
862
  // move, so this is the only reader such a rule has.
857
863
  const sender = extractPrimaryFromEmail(parsed);
858
864
  if (sender) {
859
- const active = await filterService.listByAccountAndState(accountConfigId, FilterState.Active);
865
+ const active = await lookups.activeFilters();
860
866
  if (isSenderMuted(active, sender)) {
861
867
  this.log.debug?.({ messageId, sender }, "Sender is muted; offering no calendar suggestion");
862
868
  return;
@@ -914,14 +920,42 @@ export class BodySyncService {
914
920
  * move); the caller applies them. A no-op returning no actions when no
915
921
  * {@link FilterConfig} is wired.
916
922
  *
917
- * Isolation lives in {@link FilterPipeline.evaluate}: this read/decision phase
918
- * never fails the body store (the #1246 placement precedent). The applies of
919
- * the returned decision are the caller's and are deliberately unswallowed.
923
+ * A failed filter read never fails the body store (the #1246 placement
924
+ * precedent). The applies of the returned decision are the caller's and are
925
+ * deliberately unswallowed.
920
926
  */
921
- async evaluateFilters(accountConfigId, accountId, messageId, parsed) {
922
- if (!this.filterPipeline)
927
+ async evaluateFilters(lookups, messageId, parsed) {
928
+ const { filterPipeline } = this;
929
+ if (!filterPipeline)
923
930
  return { labels: [] };
924
- return this.filterPipeline.evaluate(accountConfigId, accountId, messageId, toFilterMessage(parsed));
931
+ return lookups
932
+ .usableFilters()
933
+ .then((usable) => filterPipeline.decide(usable, toFilterMessage(parsed)))
934
+ .catch((error) => {
935
+ this.log.error?.({
936
+ alert: "body_sync_filter_match_failed",
937
+ messageId,
938
+ errorName: error?.name,
939
+ error: inspect(error),
940
+ }, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
941
+ return { labels: [] };
942
+ });
943
+ }
944
+ batchLookups(accountConfigId, accountId) {
945
+ const { placementConfig, filterPipeline } = this;
946
+ const filterService = this.filterConfig?.filterService ?? this.calendarConfig?.filterService;
947
+ const activeFilters = once(async () => filterService
948
+ ? filterService.listByAccountAndState(accountConfigId, FilterState.Active)
949
+ : []);
950
+ return {
951
+ roleMailboxes: once(async () => placementConfig
952
+ ? resolveFolderRoleMailboxes(placementConfig.mailboxSpecialUseService, accountId)
953
+ : { junkMailbox: null, inboxMailbox: null }),
954
+ activeFilters,
955
+ usableFilters: once(async () => filterPipeline
956
+ ? filterPipeline.prepare(accountConfigId, accountId, await activeFilters())
957
+ : []),
958
+ };
925
959
  }
926
960
  /**
927
961
  * Resolve the placement decision for a message, BEFORE the single Message
@@ -950,10 +984,10 @@ export class BodySyncService {
950
984
  * to leave the message alone or placement itself failed; the alert log is
951
985
  * what distinguishes the latter, and neither case marks the message decided.
952
986
  */
953
- async resolvePlacement(messageId, accountId, accountConfigId, parsed, classification) {
987
+ async resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification) {
954
988
  if (!this.placementConfig)
955
989
  return {};
956
- return this.computePlacement(this.placementConfig, messageId, accountId, accountConfigId, parsed, classification).catch((error) => {
990
+ return this.computePlacement(this.placementConfig, lookups, messageId, accountId, accountConfigId, parsed, classification).catch((error) => {
957
991
  this.log.error?.({
958
992
  alert: "body_sync_placement_failed",
959
993
  messageId,
@@ -965,7 +999,7 @@ export class BodySyncService {
965
999
  return {};
966
1000
  });
967
1001
  }
968
- async computePlacement(placementConfig, messageId, accountId, accountConfigId, parsed, classification) {
1002
+ async computePlacement(placementConfig, lookups, messageId, accountId, accountConfigId, parsed, classification) {
969
1003
  const { mailboxSpecialUseService } = placementConfig;
970
1004
  const message = await this.messageService.get(messageId);
971
1005
  // Issue #383 / #1011: placement still runs once per message (RFC 039
@@ -974,7 +1008,7 @@ export class BodySyncService {
974
1008
  // (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
975
1009
  // true)`) skip the decision steps entirely, so only the pass that first
976
1010
  // stores a body ever reaches this method.
977
- const roleMailboxes = await resolveFolderRoleMailboxes(mailboxSpecialUseService, accountId);
1011
+ const roleMailboxes = await lookups.roleMailboxes();
978
1012
  const { junkMailbox, inboxMailbox } = roleMailboxes;
979
1013
  const placement = placementOf(message.mailboxId, roleMailboxes);
980
1014
  const fromEmail = extractPrimaryFromEmail(parsed);
@@ -1,4 +1,4 @@
1
- import type { IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
1
+ import type { FilterItem, IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
2
2
  import type { PlacementMoveService } from "../placement-move.js";
3
3
  import { type FilterMessage } from "./match.js";
4
4
  export interface FilterLogger {
@@ -34,25 +34,23 @@ export interface FilterDecision {
34
34
  filterId: string;
35
35
  };
36
36
  }
37
+ export type PreparedFilters = readonly FilterItem[];
37
38
  /**
38
39
  * Evaluates a synced message against the account's active filters and resolves
39
40
  * the actions to apply (RFC 034). Reads only — listing filters and refreshing
40
41
  * lazy expiry; the caller applies the
41
42
  * resulting {@link FilterDecision}.
42
43
  *
43
- * `evaluate` isolates every failure in this read/decision phase from body sync,
44
- * matching the placement-resolution precedent (#1246): a schema-drifted query
45
- * must never fail the surrounding message store, since the body is
46
- * already durable by the time this runs. The apply of the decision — label
47
- * writes and the move enqueue — is the caller's responsibility and is *not*
48
- * swallowed there, so a genuine DDB/SQS write failure still propagates and
49
- * requeues the message.
44
+ * `prepare` narrows the account's active filters to the ones that can act on
45
+ * one account's mail; `decide` matches a message against them without a read.
46
+ * The apply of the decision — label writes and the move enqueue — is the
47
+ * caller's responsibility.
50
48
  */
51
49
  export declare class FilterPipeline {
52
50
  private readonly config;
53
51
  private readonly log;
54
52
  constructor(config: FilterConfig, log: FilterLogger);
55
- evaluate(accountConfigId: string, accountId: string, messageId: string, msg: FilterMessage): Promise<FilterDecision>;
53
+ decide(prepared: PreparedFilters, msg: FilterMessage): FilterDecision;
56
54
  /**
57
55
  * A filter sits on the account config and sees every account's mail, but its
58
56
  * folder is one account's: mail of another account is not this filter's to
@@ -60,5 +58,5 @@ export declare class FilterPipeline {
60
58
  * more turns the filter off, with the reason the filter list shows.
61
59
  */
62
60
  private appliesTo;
63
- private match;
61
+ prepare(accountConfigId: string, accountId: string, active: readonly FilterItem[]): Promise<PreparedFilters>;
64
62
  }
@@ -1,4 +1,3 @@
1
- import { inspect } from "node:util";
2
1
  import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
3
2
  import { literalClausesMatch, NO_ACTION, selectMoveWinner, } from "./match.js";
4
3
  const EMPTY_DECISION = { labels: [] };
@@ -8,13 +7,10 @@ const EMPTY_DECISION = { labels: [] };
8
7
  * lazy expiry; the caller applies the
9
8
  * resulting {@link FilterDecision}.
10
9
  *
11
- * `evaluate` isolates every failure in this read/decision phase from body sync,
12
- * matching the placement-resolution precedent (#1246): a schema-drifted query
13
- * must never fail the surrounding message store, since the body is
14
- * already durable by the time this runs. The apply of the decision — label
15
- * writes and the move enqueue — is the caller's responsibility and is *not*
16
- * swallowed there, so a genuine DDB/SQS write failure still propagates and
17
- * requeues the message.
10
+ * `prepare` narrows the account's active filters to the ones that can act on
11
+ * one account's mail; `decide` matches a message against them without a read.
12
+ * The apply of the decision — label writes and the move enqueue — is the
13
+ * caller's responsibility.
18
14
  */
19
15
  export class FilterPipeline {
20
16
  config;
@@ -23,17 +19,32 @@ export class FilterPipeline {
23
19
  this.config = config;
24
20
  this.log = log;
25
21
  }
26
- async evaluate(accountConfigId, accountId, messageId, msg) {
27
- return this.match(accountConfigId, accountId, msg).catch((error) => {
28
- this.log.error?.({
29
- alert: "body_sync_filter_match_failed",
30
- messageId,
31
- accountConfigId,
32
- errorName: error?.name,
33
- error: inspect(error),
34
- }, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
22
+ decide(prepared, msg) {
23
+ const matched = prepared.filter((filter) => filterMatches(filter, msg));
24
+ if (matched.length === 0)
35
25
  return EMPTY_DECISION;
36
- });
26
+ const labels = [];
27
+ const seenLabels = new Set();
28
+ for (const filter of matched) {
29
+ if (filter.actionLabelId === NO_ACTION)
30
+ continue;
31
+ if (seenLabels.has(filter.actionLabelId))
32
+ continue;
33
+ seenLabels.add(filter.actionLabelId);
34
+ labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
35
+ }
36
+ const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
37
+ return {
38
+ labels,
39
+ ...(winner
40
+ ? {
41
+ move: {
42
+ destinationMailboxId: winner.actionMailboxId,
43
+ filterId: winner.filterId,
44
+ },
45
+ }
46
+ : {}),
47
+ };
37
48
  }
38
49
  /**
39
50
  * A filter sits on the account config and sees every account's mail, but its
@@ -54,11 +65,8 @@ export class FilterPipeline {
54
65
  this.log.warn?.({ filterId: filter.filterId, accountConfigId }, "Filter turned off: the folder it moves mail into no longer exists");
55
66
  return false;
56
67
  }
57
- async match(accountConfigId, accountId, msg) {
58
- const active = await this.config.filterService.listByAccountAndState(accountConfigId, FilterState.Active);
59
- if (active.length === 0)
60
- return EMPTY_DECISION;
61
- const matched = [];
68
+ async prepare(accountConfigId, accountId, active) {
69
+ const filters = [];
62
70
  for (const filter of active) {
63
71
  // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
64
72
  // its expiresAt patches it to Expired on this read and drops it from
@@ -68,33 +76,9 @@ export class FilterPipeline {
68
76
  continue;
69
77
  if (!(await this.appliesTo(accountConfigId, accountId, usable)))
70
78
  continue;
71
- if (filterMatches(usable, msg))
72
- matched.push(usable);
79
+ filters.push(usable);
73
80
  }
74
- if (matched.length === 0)
75
- return EMPTY_DECISION;
76
- const labels = [];
77
- const seenLabels = new Set();
78
- for (const filter of matched) {
79
- if (filter.actionLabelId === NO_ACTION)
80
- continue;
81
- if (seenLabels.has(filter.actionLabelId))
82
- continue;
83
- seenLabels.add(filter.actionLabelId);
84
- labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
85
- }
86
- const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
87
- return {
88
- labels,
89
- ...(winner
90
- ? {
91
- move: {
92
- destinationMailboxId: winner.actionMailboxId,
93
- filterId: winner.filterId,
94
- },
95
- }
96
- : {}),
97
- };
81
+ return filters;
98
82
  }
99
83
  }
100
84
  const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
@@ -4,7 +4,6 @@
4
4
  * Modern async/await replacement for the node-imap based ImapConnection.
5
5
  * Provides the same interface but uses ImapFlow under the hood.
6
6
  */
7
- import { Readable } from "node:stream";
8
7
  import type { FlatMailboxInfo, ImapBoxStatus, ImapConnectionConfig, ImapConnectionState, ImapEnvelopeSnapshot, ImapMailboxStatus, ImapMessage, ImapNamespaces } from "./types.js";
9
8
  /**
10
9
  * Convert an envelope date into an ISO string, tolerating non-Date values.
@@ -196,21 +195,9 @@ export declare class ImapFlowConnection {
196
195
  * @returns The raw message body, or null when the UID FETCH matched no message
197
196
  */
198
197
  fetchMessageBody: (uid: number) => Promise<Buffer | null>;
199
- /**
200
- * Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
201
- * ranged UID FETCH on a single connection — the desktop-client pattern.
202
- *
203
- * Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
204
- * one SELECT (the caller opens the box once for the whole batch). Yields
205
- * `{ uid, source }` as each message arrives so the caller can stream each
206
- * body straight to storage without buffering the whole batch.
207
- *
208
- * `source` is a readable stream over the message bytes — callers must
209
- * consume it (e.g. pipe to an upload) before requesting the next item.
210
- */
211
198
  fetchMessageBodies(uids: number[]): AsyncGenerator<{
212
199
  uid: number;
213
- source: Readable;
200
+ source: Buffer;
214
201
  }>;
215
202
  /**
216
203
  * Parse the References header from IMAP headers buffer.
@@ -4,7 +4,6 @@
4
4
  * Modern async/await replacement for the node-imap based ImapConnection.
5
5
  * Provides the same interface but uses ImapFlow under the hood.
6
6
  */
7
- import { Readable } from "node:stream";
8
7
  import { ImapFlow } from "imapflow";
9
8
  import { simpleParser } from "mailparser";
10
9
  import { MailConnectionError } from "./types.js";
@@ -613,18 +612,6 @@ export class ImapFlowConnection {
613
612
  }
614
613
  return null;
615
614
  };
616
- /**
617
- * Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
618
- * ranged UID FETCH on a single connection — the desktop-client pattern.
619
- *
620
- * Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
621
- * one SELECT (the caller opens the box once for the whole batch). Yields
622
- * `{ uid, source }` as each message arrives so the caller can stream each
623
- * body straight to storage without buffering the whole batch.
624
- *
625
- * `source` is a readable stream over the message bytes — callers must
626
- * consume it (e.g. pipe to an upload) before requesting the next item.
627
- */
628
615
  async *fetchMessageBodies(uids) {
629
616
  this.ensureConnected();
630
617
  const { client } = this;
@@ -652,7 +639,7 @@ export class ImapFlowConnection {
652
639
  if (msg.uid == null || !msg.source) {
653
640
  continue;
654
641
  }
655
- yield { uid: msg.uid, source: Readable.from(msg.source) };
642
+ yield { uid: msg.uid, source: msg.source };
656
643
  }
657
644
  }
658
645
  catch (error) {
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export { type ImapTestConfig, testImapConnection, } from "./connection-test.js";
16
16
  export { type CreatedFilter, createFilter, type FilterPage, type FilterPatch, listFilters, type NewFilter, resolveFilterUpdate, updateFilter, } from "./filter-operations.js";
17
17
  export { extractListId, normalizeListId } from "./filters/list-id.js";
18
18
  export { clauseMatches, type FilterMessage, type LiteralClauseNarrowing, literalClausesMatch, literalClauseTerms, NO_ACTION, selectMoveWinner, } from "./filters/match.js";
19
- export { type FilterConfig, type FilterDecision, type FilterLogger, FilterPipeline, } from "./filters/pipeline.js";
19
+ export { type FilterConfig, type FilterDecision, type FilterLogger, FilterPipeline, type PreparedFilters, } from "./filters/pipeline.js";
20
20
  export { type FlagPushConfig, type FlagPushEvent, type FlagPushLogger, type FlagPushOperationValue, FlagPushService, } from "./flag-push.js";
21
21
  export { type FlagPushTerminalOutcome, type ResolveExhaustedFlagPushDeps, type ResolveExhaustedFlagPushInput, type ResolveExhaustedFlagPushResult, resolveExhaustedFlagPushFailure, } from "./flag-push-terminal.js";
22
22
  export { type FlagQueueConfig, type FlagQueueLogger, FlagQueueService, type UpdateFlagsInput, type UpdateFlagsResult, } from "./flag-queue.js";
@@ -1,4 +1,3 @@
1
- import { Readable } from "node:stream";
2
1
  import type { FlatMailboxInfo, IImapConnection, ImapBodyStructure, ImapBoxStatus, ImapConnectionState, ImapEnvelopeSnapshot, ImapMailboxStatus, ImapMessage, ImapNamespaces } from "../types.js";
3
2
  interface StoredMessage {
4
3
  raw: Buffer;
@@ -112,7 +111,7 @@ export declare class FakeImapConnection implements IImapConnection {
112
111
  fetchMessageBody: (uid: number) => Promise<Buffer | null>;
113
112
  fetchMessageBodies(uids: number[]): AsyncGenerator<{
114
113
  uid: number;
115
- source: Readable;
114
+ source: Buffer;
116
115
  }>;
117
116
  private touch;
118
117
  addFlags: (uids: number[], flags: string[]) => Promise<void>;
@@ -1,4 +1,3 @@
1
- import { Readable } from "node:stream";
2
1
  import { simpleParser } from "mailparser";
3
2
  /** An error shaped the way ImapFlow reports a tagged NO from the server. */
4
3
  export const imapError = (code, text) => Object.assign(new Error(`Command failed: [${code}] ${text}`), {
@@ -393,7 +392,7 @@ export class FakeImapConnection {
393
392
  for (const uid of uids) {
394
393
  const stored = folder.messages.get(uid);
395
394
  if (stored)
396
- yield { uid, source: Readable.from([stored.raw]) };
395
+ yield { uid, source: stored.raw };
397
396
  }
398
397
  }
399
398
  touch = (detail, uids, change) => {
package/dist/types.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Types for the remit-mailbox-service
3
3
  */
4
- import type { Readable } from "node:stream";
5
4
  import type { ImapTransport } from "imapflow";
6
5
  export type { ImapTransport };
7
6
  /**
@@ -233,13 +232,12 @@ export interface IImapConnection {
233
232
  fetchMessageBody(uid: number): Promise<Buffer | null>;
234
233
  /**
235
234
  * Fetch full message bodies for many UIDs in ONE pipelined ranged UID FETCH.
236
- * Yields `{ uid, source }` per message; `source` is a readable stream the
237
- * caller must consume before requesting the next item. Requires a mailbox to
238
- * be open (one SELECT for the whole batch).
235
+ * Yields `{ uid, source }` per message. Requires a mailbox to be open (one
236
+ * SELECT for the whole batch).
239
237
  */
240
238
  fetchMessageBodies(uids: number[]): AsyncGenerator<{
241
239
  uid: number;
242
- source: Readable;
240
+ source: Buffer;
243
241
  }>;
244
242
  /**
245
243
  * Add flags to messages by UID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.118",
3
+ "version": "0.0.119",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -62,6 +62,7 @@
62
62
  "imapflow": "^2.9.1",
63
63
  "mailparser": "3.12.2",
64
64
  "p-map": "^7.0.4",
65
+ "async-mutex": "0.5.0",
65
66
  "tldts": "^7.4.9",
66
67
  "@remit/data-ports": "*",
67
68
  "@remit/domain-enums": "*",