@remit/mailbox-service 0.0.110 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,41 +1,19 @@
1
1
  import { inspect } from "node:util";
2
2
  import type {
3
3
  FilterItem,
4
- IFilterAnchorRepository,
5
4
  IFilterRepository,
5
+ IMailboxRepository,
6
6
  IMessageLabelRepository,
7
7
  } from "@remit/data-ports";
8
- import { FilterState } from "@remit/domain-enums";
9
- import {
10
- isEmbeddingCapabilityAbsence,
11
- isEmbeddingCapabilityUnavailable,
12
- recordEmbeddingCapabilityAbsence,
13
- } from "@remit/search-service/capability";
8
+ import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
14
9
  import type { PlacementMoveService } from "../placement-move.js";
15
10
  import {
16
- type AnchorEmbedder,
17
- refreshAnchorForEmbedder,
18
- } from "./anchor-drift.js";
19
- import {
20
- buildMatchText,
21
- cosineSimilarity,
22
- DEFAULT_SEMANTIC_MATCH_THRESHOLD,
23
11
  type FilterMessage,
24
12
  literalClausesMatch,
25
13
  NO_ACTION,
26
14
  selectMoveWinner,
27
15
  } from "./match.js";
28
16
 
29
- /**
30
- * Turns the candidate message's text into a single message-level vector to
31
- * compare against a filter's persisted `anchorEmbedding`. The anchor side is
32
- * embedded here only to repair a drifted anchor in place (see
33
- * {@link refreshAnchorForEmbedder}); the match itself reads `FilterAnchor` as
34
- * a fixed fact (RFC 034 Decision 2.1/2.3), and the incoming message is
35
- * embedded once per message, only when a semantic filter is actually in play.
36
- */
37
- export type MessageEmbedder = AnchorEmbedder;
38
-
39
17
  export interface FilterLogger {
40
18
  info(obj: Record<string, unknown>, msg: string): void;
41
19
  debug?(obj: Record<string, unknown>, msg: string): void;
@@ -47,19 +25,13 @@ export interface FilterLogger {
47
25
  * Dependencies for the index-time filter pass (RFC 034). Optional on
48
26
  * {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
49
27
  * without it simply runs no filter matching.
50
- *
51
- * `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
52
- * without it, so they are skipped when it is absent, but literal-clause filters
53
- * always run. The `FilterAnchor` write path (a separate ticket) and a wired
54
- * embedder are what light the semantic half up.
55
28
  */
56
29
  export interface FilterConfig {
57
30
  filterService: IFilterRepository;
58
- filterAnchorService: IFilterAnchorRepository;
59
31
  messageLabelService: IMessageLabelRepository;
32
+ /** Names the account a filter's folder belongs to. */
33
+ mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
60
34
  placementMoveService: PlacementMoveService;
61
- embedder?: MessageEmbedder;
62
- similarityThreshold?: number;
63
35
  }
64
36
 
65
37
  /**
@@ -76,13 +48,13 @@ const EMPTY_DECISION: FilterDecision = { labels: [] };
76
48
 
77
49
  /**
78
50
  * Evaluates a synced message against the account's active filters and resolves
79
- * the actions to apply (RFC 034). Reads only — listing filters, refreshing lazy
80
- * expiry, reading anchors, and embedding the message; the caller applies the
51
+ * the actions to apply (RFC 034). Reads only — listing filters and refreshing
52
+ * lazy expiry; the caller applies the
81
53
  * resulting {@link FilterDecision}.
82
54
  *
83
55
  * `evaluate` isolates every failure in this read/decision phase from body sync,
84
- * matching the placement-resolution precedent (#1246): a schema-drifted query or
85
- * a bad anchor must never fail the surrounding message store, since the body is
56
+ * matching the placement-resolution precedent (#1246): a schema-drifted query
57
+ * must never fail the surrounding message store, since the body is
86
58
  * already durable by the time this runs. The apply of the decision — label
87
59
  * writes and the move enqueue — is the caller's responsibility and is *not*
88
60
  * swallowed there, so a genuine DDB/SQS write failure still propagates and
@@ -96,26 +68,57 @@ export class FilterPipeline {
96
68
 
97
69
  async evaluate(
98
70
  accountConfigId: string,
71
+ accountId: string,
99
72
  messageId: string,
100
73
  msg: FilterMessage,
101
74
  ): Promise<FilterDecision> {
102
- return this.match(accountConfigId, msg).catch((error: unknown) => {
103
- this.log.error?.(
104
- {
105
- alert: "body_sync_filter_match_failed",
106
- messageId,
107
- accountConfigId,
108
- errorName: (error as { name?: string })?.name,
109
- error: inspect(error),
110
- },
111
- "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
112
- );
113
- return EMPTY_DECISION;
75
+ return this.match(accountConfigId, accountId, msg).catch(
76
+ (error: unknown) => {
77
+ this.log.error?.(
78
+ {
79
+ alert: "body_sync_filter_match_failed",
80
+ messageId,
81
+ accountConfigId,
82
+ errorName: (error as { name?: string })?.name,
83
+ error: inspect(error),
84
+ },
85
+ "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
86
+ );
87
+ return EMPTY_DECISION;
88
+ },
89
+ );
90
+ }
91
+
92
+ /**
93
+ * A filter sits on the account config and sees every account's mail, but its
94
+ * folder is one account's: mail of another account is not this filter's to
95
+ * act on, exactly as if it had not matched. A folder no account holds any
96
+ * more turns the filter off, with the reason the filter list shows.
97
+ */
98
+ private async appliesTo(
99
+ accountConfigId: string,
100
+ accountId: string,
101
+ filter: FilterItem,
102
+ ): Promise<boolean> {
103
+ if (filter.actionMailboxId === NO_ACTION) return true;
104
+ const owner = await this.config.mailboxService.resolveAccountId(
105
+ filter.actionMailboxId,
106
+ );
107
+ if (owner !== null) return owner === accountId;
108
+ await this.config.filterService.update(accountConfigId, filter.filterId, {
109
+ state: FilterState.Disabled,
110
+ disabledReason: FilterDisabledReason.FolderMissing,
114
111
  });
112
+ this.log.warn?.(
113
+ { filterId: filter.filterId, accountConfigId },
114
+ "Filter turned off: the folder it moves mail into no longer exists",
115
+ );
116
+ return false;
115
117
  }
116
118
 
117
119
  private async match(
118
120
  accountConfigId: string,
121
+ accountId: string,
119
122
  msg: FilterMessage,
120
123
  ): Promise<FilterDecision> {
121
124
  const active = await this.config.filterService.listByAccountAndState(
@@ -124,44 +127,6 @@ export class FilterPipeline {
124
127
  );
125
128
  if (active.length === 0) return EMPTY_DECISION;
126
129
 
127
- // The message is embedded lazily and at most once: `undefined` means not
128
- // yet computed, `null` means computed-but-unavailable (no embedder). The
129
- // embed only ever fires when a filter survives the literal pre-filter and
130
- // carries an anchor — a purely-literal account never pays for it.
131
- let messageEmbedding: number[] | null | undefined;
132
- const embed = async (): Promise<number[] | null> => {
133
- if (messageEmbedding !== undefined) return messageEmbedding;
134
- const embedder = this.config.embedder;
135
- if (!embedder) {
136
- messageEmbedding = null;
137
- return null;
138
- }
139
- messageEmbedding = await embedder
140
- .embed(buildMatchText(msg))
141
- .catch((error: unknown) => {
142
- // This deployment carries no embedding pipeline at all — the
143
- // container images ship without `@huggingface/transformers`. That is
144
- // the same absence `SEARCH_EMBEDDING_PROVIDER=off` states, so it takes
145
- // the same designed skip rather than an error-level
146
- // `filter_anchor_match_failed` per semantic filter per message. The
147
- // absence is remembered process-wide, so this is the only embed that
148
- // pays for it: `buildFilterConfig` wires no embedder afterwards.
149
- if (!isEmbeddingCapabilityAbsence(error)) throw error;
150
- if (!isEmbeddingCapabilityUnavailable()) {
151
- this.log.warn?.(
152
- {
153
- accountConfigId,
154
- error: inspect(error),
155
- },
156
- "No embedding pipeline in this deployment; semantic filters are skipped (literal filters still run)",
157
- );
158
- }
159
- recordEmbeddingCapabilityAbsence(error);
160
- return null;
161
- });
162
- return messageEmbedding;
163
- };
164
-
165
130
  const matched: FilterItem[] = [];
166
131
  for (const filter of active) {
167
132
  // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
@@ -169,32 +134,8 @@ export class FilterPipeline {
169
134
  // evaluation, independent of whether the TTL delete has run.
170
135
  const usable = await this.config.filterService.refreshExpiry(filter);
171
136
  if (usable.state !== FilterState.Active) continue;
172
- // A bad or stale anchor vector (a dimension mismatch under a changed
173
- // embedding model) throws from cosineSimilarity. Isolate it to this
174
- // filter: it is skipped and loudly logged, and every other filter on
175
- // the message still evaluates and applies. This sits inside the
176
- // evaluate-level catch — a whole-phase failure still degrades to no
177
- // filter actions — but stops one poisoned anchor from doing so.
178
- const isMatch = await this.filterMatches(
179
- accountConfigId,
180
- usable,
181
- msg,
182
- embed,
183
- ).catch((error: unknown) => {
184
- this.log.error?.(
185
- {
186
- alert: "filter_anchor_match_failed",
187
- filterId: usable.filterId,
188
- accountConfigId,
189
- errorName: (error as { name?: string })?.name,
190
- error: inspect(error),
191
- },
192
- "Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
193
- );
194
- return "skip" as const;
195
- });
196
- if (isMatch === "skip") continue;
197
- if (isMatch) matched.push(usable);
137
+ if (!(await this.appliesTo(accountConfigId, accountId, usable))) continue;
138
+ if (filterMatches(usable, msg)) matched.push(usable);
198
139
  }
199
140
  if (matched.length === 0) return EMPTY_DECISION;
200
141
 
@@ -223,62 +164,7 @@ export class FilterPipeline {
223
164
  : {}),
224
165
  };
225
166
  }
226
-
227
- private async filterMatches(
228
- accountConfigId: string,
229
- filter: FilterItem,
230
- msg: FilterMessage,
231
- embed: () => Promise<number[] | null>,
232
- ): Promise<boolean> {
233
- if (
234
- !literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
235
- ) {
236
- return false;
237
- }
238
-
239
- if (!filter.hasAnchor) {
240
- // A filter with neither clauses nor an anchor has no predicate and
241
- // matches nothing; a literal-only filter matched iff it had clauses.
242
- return filter.literalClauses.length > 0;
243
- }
244
-
245
- // One deterministic point read per semantic candidate that survived the
246
- // literal pre-filter — never a scan, never the anchor message itself
247
- // (RFC 034 Decision 2.3).
248
- let anchor = await this.config.filterAnchorService.get(
249
- accountConfigId,
250
- filter.filterId,
251
- );
252
- if (!anchor) {
253
- this.log.warn?.(
254
- { filterId: filter.filterId, accountConfigId },
255
- "Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
256
- );
257
- return false;
258
- }
259
-
260
- // A re-embed failure here propagates to the per-filter catch in
261
- // `match()`, which logs and skips this filter for this one evaluation
262
- // exactly as an unrecoverable stale anchor does today.
263
- anchor = await refreshAnchorForEmbedder(
264
- {
265
- anchorRepository: this.config.filterAnchorService,
266
- embedder: this.config.embedder,
267
- },
268
- anchor,
269
- );
270
-
271
- const vector = await embed();
272
- if (!vector) {
273
- this.log.debug?.(
274
- { filterId: filter.filterId },
275
- "No embedder configured; skipping semantic filter",
276
- );
277
- return false;
278
- }
279
-
280
- const threshold =
281
- this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
282
- return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
283
- }
284
167
  }
168
+
169
+ const filterMatches = (filter: FilterItem, msg: FilterMessage): boolean =>
170
+ literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
@@ -1,18 +1,17 @@
1
+ import type { IMessageRepository, MessageItem } from "@remit/data-ports";
1
2
  import type { FlagPushLogger } from "./flag-push.js";
2
3
  import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
3
- import {
4
- reconcileStaleMessage,
5
- type StaleMessageReconcileDeps,
6
- } from "./stale-message-reconcile.js";
7
4
  import type { IImapConnection } from "./types.js";
8
5
 
9
- export interface ResolveExhaustedFlagPushDeps
10
- extends StaleMessageReconcileDeps {
6
+ export interface ResolveExhaustedFlagPushDeps {
7
+ messageService: Pick<IMessageRepository, "deleteIfPlacement">;
11
8
  markerService: { delete(messageId: string, flagName: string): Promise<void> };
12
9
  log: FlagPushLogger;
13
10
  }
14
11
 
15
12
  export interface ResolveExhaustedFlagPushInput {
13
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
14
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
16
15
  accountId: string;
17
16
  accountConfigId: string;
18
17
  messageId: string;
@@ -39,7 +38,7 @@ export interface ResolveExhaustedFlagPushResult {
39
38
  * IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
40
39
  * FETCH coming back empty. Per invariant 2, an external delete supersedes
41
40
  * the marker entirely: the marker is dropped and the stale
42
- * Message/ThreadMessage rows are deleted via {@link reconcileStaleMessage}.
41
+ * Message/ThreadMessage rows are deleted via `deleteIfPlacement`.
43
42
  * Metric only, no alarm — routine.
44
43
  * 2. BROKEN — the message still exists, but the flag push keeps failing.
45
44
  * Broken code or a broken account, not a transient blip. The marker is
@@ -79,10 +78,9 @@ export const resolveExhaustedFlagPushFailure = async (
79
78
 
80
79
  if (await isMessageGoneFromOpenMailbox(connection, uid)) {
81
80
  await deps.markerService.delete(messageId, flagName);
82
- const { threadMessagesDeleted } = await reconcileStaleMessage(
83
- deps,
84
- accountConfigId,
81
+ const reconciled = await deps.messageService.deleteIfPlacement(
85
82
  messageId,
83
+ input.placement,
86
84
  );
87
85
  deps.log.info(
88
86
  {
@@ -93,7 +91,7 @@ export const resolveExhaustedFlagPushFailure = async (
93
91
  flagName,
94
92
  uid,
95
93
  mailboxPath,
96
- threadMessagesDeleted,
94
+ reconciled,
97
95
  },
98
96
  "Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
99
97
  );