@remit/imap-worker 0.0.22 → 0.0.23
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/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"@remit/logger-lambda": "*",
|
|
38
38
|
"@remit/mailbox-service": "*",
|
|
39
39
|
"@remit/search-index-worker": "*",
|
|
40
|
+
"@remit/search-service": "*",
|
|
40
41
|
"@remit/sqs-client": "*",
|
|
41
42
|
"@remit/backend": "*",
|
|
42
43
|
"@remit/domain-enums": "*",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
FilterItem,
|
|
5
|
+
IFilterAnchorRepository,
|
|
6
|
+
IFilterRepository,
|
|
7
|
+
IMessageLabelRepository,
|
|
8
|
+
} from "@remit/data-ports";
|
|
9
|
+
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
10
|
+
import {
|
|
11
|
+
type FilterMessage,
|
|
12
|
+
FilterPipeline,
|
|
13
|
+
type MessageEmbedder,
|
|
14
|
+
NO_ACTION,
|
|
15
|
+
type PlacementMoveService,
|
|
16
|
+
} from "@remit/mailbox-service";
|
|
17
|
+
import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
|
|
18
|
+
|
|
19
|
+
const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
|
|
20
|
+
({
|
|
21
|
+
filterId: "flt-semantic",
|
|
22
|
+
accountConfigId: "cfg-1",
|
|
23
|
+
name: "receipts",
|
|
24
|
+
scope: "Standing",
|
|
25
|
+
state: FilterState.Active,
|
|
26
|
+
hasAnchor: true,
|
|
27
|
+
ruleChangedAt: 1,
|
|
28
|
+
matchOperator: FilterMatchOperator.Or,
|
|
29
|
+
literalClauses: [],
|
|
30
|
+
actionLabelId: NO_ACTION,
|
|
31
|
+
actionMailboxId: destinationMailboxId,
|
|
32
|
+
createdAt: 1,
|
|
33
|
+
updatedAt: 1,
|
|
34
|
+
}) as unknown as FilterItem;
|
|
35
|
+
|
|
36
|
+
const repos = (filter: FilterItem): FilterConfigDeps => ({
|
|
37
|
+
filterService: {
|
|
38
|
+
listByAccountAndState: async () => [filter],
|
|
39
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
40
|
+
} as unknown as IFilterRepository,
|
|
41
|
+
filterAnchorService: {
|
|
42
|
+
get: async () => ({ anchorEmbedding: [1, 0, 0] }),
|
|
43
|
+
} as unknown as IFilterAnchorRepository,
|
|
44
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
45
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const message: FilterMessage = {
|
|
49
|
+
from: "billing@stripe.com",
|
|
50
|
+
fromName: "Stripe",
|
|
51
|
+
subject: "Your receipt",
|
|
52
|
+
text: "Thanks for your payment",
|
|
53
|
+
listId: "",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("buildFilterConfig", () => {
|
|
57
|
+
it("returns undefined without a placement mover — no move path, filters stay off", () => {
|
|
58
|
+
const deps = repos(anchorOnlyFilter("mbx-archive"));
|
|
59
|
+
deps.placementMoveService = undefined;
|
|
60
|
+
|
|
61
|
+
assert.equal(buildFilterConfig(deps), undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("wires an embedder into the config", () => {
|
|
65
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
66
|
+
|
|
67
|
+
assert.ok(config);
|
|
68
|
+
assert.ok(config.embedder);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
|
|
72
|
+
let embedCalls = 0;
|
|
73
|
+
const embedder: MessageEmbedder = {
|
|
74
|
+
embed: async () => {
|
|
75
|
+
embedCalls += 1;
|
|
76
|
+
return [1, 0, 0];
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const config = buildFilterConfig(
|
|
80
|
+
repos(anchorOnlyFilter("mbx-archive")),
|
|
81
|
+
embedder,
|
|
82
|
+
);
|
|
83
|
+
assert.ok(config);
|
|
84
|
+
|
|
85
|
+
const decision = await new FilterPipeline(config, {
|
|
86
|
+
info: () => {},
|
|
87
|
+
}).evaluate("cfg-1", "m-1", message);
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(decision.move, {
|
|
90
|
+
destinationMailboxId: "mbx-archive",
|
|
91
|
+
filterId: "flt-semantic",
|
|
92
|
+
});
|
|
93
|
+
assert.equal(embedCalls, 1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not match when the message embedding diverges from the anchor", async () => {
|
|
97
|
+
const embedder: MessageEmbedder = {
|
|
98
|
+
embed: async () => [0, 1, 0],
|
|
99
|
+
};
|
|
100
|
+
const config = buildFilterConfig(
|
|
101
|
+
repos(anchorOnlyFilter("mbx-archive")),
|
|
102
|
+
embedder,
|
|
103
|
+
);
|
|
104
|
+
assert.ok(config);
|
|
105
|
+
|
|
106
|
+
const decision = await new FilterPipeline(config, {
|
|
107
|
+
info: () => {},
|
|
108
|
+
}).evaluate("cfg-1", "m-2", message);
|
|
109
|
+
|
|
110
|
+
assert.equal(decision.move, undefined);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IFilterAnchorRepository,
|
|
3
|
+
IFilterRepository,
|
|
4
|
+
IMessageLabelRepository,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import type {
|
|
7
|
+
FilterConfig,
|
|
8
|
+
MessageEmbedder,
|
|
9
|
+
PlacementMoveService,
|
|
10
|
+
} from "@remit/mailbox-service";
|
|
11
|
+
import { getMessageEmbedder } from "./message-embedder.js";
|
|
12
|
+
|
|
13
|
+
export interface FilterConfigDeps {
|
|
14
|
+
filterService: IFilterRepository;
|
|
15
|
+
filterAnchorService: IFilterAnchorRepository;
|
|
16
|
+
messageLabelService: IMessageLabelRepository;
|
|
17
|
+
placementMoveService?: PlacementMoveService;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Assemble the index-time filter config the body-sync pass runs (RFC 034). The
|
|
22
|
+
* embedder is provisioned from env exactly as the backend read-path and
|
|
23
|
+
* search-index worker provision theirs, so a semantic (anchor-only) filter is
|
|
24
|
+
* evaluated on incoming mail instead of silently skipped.
|
|
25
|
+
*
|
|
26
|
+
* Absent the placement mover there is no move path, so filters stay off — a
|
|
27
|
+
* matched filter's actions reuse the same enqueue plumbing the placement mover
|
|
28
|
+
* owns.
|
|
29
|
+
*/
|
|
30
|
+
export const buildFilterConfig = (
|
|
31
|
+
deps: FilterConfigDeps,
|
|
32
|
+
embedder?: MessageEmbedder,
|
|
33
|
+
): FilterConfig | undefined => {
|
|
34
|
+
const { placementMoveService } = deps;
|
|
35
|
+
if (!placementMoveService) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
filterService: deps.filterService,
|
|
38
|
+
filterAnchorService: deps.filterAnchorService,
|
|
39
|
+
messageLabelService: deps.messageLabelService,
|
|
40
|
+
placementMoveService,
|
|
41
|
+
embedder: embedder ?? getMessageEmbedder(),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -3,7 +3,6 @@ import type { Logger } from "@remit/logger-lambda";
|
|
|
3
3
|
import { MetricUnit, metrics } from "@remit/logger-lambda";
|
|
4
4
|
import {
|
|
5
5
|
BodySyncService,
|
|
6
|
-
type FilterConfig,
|
|
7
6
|
guardConnectionCursor,
|
|
8
7
|
isCursorRebuildNeeded,
|
|
9
8
|
MailboxCursorPausedError,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
createConnectionScopeWithCredentials,
|
|
20
19
|
} from "../connection-scope.js";
|
|
21
20
|
import type { SyncMessageBodyEvent } from "../events.js";
|
|
21
|
+
import { buildFilterConfig } from "../filter-config.js";
|
|
22
22
|
import { isNotFoundError } from "../is-not-found.js";
|
|
23
23
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
24
24
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
@@ -237,16 +237,14 @@ export const syncMessageBody = async (
|
|
|
237
237
|
|
|
238
238
|
// A matched filter's actions (label upsert, exclusive move) apply on
|
|
239
239
|
// the same body-sync pass, reusing the placement mover for the move
|
|
240
|
-
// (RFC 034 Decision 3.1).
|
|
241
|
-
//
|
|
242
|
-
const filterConfig
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
: undefined;
|
|
240
|
+
// (RFC 034 Decision 3.1). The env-provisioned embedder lets a semantic
|
|
241
|
+
// (anchor-only) filter match here instead of being silently skipped.
|
|
242
|
+
const filterConfig = buildFilterConfig({
|
|
243
|
+
filterService,
|
|
244
|
+
filterAnchorService,
|
|
245
|
+
messageLabelService,
|
|
246
|
+
placementMoveService,
|
|
247
|
+
});
|
|
250
248
|
|
|
251
249
|
const bodySyncService = new BodySyncService(
|
|
252
250
|
messageService,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
_resetMessageEmbedderForTest,
|
|
5
|
+
getMessageEmbedder,
|
|
6
|
+
} from "./message-embedder.js";
|
|
7
|
+
|
|
8
|
+
describe("getMessageEmbedder", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
_resetMessageEmbedderForTest();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("adapts the batch embedding service into a single-text message embedder", async () => {
|
|
14
|
+
const vector = await getMessageEmbedder().embed("invoice from stripe");
|
|
15
|
+
|
|
16
|
+
assert.ok(Array.isArray(vector));
|
|
17
|
+
assert.ok(vector.length > 0);
|
|
18
|
+
assert.ok(vector.every((value) => typeof value === "number"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("embeds the same text deterministically", async () => {
|
|
22
|
+
const embedder = getMessageEmbedder();
|
|
23
|
+
const a = await embedder.embed("your monthly receipt");
|
|
24
|
+
const b = await embedder.embed("your monthly receipt");
|
|
25
|
+
|
|
26
|
+
assert.deepEqual(a, b);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("memoizes the embedder across calls", () => {
|
|
30
|
+
assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rebuilds after a reset", () => {
|
|
34
|
+
const first = getMessageEmbedder();
|
|
35
|
+
_resetMessageEmbedderForTest();
|
|
36
|
+
|
|
37
|
+
assert.notStrictEqual(getMessageEmbedder(), first);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { MessageEmbedder } from "@remit/mailbox-service";
|
|
2
|
+
import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adapt the batch {@link EmbeddingService} the rest of the stack composes from
|
|
6
|
+
* env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
|
|
7
|
+
* incoming message becomes one message-level vector to compare against a filter's
|
|
8
|
+
* persisted anchor (RFC 034 Decision 2.1/2.3).
|
|
9
|
+
*
|
|
10
|
+
* The embedder is selected by the same `SEARCH_EMBEDDING_*` env the backend and
|
|
11
|
+
* search-index worker read, so the worker embeds under the identical model the
|
|
12
|
+
* anchors were built with. The instance is memoized across warm invocations so a
|
|
13
|
+
* local Transformers.js model loads once per container rather than per event.
|
|
14
|
+
*/
|
|
15
|
+
let cached: MessageEmbedder | undefined;
|
|
16
|
+
|
|
17
|
+
export const getMessageEmbedder = (): MessageEmbedder => {
|
|
18
|
+
if (cached) return cached;
|
|
19
|
+
const service = buildEmbeddingServiceFromEnv();
|
|
20
|
+
cached = {
|
|
21
|
+
embed: async (text: string): Promise<number[]> => {
|
|
22
|
+
const [vector] = await service.embed([text]);
|
|
23
|
+
return vector;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return cached;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Reset the singleton — test use only. */
|
|
30
|
+
export const _resetMessageEmbedderForTest = (): void => {
|
|
31
|
+
cached = undefined;
|
|
32
|
+
};
|