@remit/mailbox-service 1.0.0 → 2.0.0

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/dist/body-sync.js CHANGED
@@ -15,7 +15,7 @@ import { mapMessagePartsToContent, parsePartSource, } from "./body-part-mapper.j
15
15
  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
- import { FilterPipeline, } from "./filters/pipeline.js";
18
+ import { FilterPipeline, filtersForArrival, } from "./filters/pipeline.js";
19
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";
@@ -119,6 +119,13 @@ export const toParsedBody = (parsed) => ({
119
119
  const noopLogger = {
120
120
  info: () => { },
121
121
  };
122
+ const internalDateOf = (internalDates, messageId) => {
123
+ const internalDate = internalDates.get(messageId);
124
+ if (internalDate === undefined) {
125
+ throw new Error(`${messageId} was fetched but never read from the batch`);
126
+ }
127
+ return internalDate;
128
+ };
122
129
  // A failed load is forgotten, so the next message retries it rather than the
123
130
  // rest of the batch inheriting the failure.
124
131
  const once = (load) => {
@@ -188,6 +195,7 @@ export class BodySyncService {
188
195
  // decision re-run. A message with no stored body inside a forced batch
189
196
  // is still a first store and takes the full decision pass.
190
197
  const reStoredMessageIds = new Set();
198
+ const internalDates = new Map();
191
199
  // One read per round, not per message (issue #72). The list is small by
192
200
  // design and almost always empty, so a lookup per message would put a
193
201
  // query on the hot path for a state that is nearly never set.
@@ -228,6 +236,7 @@ export class BodySyncService {
228
236
  if (message.bodyStorageKey)
229
237
  reStoredMessageIds.add(messageId);
230
238
  pending.set(message.uid, messageId);
239
+ internalDates.set(messageId, message.internalDate);
231
240
  }
232
241
  if (pending.size === 0) {
233
242
  return this.buildResult([], skippedCount, []);
@@ -274,7 +283,10 @@ export class BodySyncService {
274
283
  }
275
284
  const stored = await pMap(fetchedBodies(), async ({ uid, messageId, body }) => {
276
285
  // A body larger than the whole budget takes all of it and goes alone.
277
- const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body, folder.placement, reStoredMessageIds.has(messageId), lookups, location), Math.min(body.length, bytesInFlight));
286
+ const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body, {
287
+ placement: folder.placement,
288
+ internalDate: internalDateOf(internalDates, messageId),
289
+ }, reStoredMessageIds.has(messageId), lookups, location), Math.min(body.length, bytesInFlight));
278
290
  if (outcome.kind === "Failed")
279
291
  stream.failed = true;
280
292
  return { uid, messageId, outcome };
@@ -355,7 +367,7 @@ export class BodySyncService {
355
367
  failedMessageIds,
356
368
  };
357
369
  }
358
- async storeFetchedBody(messageId, accountId, accountConfigId, body, placement, isReStore, lookups, location) {
370
+ async storeFetchedBody(messageId, accountId, accountConfigId, body, arrival, isReStore, lookups, location) {
359
371
  const stored = await this.storageService
360
372
  .storeMessageBody({
361
373
  accountConfigId,
@@ -363,7 +375,7 @@ export class BodySyncService {
363
375
  messageId,
364
376
  content: body,
365
377
  })
366
- .then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, placement, isReStore, lookups))
378
+ .then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, arrival, isReStore, lookups))
367
379
  .then(() => ({ kind: "Stored" }), (error) => ({ kind: "Failed", error }));
368
380
  if (stored.kind !== "Failed")
369
381
  return stored;
@@ -404,7 +416,7 @@ export class BodySyncService {
404
416
  * Returns the parsed mail so callers that already need it (both do) don't
405
417
  * pay for mailparser twice.
406
418
  */
407
- async applyPostStoreSteps(messageId, accountId, accountConfigId, body, bodyRef, placement,
419
+ async applyPostStoreSteps(messageId, accountId, accountConfigId, body, bodyRef, arrival,
408
420
  /**
409
421
  * Whether this call is a re-store of an already-classified message
410
422
  * rather than a first store. The two re-entrant paths (`force: true`
@@ -453,14 +465,14 @@ export class BodySyncService {
453
465
  // `flags.category` override when one applies (issue #299). The derived
454
466
  // fields are folded into the single Message update below — they are NOT
455
467
  // written here.
456
- const classification = await this.classifyMessage(accountConfigId, parsed, placement);
468
+ const classification = await this.classifyMessage(accountConfigId, parsed, arrival.placement);
457
469
  // Decide the placement move from the in-memory classification before the
458
470
  // write, so its `movedByRemit` flag and audit verdict join the same
459
471
  // UpdateItem. The verdict is recorded whenever Remit decided to act
460
472
  // (action != leave); the move is enqueued only for a confident,
461
473
  // actionable verdict.
462
474
  const resolved = await this.resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification);
463
- const filterDecision = await this.evaluateFilters(lookups, messageId, parsed);
475
+ const filterDecision = await this.evaluateFilters(lookups, messageId, parsed, arrival);
464
476
  // Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
465
477
  // guard in syncBodies treats a stored bodyStorageKey as "fully synced",
466
478
  // so if we wrote it first and the parsed-cache write then failed, the
@@ -651,7 +663,7 @@ export class BodySyncService {
651
663
  // a stored body — the NoSuchKey fallback above; a row with no
652
664
  // bodyStorageKey is a genuine first store and takes the full decision
653
665
  // pass.
654
- parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
666
+ parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, { placement: folder.placement, internalDate: message.internalDate }, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
655
667
  }
656
668
  else {
657
669
  parsed = await simpleParser(body);
@@ -917,13 +929,13 @@ export class BodySyncService {
917
929
  * precedent). The applies of the returned decision are the caller's and are
918
930
  * deliberately unswallowed.
919
931
  */
920
- async evaluateFilters(lookups, messageId, parsed) {
932
+ async evaluateFilters(lookups, messageId, parsed, arrival) {
921
933
  const { filterPipeline } = this;
922
934
  if (!filterPipeline)
923
935
  return { labels: [] };
924
936
  return lookups
925
937
  .usableFilters()
926
- .then((usable) => filterPipeline.decide(usable, toFilterMessage(parsed)))
938
+ .then((usable) => filterPipeline.decide(filtersForArrival(usable, arrival), toFilterMessage(parsed)))
927
939
  .catch((error) => {
928
940
  this.log.error?.({
929
941
  alert: "body_sync_filter_match_failed",
@@ -27,9 +27,9 @@ export interface FilterMessage {
27
27
  */
28
28
  export declare const clauseMatches: (clause: FilterClause, msg: FilterMessage) => boolean;
29
29
  /**
30
- * Whether the literal clauses match under their combine operator. Empty clauses
31
- * carry no literal constraint and pass vacuously; a caller that needs a
32
- * predicate checks for clauses itself.
30
+ * Whether the literal clauses match under their combine operator. A filter with
31
+ * no clauses states nothing about the message, so it matches nothing: read as
32
+ * a vacuous pass it would file every message in every folder.
33
33
  */
34
34
  export declare const literalClausesMatch: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"], msg: FilterMessage) => boolean;
35
35
  /**
@@ -65,13 +65,13 @@ export const clauseMatches = (clause, msg) => {
65
65
  }
66
66
  };
67
67
  /**
68
- * Whether the literal clauses match under their combine operator. Empty clauses
69
- * carry no literal constraint and pass vacuously; a caller that needs a
70
- * predicate checks for clauses itself.
68
+ * Whether the literal clauses match under their combine operator. A filter with
69
+ * no clauses states nothing about the message, so it matches nothing: read as
70
+ * a vacuous pass it would file every message in every folder.
71
71
  */
72
72
  export const literalClausesMatch = (clauses, operator, msg) => {
73
73
  if (clauses.length === 0)
74
- return true;
74
+ return false;
75
75
  return operator === FilterMatchOperator.Or
76
76
  ? clauses.some((clause) => clauseMatches(clause, msg))
77
77
  : clauses.every((clause) => clauseMatches(clause, msg));
@@ -118,7 +118,7 @@ const clauseNarrowing = (clause) => {
118
118
  export const literalClauseTerms = (clauses, operator) => {
119
119
  const combine = operator === FilterMatchOperator.Or ? "or" : "and";
120
120
  if (clauses.length === 0)
121
- return { terms: [], operator: combine };
121
+ return null;
122
122
  const narrowings = clauses.map(clauseNarrowing);
123
123
  const terms = narrowings.flatMap((narrowing) => narrowing.kind === "Term" ? [narrowing.term] : []);
124
124
  if (combine === "and") {
@@ -1,4 +1,5 @@
1
1
  import type { FilterItem, IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
2
+ import type { FolderPlacement } from "../heuristics/classifyPlacement.js";
2
3
  import type { PlacementMoveService } from "../placement-move.js";
3
4
  import { type FilterMessage } from "./match.js";
4
5
  export interface FilterLogger {
@@ -60,3 +61,15 @@ export declare class FilterPipeline {
60
61
  private appliesTo;
61
62
  prepare(accountConfigId: string, accountId: string, active: readonly FilterItem[]): Promise<PreparedFilters>;
62
63
  }
64
+ /**
65
+ * The filters a stored body is matched against. A filter acts on new mail:
66
+ * mail that arrived in Inbox or Junk, the folders mail is delivered to, after
67
+ * its rule or action was last set. Mail filed anywhere else, or delivered
68
+ * before the rule existed, is never moved by a body sync, which also runs for
69
+ * every old message a resync fetches again; applying a rule to existing mail
70
+ * is the organize job's.
71
+ */
72
+ export declare const filtersForArrival: (prepared: PreparedFilters, arrival: {
73
+ placement: FolderPlacement;
74
+ internalDate: number;
75
+ }) => PreparedFilters;
@@ -81,4 +81,17 @@ export class FilterPipeline {
81
81
  return filters;
82
82
  }
83
83
  }
84
+ /**
85
+ * The filters a stored body is matched against. A filter acts on new mail:
86
+ * mail that arrived in Inbox or Junk, the folders mail is delivered to, after
87
+ * its rule or action was last set. Mail filed anywhere else, or delivered
88
+ * before the rule existed, is never moved by a body sync, which also runs for
89
+ * every old message a resync fetches again; applying a rule to existing mail
90
+ * is the organize job's.
91
+ */
92
+ export const filtersForArrival = (prepared, arrival) => {
93
+ if (arrival.placement === "other")
94
+ return [];
95
+ return prepared.filter((filter) => arrival.internalDate >= filter.actionChangedAt * 1000);
96
+ };
84
97
  const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
@@ -55,6 +55,7 @@ export declare class FakeImapServer {
55
55
  flags?: string[];
56
56
  uid?: number;
57
57
  bodyStructure?: ImapBodyStructure | null;
58
+ internalDate?: Date;
58
59
  }): number;
59
60
  /** Another client expunged the message. */
60
61
  expunge(path: string, uid: number): void;
@@ -101,7 +101,7 @@ export class FakeImapServer {
101
101
  raw,
102
102
  flags: new Set(options.flags ?? []),
103
103
  modseq: this.modseq++,
104
- internalDate: new Date(1_700_000_000_000 + uid * 1000),
104
+ internalDate: options.internalDate ?? new Date(1_700_000_000_000 + uid * 1000),
105
105
  bodyStructure: options.bodyStructure,
106
106
  });
107
107
  return uid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",