@remit/mailbox-service 0.0.111 → 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.
- package/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -3,9 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import type { FilterItem } from "@remit/data-ports";
|
|
4
4
|
import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
|
|
5
5
|
import {
|
|
6
|
-
buildMatchText,
|
|
7
6
|
clauseMatches,
|
|
8
|
-
cosineSimilarity,
|
|
9
7
|
type FilterMessage,
|
|
10
8
|
literalClausesMatch,
|
|
11
9
|
literalClauseTerms,
|
|
@@ -197,7 +195,7 @@ describe("clauseMatches", () => {
|
|
|
197
195
|
});
|
|
198
196
|
|
|
199
197
|
describe("literalClausesMatch", () => {
|
|
200
|
-
it("passes vacuously with no clauses
|
|
198
|
+
it("passes vacuously with no clauses", () => {
|
|
201
199
|
assert.equal(
|
|
202
200
|
literalClausesMatch([], FilterMatchOperator.And, message()),
|
|
203
201
|
true,
|
|
@@ -243,27 +241,6 @@ describe("literalClausesMatch", () => {
|
|
|
243
241
|
});
|
|
244
242
|
});
|
|
245
243
|
|
|
246
|
-
describe("cosineSimilarity", () => {
|
|
247
|
-
it("scores identical vectors as 1", () => {
|
|
248
|
-
assert.equal(cosineSimilarity([1, 2, 3], [1, 2, 3]), 1);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("scores orthogonal vectors as 0", () => {
|
|
252
|
-
assert.equal(cosineSimilarity([1, 0], [0, 1]), 0);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("scores a zero vector as 0 rather than dividing by zero", () => {
|
|
256
|
-
assert.equal(cosineSimilarity([0, 0], [1, 1]), 0);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("throws on a dimension mismatch instead of scoring incomparable vectors", () => {
|
|
260
|
-
assert.throws(
|
|
261
|
-
() => cosineSimilarity([1, 2], [1, 2, 3]),
|
|
262
|
-
/dimension mismatch/,
|
|
263
|
-
);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
244
|
describe("selectMoveWinner", () => {
|
|
268
245
|
const filter = (
|
|
269
246
|
filterId: string,
|
|
@@ -311,20 +288,6 @@ describe("selectMoveWinner", () => {
|
|
|
311
288
|
});
|
|
312
289
|
});
|
|
313
290
|
|
|
314
|
-
describe("buildMatchText", () => {
|
|
315
|
-
it("joins subject and body and bounds the length", () => {
|
|
316
|
-
const text = buildMatchText(message({ subject: "hello", text: "world" }));
|
|
317
|
-
assert.equal(text, "hello\nworld");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("caps at 512 characters", () => {
|
|
321
|
-
const text = buildMatchText(
|
|
322
|
-
message({ subject: "", text: "x".repeat(1000) }),
|
|
323
|
-
);
|
|
324
|
-
assert.equal(text.length, 512);
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
291
|
describe("literalClauseTerms (reader #459)", () => {
|
|
329
292
|
it("narrows From and Subject onto the columns that carry them", () => {
|
|
330
293
|
assert.deepEqual(
|
package/src/filters/match.ts
CHANGED
|
@@ -16,25 +16,9 @@ type FilterClause = FilterItem["literalClauses"][number];
|
|
|
16
16
|
*/
|
|
17
17
|
export const NO_ACTION = FILTER_NO_ACTION;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Default cosine cut-off for a semantic-anchor match (RFC 031 "the semantic
|
|
21
|
-
* anchor evaluates as a kNN threshold against the new message's embedding").
|
|
22
|
-
* Tunable per pipeline via {@link FilterConfig.similarityThreshold}.
|
|
23
|
-
*/
|
|
24
|
-
export const DEFAULT_SEMANTIC_MATCH_THRESHOLD = 0.75;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Bound on the text embedded for a semantic match — the same 512-char budget
|
|
28
|
-
* `buildTextPreview` applies to chunk vectors and `FilterAnchor.anchorSourceText`
|
|
29
|
-
* persists, so the candidate side of the comparison is derived on the same
|
|
30
|
-
* footing as the anchor side.
|
|
31
|
-
*/
|
|
32
|
-
const MATCH_TEXT_LIMIT = 512;
|
|
33
|
-
|
|
34
19
|
/**
|
|
35
20
|
* The message fields a filter evaluates against — the literal-clause targets
|
|
36
|
-
* (from / subject / body)
|
|
37
|
-
* value object so the matcher stays independent of the `ParsedMail` shape and is
|
|
21
|
+
* (from / subject / body / list). A plain value object so the matcher stays independent of the `ParsedMail` shape and is
|
|
38
22
|
* trivially constructed in a test.
|
|
39
23
|
*/
|
|
40
24
|
export interface FilterMessage {
|
|
@@ -112,8 +96,8 @@ export const clauseMatches = (
|
|
|
112
96
|
|
|
113
97
|
/**
|
|
114
98
|
* Whether the literal clauses match under their combine operator. Empty clauses
|
|
115
|
-
* carry no literal constraint and pass vacuously
|
|
116
|
-
*
|
|
99
|
+
* carry no literal constraint and pass vacuously; a caller that needs a
|
|
100
|
+
* predicate checks for clauses itself.
|
|
117
101
|
*/
|
|
118
102
|
export const literalClausesMatch = (
|
|
119
103
|
clauses: readonly FilterClause[],
|
|
@@ -207,31 +191,6 @@ export const literalClauseTerms = (
|
|
|
207
191
|
return { terms, operator: "or" };
|
|
208
192
|
};
|
|
209
193
|
|
|
210
|
-
/**
|
|
211
|
-
* Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
|
|
212
|
-
* rather than silently scoring incomparable vectors — a mismatch means a stale
|
|
213
|
-
* anchor embedded under a different model (`anchorEmbeddingId`), which is a
|
|
214
|
-
* migration concern, not a match to guess at.
|
|
215
|
-
*/
|
|
216
|
-
export const cosineSimilarity = (
|
|
217
|
-
a: readonly number[],
|
|
218
|
-
b: readonly number[],
|
|
219
|
-
): number => {
|
|
220
|
-
if (a.length !== b.length) {
|
|
221
|
-
throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
|
|
222
|
-
}
|
|
223
|
-
let dot = 0;
|
|
224
|
-
let normA = 0;
|
|
225
|
-
let normB = 0;
|
|
226
|
-
for (let i = 0; i < a.length; i++) {
|
|
227
|
-
dot += a[i] * b[i];
|
|
228
|
-
normA += a[i] * a[i];
|
|
229
|
-
normB += b[i] * b[i];
|
|
230
|
-
}
|
|
231
|
-
if (normA === 0 || normB === 0) return 0;
|
|
232
|
-
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
233
|
-
};
|
|
234
|
-
|
|
235
194
|
/**
|
|
236
195
|
* The move a message ends in when several filters matched: the filter whose
|
|
237
196
|
* predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
|
|
@@ -264,11 +223,3 @@ export const selectMoveWinner = (
|
|
|
264
223
|
}
|
|
265
224
|
return winner;
|
|
266
225
|
};
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The candidate text embedded once for a semantic match — subject then body,
|
|
270
|
-
* bounded to the anchor's `anchorSourceText` budget so both sides of the cosine
|
|
271
|
-
* comparison are derived from comparable inputs.
|
|
272
|
-
*/
|
|
273
|
-
export const buildMatchText = (msg: FilterMessage): string =>
|
|
274
|
-
`${msg.subject}\n${msg.text}`.slice(0, MATCH_TEXT_LIMIT);
|
|
@@ -1,330 +1,199 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The index-time filter pass over an anchorless literal filter — the shape the
|
|
3
|
-
* organize widen creates on a deployment without the vector pipeline: `From`
|
|
4
|
-
* clauses combined with `Or`, `hasAnchor: false`. This is what keeps a standing
|
|
5
|
-
* "move all mail from these senders" filter working on future mail with no
|
|
6
|
-
* vectors, so it is pinned directly: a matching sender resolves the move, a
|
|
7
|
-
* non-matching one resolves nothing, and the embedder is never touched.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import assert from "node:assert/strict";
|
|
11
|
-
import { describe, it } from "node:test";
|
|
12
|
-
import type {
|
|
13
|
-
FilterItem,
|
|
14
|
-
IFilterAnchorRepository,
|
|
15
|
-
IFilterRepository,
|
|
16
|
-
IMessageLabelRepository,
|
|
17
|
-
} from "@remit/data-ports";
|
|
2
|
+
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
|
3
|
+
import type { AccountItem, CreateFilterInput } from "@remit/data-ports";
|
|
18
4
|
import {
|
|
5
|
+
AccountAuthType,
|
|
6
|
+
ConnectionState,
|
|
19
7
|
FilterClauseField,
|
|
20
8
|
FilterMatchOperator,
|
|
21
|
-
|
|
9
|
+
FilterScope,
|
|
22
10
|
} from "@remit/domain-enums";
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
|
|
11
|
+
import { PlacementMoveService } from "../placement-move.js";
|
|
12
|
+
import {
|
|
13
|
+
openStore,
|
|
14
|
+
type Store,
|
|
15
|
+
seedAccount,
|
|
16
|
+
seedMailbox,
|
|
17
|
+
} from "../test-helpers/test-store.js";
|
|
18
|
+
import type { FilterMessage } from "./match.js";
|
|
19
|
+
import { FilterPipeline } from "./pipeline.js";
|
|
20
|
+
|
|
21
|
+
const ACCOUNT = "cfg-1";
|
|
22
|
+
|
|
23
|
+
let store: Store;
|
|
24
|
+
let account: AccountItem;
|
|
25
|
+
let archive: string;
|
|
26
|
+
let projects: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
store = await openStore();
|
|
30
|
+
account = await seedAccount(store, { accountConfigId: ACCOUNT });
|
|
31
|
+
archive = (await seedMailbox(store, account, "Archive")).mailboxId;
|
|
32
|
+
projects = (await seedMailbox(store, account, "Projects")).mailboxId;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
mock.timers.reset();
|
|
37
|
+
await store.close();
|
|
38
|
+
});
|
|
26
39
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const fromGithub: FilterMessage = {
|
|
41
|
+
from: "npm@github.com",
|
|
42
|
+
fromName: "npm",
|
|
43
|
+
subject: "A new version of left-pad is available",
|
|
44
|
+
text: "body",
|
|
45
|
+
listId: "",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const receipt: FilterMessage = {
|
|
49
|
+
from: "billing@stripe.com",
|
|
50
|
+
fromName: "Stripe",
|
|
51
|
+
subject: "Your receipt",
|
|
52
|
+
text: "Thanks for your payment",
|
|
53
|
+
listId: "",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const senders = (actionMailboxId: string) =>
|
|
57
|
+
store.filter.create({
|
|
58
|
+
accountConfigId: ACCOUNT,
|
|
59
|
+
name: "rule",
|
|
60
|
+
scope: FilterScope.Standing,
|
|
36
61
|
matchOperator: FilterMatchOperator.Or,
|
|
37
62
|
literalClauses: [
|
|
38
63
|
{ field: FilterClauseField.From, value: "npm@github.com" },
|
|
39
64
|
{ field: FilterClauseField.From, value: "notifications@github.com" },
|
|
40
65
|
],
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
createdAt: 1,
|
|
44
|
-
updatedAt: 1,
|
|
45
|
-
}) as unknown as FilterItem;
|
|
66
|
+
actionMailboxId,
|
|
67
|
+
});
|
|
46
68
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return [];
|
|
63
|
-
},
|
|
64
|
-
embeddingId: "test-model@0",
|
|
69
|
+
const pipeline = () =>
|
|
70
|
+
new FilterPipeline(
|
|
71
|
+
{
|
|
72
|
+
filterService: store.filter,
|
|
73
|
+
messageLabelService: store.messageLabel,
|
|
74
|
+
mailboxService: store.mailbox,
|
|
75
|
+
placementMoveService: new PlacementMoveService({
|
|
76
|
+
messageService: store.message,
|
|
77
|
+
threadMessageService: store.threadMessage,
|
|
78
|
+
markerService: store.placementMove,
|
|
79
|
+
addressService: store.address,
|
|
80
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
81
|
+
mailboxService: store.mailbox,
|
|
82
|
+
sqsQueueUrl: "https://sqs.test/message-mgmt",
|
|
83
|
+
}),
|
|
65
84
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const decision = await pipeline.evaluate(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
85
|
+
{ info: () => {}, error: () => {} },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
describe("FilterPipeline over literal filters", () => {
|
|
89
|
+
it("resolves the move for a matching sender", async () => {
|
|
90
|
+
const filter = await senders(archive);
|
|
91
|
+
|
|
92
|
+
const decision = await pipeline().evaluate(
|
|
93
|
+
ACCOUNT,
|
|
94
|
+
account.accountId,
|
|
95
|
+
"m-1",
|
|
96
|
+
fromGithub,
|
|
97
|
+
);
|
|
80
98
|
|
|
81
99
|
assert.deepEqual(decision.move, {
|
|
82
|
-
destinationMailboxId:
|
|
83
|
-
filterId:
|
|
100
|
+
destinationMailboxId: archive,
|
|
101
|
+
filterId: filter.filterId,
|
|
84
102
|
});
|
|
85
|
-
// A literal-only filter is decided by its clauses alone — the embedder is
|
|
86
|
-
// never constructed or called.
|
|
87
|
-
assert.equal(state.embedCalls, 0);
|
|
88
103
|
});
|
|
89
104
|
|
|
90
|
-
it("resolves nothing for a sender
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
it("resolves nothing for a sender no clause names", async () => {
|
|
106
|
+
await senders(archive);
|
|
107
|
+
|
|
108
|
+
const decision = await pipeline().evaluate(
|
|
109
|
+
ACCOUNT,
|
|
110
|
+
account.accountId,
|
|
111
|
+
"m-2",
|
|
112
|
+
{
|
|
113
|
+
...fromGithub,
|
|
114
|
+
from: "hello@stripe.com",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
99
117
|
|
|
100
118
|
assert.equal(decision.move, undefined);
|
|
101
119
|
assert.deepEqual(decision.labels, []);
|
|
102
|
-
assert.equal(state.embedCalls, 0);
|
|
103
120
|
});
|
|
104
|
-
});
|
|
105
121
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
actionChangedAt: 2,
|
|
119
|
-
}) as unknown as FilterItem;
|
|
120
|
-
|
|
121
|
-
it("resolves a single destination from the more-recently-changed action", async () => {
|
|
122
|
-
const older = {
|
|
123
|
-
...senderFilter("mbx-archive"),
|
|
124
|
-
actionChangedAt: 1,
|
|
125
|
-
} as unknown as FilterItem;
|
|
126
|
-
const { pipeline } = buildPipeline(older, rivalFilter());
|
|
127
|
-
const decision = await pipeline.evaluate("cfg-1", "m-3", {
|
|
128
|
-
from: "npm@github.com",
|
|
129
|
-
fromName: "npm",
|
|
130
|
-
subject: "A new version of left-pad is available",
|
|
131
|
-
text: "body",
|
|
132
|
-
listId: "",
|
|
133
|
-
});
|
|
122
|
+
it("gives a contested message to the more recently changed action", async () => {
|
|
123
|
+
mock.timers.enable({ apis: ["Date"], now: 1_000_000 });
|
|
124
|
+
await senders(archive);
|
|
125
|
+
mock.timers.setTime(9_000_000);
|
|
126
|
+
const rival = await senders(projects);
|
|
127
|
+
|
|
128
|
+
const decision = await pipeline().evaluate(
|
|
129
|
+
ACCOUNT,
|
|
130
|
+
account.accountId,
|
|
131
|
+
"m-3",
|
|
132
|
+
fromGithub,
|
|
133
|
+
);
|
|
134
134
|
|
|
135
135
|
assert.deepEqual(decision.move, {
|
|
136
|
-
destinationMailboxId:
|
|
137
|
-
filterId:
|
|
136
|
+
destinationMailboxId: projects,
|
|
137
|
+
filterId: rival.filterId,
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
describe("FilterPipeline
|
|
143
|
-
|
|
144
|
-
({
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
listId: "",
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
|
|
169
|
-
const putCalls: Array<Record<string, unknown>> = [];
|
|
170
|
-
let embedCalls = 0;
|
|
171
|
-
const config: FilterConfig = {
|
|
172
|
-
filterService: {
|
|
173
|
-
listByAccountAndState: async () => [anchoredFilter()],
|
|
174
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
175
|
-
} as unknown as IFilterRepository,
|
|
176
|
-
filterAnchorService: {
|
|
177
|
-
get: async () => ({
|
|
178
|
-
accountConfigId: "cfg-1",
|
|
179
|
-
filterId: "flt-semantic",
|
|
180
|
-
// Stale-model space — orthogonal to what the current model
|
|
181
|
-
// produces, so a raw comparison against it would not match.
|
|
182
|
-
anchorEmbedding: [0, 1, 0],
|
|
183
|
-
anchorEmbeddingId: "old-model@3",
|
|
184
|
-
anchorSourceText: "your receipt is ready",
|
|
185
|
-
anchorMessageId: "msg-anchor",
|
|
186
|
-
}),
|
|
187
|
-
put: async (input: Record<string, unknown>) => {
|
|
188
|
-
putCalls.push(input);
|
|
189
|
-
return { ...input, createdAt: 1, updatedAt: 2 };
|
|
190
|
-
},
|
|
191
|
-
} as unknown as IFilterAnchorRepository,
|
|
192
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
193
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
194
|
-
embedder: {
|
|
195
|
-
embed: async () => {
|
|
196
|
-
embedCalls += 1;
|
|
197
|
-
return [1, 0, 0];
|
|
198
|
-
},
|
|
199
|
-
embeddingId: "new-model@3",
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const decision = await new FilterPipeline(config, {
|
|
204
|
-
info: () => {},
|
|
205
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
206
|
-
|
|
207
|
-
assert.deepEqual(
|
|
208
|
-
decision.labels,
|
|
209
|
-
[{ labelId: "lbl-1", filterId: "flt-semantic" }],
|
|
210
|
-
"the anchor matches once re-embedded into the current model's space",
|
|
142
|
+
describe("FilterPipeline scopes a filter to its folder's account", () => {
|
|
143
|
+
it("leaves another account's mail alone, as if the filter had not matched", async () => {
|
|
144
|
+
const sibling = await store.account.create({
|
|
145
|
+
accountConfigId: ACCOUNT,
|
|
146
|
+
username: "me-too@example.com",
|
|
147
|
+
email: "me-too@example.com",
|
|
148
|
+
authType: AccountAuthType.Password,
|
|
149
|
+
passwordHash: '{"ciphertext":"x"}',
|
|
150
|
+
imapHost: "imap.example.com",
|
|
151
|
+
imapPort: 993,
|
|
152
|
+
imapTls: true,
|
|
153
|
+
imapStartTls: false,
|
|
154
|
+
isActive: true,
|
|
155
|
+
connectionState: ConnectionState.Authenticated,
|
|
156
|
+
});
|
|
157
|
+
const travel = await seedMailbox(store, sibling, "Travel");
|
|
158
|
+
const filter = await senders(travel.mailboxId);
|
|
159
|
+
|
|
160
|
+
const mine = await pipeline().evaluate(
|
|
161
|
+
ACCOUNT,
|
|
162
|
+
account.accountId,
|
|
163
|
+
"m-1",
|
|
164
|
+
fromGithub,
|
|
211
165
|
);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
"
|
|
166
|
+
const theirs = await pipeline().evaluate(
|
|
167
|
+
ACCOUNT,
|
|
168
|
+
sibling.accountId,
|
|
169
|
+
"m-2",
|
|
170
|
+
fromGithub,
|
|
216
171
|
);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
anchorSourceText: "your receipt is ready",
|
|
223
|
-
anchorMessageId: "msg-anchor",
|
|
172
|
+
|
|
173
|
+
assert.deepEqual(mine, { labels: [] });
|
|
174
|
+
assert.deepEqual(theirs.move, {
|
|
175
|
+
destinationMailboxId: travel.mailboxId,
|
|
176
|
+
filterId: filter.filterId,
|
|
224
177
|
});
|
|
225
178
|
assert.equal(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"one embed re-embeds the anchor's source text, one embeds the candidate message",
|
|
179
|
+
(await store.filter.get(ACCOUNT, filter.filterId)).state,
|
|
180
|
+
"Active",
|
|
229
181
|
);
|
|
230
182
|
});
|
|
231
183
|
|
|
232
|
-
it("
|
|
233
|
-
|
|
234
|
-
let embedCalls = 0;
|
|
235
|
-
const config: FilterConfig = {
|
|
236
|
-
filterService: {
|
|
237
|
-
listByAccountAndState: async () => [anchoredFilter()],
|
|
238
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
239
|
-
} as unknown as IFilterRepository,
|
|
240
|
-
filterAnchorService: {
|
|
241
|
-
get: async () => ({
|
|
242
|
-
anchorEmbedding: [1, 0, 0],
|
|
243
|
-
anchorEmbeddingId: "current-model@3",
|
|
244
|
-
anchorSourceText: "your receipt is ready",
|
|
245
|
-
anchorMessageId: "msg-anchor",
|
|
246
|
-
}),
|
|
247
|
-
put: async () => {
|
|
248
|
-
putCalls += 1;
|
|
249
|
-
throw new Error("must not be called");
|
|
250
|
-
},
|
|
251
|
-
} as unknown as IFilterAnchorRepository,
|
|
252
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
253
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
254
|
-
embedder: {
|
|
255
|
-
embed: async () => {
|
|
256
|
-
embedCalls += 1;
|
|
257
|
-
return [1, 0, 0];
|
|
258
|
-
},
|
|
259
|
-
embeddingId: "current-model@3",
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const decision = await new FilterPipeline(config, {
|
|
264
|
-
info: () => {},
|
|
265
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
184
|
+
it("turns the filter off when no account holds its folder any more", async () => {
|
|
185
|
+
const filter = await senders("mbx-gone");
|
|
266
186
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
embedCalls,
|
|
273
|
-
1,
|
|
274
|
-
"only the candidate message is embedded — no wasted re-embed call",
|
|
187
|
+
const decision = await pipeline().evaluate(
|
|
188
|
+
ACCOUNT,
|
|
189
|
+
account.accountId,
|
|
190
|
+
"m-1",
|
|
191
|
+
fromGithub,
|
|
275
192
|
);
|
|
276
|
-
});
|
|
277
193
|
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
name: "stripe receipts",
|
|
283
|
-
scope: "Standing",
|
|
284
|
-
state: FilterState.Active,
|
|
285
|
-
hasAnchor: false,
|
|
286
|
-
ruleChangedAt: 1,
|
|
287
|
-
matchOperator: FilterMatchOperator.Or,
|
|
288
|
-
literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
|
|
289
|
-
actionLabelId: "lbl-literal",
|
|
290
|
-
actionMailboxId: NO_ACTION,
|
|
291
|
-
createdAt: 1,
|
|
292
|
-
updatedAt: 1,
|
|
293
|
-
} as unknown as FilterItem;
|
|
294
|
-
|
|
295
|
-
const config: FilterConfig = {
|
|
296
|
-
filterService: {
|
|
297
|
-
listByAccountAndState: async () => [anchoredFilter(), literalFilter],
|
|
298
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
299
|
-
} as unknown as IFilterRepository,
|
|
300
|
-
filterAnchorService: {
|
|
301
|
-
get: async () => ({
|
|
302
|
-
anchorEmbedding: [0, 1, 0],
|
|
303
|
-
anchorEmbeddingId: "old-model@3",
|
|
304
|
-
anchorSourceText: "your receipt is ready",
|
|
305
|
-
anchorMessageId: "msg-anchor",
|
|
306
|
-
}),
|
|
307
|
-
put: async () => {
|
|
308
|
-
throw new Error("SQLITE_BUSY");
|
|
309
|
-
},
|
|
310
|
-
} as unknown as IFilterAnchorRepository,
|
|
311
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
312
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
313
|
-
embedder: {
|
|
314
|
-
embed: async () => [1, 0, 0],
|
|
315
|
-
embeddingId: "new-model@3",
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
const decision = await new FilterPipeline(config, {
|
|
320
|
-
info: () => {},
|
|
321
|
-
error: () => {},
|
|
322
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
323
|
-
|
|
324
|
-
assert.deepEqual(
|
|
325
|
-
decision.labels,
|
|
326
|
-
[{ labelId: "lbl-literal", filterId: "flt-literal" }],
|
|
327
|
-
"the anchored filter's re-embed failure is isolated — the literal filter still applies",
|
|
328
|
-
);
|
|
194
|
+
assert.deepEqual(decision, { labels: [] });
|
|
195
|
+
const stored = await store.filter.get(ACCOUNT, filter.filterId);
|
|
196
|
+
assert.equal(stored.state, "Disabled");
|
|
197
|
+
assert.equal(stored.disabledReason, "FolderMissing");
|
|
329
198
|
});
|
|
330
199
|
});
|