@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
|
@@ -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
|
+
};
|
package/src/filters/pipeline.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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 === ""
|
|
119
|
-
|
|
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,
|