@remit/imap-worker 0.0.71 → 0.0.73

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.
@@ -1,171 +0,0 @@
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
- import { _resetMessageEmbedderForTest } from "./message-embedder.js";
19
-
20
- const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
21
- ({
22
- filterId: "flt-semantic",
23
- accountConfigId: "cfg-1",
24
- name: "receipts",
25
- scope: "Standing",
26
- state: FilterState.Active,
27
- hasAnchor: true,
28
- ruleChangedAt: 1,
29
- matchOperator: FilterMatchOperator.Or,
30
- literalClauses: [],
31
- actionLabelId: NO_ACTION,
32
- actionMailboxId: destinationMailboxId,
33
- createdAt: 1,
34
- updatedAt: 1,
35
- }) as unknown as FilterItem;
36
-
37
- const CURRENT_MODEL = "test-model@3";
38
-
39
- const repos = (filter: FilterItem): FilterConfigDeps => ({
40
- filterService: {
41
- listByAccountAndState: async () => [filter],
42
- refreshExpiry: async (f: FilterItem) => f,
43
- } as unknown as IFilterRepository,
44
- filterAnchorService: {
45
- get: async () => ({
46
- anchorEmbedding: [1, 0, 0],
47
- anchorEmbeddingId: CURRENT_MODEL,
48
- }),
49
- } as unknown as IFilterAnchorRepository,
50
- messageLabelService: {} as unknown as IMessageLabelRepository,
51
- placementMoveService: {} as unknown as PlacementMoveService,
52
- });
53
-
54
- const message: FilterMessage = {
55
- from: "billing@stripe.com",
56
- fromName: "Stripe",
57
- subject: "Your receipt",
58
- text: "Thanks for your payment",
59
- listId: "",
60
- };
61
-
62
- describe("buildFilterConfig", () => {
63
- it("returns undefined without a placement mover — no move path, filters stay off", () => {
64
- const deps = repos(anchorOnlyFilter("mbx-archive"));
65
- deps.placementMoveService = undefined;
66
-
67
- assert.equal(buildFilterConfig(deps), undefined);
68
- });
69
-
70
- it("wires an embedder into the config", () => {
71
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
72
-
73
- assert.ok(config);
74
- assert.ok(config.embedder);
75
- });
76
-
77
- it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
78
- let embedCalls = 0;
79
- const embedder: MessageEmbedder = {
80
- embed: async () => {
81
- embedCalls += 1;
82
- return [1, 0, 0];
83
- },
84
- embeddingId: CURRENT_MODEL,
85
- };
86
- const config = buildFilterConfig(
87
- repos(anchorOnlyFilter("mbx-archive")),
88
- embedder,
89
- );
90
- assert.ok(config);
91
-
92
- const decision = await new FilterPipeline(config, {
93
- info: () => {},
94
- }).evaluate("cfg-1", "m-1", message);
95
-
96
- assert.deepEqual(decision.move, {
97
- destinationMailboxId: "mbx-archive",
98
- filterId: "flt-semantic",
99
- });
100
- assert.equal(embedCalls, 1);
101
- });
102
-
103
- it("does not match when the message embedding diverges from the anchor", async () => {
104
- const embedder: MessageEmbedder = {
105
- embed: async () => [0, 1, 0],
106
- embeddingId: CURRENT_MODEL,
107
- };
108
- const config = buildFilterConfig(
109
- repos(anchorOnlyFilter("mbx-archive")),
110
- embedder,
111
- );
112
- assert.ok(config);
113
-
114
- const decision = await new FilterPipeline(config, {
115
- info: () => {},
116
- }).evaluate("cfg-1", "m-2", message);
117
-
118
- assert.equal(decision.move, undefined);
119
- });
120
- });
121
-
122
- // An instance with semantic search off (issue #1068). `off` selects an embedder
123
- // that throws by design, and the throw lands in the pipeline's per-filter catch:
124
- // one error-level `filter_anchor_match_failed` for every semantic filter on
125
- // every message it syncs. The pipeline already has a designed skip for a
126
- // missing embedder, so the config passes none.
127
- describe("buildFilterConfig with semantic search off", () => {
128
- const withProviderOff = async (body: () => Promise<void>): Promise<void> => {
129
- const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
130
- process.env.SEARCH_EMBEDDING_PROVIDER = "off";
131
- _resetMessageEmbedderForTest();
132
- try {
133
- await body();
134
- } finally {
135
- if (previous === undefined) {
136
- delete process.env.SEARCH_EMBEDDING_PROVIDER;
137
- } else {
138
- process.env.SEARCH_EMBEDDING_PROVIDER = previous;
139
- }
140
- _resetMessageEmbedderForTest();
141
- }
142
- };
143
-
144
- it("wires no embedder", async () => {
145
- await withProviderOff(async () => {
146
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
147
-
148
- assert.ok(config);
149
- assert.equal(config.embedder, undefined);
150
- });
151
- });
152
-
153
- it("skips the semantic filter without logging an error", async () => {
154
- await withProviderOff(async () => {
155
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
156
- assert.ok(config);
157
-
158
- const errors: unknown[] = [];
159
- const decision = await new FilterPipeline(config, {
160
- info: () => {},
161
- debug: () => {},
162
- warn: () => {},
163
- error: (fields) => errors.push(fields.alert),
164
- }).evaluate("cfg-1", "m-off", message);
165
-
166
- assert.deepEqual(decision.labels, []);
167
- assert.equal(decision.move, undefined);
168
- assert.deepEqual(errors, []);
169
- });
170
- });
171
- });
@@ -1,63 +0,0 @@
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 {
12
- EMBEDDING_PROVIDER_OFF,
13
- readEmbeddingProviderFromEnv,
14
- } from "@remit/search-service/from-env";
15
- import { getMessageEmbedder } from "./message-embedder.js";
16
-
17
- export interface FilterConfigDeps {
18
- filterService: IFilterRepository;
19
- filterAnchorService: IFilterAnchorRepository;
20
- messageLabelService: IMessageLabelRepository;
21
- placementMoveService?: PlacementMoveService;
22
- }
23
-
24
- /**
25
- * The env-selected embedder, or none on an instance with semantic search off.
26
- *
27
- * `off` embeds nothing by design, and its embedder says so by throwing
28
- * (EmbeddingDisabledError). The pipeline has a designed skip for a *missing*
29
- * embedder — semantic filters are passed over and logged at debug — but a
30
- * present embedder that throws lands in the per-filter catch instead, which is
31
- * an error-level `filter_anchor_match_failed` for every semantic filter on
32
- * every synced message. Same outcome, a log nobody can read.
33
- */
34
- const embedderFromEnv = (): MessageEmbedder | undefined =>
35
- readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF
36
- ? undefined
37
- : getMessageEmbedder();
38
-
39
- /**
40
- * Assemble the index-time filter config the body-sync pass runs (RFC 034). The
41
- * embedder is provisioned from env exactly as the backend read-path and
42
- * search-index worker provision theirs, so a semantic (anchor-only) filter is
43
- * evaluated on incoming mail instead of silently skipped.
44
- *
45
- * Absent the placement mover there is no move path, so filters stay off — a
46
- * matched filter's actions reuse the same enqueue plumbing the placement mover
47
- * owns.
48
- */
49
- export const buildFilterConfig = (
50
- deps: FilterConfigDeps,
51
- embedder?: MessageEmbedder,
52
- ): FilterConfig | undefined => {
53
- const { placementMoveService } = deps;
54
- if (!placementMoveService) return undefined;
55
- const resolved = embedder ?? embedderFromEnv();
56
- return {
57
- filterService: deps.filterService,
58
- filterAnchorService: deps.filterAnchorService,
59
- messageLabelService: deps.messageLabelService,
60
- placementMoveService,
61
- ...(resolved ? { embedder: resolved } : {}),
62
- };
63
- };
@@ -1,135 +0,0 @@
1
- import type {
2
- IMessageRepository,
3
- IThreadMessageRepository,
4
- MessageItem,
5
- ThreadMessageItem,
6
- } from "@remit/data-ports";
7
- import { MessageStatus } from "@remit/domain-enums";
8
- import { isNotFoundError } from "../is-not-found.js";
9
- import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
10
-
11
- export interface RestoreSourcePlacementDeps {
12
- messageService: Pick<IMessageRepository, "updateForMove">;
13
- threadMessageService: Pick<
14
- IThreadMessageRepository,
15
- "findAllByMessageId" | "update"
16
- >;
17
- }
18
-
19
- export interface RestoreSourcePlacementInput {
20
- accountConfigId: string;
21
- messageId: string;
22
- sourceMailboxId: string;
23
- uid: number;
24
- /**
25
- * What the row records about the mutation that will now never happen.
26
- * `synced` where the row is a faithful projection of the source again — the
27
- * server never received the command, or has just said the message is still
28
- * there. `failed` where the product refused an operation the user asked for,
29
- * which is a failure the row has to carry.
30
- */
31
- syncStatus: MessageItem["syncStatus"];
32
- /**
33
- * The listing rows, where the caller has already read them to pick this
34
- * outcome. Omitted, they are read here.
35
- */
36
- threadMessages?: ThreadMessageItem[];
37
- }
38
-
39
- /**
40
- * Put a row back on the pre-mutation source pair and hand its listing rows back
41
- * with it.
42
- *
43
- * `status: moving` has to be cleared here or the row is stuck: a give-up that
44
- * writes `syncStatus: failed` and returns leaves the row naming the destination
45
- * with the source's uid — the pair `bindsForeignUid` calls a lie and
46
- * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
47
- * (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
48
- * on a UIDVALIDITY change), so the message is undeletable and unmovable for good
49
- * (issue #1005).
50
- *
51
- * `updateForMove`, not `updateUid`: both write the same settled pair, but
52
- * `updateUid` also appends a `message.moved` outbox row so the search index can
53
- * refresh the mailbox it stores per message. Nothing here moved. The optimistic
54
- * `updateForMove` that pointed the row at the destination never enqueued one
55
- * either, so the index still holds the source and a revert has nothing to
56
- * correct — the re-index would be a forced pass over an unchanged document.
57
- *
58
- * The caller must have evidence, not an inference: this writes a placement, and
59
- * a placement written on a guess binds live mail to a uid that names somebody
60
- * else's message. Two kinds of caller qualify, and nothing else does.
61
- *
62
- * A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
63
- * answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
64
- * this the server's own answer rather than a revert on ambiguity (what PR #652
65
- * was pulled for).
66
- *
67
- * A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
68
- * openBox guard threw before the outbound command was issued (issue #1203), and
69
- * restores the pre-mutation pair off its own event. On a FIRST delivery that is
70
- * not an inference but an undo: the command provably never left, so the row is
71
- * put back exactly as it was found. A redelivery cannot claim that — the
72
- * earlier attempt's tagged OK can be lost with the connection — so it asks
73
- * `probePausedPlacement` which folder holds the message and only reaches here
74
- * once the answer rules the destination out. Either way the source mailbox is
75
- * by definition awaiting a cursor rebuild, which matches its rows by Message-ID
76
- * and adjudicates the uid: handing the row back is what puts it in the set the
77
- * rebuild walks, since a row still naming the destination is in neither
78
- * folder's.
79
- *
80
- * A row another path deleted while the probe was in flight has nothing left to
81
- * restore, and a thread row whose composites have moved on is being rewritten
82
- * by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
83
- * the conditional-check miss as one — and both are settled states here, not
84
- * faults. Throwing on them would escape the caller's `.catch()` and cost the
85
- * record its ack and its resync, which is the contract the terminal resolvers
86
- * state: never re-thrown.
87
- */
88
- export const restoreSourcePlacement = async (
89
- deps: RestoreSourcePlacementDeps,
90
- input: RestoreSourcePlacementInput,
91
- ): Promise<void> => {
92
- const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
93
- input;
94
-
95
- const skipNotFound = (error: unknown): void => {
96
- if (isNotFoundError(error)) return;
97
- throw error;
98
- };
99
-
100
- const messageRestored = await deps.messageService
101
- .updateForMove(messageId, {
102
- mailboxId: sourceMailboxId,
103
- uid,
104
- status: MessageStatus.active,
105
- syncStatus,
106
- })
107
- .then(() => true)
108
- .catch((error: unknown) => {
109
- skipNotFound(error);
110
- return false;
111
- });
112
- if (!messageRestored) return;
113
-
114
- const threadMessages =
115
- input.threadMessages ??
116
- (await deps.threadMessageService.findAllByMessageId(
117
- accountConfigId,
118
- messageId,
119
- ));
120
- for (const threadMessage of threadMessages) {
121
- const args = buildThreadMessageMoveRevert(
122
- threadMessage,
123
- uid,
124
- sourceMailboxId,
125
- );
126
- await deps.threadMessageService
127
- .update(
128
- threadMessage.accountConfigId,
129
- threadMessage.threadMessageId,
130
- args.set,
131
- { composites: args.composites },
132
- )
133
- .catch(skipNotFound);
134
- }
135
- };
@@ -1,77 +0,0 @@
1
- import type { ThreadMessageItem } from "@remit/data-ports";
2
-
3
- /**
4
- * Build the `set` and `composites` payload for the ThreadMessage update on a
5
- * MESSAGE_DELETE move-to-trash.
6
- *
7
- * The CURRENT row state goes in `composites`; the NEW values go in `set`.
8
- * ElectroDB uses `composites` to run the conditional check on the existing row
9
- * AND to compute the previous sort-key values needed to recompute the new ones.
10
- * Passing the NEW values in `composites` makes the conditional check fail with
11
- * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
12
- * the caller silently drops the update. Same root cause as PR #186 fixed for
13
- * `flag-queue.ts`.
14
- */
15
- export type ThreadMessageRowState = Pick<
16
- ThreadMessageItem,
17
- | "sentDate"
18
- | "mailboxId"
19
- | "isRead"
20
- | "isDeleted"
21
- | "hasStars"
22
- | "hasAttachment"
23
- >;
24
-
25
- const currentComposites = (threadMessage: ThreadMessageRowState) => ({
26
- sentDate: threadMessage.sentDate,
27
- mailboxId: threadMessage.mailboxId,
28
- isRead: threadMessage.isRead,
29
- isDeleted: threadMessage.isDeleted,
30
- hasStars: threadMessage.hasStars,
31
- hasAttachment: threadMessage.hasAttachment,
32
- });
33
-
34
- export const buildThreadMessageTrashUpdate = (
35
- threadMessage: ThreadMessageRowState,
36
- newUid: number,
37
- destinationMailboxId: string,
38
- ) => ({
39
- set: {
40
- uid: newUid,
41
- mailboxId: destinationMailboxId,
42
- isDeleted: true,
43
- },
44
- composites: currentComposites(threadMessage),
45
- });
46
-
47
- /**
48
- * The inverse payload: put the thread row back where the message still is on
49
- * the server. The delete was recorded optimistically, so a delete that gives up
50
- * with the message still at its source has to hand the row back rather than
51
- * leave it claiming Trash — an invisible `failed` on a row the user cannot see
52
- * is the shape of the incident this whole change is about.
53
- */
54
- export const buildThreadMessageMoveRevert = (
55
- threadMessage: ThreadMessageRowState,
56
- sourceUid: number,
57
- sourceMailboxId: string,
58
- ) => ({
59
- set: {
60
- uid: sourceUid,
61
- mailboxId: sourceMailboxId,
62
- isDeleted: false,
63
- },
64
- composites: currentComposites(threadMessage),
65
- });
66
-
67
- /**
68
- * Hand a row back after an abandoned expunge. The mail never left Trash, so
69
- * only the deletion mark reverts — the uid and mailbox on the row are still
70
- * where the server has it.
71
- */
72
- export const buildThreadMessageUndelete = (
73
- threadMessage: ThreadMessageRowState,
74
- ) => ({
75
- set: { isDeleted: false },
76
- composites: currentComposites(threadMessage),
77
- });
@@ -1,17 +0,0 @@
1
- /**
2
- * A repository lookup or write for a row that no longer exists rejects with a
3
- * `NotFoundError` (name-matched, since the class crosses the data-ports adapter
4
- * boundary — see `@remit/data-ports/errors`).
5
- *
6
- * A worker event that references a deliberately-deleted mailbox is completed or
7
- * moot work, never a transient fault: every redelivery re-throws the same
8
- * `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
9
- * that permanently-failing head message stalls the whole account's per-group
10
- * FIFO. Handlers use this predicate to resolve such an event terminally (ack
11
- * with a WARN) instead of retrying forever (issues #287, #289, #290).
12
- *
13
- * The guard is narrow on purpose: only a genuine not-found terminates. Real
14
- * IMAP/infra failures carry other errors and must still propagate to be retried.
15
- */
16
- export const isNotFoundError = (error: unknown): boolean =>
17
- (error as { name?: string })?.name === "NotFoundError";
@@ -1,39 +0,0 @@
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
- });
@@ -1,33 +0,0 @@
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
- embeddingId: service.embeddingId,
26
- };
27
- return cached;
28
- };
29
-
30
- /** Reset the singleton — test use only. */
31
- export const _resetMessageEmbedderForTest = (): void => {
32
- cached = undefined;
33
- };