@remit/mailbox-service 0.0.90 → 0.0.92
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 +6 -1
- package/src/filters/filter-config.test.ts +229 -0
- package/src/filters/filter-config.ts +69 -0
- package/src/filters/message-embedder.test.ts +39 -0
- package/src/filters/message-embedder.ts +33 -0
- package/src/filters/pipeline.ts +28 -1
- package/src/index.ts +13 -0
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +5 -1
- package/src/message-delete-unsettled-placement.test.ts +25 -17
- package/src/message-move-copy.test.ts +28 -1
- package/src/message-move-empty-trash.test.ts +48 -1
- package/src/message-move-same-mailbox.test.ts +5 -2
- package/src/message-move-unsettled-placement.test.ts +56 -17
- package/src/message-move.ts +189 -62
- package/src/message-sync-repoint.test.ts +22 -2
- package/src/message-sync.ts +9 -10
- package/src/placement-move-terminal.test.ts +198 -133
- package/src/placement-move-terminal.ts +47 -14
- package/src/placement-move-unsettled.test.ts +18 -4
- package/src/placement-move.ts +65 -9
- package/src/placement-settled.test.ts +36 -6
- package/src/placement-settled.ts +28 -25
- package/src/placement-transition-race.test.ts +482 -0
- package/src/restore-source-placement.ts +157 -0
- package/src/spam-report.test.ts +13 -10
- package/src/spam-report.ts +28 -2
- package/src/thread-message-rows.ts +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.92",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"default": "./src/index.ts"
|
|
11
11
|
},
|
|
12
|
+
"./filter-config": {
|
|
13
|
+
"types": "./src/filters/filter-config.ts",
|
|
14
|
+
"default": "./src/filters/filter-config.ts"
|
|
15
|
+
},
|
|
12
16
|
"./body-sync-queue": {
|
|
13
17
|
"types": "./src/body-sync-queue.ts",
|
|
14
18
|
"default": "./src/body-sync-queue.ts"
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
"@remit/data-ports": "*",
|
|
43
47
|
"@remit/domain-enums": "*",
|
|
44
48
|
"@remit/mail-oauth-service": "*",
|
|
49
|
+
"@remit/search-service": "*",
|
|
45
50
|
"@remit/secrets-service": "*",
|
|
46
51
|
"@remit/sqs-client": "*",
|
|
47
52
|
"@remit/storage-service": "*",
|
|
@@ -0,0 +1,229 @@
|
|
|
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 { _resetEmbeddingCapabilityForTest } from "@remit/search-service/capability";
|
|
11
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
12
|
+
import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
|
|
13
|
+
import { type FilterMessage, NO_ACTION } from "./match.js";
|
|
14
|
+
import { _resetMessageEmbedderForTest } from "./message-embedder.js";
|
|
15
|
+
import { FilterPipeline, type MessageEmbedder } from "./pipeline.js";
|
|
16
|
+
|
|
17
|
+
const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
|
|
18
|
+
({
|
|
19
|
+
filterId: "flt-semantic",
|
|
20
|
+
accountConfigId: "cfg-1",
|
|
21
|
+
name: "receipts",
|
|
22
|
+
scope: "Standing",
|
|
23
|
+
state: FilterState.Active,
|
|
24
|
+
hasAnchor: true,
|
|
25
|
+
ruleChangedAt: 1,
|
|
26
|
+
matchOperator: FilterMatchOperator.Or,
|
|
27
|
+
literalClauses: [],
|
|
28
|
+
actionLabelId: NO_ACTION,
|
|
29
|
+
actionMailboxId: destinationMailboxId,
|
|
30
|
+
createdAt: 1,
|
|
31
|
+
updatedAt: 1,
|
|
32
|
+
}) as unknown as FilterItem;
|
|
33
|
+
|
|
34
|
+
const CURRENT_MODEL = "test-model@3";
|
|
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 () => ({
|
|
43
|
+
anchorEmbedding: [1, 0, 0],
|
|
44
|
+
anchorEmbeddingId: CURRENT_MODEL,
|
|
45
|
+
}),
|
|
46
|
+
} as unknown as IFilterAnchorRepository,
|
|
47
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
48
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const message: FilterMessage = {
|
|
52
|
+
from: "billing@stripe.com",
|
|
53
|
+
fromName: "Stripe",
|
|
54
|
+
subject: "Your receipt",
|
|
55
|
+
text: "Thanks for your payment",
|
|
56
|
+
listId: "",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("buildFilterConfig", () => {
|
|
60
|
+
it("returns undefined without a placement mover — no move path, filters stay off", () => {
|
|
61
|
+
const deps = repos(anchorOnlyFilter("mbx-archive"));
|
|
62
|
+
deps.placementMoveService = undefined;
|
|
63
|
+
|
|
64
|
+
assert.equal(buildFilterConfig(deps), undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("wires an embedder into the config", () => {
|
|
68
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
69
|
+
|
|
70
|
+
assert.ok(config);
|
|
71
|
+
assert.ok(config.embedder);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
|
|
75
|
+
let embedCalls = 0;
|
|
76
|
+
const embedder: MessageEmbedder = {
|
|
77
|
+
embed: async () => {
|
|
78
|
+
embedCalls += 1;
|
|
79
|
+
return [1, 0, 0];
|
|
80
|
+
},
|
|
81
|
+
embeddingId: CURRENT_MODEL,
|
|
82
|
+
};
|
|
83
|
+
const config = buildFilterConfig(
|
|
84
|
+
repos(anchorOnlyFilter("mbx-archive")),
|
|
85
|
+
embedder,
|
|
86
|
+
);
|
|
87
|
+
assert.ok(config);
|
|
88
|
+
|
|
89
|
+
const decision = await new FilterPipeline(config, {
|
|
90
|
+
info: () => {},
|
|
91
|
+
}).evaluate("cfg-1", "m-1", message);
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(decision.move, {
|
|
94
|
+
destinationMailboxId: "mbx-archive",
|
|
95
|
+
filterId: "flt-semantic",
|
|
96
|
+
});
|
|
97
|
+
assert.equal(embedCalls, 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not match when the message embedding diverges from the anchor", async () => {
|
|
101
|
+
const embedder: MessageEmbedder = {
|
|
102
|
+
embed: async () => [0, 1, 0],
|
|
103
|
+
embeddingId: CURRENT_MODEL,
|
|
104
|
+
};
|
|
105
|
+
const config = buildFilterConfig(
|
|
106
|
+
repos(anchorOnlyFilter("mbx-archive")),
|
|
107
|
+
embedder,
|
|
108
|
+
);
|
|
109
|
+
assert.ok(config);
|
|
110
|
+
|
|
111
|
+
const decision = await new FilterPipeline(config, {
|
|
112
|
+
info: () => {},
|
|
113
|
+
}).evaluate("cfg-1", "m-2", message);
|
|
114
|
+
|
|
115
|
+
assert.equal(decision.move, undefined);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// An instance with semantic search off (issue #1068). `off` selects an embedder
|
|
120
|
+
// that throws by design, and the throw lands in the pipeline's per-filter catch:
|
|
121
|
+
// one error-level `filter_anchor_match_failed` for every semantic filter on
|
|
122
|
+
// every message it syncs. The pipeline already has a designed skip for a
|
|
123
|
+
// missing embedder, so the config passes none.
|
|
124
|
+
describe("buildFilterConfig with semantic search off", () => {
|
|
125
|
+
const withProviderOff = async (body: () => Promise<void>): Promise<void> => {
|
|
126
|
+
const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
127
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = "off";
|
|
128
|
+
_resetMessageEmbedderForTest();
|
|
129
|
+
try {
|
|
130
|
+
await body();
|
|
131
|
+
} finally {
|
|
132
|
+
if (previous === undefined) {
|
|
133
|
+
delete process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
134
|
+
} else {
|
|
135
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = previous;
|
|
136
|
+
}
|
|
137
|
+
_resetMessageEmbedderForTest();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it("wires no embedder", async () => {
|
|
142
|
+
await withProviderOff(async () => {
|
|
143
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
144
|
+
|
|
145
|
+
assert.ok(config);
|
|
146
|
+
assert.equal(config.embedder, undefined);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("skips the semantic filter without logging an error", async () => {
|
|
151
|
+
await withProviderOff(async () => {
|
|
152
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
153
|
+
assert.ok(config);
|
|
154
|
+
|
|
155
|
+
const errors: unknown[] = [];
|
|
156
|
+
const decision = await new FilterPipeline(config, {
|
|
157
|
+
info: () => {},
|
|
158
|
+
debug: () => {},
|
|
159
|
+
warn: () => {},
|
|
160
|
+
error: (fields) => errors.push(fields.alert),
|
|
161
|
+
}).evaluate("cfg-1", "m-off", message);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(decision.labels, []);
|
|
164
|
+
assert.equal(decision.move, undefined);
|
|
165
|
+
assert.deepEqual(errors, []);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The self-host `local` profile asks for the Transformers.js embedder, but the
|
|
171
|
+
// backend and imap-worker images ship without `@huggingface/transformers`
|
|
172
|
+
// (npm-scripts/docker-bundle.mjs). The first embed throws ERR_MODULE_NOT_FOUND,
|
|
173
|
+
// which is the same capability absence `off` states and must take the same
|
|
174
|
+
// designed skip — not an error-level `filter_anchor_match_failed` per semantic
|
|
175
|
+
// filter per message on a user-facing GET.
|
|
176
|
+
describe("buildFilterConfig when this deployment carries no embedder", () => {
|
|
177
|
+
const moduleNotFound = (): Error =>
|
|
178
|
+
Object.assign(
|
|
179
|
+
new Error("Cannot find package '@huggingface/transformers'"),
|
|
180
|
+
{ code: "ERR_MODULE_NOT_FOUND" },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
it("skips the semantic filter, logs no error, and stops wiring an embedder", async () => {
|
|
184
|
+
const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
185
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = "local";
|
|
186
|
+
_resetMessageEmbedderForTest();
|
|
187
|
+
_resetEmbeddingCapabilityForTest();
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const unavailable: MessageEmbedder = {
|
|
191
|
+
embed: async () => {
|
|
192
|
+
throw moduleNotFound();
|
|
193
|
+
},
|
|
194
|
+
embeddingId: CURRENT_MODEL,
|
|
195
|
+
};
|
|
196
|
+
const config = buildFilterConfig(
|
|
197
|
+
repos(anchorOnlyFilter("mbx-archive")),
|
|
198
|
+
unavailable,
|
|
199
|
+
);
|
|
200
|
+
assert.ok(config);
|
|
201
|
+
|
|
202
|
+
const errors: unknown[] = [];
|
|
203
|
+
const decision = await new FilterPipeline(config, {
|
|
204
|
+
info: () => {},
|
|
205
|
+
debug: () => {},
|
|
206
|
+
warn: () => {},
|
|
207
|
+
error: (fields) => errors.push(fields.alert),
|
|
208
|
+
}).evaluate("cfg-1", "m-local", message);
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(decision.labels, []);
|
|
211
|
+
assert.equal(decision.move, undefined);
|
|
212
|
+
assert.deepEqual(errors, []);
|
|
213
|
+
|
|
214
|
+
// Probed once, not per filter or per message: every later config on this
|
|
215
|
+
// process wires no embedder at all, so nothing else attempts the embed.
|
|
216
|
+
const next = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
217
|
+
assert.ok(next);
|
|
218
|
+
assert.equal(next.embedder, undefined);
|
|
219
|
+
} finally {
|
|
220
|
+
if (previous === undefined) {
|
|
221
|
+
delete process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
222
|
+
} else {
|
|
223
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = previous;
|
|
224
|
+
}
|
|
225
|
+
_resetMessageEmbedderForTest();
|
|
226
|
+
_resetEmbeddingCapabilityForTest();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IFilterAnchorRepository,
|
|
3
|
+
IFilterRepository,
|
|
4
|
+
IMessageLabelRepository,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { isEmbeddingCapabilityUnavailable } from "@remit/search-service/capability";
|
|
7
|
+
import {
|
|
8
|
+
EMBEDDING_PROVIDER_OFF,
|
|
9
|
+
readEmbeddingProviderFromEnv,
|
|
10
|
+
} from "@remit/search-service/from-env";
|
|
11
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
12
|
+
import { getMessageEmbedder } from "./message-embedder.js";
|
|
13
|
+
import type { FilterConfig, MessageEmbedder } from "./pipeline.js";
|
|
14
|
+
|
|
15
|
+
export interface FilterConfigDeps {
|
|
16
|
+
filterService: IFilterRepository;
|
|
17
|
+
filterAnchorService: IFilterAnchorRepository;
|
|
18
|
+
messageLabelService: IMessageLabelRepository;
|
|
19
|
+
placementMoveService?: PlacementMoveService;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The env-selected embedder, or none on an instance that cannot embed.
|
|
24
|
+
*
|
|
25
|
+
* `off` embeds nothing by design, and its embedder says so by throwing
|
|
26
|
+
* (EmbeddingDisabledError). The pipeline has a designed skip for a *missing*
|
|
27
|
+
* embedder — semantic filters are passed over and logged at debug — but a
|
|
28
|
+
* present embedder that throws lands in the per-filter catch instead, which is
|
|
29
|
+
* an error-level `filter_anchor_match_failed` for every semantic filter on
|
|
30
|
+
* every synced message. Same outcome, a log nobody can read.
|
|
31
|
+
*
|
|
32
|
+
* `local` on the self-host profiles reaches the same place by a different road:
|
|
33
|
+
* the backend and imap-worker images ship without `@huggingface/transformers`,
|
|
34
|
+
* so the first embed throws ERR_MODULE_NOT_FOUND. The pipeline classifies that
|
|
35
|
+
* one throw through `@remit/search-service/capability` and remembers it, and
|
|
36
|
+
* from then on the config here wires no embedder either — one probe per process,
|
|
37
|
+
* not one per filter per message.
|
|
38
|
+
*/
|
|
39
|
+
const embedderFromEnv = (): MessageEmbedder | undefined =>
|
|
40
|
+
readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF ||
|
|
41
|
+
isEmbeddingCapabilityUnavailable()
|
|
42
|
+
? undefined
|
|
43
|
+
: getMessageEmbedder();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Assemble the filter config a body-materializing pass runs (RFC 034), shared by
|
|
47
|
+
* the imap-worker's sync path and the backend's read-path backfill so both embed
|
|
48
|
+
* under the same model that produced the anchors — a semantic (anchor-only)
|
|
49
|
+
* filter is evaluated on either path instead of silently skipped.
|
|
50
|
+
*
|
|
51
|
+
* Absent the placement mover there is no move path, so filters stay off — a
|
|
52
|
+
* matched filter's actions reuse the same enqueue plumbing the placement mover
|
|
53
|
+
* owns.
|
|
54
|
+
*/
|
|
55
|
+
export const buildFilterConfig = (
|
|
56
|
+
deps: FilterConfigDeps,
|
|
57
|
+
embedder?: MessageEmbedder,
|
|
58
|
+
): FilterConfig | undefined => {
|
|
59
|
+
const { placementMoveService } = deps;
|
|
60
|
+
if (!placementMoveService) return undefined;
|
|
61
|
+
const resolved = embedder ?? embedderFromEnv();
|
|
62
|
+
return {
|
|
63
|
+
filterService: deps.filterService,
|
|
64
|
+
filterAnchorService: deps.filterAnchorService,
|
|
65
|
+
messageLabelService: deps.messageLabelService,
|
|
66
|
+
placementMoveService,
|
|
67
|
+
...(resolved ? { embedder: resolved } : {}),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
@@ -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,33 @@
|
|
|
1
|
+
import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
|
|
2
|
+
import type { MessageEmbedder } from "./pipeline.js";
|
|
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 search-index
|
|
11
|
+
* worker reads, so every filter pass embeds under the identical model the anchors
|
|
12
|
+
* were built with. The instance is memoized across warm invocations so a local
|
|
13
|
+
* 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
|
+
};
|
package/src/filters/pipeline.ts
CHANGED
|
@@ -6,6 +6,11 @@ import type {
|
|
|
6
6
|
IMessageLabelRepository,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
8
|
import { FilterState } from "@remit/domain-enums";
|
|
9
|
+
import {
|
|
10
|
+
isEmbeddingCapabilityAbsence,
|
|
11
|
+
isEmbeddingCapabilityUnavailable,
|
|
12
|
+
recordEmbeddingCapabilityAbsence,
|
|
13
|
+
} from "@remit/search-service/capability";
|
|
9
14
|
import type { PlacementMoveService } from "../placement-move.js";
|
|
10
15
|
import {
|
|
11
16
|
type AnchorEmbedder,
|
|
@@ -131,7 +136,29 @@ export class FilterPipeline {
|
|
|
131
136
|
messageEmbedding = null;
|
|
132
137
|
return null;
|
|
133
138
|
}
|
|
134
|
-
messageEmbedding = await embedder
|
|
139
|
+
messageEmbedding = await embedder
|
|
140
|
+
.embed(buildMatchText(msg))
|
|
141
|
+
.catch((error: unknown) => {
|
|
142
|
+
// This deployment carries no embedding pipeline at all — the
|
|
143
|
+
// container images ship without `@huggingface/transformers`. That is
|
|
144
|
+
// the same absence `SEARCH_EMBEDDING_PROVIDER=off` states, so it takes
|
|
145
|
+
// the same designed skip rather than an error-level
|
|
146
|
+
// `filter_anchor_match_failed` per semantic filter per message. The
|
|
147
|
+
// absence is remembered process-wide, so this is the only embed that
|
|
148
|
+
// pays for it: `buildFilterConfig` wires no embedder afterwards.
|
|
149
|
+
if (!isEmbeddingCapabilityAbsence(error)) throw error;
|
|
150
|
+
if (!isEmbeddingCapabilityUnavailable()) {
|
|
151
|
+
this.log.warn?.(
|
|
152
|
+
{
|
|
153
|
+
accountConfigId,
|
|
154
|
+
error: inspect(error),
|
|
155
|
+
},
|
|
156
|
+
"No embedding pipeline in this deployment; semantic filters are skipped (literal filters still run)",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
recordEmbeddingCapabilityAbsence(error);
|
|
160
|
+
return null;
|
|
161
|
+
});
|
|
135
162
|
return messageEmbedding;
|
|
136
163
|
};
|
|
137
164
|
|
package/src/index.ts
CHANGED
|
@@ -193,6 +193,7 @@ export {
|
|
|
193
193
|
type SyncAccountInfo,
|
|
194
194
|
} from "./mailbox-sync.js";
|
|
195
195
|
export {
|
|
196
|
+
type DeleteMessagesOutcome,
|
|
196
197
|
type DeleteOptions,
|
|
197
198
|
type MessageMoveConfig,
|
|
198
199
|
type MessageMoveLogger,
|
|
@@ -267,6 +268,12 @@ export {
|
|
|
267
268
|
resolveMailboxRole,
|
|
268
269
|
shapeFromMessageData,
|
|
269
270
|
} from "./quarantine.js";
|
|
271
|
+
export {
|
|
272
|
+
type RestoreSourcePlacementDeps,
|
|
273
|
+
type RestoreSourcePlacementInput,
|
|
274
|
+
type RestoreSourcePlacementOutcome,
|
|
275
|
+
restoreSourcePlacement,
|
|
276
|
+
} from "./restore-source-placement.js";
|
|
270
277
|
export {
|
|
271
278
|
extractSnippetFromEmail,
|
|
272
279
|
generateSnippet,
|
|
@@ -286,6 +293,12 @@ export {
|
|
|
286
293
|
type StaleMessageReconcileDeps,
|
|
287
294
|
type StaleMessageReconcileResult,
|
|
288
295
|
} from "./stale-message-reconcile.js";
|
|
296
|
+
export {
|
|
297
|
+
buildThreadMessageMoveRevert,
|
|
298
|
+
buildThreadMessageTrashUpdate,
|
|
299
|
+
buildThreadMessageUndelete,
|
|
300
|
+
type ThreadMessageRowState,
|
|
301
|
+
} from "./thread-message-rows.js";
|
|
289
302
|
export type {
|
|
290
303
|
FlatMailboxInfo,
|
|
291
304
|
IImapConnection,
|
|
@@ -30,8 +30,11 @@ const buildWorld = (trashExists: boolean) => {
|
|
|
30
30
|
get: async () => [message],
|
|
31
31
|
update: async (_id: string, patch: Record<string, unknown>) =>
|
|
32
32
|
Object.assign(message, patch),
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
transitionPlacement: async (
|
|
34
|
+
_id: string,
|
|
35
|
+
_expected: Record<string, unknown>,
|
|
36
|
+
patch: Record<string, unknown>,
|
|
37
|
+
) => Object.assign(message, patch),
|
|
35
38
|
} as unknown as IMessageRepository;
|
|
36
39
|
|
|
37
40
|
const threadMessageService = {
|
|
@@ -52,7 +52,11 @@ const buildWorld = (
|
|
|
52
52
|
patches.push(patch);
|
|
53
53
|
return Object.assign(message, patch);
|
|
54
54
|
},
|
|
55
|
-
|
|
55
|
+
transitionPlacement: async (
|
|
56
|
+
_id: string,
|
|
57
|
+
_expected: Record<string, unknown>,
|
|
58
|
+
patch: Record<string, unknown>,
|
|
59
|
+
) => {
|
|
56
60
|
patches.push(patch);
|
|
57
61
|
return Object.assign(message, patch);
|
|
58
62
|
},
|
|
@@ -68,11 +68,20 @@ const movingRow = () => ({
|
|
|
68
68
|
originalUid: INBOX_UID,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A row a delete gave up on. R3 defines the give-up as a hand-back: the row
|
|
73
|
+
* goes back on the placement the mail server confirmed, and only then carries
|
|
74
|
+
* `abandoned`. The pair is honest, so nothing downstream refuses it — the
|
|
75
|
+
* failure is a statement the reading pane makes, never a gate.
|
|
76
|
+
*/
|
|
72
77
|
const strandedRow = () => ({
|
|
73
|
-
...movingRow(),
|
|
74
78
|
messageId: STRANDED_ID,
|
|
75
|
-
|
|
79
|
+
mailboxId: INBOX,
|
|
80
|
+
uid: INBOX_UID,
|
|
81
|
+
status: "active",
|
|
82
|
+
syncStatus: "abandoned",
|
|
83
|
+
abandonedMutation: "delete",
|
|
84
|
+
originalMailboxId: INBOX,
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
/** An ordinary settled row. */
|
|
@@ -127,7 +136,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
|
|
|
127
136
|
patches.push({ messageId: id, patch });
|
|
128
137
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
129
138
|
},
|
|
130
|
-
|
|
139
|
+
transitionPlacement: async (
|
|
140
|
+
id: string,
|
|
141
|
+
_expected: Record<string, unknown>,
|
|
142
|
+
patch: Record<string, unknown>,
|
|
143
|
+
) => {
|
|
131
144
|
patches.push({ messageId: id, patch });
|
|
132
145
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
133
146
|
},
|
|
@@ -329,23 +342,18 @@ describe("the gate refuses only a uid that names somebody else (#845.3)", () =>
|
|
|
329
342
|
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
330
343
|
});
|
|
331
344
|
|
|
332
|
-
it("
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
345
|
+
it("acts on a row a delete gave up on, rather than refusing it", async () => {
|
|
346
|
+
// A give-up hands the row back to the placement the server confirmed
|
|
347
|
+
// before it stops (R3), so the "Delete again" the reading pane offers is
|
|
348
|
+
// an ordinary delete. Refusing it — which is what a give-up left `moving`
|
|
349
|
+
// used to earn — made that button a dead end.
|
|
337
350
|
const { service, events } = buildWorld([strandedRow()]);
|
|
338
351
|
|
|
339
352
|
const startedAt = Date.now();
|
|
340
|
-
await
|
|
341
|
-
() => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
|
|
342
|
-
(error: unknown) =>
|
|
343
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
344
|
-
error.publicApiError?.details?.reason === "unverified",
|
|
345
|
-
);
|
|
353
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
|
|
346
354
|
|
|
347
|
-
assert.
|
|
348
|
-
assert.ok(Date.now() - startedAt < 100, "
|
|
355
|
+
assert.equal(events.length, 1);
|
|
356
|
+
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
349
357
|
});
|
|
350
358
|
});
|
|
351
359
|
|
|
@@ -69,6 +69,11 @@ const buildWorld = () => {
|
|
|
69
69
|
category: "primary",
|
|
70
70
|
classificationState: "Examined",
|
|
71
71
|
hasListUnsubscribe: false,
|
|
72
|
+
// Settled, which is what a delete's predicate asks for: a row every
|
|
73
|
+
// repository read carries a status, and leaving it off made the
|
|
74
|
+
// fixture answer for a state no row is ever in.
|
|
75
|
+
status: "active",
|
|
76
|
+
syncStatus: "synced",
|
|
72
77
|
},
|
|
73
78
|
],
|
|
74
79
|
]);
|
|
@@ -130,6 +135,19 @@ const buildWorld = () => {
|
|
|
130
135
|
if (m) Object.assign(m, patch);
|
|
131
136
|
return m;
|
|
132
137
|
},
|
|
138
|
+
transitionPlacement: async (
|
|
139
|
+
id: string,
|
|
140
|
+
expected: Record<string, unknown>,
|
|
141
|
+
patch: Record<string, unknown>,
|
|
142
|
+
) => {
|
|
143
|
+
const m = messages.get(id) as Record<string, unknown> | undefined;
|
|
144
|
+
if (!m) return undefined;
|
|
145
|
+
for (const [field, want] of Object.entries(expected)) {
|
|
146
|
+
if (want !== undefined && m[field] !== want) return undefined;
|
|
147
|
+
}
|
|
148
|
+
Object.assign(m, patch);
|
|
149
|
+
return m;
|
|
150
|
+
},
|
|
133
151
|
} as unknown as IMessageRepository;
|
|
134
152
|
|
|
135
153
|
const threadMessageService = {
|
|
@@ -457,7 +475,7 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
|
|
|
457
475
|
});
|
|
458
476
|
|
|
459
477
|
it("copy then delete of the copy removes the copy row and leaves the original", async () => {
|
|
460
|
-
const { service, threadRows } = buildWorld();
|
|
478
|
+
const { service, threadRows, messages } = buildWorld();
|
|
461
479
|
|
|
462
480
|
await service.copyMessages(
|
|
463
481
|
ACCOUNT_CONFIG,
|
|
@@ -467,6 +485,15 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
|
|
|
467
485
|
);
|
|
468
486
|
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
469
487
|
|
|
488
|
+
// A fresh copy row is `moving` until the server hands back its COPYUID, and
|
|
489
|
+
// a delete against it would name uid 0. Settle it the way `updateUid`
|
|
490
|
+
// does, which is also the only state in which a user can see it to delete.
|
|
491
|
+
Object.assign(messages.get(copyId) ?? {}, {
|
|
492
|
+
uid: 77,
|
|
493
|
+
status: "active",
|
|
494
|
+
syncStatus: "synced",
|
|
495
|
+
});
|
|
496
|
+
|
|
470
497
|
await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
|
|
471
498
|
permanent: true,
|
|
472
499
|
});
|