@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,143 @@
1
+ import type { FilterItem } from "@remit/data-ports";
2
+ import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
3
+
4
+ type FilterClause = FilterItem["literalClauses"][number];
5
+
6
+ /**
7
+ * The `"None"` sentinel a filter's action fields carry when that action is
8
+ * absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
9
+ * empty/optional strings, so a missing action is this exact value, never `""`.
10
+ */
11
+ export const NO_ACTION = "None";
12
+
13
+ /**
14
+ * Default cosine cut-off for a semantic-anchor match (RFC 031 "the semantic
15
+ * anchor evaluates as a kNN threshold against the new message's embedding").
16
+ * Tunable per pipeline via {@link FilterConfig.similarityThreshold}.
17
+ */
18
+ export const DEFAULT_SEMANTIC_MATCH_THRESHOLD = 0.75;
19
+
20
+ /**
21
+ * Bound on the text embedded for a semantic match — the same 512-char budget
22
+ * `buildTextPreview` applies to chunk vectors and `FilterAnchor.anchorSourceText`
23
+ * persists, so the candidate side of the comparison is derived on the same
24
+ * footing as the anchor side.
25
+ */
26
+ const MATCH_TEXT_LIMIT = 512;
27
+
28
+ /**
29
+ * The message fields a filter evaluates against — the literal-clause targets
30
+ * (from / subject / body) plus the text embedded for a semantic anchor. A plain
31
+ * value object so the matcher stays independent of the `ParsedMail` shape and is
32
+ * trivially constructed in a test.
33
+ */
34
+ export interface FilterMessage {
35
+ from: string;
36
+ fromName: string;
37
+ subject: string;
38
+ text: string;
39
+ }
40
+
41
+ const includesFold = (haystack: string, needle: string): boolean =>
42
+ haystack.toLowerCase().includes(needle.toLowerCase());
43
+
44
+ /**
45
+ * Whether one literal clause matches the message. From matches against the
46
+ * sender address and display name; Subject against the subject; HasWords against
47
+ * subject or body. An empty clause value never matches.
48
+ */
49
+ export const clauseMatches = (
50
+ clause: FilterClause,
51
+ msg: FilterMessage,
52
+ ): boolean => {
53
+ const value = clause.value.trim();
54
+ if (value === "") return false;
55
+ switch (clause.field) {
56
+ case FilterClauseField.From:
57
+ return includesFold(msg.from, value) || includesFold(msg.fromName, value);
58
+ case FilterClauseField.Subject:
59
+ return includesFold(msg.subject, value);
60
+ case FilterClauseField.HasWords:
61
+ return includesFold(msg.subject, value) || includesFold(msg.text, value);
62
+ default:
63
+ return false;
64
+ }
65
+ };
66
+
67
+ /**
68
+ * Whether the literal clauses match under their combine operator. Empty clauses
69
+ * carry no literal constraint and pass vacuously — a purely-semantic filter (no
70
+ * clauses, only an anchor) is gated by its anchor alone, not by this.
71
+ */
72
+ export const literalClausesMatch = (
73
+ clauses: readonly FilterClause[],
74
+ operator: FilterItem["matchOperator"],
75
+ msg: FilterMessage,
76
+ ): boolean => {
77
+ if (clauses.length === 0) return true;
78
+ return operator === FilterMatchOperator.Or
79
+ ? clauses.some((clause) => clauseMatches(clause, msg))
80
+ : clauses.every((clause) => clauseMatches(clause, msg));
81
+ };
82
+
83
+ /**
84
+ * Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
85
+ * rather than silently scoring incomparable vectors — a mismatch means a stale
86
+ * anchor embedded under a different model (`anchorEmbeddingId`), which is a
87
+ * migration concern, not a match to guess at.
88
+ */
89
+ export const cosineSimilarity = (
90
+ a: readonly number[],
91
+ b: readonly number[],
92
+ ): number => {
93
+ if (a.length !== b.length) {
94
+ throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
95
+ }
96
+ let dot = 0;
97
+ let normA = 0;
98
+ let normB = 0;
99
+ for (let i = 0; i < a.length; i++) {
100
+ dot += a[i] * b[i];
101
+ normA += a[i] * a[i];
102
+ normB += b[i] * b[i];
103
+ }
104
+ if (normA === 0 || normB === 0) return 0;
105
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
106
+ };
107
+
108
+ /**
109
+ * The move a message ends in when several filters matched: the most-recently
110
+ * *changed* filter wins (RFC 034 Decision 3.2), tie-broken on `filterId` for the
111
+ * unreachable identical-timestamp case. `ruleChangedAt` — not `updatedAt` — is
112
+ * the signal, so a cosmetic rename never flips an exclusive move.
113
+ */
114
+ export const selectMoveWinner = (
115
+ candidates: readonly FilterItem[],
116
+ ): FilterItem | undefined => {
117
+ let winner: FilterItem | undefined;
118
+ for (const candidate of candidates) {
119
+ if (!winner) {
120
+ winner = candidate;
121
+ continue;
122
+ }
123
+ if (candidate.ruleChangedAt > winner.ruleChangedAt) {
124
+ winner = candidate;
125
+ continue;
126
+ }
127
+ if (
128
+ candidate.ruleChangedAt === winner.ruleChangedAt &&
129
+ candidate.filterId > winner.filterId
130
+ ) {
131
+ winner = candidate;
132
+ }
133
+ }
134
+ return winner;
135
+ };
136
+
137
+ /**
138
+ * The candidate text embedded once for a semantic match — subject then body,
139
+ * bounded to the anchor's `anchorSourceText` budget so both sides of the cosine
140
+ * comparison are derived from comparable inputs.
141
+ */
142
+ export const buildMatchText = (msg: FilterMessage): string =>
143
+ `${msg.subject}\n${msg.text}`.slice(0, MATCH_TEXT_LIMIT);
@@ -0,0 +1,243 @@
1
+ import { inspect } from "node:util";
2
+ import type {
3
+ FilterItem,
4
+ IFilterAnchorRepository,
5
+ IFilterRepository,
6
+ IMessageLabelRepository,
7
+ } from "@remit/data-ports";
8
+ import { FilterState } from "@remit/domain-enums";
9
+ import type { PlacementMoveService } from "../placement-move.js";
10
+ import {
11
+ buildMatchText,
12
+ cosineSimilarity,
13
+ DEFAULT_SEMANTIC_MATCH_THRESHOLD,
14
+ type FilterMessage,
15
+ literalClausesMatch,
16
+ NO_ACTION,
17
+ selectMoveWinner,
18
+ } from "./match.js";
19
+
20
+ /**
21
+ * Turns the candidate message's text into a single message-level vector to
22
+ * compare against a filter's persisted `anchorEmbedding`. The anchor side is
23
+ * never embedded here — it is read from `FilterAnchor` as a fixed fact (RFC 034
24
+ * Decision 2.1/2.3); only the incoming message is embedded, and only once per
25
+ * message, and only when a semantic filter is actually in play.
26
+ */
27
+ export interface MessageEmbedder {
28
+ embed(text: string): Promise<number[]>;
29
+ }
30
+
31
+ export interface FilterLogger {
32
+ info(obj: Record<string, unknown>, msg: string): void;
33
+ debug?(obj: Record<string, unknown>, msg: string): void;
34
+ warn?(obj: Record<string, unknown>, msg: string): void;
35
+ error?(obj: Record<string, unknown>, msg: string): void;
36
+ }
37
+
38
+ /**
39
+ * Dependencies for the index-time filter pass (RFC 034). Optional on
40
+ * {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
41
+ * without it simply runs no filter matching.
42
+ *
43
+ * `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
44
+ * without it, so they are skipped when it is absent, but literal-clause filters
45
+ * always run. The `FilterAnchor` write path (a separate ticket) and a wired
46
+ * embedder are what light the semantic half up.
47
+ */
48
+ export interface FilterConfig {
49
+ filterService: IFilterRepository;
50
+ filterAnchorService: IFilterAnchorRepository;
51
+ messageLabelService: IMessageLabelRepository;
52
+ placementMoveService: PlacementMoveService;
53
+ embedder?: MessageEmbedder;
54
+ similarityThreshold?: number;
55
+ }
56
+
57
+ /**
58
+ * What the matched filters resolve to for one message: every matching filter's
59
+ * label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
60
+ * most-recently-changed matching filter, Decision 3.2).
61
+ */
62
+ export interface FilterDecision {
63
+ labels: Array<{ labelId: string; filterId: string }>;
64
+ move?: { destinationMailboxId: string; filterId: string };
65
+ }
66
+
67
+ const EMPTY_DECISION: FilterDecision = { labels: [] };
68
+
69
+ /**
70
+ * Evaluates a synced message against the account's active filters and resolves
71
+ * the actions to apply (RFC 034). Reads only — listing filters, refreshing lazy
72
+ * expiry, reading anchors, and embedding the message; the caller applies the
73
+ * resulting {@link FilterDecision}.
74
+ *
75
+ * `evaluate` isolates every failure in this read/decision phase from body sync,
76
+ * matching the placement-resolution precedent (#1246): a schema-drifted query or
77
+ * a bad anchor must never fail the surrounding message store, since the body is
78
+ * already durable by the time this runs. The apply of the decision — label
79
+ * writes and the move enqueue — is the caller's responsibility and is *not*
80
+ * swallowed there, so a genuine DDB/SQS write failure still propagates and
81
+ * requeues the message.
82
+ */
83
+ export class FilterPipeline {
84
+ constructor(
85
+ private readonly config: FilterConfig,
86
+ private readonly log: FilterLogger,
87
+ ) {}
88
+
89
+ async evaluate(
90
+ accountConfigId: string,
91
+ messageId: string,
92
+ msg: FilterMessage,
93
+ ): Promise<FilterDecision> {
94
+ return this.match(accountConfigId, msg).catch((error: unknown) => {
95
+ this.log.error?.(
96
+ {
97
+ alert: "body_sync_filter_match_failed",
98
+ messageId,
99
+ accountConfigId,
100
+ errorName: (error as { name?: string })?.name,
101
+ error: inspect(error),
102
+ },
103
+ "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
104
+ );
105
+ return EMPTY_DECISION;
106
+ });
107
+ }
108
+
109
+ private async match(
110
+ accountConfigId: string,
111
+ msg: FilterMessage,
112
+ ): Promise<FilterDecision> {
113
+ const active = await this.config.filterService.listByAccountAndState(
114
+ accountConfigId,
115
+ FilterState.Active,
116
+ );
117
+ if (active.length === 0) return EMPTY_DECISION;
118
+
119
+ // The message is embedded lazily and at most once: `undefined` means not
120
+ // yet computed, `null` means computed-but-unavailable (no embedder). The
121
+ // embed only ever fires when a filter survives the literal pre-filter and
122
+ // carries an anchor — a purely-literal account never pays for it.
123
+ let messageEmbedding: number[] | null | undefined;
124
+ const embed = async (): Promise<number[] | null> => {
125
+ if (messageEmbedding !== undefined) return messageEmbedding;
126
+ const embedder = this.config.embedder;
127
+ if (!embedder) {
128
+ messageEmbedding = null;
129
+ return null;
130
+ }
131
+ messageEmbedding = await embedder.embed(buildMatchText(msg));
132
+ return messageEmbedding;
133
+ };
134
+
135
+ const matched: FilterItem[] = [];
136
+ for (const filter of active) {
137
+ // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
138
+ // its expiresAt patches it to Expired on this read and drops it from
139
+ // evaluation, independent of whether the TTL delete has run.
140
+ const usable = await this.config.filterService.refreshExpiry(filter);
141
+ if (usable.state !== FilterState.Active) continue;
142
+ // A bad or stale anchor vector (a dimension mismatch under a changed
143
+ // embedding model) throws from cosineSimilarity. Isolate it to this
144
+ // filter: it is skipped and loudly logged, and every other filter on
145
+ // the message still evaluates and applies. This sits inside the
146
+ // evaluate-level catch — a whole-phase failure still degrades to no
147
+ // filter actions — but stops one poisoned anchor from doing so.
148
+ const isMatch = await this.filterMatches(
149
+ accountConfigId,
150
+ usable,
151
+ msg,
152
+ embed,
153
+ ).catch((error: unknown) => {
154
+ this.log.error?.(
155
+ {
156
+ alert: "filter_anchor_match_failed",
157
+ filterId: usable.filterId,
158
+ accountConfigId,
159
+ errorName: (error as { name?: string })?.name,
160
+ error: inspect(error),
161
+ },
162
+ "Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
163
+ );
164
+ return "skip" as const;
165
+ });
166
+ if (isMatch === "skip") continue;
167
+ if (isMatch) matched.push(usable);
168
+ }
169
+ if (matched.length === 0) return EMPTY_DECISION;
170
+
171
+ const labels: FilterDecision["labels"] = [];
172
+ const seenLabels = new Set<string>();
173
+ for (const filter of matched) {
174
+ if (filter.actionLabelId === NO_ACTION) continue;
175
+ if (seenLabels.has(filter.actionLabelId)) continue;
176
+ seenLabels.add(filter.actionLabelId);
177
+ labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
178
+ }
179
+
180
+ const winner = selectMoveWinner(
181
+ matched.filter((filter) => filter.actionMailboxId !== NO_ACTION),
182
+ );
183
+
184
+ return {
185
+ labels,
186
+ ...(winner
187
+ ? {
188
+ move: {
189
+ destinationMailboxId: winner.actionMailboxId,
190
+ filterId: winner.filterId,
191
+ },
192
+ }
193
+ : {}),
194
+ };
195
+ }
196
+
197
+ private async filterMatches(
198
+ accountConfigId: string,
199
+ filter: FilterItem,
200
+ msg: FilterMessage,
201
+ embed: () => Promise<number[] | null>,
202
+ ): Promise<boolean> {
203
+ if (
204
+ !literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
205
+ ) {
206
+ return false;
207
+ }
208
+
209
+ if (!filter.hasAnchor) {
210
+ // A filter with neither clauses nor an anchor has no predicate and
211
+ // matches nothing; a literal-only filter matched iff it had clauses.
212
+ return filter.literalClauses.length > 0;
213
+ }
214
+
215
+ // One deterministic point read per semantic candidate that survived the
216
+ // literal pre-filter — never a scan, never the anchor message itself
217
+ // (RFC 034 Decision 2.3).
218
+ const anchor = await this.config.filterAnchorService.get(
219
+ accountConfigId,
220
+ filter.filterId,
221
+ );
222
+ if (!anchor) {
223
+ this.log.warn?.(
224
+ { filterId: filter.filterId, accountConfigId },
225
+ "Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
226
+ );
227
+ return false;
228
+ }
229
+
230
+ const vector = await embed();
231
+ if (!vector) {
232
+ this.log.debug?.(
233
+ { filterId: filter.filterId },
234
+ "No embedder configured; skipping semantic filter",
235
+ );
236
+ return false;
237
+ }
238
+
239
+ const threshold =
240
+ this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
241
+ return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
242
+ }
243
+ }
@@ -0,0 +1,156 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMessageRepository,
5
+ IThreadMessageRepository,
6
+ } from "@remit/data-ports";
7
+ import type { FlagPushLogger } from "./flag-push.js";
8
+ import {
9
+ type ResolveExhaustedFlagPushDeps,
10
+ resolveExhaustedFlagPushFailure,
11
+ } from "./flag-push-terminal.js";
12
+ import type { IImapConnection } from "./types.js";
13
+
14
+ interface LogEntry {
15
+ obj: Record<string, unknown>;
16
+ msg: string;
17
+ }
18
+
19
+ const buildLogger = (): {
20
+ log: FlagPushLogger;
21
+ infos: LogEntry[];
22
+ errors: LogEntry[];
23
+ } => {
24
+ const infos: LogEntry[] = [];
25
+ const errors: LogEntry[] = [];
26
+ return {
27
+ log: {
28
+ info: (obj, msg) => infos.push({ obj, msg }),
29
+ error: (obj, msg) => errors.push({ obj, msg }),
30
+ },
31
+ infos,
32
+ errors,
33
+ };
34
+ };
35
+
36
+ const buildConnection = (present: Set<number>): IImapConnection =>
37
+ ({
38
+ openBox: async () => ({}) as never,
39
+ fetchMessages: async (uids: number[]) =>
40
+ uids
41
+ .filter((uid) => present.has(uid))
42
+ .map((uid) => ({ uid }) as unknown as never),
43
+ }) as unknown as IImapConnection;
44
+
45
+ describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors #1289/#1270 for flag pushes)", () => {
46
+ it("RECONCILED (expected): the message is gone from its mailbox — marker dropped, stale row reconciled, no alarm", async () => {
47
+ const deletedMessages: string[] = [];
48
+ const deletedThreadMessages: Array<{
49
+ accountConfigId: string;
50
+ threadMessageId: string;
51
+ }> = [];
52
+ const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
53
+ const { log, infos, errors } = buildLogger();
54
+
55
+ const deps: ResolveExhaustedFlagPushDeps = {
56
+ markerService: {
57
+ delete: async (messageId: string, flagName: string) => {
58
+ markerDeletes.push({ messageId, flagName });
59
+ },
60
+ },
61
+ messageService: {
62
+ delete: async (id: string) => {
63
+ deletedMessages.push(id);
64
+ },
65
+ } as unknown as Pick<IMessageRepository, "delete">,
66
+ threadMessageService: {
67
+ findAllByMessageId: async (
68
+ accountConfigId: string,
69
+ messageId: string,
70
+ ) => [{ accountConfigId, threadMessageId: `tm-${messageId}` }],
71
+ deleteMany: async (
72
+ keys: Array<{ accountConfigId: string; threadMessageId: string }>,
73
+ ) => {
74
+ deletedThreadMessages.push(...keys);
75
+ },
76
+ } as unknown as Pick<
77
+ IThreadMessageRepository,
78
+ "findAllByMessageId" | "deleteMany"
79
+ >,
80
+ log,
81
+ };
82
+
83
+ const result = await resolveExhaustedFlagPushFailure(deps, {
84
+ accountId: "acc-1",
85
+ accountConfigId: "cfg-1",
86
+ messageId: "msg-gone",
87
+ flagName: "\\Seen",
88
+ uid: 101,
89
+ mailboxPath: "INBOX",
90
+ getConnection: async () => buildConnection(new Set()),
91
+ });
92
+
93
+ assert.equal(result.outcome, "reconciled");
94
+ assert.deepEqual(markerDeletes, [
95
+ { messageId: "msg-gone", flagName: "\\Seen" },
96
+ ]);
97
+ assert.deepEqual(deletedMessages, ["msg-gone"]);
98
+ assert.equal(deletedThreadMessages.length, 1);
99
+
100
+ assert.equal(errors.length, 0, "reconciled outcome must not log an alert");
101
+ const metricLog = infos.find(
102
+ (entry) => entry.obj.metric === "flag_push_stale_row_reconciled",
103
+ );
104
+ assert.ok(metricLog, "expected a routine reconciliation metric log");
105
+ });
106
+
107
+ it("BROKEN (should never happen): the message still exists — marker left in place, alert logged", async () => {
108
+ const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
109
+ const { log, errors } = buildLogger();
110
+
111
+ const deps: ResolveExhaustedFlagPushDeps = {
112
+ markerService: {
113
+ delete: async (messageId: string, flagName: string) => {
114
+ markerDeletes.push({ messageId, flagName });
115
+ },
116
+ },
117
+ messageService: {
118
+ delete: async () => {
119
+ throw new Error("must not be called — message still exists");
120
+ },
121
+ } as unknown as Pick<IMessageRepository, "delete">,
122
+ threadMessageService: {
123
+ findAllByMessageId: async () => {
124
+ throw new Error("must not be called — message still exists");
125
+ },
126
+ deleteMany: async () => {
127
+ throw new Error("must not be called — message still exists");
128
+ },
129
+ } as unknown as Pick<
130
+ IThreadMessageRepository,
131
+ "findAllByMessageId" | "deleteMany"
132
+ >,
133
+ log,
134
+ };
135
+
136
+ const result = await resolveExhaustedFlagPushFailure(deps, {
137
+ accountId: "acc-1",
138
+ accountConfigId: "cfg-1",
139
+ messageId: "msg-still-here",
140
+ flagName: "\\Flagged",
141
+ uid: 202,
142
+ mailboxPath: "INBOX",
143
+ getConnection: async () => buildConnection(new Set([202])),
144
+ });
145
+
146
+ assert.equal(result.outcome, "broken");
147
+ assert.deepEqual(
148
+ markerDeletes,
149
+ [],
150
+ "marker must be left in place — resync never reverts a pending flag while it is",
151
+ );
152
+
153
+ const alerted = errors.find((e) => e.obj.alert === "flag_push_failed");
154
+ assert.ok(alerted, "expected an alertable flag_push_failed log");
155
+ });
156
+ });
@@ -0,0 +1,106 @@
1
+ import type { FlagPushLogger } from "./flag-push.js";
2
+ import {
3
+ reconcileStaleMessage,
4
+ type StaleMessageReconcileDeps,
5
+ } from "./stale-message-reconcile.js";
6
+ import type { IImapConnection } from "./types.js";
7
+
8
+ export interface ResolveExhaustedFlagPushDeps
9
+ extends StaleMessageReconcileDeps {
10
+ markerService: { delete(messageId: string, flagName: string): Promise<void> };
11
+ log: FlagPushLogger;
12
+ }
13
+
14
+ export interface ResolveExhaustedFlagPushInput {
15
+ accountId: string;
16
+ accountConfigId: string;
17
+ messageId: string;
18
+ flagName: string;
19
+ uid: number;
20
+ mailboxPath: string;
21
+ getConnection: () => Promise<IImapConnection>;
22
+ }
23
+
24
+ export type FlagPushTerminalOutcome = "reconciled" | "broken";
25
+
26
+ export interface ResolveExhaustedFlagPushResult {
27
+ outcome: FlagPushTerminalOutcome;
28
+ }
29
+
30
+ /**
31
+ * Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
32
+ * redelivery budget into exactly one of two terminal outcomes, mirroring
33
+ * `resolveExhaustedPlacementMoveFailure` (#1289) and
34
+ * `resolveExhaustedBodySyncFailures` (#1270) for the same failure taxonomy
35
+ * (epic #1281 invariant 3) — no third, softer outcome.
36
+ *
37
+ * 1. RECONCILED (expected) — the message no longer exists at its mailbox on
38
+ * IMAP. Per invariant 2, an external delete supersedes the marker
39
+ * entirely: the marker is dropped and the stale Message/ThreadMessage rows
40
+ * are deleted via {@link reconcileStaleMessage}. Metric only, no alarm —
41
+ * routine.
42
+ * 2. BROKEN — the message still exists, but the flag push keeps failing.
43
+ * Broken code or a broken account, not a transient blip. The marker is
44
+ * left in place (not cleared) — while pending, resync never reverts the
45
+ * local flag (invariant 3 of this issue's own spec), so leaving it does
46
+ * not risk correctness, and dropping it here would discard the only
47
+ * record that IMAP still owes this push. Logged with an `alert`-shaped
48
+ * entry for an operator alarm; never re-thrown (terminal — the caller acks
49
+ * either way, since retrying a stale or permanently-broken push can never
50
+ * succeed).
51
+ */
52
+ export const resolveExhaustedFlagPushFailure = async (
53
+ deps: ResolveExhaustedFlagPushDeps,
54
+ input: ResolveExhaustedFlagPushInput,
55
+ ): Promise<ResolveExhaustedFlagPushResult> => {
56
+ const {
57
+ accountId,
58
+ accountConfigId,
59
+ messageId,
60
+ flagName,
61
+ uid,
62
+ mailboxPath,
63
+ getConnection,
64
+ } = input;
65
+
66
+ const connection = await getConnection();
67
+ await connection.openBox(mailboxPath, true);
68
+ const found = await connection.fetchMessages([uid]);
69
+
70
+ if (found.length === 0) {
71
+ await deps.markerService.delete(messageId, flagName);
72
+ const { threadMessagesDeleted } = await reconcileStaleMessage(
73
+ deps,
74
+ accountConfigId,
75
+ messageId,
76
+ );
77
+ deps.log.info(
78
+ {
79
+ metric: "flag_push_stale_row_reconciled",
80
+ accountId,
81
+ accountConfigId,
82
+ messageId,
83
+ flagName,
84
+ uid,
85
+ mailboxPath,
86
+ threadMessagesDeleted,
87
+ },
88
+ "Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
89
+ );
90
+ return { outcome: "reconciled" };
91
+ }
92
+
93
+ deps.log.error(
94
+ {
95
+ alert: "flag_push_failed",
96
+ accountId,
97
+ accountConfigId,
98
+ messageId,
99
+ flagName,
100
+ uid,
101
+ mailboxPath,
102
+ },
103
+ "Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation",
104
+ );
105
+ return { outcome: "broken" };
106
+ };