@remit/mailbox-service 0.0.61 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,55 @@
1
+ import type {
2
+ FilterAnchorItem,
3
+ IFilterAnchorRepository,
4
+ } from "@remit/data-ports";
5
+
6
+ /**
7
+ * The half of an embedding service an anchor refresh needs: the text-to-vector
8
+ * call and the `<modelId>@<dimensions>` identifier of the model behind it (the
9
+ * same scheme `EmbeddingService.embeddingId` derives). Compared against
10
+ * `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
11
+ * swap would otherwise pass through silently (RFC 039 Decision 1a, reader
12
+ * #295).
13
+ */
14
+ export interface AnchorEmbedder {
15
+ embed(text: string): Promise<number[]>;
16
+ readonly embeddingId: string;
17
+ }
18
+
19
+ export interface AnchorDriftDeps {
20
+ anchorRepository: Pick<IFilterAnchorRepository, "put">;
21
+ /** Absent on a deployment with no embedder wired; then nothing can drift. */
22
+ embedder: AnchorEmbedder | undefined;
23
+ }
24
+
25
+ /**
26
+ * The anchor to score against, re-embedded in place when the embedding model
27
+ * has drifted since it was written (RFC 039 Decision 1a). No migration job
28
+ * walks these rows proactively, so the refresh is lazy: the first read that
29
+ * notices the stamp no longer matches the configured model re-embeds the
30
+ * already-persisted `anchorSourceText` and writes it back under the current
31
+ * id. A failure here is never a terminal state — the row is left as it was,
32
+ * so the next read that reaches this anchor retries.
33
+ *
34
+ * The single mechanism behind both index-time matching
35
+ * ({@link FilterPipeline}) and the back-apply pass's cross-filter precedence
36
+ * check, which must agree on what a filter currently matches (reader #399).
37
+ */
38
+ export const refreshAnchorForEmbedder = async (
39
+ deps: AnchorDriftDeps,
40
+ anchor: FilterAnchorItem,
41
+ ): Promise<FilterAnchorItem> => {
42
+ const { embedder } = deps;
43
+ if (!embedder || anchor.anchorEmbeddingId === embedder.embeddingId) {
44
+ return anchor;
45
+ }
46
+ const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
47
+ return deps.anchorRepository.put({
48
+ accountConfigId: anchor.accountConfigId,
49
+ filterId: anchor.filterId,
50
+ anchorEmbedding,
51
+ anchorEmbeddingId: embedder.embeddingId,
52
+ anchorSourceText: anchor.anchorSourceText,
53
+ anchorMessageId: anchor.anchorMessageId,
54
+ });
55
+ };
@@ -7,6 +7,10 @@ import type {
7
7
  } from "@remit/data-ports";
8
8
  import { FilterState } from "@remit/domain-enums";
9
9
  import type { PlacementMoveService } from "../placement-move.js";
10
+ import {
11
+ type AnchorEmbedder,
12
+ refreshAnchorForEmbedder,
13
+ } from "./anchor-drift.js";
10
14
  import {
11
15
  buildMatchText,
12
16
  cosineSimilarity,
@@ -20,22 +24,12 @@ import {
20
24
  /**
21
25
  * Turns the candidate message's text into a single message-level vector to
22
26
  * 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.
27
+ * embedded here only to repair a drifted anchor in place (see
28
+ * {@link refreshAnchorForEmbedder}); the match itself reads `FilterAnchor` as
29
+ * a fixed fact (RFC 034 Decision 2.1/2.3), and the incoming message is
30
+ * embedded once per message, only when a semantic filter is actually in play.
26
31
  */
27
- export interface MessageEmbedder {
28
- embed(text: string): Promise<number[]>;
29
- /**
30
- * `<modelId>@<dimensions>` identifier of the model currently configured —
31
- * the same scheme `EmbeddingService.embeddingId`
32
- * (`packages/search-service/src/embeddings.ts`) derives. Compared against
33
- * `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
34
- * swap would otherwise pass through silently (RFC 039 Decision 1a, reader
35
- * #295).
36
- */
37
- readonly embeddingId: string;
38
- }
32
+ export type MessageEmbedder = AnchorEmbedder;
39
33
 
40
34
  export interface FilterLogger {
41
35
  info(obj: Record<string, unknown>, msg: string): void;
@@ -236,26 +230,16 @@ export class FilterPipeline {
236
230
  return false;
237
231
  }
238
232
 
239
- const embedder = this.config.embedder;
240
- if (embedder && anchor.anchorEmbeddingId !== embedder.embeddingId) {
241
- // The embedding model has drifted since this anchor was last written
242
- // (RFC 039 Decision 1a) — re-embed the already-persisted
243
- // `anchorSourceText` and write it back in place, lazily, on this read.
244
- // No migration job walks these rows proactively; a re-embed failure
245
- // here propagates to the per-filter catch in `match()`, which logs and
246
- // skips this filter for this one evaluation exactly as an unrecoverable
247
- // stale anchor does today — never a terminal state, so the next
248
- // message that reaches this filter retries.
249
- const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
250
- anchor = await this.config.filterAnchorService.put({
251
- accountConfigId,
252
- filterId: filter.filterId,
253
- anchorEmbedding,
254
- anchorEmbeddingId: embedder.embeddingId,
255
- anchorSourceText: anchor.anchorSourceText,
256
- anchorMessageId: anchor.anchorMessageId,
257
- });
258
- }
233
+ // A re-embed failure here propagates to the per-filter catch in
234
+ // `match()`, which logs and skips this filter for this one evaluation
235
+ // exactly as an unrecoverable stale anchor does today.
236
+ anchor = await refreshAnchorForEmbedder(
237
+ {
238
+ anchorRepository: this.config.filterAnchorService,
239
+ embedder: this.config.embedder,
240
+ },
241
+ anchor,
242
+ );
259
243
 
260
244
  const vector = await embed();
261
245
  if (!vector) {
@@ -71,7 +71,7 @@ describe("classifyDisplayNameCorrespondence", () => {
71
71
  );
72
72
  });
73
73
 
74
- it("claims nothing when the display name is the address itself", () => {
74
+ it("claims nothing when the display name is an address at the sending domain", () => {
75
75
  assert.equal(
76
76
  classifyDisplayNameCorrespondence(
77
77
  "billing@serviceupdatebank.atlassian.net",
@@ -81,6 +81,60 @@ describe("classifyDisplayNameCorrespondence", () => {
81
81
  );
82
82
  });
83
83
 
84
+ it("claims nothing when the display name spells the envelope address itself", () => {
85
+ assert.equal(
86
+ classifyDisplayNameCorrespondence("matthijs@ischen.nl", "ischen.nl"),
87
+ DisplayNameCorrespondence.NoClaim,
88
+ );
89
+ });
90
+
91
+ it("claims nothing when the display name is an address on a sibling subdomain", () => {
92
+ assert.equal(
93
+ classifyDisplayNameCorrespondence(
94
+ "noreply@example.co.uk",
95
+ "mail.example.co.uk",
96
+ ),
97
+ DisplayNameCorrespondence.NoClaim,
98
+ );
99
+ });
100
+
101
+ it("is a foreign address when the display name spells an address over another domain", () => {
102
+ assert.equal(
103
+ classifyDisplayNameCorrespondence(
104
+ "matthijs@ischen.nl",
105
+ "secresaludguaviare.gov.co",
106
+ ),
107
+ DisplayNameCorrespondence.ForeignAddress,
108
+ );
109
+ });
110
+
111
+ it("is a foreign address when the spelled-out address is decorated with a name", () => {
112
+ assert.equal(
113
+ classifyDisplayNameCorrespondence(
114
+ "Matthijs (matthijs@ischen.nl)",
115
+ "secresaludguaviare.gov.co",
116
+ ),
117
+ DisplayNameCorrespondence.ForeignAddress,
118
+ );
119
+ });
120
+
121
+ it("compares an at sign that spells no address as an ordinary name", () => {
122
+ assert.equal(
123
+ classifyDisplayNameCorrespondence(
124
+ "Support @ InfoMedics",
125
+ "infomedics.nl",
126
+ ),
127
+ DisplayNameCorrespondence.Corresponds,
128
+ );
129
+ assert.equal(
130
+ classifyDisplayNameCorrespondence(
131
+ "Support @ InfoMedics",
132
+ "serviceupdatebank.atlassian.net",
133
+ ),
134
+ DisplayNameCorrespondence.Unrelated,
135
+ );
136
+ });
137
+
84
138
  it("does not read the public suffix as a match for a brand containing it", () => {
85
139
  assert.equal(
86
140
  classifyDisplayNameCorrespondence("Netflix", "mailer.example.net"),
@@ -213,6 +267,25 @@ describe("extractSenderMismatch", () => {
213
267
  );
214
268
  });
215
269
 
270
+ it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
271
+ const parsed = await parse([
272
+ "From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
273
+ "To: matthijs@ischen.nl",
274
+ "Subject: Re: factura",
275
+ "X-HalOne-Spam-Probability: 1",
276
+ "",
277
+ "hola",
278
+ ]);
279
+ assert.equal(
280
+ extractSenderMismatch(parsed, {
281
+ fromDomain: "secresaludguaviare.gov.co",
282
+ spamClassified: true,
283
+ bulkSender: false,
284
+ }).displayNameCorrespondence,
285
+ DisplayNameCorrespondence.ForeignAddress,
286
+ );
287
+ });
288
+
216
289
  it("leaves the display name uncompared for a bulk sender", async () => {
217
290
  const parsed = await parse([
218
291
  "From: Dutch Cycling Weekly <bounce-9f2@mailer.esp.example>",
@@ -76,6 +76,22 @@ const editDistance = (a: string, b: string): number => {
76
76
  return previous[b.length];
77
77
  };
78
78
 
79
+ const ADDRESS_SHAPE = /[^\s<>@,;:"]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/i;
80
+
81
+ const registrableDomain = (host: string): string =>
82
+ getDomain(host) ?? host.toLowerCase();
83
+
84
+ /**
85
+ * The registrable domain of the address a display name spells out, or
86
+ * `undefined` when it holds an `@` without an address behind it — `Bob @ Acme`
87
+ * asserts no address and is left to the ordinary comparison.
88
+ */
89
+ const claimedAddressDomain = (displayName: string): string | undefined => {
90
+ const host = ADDRESS_SHAPE.exec(displayName)?.[1];
91
+ if (host === undefined) return undefined;
92
+ return registrableDomain(host);
93
+ };
94
+
79
95
  /**
80
96
  * The distance a name of this length may be from a domain label and still be
81
97
  * read as an imitation of it. Tight on purpose: an unrelated brand name and an
@@ -109,14 +125,27 @@ const lookalikeThreshold = (length: number): number => {
109
125
  * A bounded edit distance is the secondary test, and only reaches names that
110
126
  * nearly match a label — `InfoMedics` against `1nfomedics.nl`. It cannot promote
111
127
  * an unrelated name on its own.
128
+ *
129
+ * A name that spells out an address is decided before any of that. Over the
130
+ * sending domain it claims nothing — `billing@` shown over
131
+ * `serviceupdatebank.atlassian.net` names the same party the envelope does.
132
+ * Over any other registrable domain it is the strongest non-correspondence
133
+ * signal there is: the message asserts, in the one field the recipient reads,
134
+ * that it comes from an address that cannot have sent it.
112
135
  */
113
136
  export const classifyDisplayNameCorrespondence = (
114
137
  displayName: string | undefined,
115
138
  fromDomain: string,
116
139
  ): CorrespondenceValue => {
117
140
  const raw = (displayName ?? "").trim();
118
- if (raw === "" || raw.includes("@")) {
119
- return DisplayNameCorrespondence.NoClaim;
141
+ if (raw === "") return DisplayNameCorrespondence.NoClaim;
142
+
143
+ const claimed = claimedAddressDomain(raw);
144
+ if (claimed !== undefined) {
145
+ if (claimed === registrableDomain(fromDomain)) {
146
+ return DisplayNameCorrespondence.NoClaim;
147
+ }
148
+ return DisplayNameCorrespondence.ForeignAddress;
120
149
  }
121
150
 
122
151
  const name = normalize(raw);
package/src/index.ts CHANGED
@@ -59,6 +59,11 @@ export {
59
59
  testImapConnection,
60
60
  testSmtpConnection,
61
61
  } from "./connection-test.js";
62
+ export {
63
+ type AnchorDriftDeps,
64
+ type AnchorEmbedder,
65
+ refreshAnchorForEmbedder,
66
+ } from "./filters/anchor-drift.js";
62
67
  export { extractListId, normalizeListId } from "./filters/list-id.js";
63
68
  export {
64
69
  buildMatchText,