@remit/backend 0.0.107 → 0.0.108
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
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
BodySyncQueueService,
|
|
38
38
|
BodySyncService,
|
|
39
39
|
createConnection,
|
|
40
|
+
type FilterConfig,
|
|
40
41
|
FlagPushService,
|
|
41
42
|
FlagQueueService,
|
|
42
43
|
type IImapConnection,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
PlacementMoveService,
|
|
48
49
|
SpamReportService,
|
|
49
50
|
} from "@remit/mailbox-service";
|
|
51
|
+
import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
|
|
50
52
|
import { createSearchService, type SearchService } from "@remit/search-service";
|
|
51
53
|
import {
|
|
52
54
|
buildEmbeddingServiceFromEnv,
|
|
@@ -339,6 +341,39 @@ export const buildSharedDeps = (): RemitClientSharedDeps => {
|
|
|
339
341
|
};
|
|
340
342
|
};
|
|
341
343
|
|
|
344
|
+
// The read-path body backfill (describeMessage / getRawMessage →
|
|
345
|
+
// fetchAndGetBody) materializes a body that was never sync-synced, and must run
|
|
346
|
+
// the account's standing filters the same way the imap-worker's sync path does.
|
|
347
|
+
// Without this, a message a user opens before background body-sync is classified
|
|
348
|
+
// but never filter-evaluated — and once its body is stored the filter-capable
|
|
349
|
+
// sync path skips it, so an explicit standing filter silently never fires (issue
|
|
350
|
+
// #223). Only the explicit user-rule half is wired here; heuristic classifier
|
|
351
|
+
// placement stays on the sync path. It shares `buildFilterConfig` with
|
|
352
|
+
// `sync-message-body.ts`, so both paths embed a candidate message under the same
|
|
353
|
+
// env-selected model that produced the anchors and a semantic (anchor-only)
|
|
354
|
+
// filter fires on either (issue #298). Over the message-management queue's
|
|
355
|
+
// local-first PlacementMoveService; filters stay off when that queue is unset.
|
|
356
|
+
export const buildReadPathFilterConfig = (
|
|
357
|
+
repositories: RemitClientRepositories,
|
|
358
|
+
): FilterConfig | undefined => {
|
|
359
|
+
const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
360
|
+
return buildFilterConfig({
|
|
361
|
+
filterService: repositories.filter,
|
|
362
|
+
filterAnchorService: repositories.filterAnchor,
|
|
363
|
+
messageLabelService: repositories.messageLabel,
|
|
364
|
+
placementMoveService: placementMoveQueueUrl
|
|
365
|
+
? new PlacementMoveService({
|
|
366
|
+
messageService: repositories.message,
|
|
367
|
+
threadMessageService: repositories.threadMessage,
|
|
368
|
+
markerService: repositories.placementMove,
|
|
369
|
+
addressService: repositories.address,
|
|
370
|
+
mailboxSpecialUseService: repositories.mailboxSpecialUse,
|
|
371
|
+
sqsQueueUrl: placementMoveQueueUrl,
|
|
372
|
+
})
|
|
373
|
+
: undefined,
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
342
377
|
// Backend-neutral composition root: given repositories (from any data-ports
|
|
343
378
|
// implementation) and the shared services, wire the domain and queue services
|
|
344
379
|
// and assemble a RemitClient. Imports neither ElectroDB nor Drizzle — the
|
|
@@ -354,35 +389,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
|
354
389
|
bodySyncQueue,
|
|
355
390
|
} = deps;
|
|
356
391
|
|
|
357
|
-
|
|
358
|
-
// fetchAndGetBody) materializes a body that was never sync-synced, and must
|
|
359
|
-
// run the account's standing filters the same way the imap-worker's sync path
|
|
360
|
-
// does. Without this, a message a user opens before background body-sync is
|
|
361
|
-
// classified but never filter-evaluated — and once its body is stored the
|
|
362
|
-
// filter-capable sync path skips it, so an explicit standing filter silently
|
|
363
|
-
// never fires (issue #223). Only the explicit user-rule half is wired here;
|
|
364
|
-
// heuristic classifier placement stays on the sync path. Wired like
|
|
365
|
-
// `sync-message-body.ts`, over the message-management queue's local-first
|
|
366
|
-
// PlacementMoveService; filters stay off when that queue is unset.
|
|
367
|
-
const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
368
|
-
const placementMoveService = placementMoveQueueUrl
|
|
369
|
-
? new PlacementMoveService({
|
|
370
|
-
messageService: repositories.message,
|
|
371
|
-
threadMessageService: repositories.threadMessage,
|
|
372
|
-
markerService: repositories.placementMove,
|
|
373
|
-
addressService: repositories.address,
|
|
374
|
-
mailboxSpecialUseService: repositories.mailboxSpecialUse,
|
|
375
|
-
sqsQueueUrl: placementMoveQueueUrl,
|
|
376
|
-
})
|
|
377
|
-
: undefined;
|
|
378
|
-
const filterConfig = placementMoveService
|
|
379
|
-
? {
|
|
380
|
-
filterService: repositories.filter,
|
|
381
|
-
filterAnchorService: repositories.filterAnchor,
|
|
382
|
-
messageLabelService: repositories.messageLabel,
|
|
383
|
-
placementMoveService,
|
|
384
|
-
}
|
|
385
|
-
: undefined;
|
|
392
|
+
const filterConfig = buildReadPathFilterConfig(repositories);
|
|
386
393
|
|
|
387
394
|
const flagPushService = new FlagPushService({
|
|
388
395
|
markerService: repositories.flagPush,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-path body backfill must run a semantic (anchor-only) standing filter,
|
|
3
|
+
* which needs the env-selected embedder wired into the filter config
|
|
4
|
+
* `createRemitClient` hands its BodySyncService (issue #298).
|
|
5
|
+
*
|
|
6
|
+
* These drive the composed client — `createRemitClient(...).bodySync` — rather
|
|
7
|
+
* than `buildReadPathFilterConfig` alone, so replacing that call with an inline
|
|
8
|
+
* filter-config literal (which carries no embedder) fails here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
13
|
+
import type {
|
|
14
|
+
CreateMessageLabelInput,
|
|
15
|
+
FilterAnchorItem,
|
|
16
|
+
FilterItem,
|
|
17
|
+
UpdateMessageInput,
|
|
18
|
+
} from "@remit/data-ports";
|
|
19
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
20
|
+
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
21
|
+
import {
|
|
22
|
+
buildMatchText,
|
|
23
|
+
type FilterMessage,
|
|
24
|
+
type IImapConnection,
|
|
25
|
+
NO_ACTION,
|
|
26
|
+
} from "@remit/mailbox-service";
|
|
27
|
+
import type { SearchService } from "@remit/search-service";
|
|
28
|
+
import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
|
|
29
|
+
import type { SecretsService } from "@remit/secrets-service";
|
|
30
|
+
import type { StorageService } from "@remit/storage-service";
|
|
31
|
+
import {
|
|
32
|
+
buildReadPathFilterConfig,
|
|
33
|
+
createRemitClient,
|
|
34
|
+
type RemitClientRepositories,
|
|
35
|
+
} from "./create-remit-client.js";
|
|
36
|
+
|
|
37
|
+
const MESSAGE: FilterMessage = {
|
|
38
|
+
from: "billing@stripe.com",
|
|
39
|
+
fromName: "Stripe",
|
|
40
|
+
subject: "Your receipt",
|
|
41
|
+
text: "Thanks for your payment",
|
|
42
|
+
listId: "",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const eml = (message: FilterMessage): Buffer =>
|
|
46
|
+
Buffer.from(
|
|
47
|
+
[
|
|
48
|
+
`From: ${message.fromName} <${message.from}>`,
|
|
49
|
+
"To: me@example.com",
|
|
50
|
+
`Subject: ${message.subject}`,
|
|
51
|
+
"Content-Type: text/plain",
|
|
52
|
+
"",
|
|
53
|
+
message.text,
|
|
54
|
+
].join("\r\n"),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Anchor-only: no literal clauses, so it matches on the embedding alone. Its
|
|
59
|
+
* action is a label, which the fake MessageLabel repository below records —
|
|
60
|
+
* a move would run the real PlacementMoveService against SQS.
|
|
61
|
+
*/
|
|
62
|
+
const anchorOnlyFilter: FilterItem = {
|
|
63
|
+
filterId: "flt-semantic",
|
|
64
|
+
accountConfigId: "cfg-1",
|
|
65
|
+
name: "receipts",
|
|
66
|
+
scope: "Standing",
|
|
67
|
+
state: FilterState.Active,
|
|
68
|
+
hasAnchor: true,
|
|
69
|
+
ruleChangedAt: 1,
|
|
70
|
+
matchOperator: FilterMatchOperator.Or,
|
|
71
|
+
literalClauses: [],
|
|
72
|
+
actionLabelId: "lbl-receipts",
|
|
73
|
+
actionMailboxId: NO_ACTION,
|
|
74
|
+
createdAt: 1,
|
|
75
|
+
updatedAt: 1,
|
|
76
|
+
} as unknown as FilterItem;
|
|
77
|
+
|
|
78
|
+
// The read path must embed a candidate message under the same model that wrote
|
|
79
|
+
// the anchors, so an anchor written from this text matches it exactly.
|
|
80
|
+
const anchorFor = async (message: FilterMessage): Promise<FilterAnchorItem> => {
|
|
81
|
+
const embedding = buildEmbeddingServiceFromEnv();
|
|
82
|
+
const [anchorEmbedding] = await embedding.embed([buildMatchText(message)]);
|
|
83
|
+
return {
|
|
84
|
+
accountConfigId: "cfg-1",
|
|
85
|
+
filterId: "flt-semantic",
|
|
86
|
+
anchorEmbedding,
|
|
87
|
+
anchorEmbeddingId: embedding.embeddingId,
|
|
88
|
+
anchorSourceText: buildMatchText(message),
|
|
89
|
+
anchorMessageId: "m-anchor",
|
|
90
|
+
} as unknown as FilterAnchorItem;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const repositoriesWithAnchor = (
|
|
94
|
+
anchor: FilterAnchorItem,
|
|
95
|
+
applied: CreateMessageLabelInput[] = [],
|
|
96
|
+
): RemitClientRepositories =>
|
|
97
|
+
({
|
|
98
|
+
filter: {
|
|
99
|
+
listByAccountAndState: async () => [anchorOnlyFilter],
|
|
100
|
+
refreshExpiry: async (filter: FilterItem) => filter,
|
|
101
|
+
},
|
|
102
|
+
filterAnchor: { get: async () => anchor },
|
|
103
|
+
messageLabel: {
|
|
104
|
+
apply: async (input: CreateMessageLabelInput) => {
|
|
105
|
+
applied.push(input);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
message: {
|
|
109
|
+
get: async (messageId: string) => ({
|
|
110
|
+
messageId,
|
|
111
|
+
mailboxId: "mb-inbox",
|
|
112
|
+
uid: 1,
|
|
113
|
+
}),
|
|
114
|
+
update: async (_messageId: string, _input: UpdateMessageInput) => {},
|
|
115
|
+
},
|
|
116
|
+
threadMessage: {
|
|
117
|
+
findAllByMessageId: async () => [
|
|
118
|
+
{
|
|
119
|
+
threadMessageId: "tm-1",
|
|
120
|
+
sentDate: 1,
|
|
121
|
+
mailboxId: "mb-inbox",
|
|
122
|
+
isRead: false,
|
|
123
|
+
isDeleted: false,
|
|
124
|
+
hasStars: false,
|
|
125
|
+
hasAttachment: false,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
update: async () => {},
|
|
129
|
+
},
|
|
130
|
+
address: {
|
|
131
|
+
getAddress: async () => {
|
|
132
|
+
throw new NotFoundError("Address not found");
|
|
133
|
+
},
|
|
134
|
+
incrementInboundCount: async () => {},
|
|
135
|
+
},
|
|
136
|
+
envelope: { listBodyParts: async () => [] },
|
|
137
|
+
placementMove: {},
|
|
138
|
+
mailboxSpecialUse: {},
|
|
139
|
+
}) as unknown as RemitClientRepositories;
|
|
140
|
+
|
|
141
|
+
const storage = {
|
|
142
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
143
|
+
storeParsedBody: async () => {},
|
|
144
|
+
listBodyParts: async () => [],
|
|
145
|
+
} as unknown as StorageService;
|
|
146
|
+
|
|
147
|
+
const readBodyThroughClient = async (
|
|
148
|
+
repositories: RemitClientRepositories,
|
|
149
|
+
message: FilterMessage,
|
|
150
|
+
): Promise<void> => {
|
|
151
|
+
const client = createRemitClient({
|
|
152
|
+
repositories,
|
|
153
|
+
storage,
|
|
154
|
+
search: {} as unknown as SearchService,
|
|
155
|
+
secrets: {} as unknown as SecretsService,
|
|
156
|
+
sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/0/test",
|
|
157
|
+
sqsSmtpQueueUrl: "https://sqs.eu-west-1.amazonaws.com/0/test-smtp",
|
|
158
|
+
});
|
|
159
|
+
const connection = {
|
|
160
|
+
openBox: async () => {},
|
|
161
|
+
fetchMessageBody: async () => eml(message),
|
|
162
|
+
} as unknown as IImapConnection;
|
|
163
|
+
|
|
164
|
+
await client.bodySync.fetchAndGetBody(
|
|
165
|
+
"m-1",
|
|
166
|
+
"acc-1",
|
|
167
|
+
"cfg-1",
|
|
168
|
+
"INBOX",
|
|
169
|
+
async () => connection,
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
describe("createRemitClient read-path filter config", () => {
|
|
174
|
+
let previousQueueUrl: string | undefined;
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
previousQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
178
|
+
process.env.SQS_QUEUE_URL_MESSAGE_MGMT =
|
|
179
|
+
"https://sqs.eu-west-1.amazonaws.com/0/test-message-mgmt";
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
if (previousQueueUrl === undefined) {
|
|
184
|
+
delete process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
process.env.SQS_QUEUE_URL_MESSAGE_MGMT = previousQueueUrl;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("labels a message an anchor-only filter matches on a read-path backfill", async () => {
|
|
191
|
+
const applied: CreateMessageLabelInput[] = [];
|
|
192
|
+
|
|
193
|
+
await readBodyThroughClient(
|
|
194
|
+
repositoriesWithAnchor(await anchorFor(MESSAGE), applied),
|
|
195
|
+
MESSAGE,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.deepEqual(
|
|
199
|
+
applied.map(({ labelId, appliedByFilterId }) => ({
|
|
200
|
+
labelId,
|
|
201
|
+
appliedByFilterId,
|
|
202
|
+
})),
|
|
203
|
+
[{ labelId: "lbl-receipts", appliedByFilterId: "flt-semantic" }],
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("leaves a message the anchor does not match unlabelled", async () => {
|
|
208
|
+
const applied: CreateMessageLabelInput[] = [];
|
|
209
|
+
|
|
210
|
+
await readBodyThroughClient(
|
|
211
|
+
repositoriesWithAnchor(
|
|
212
|
+
await anchorFor({
|
|
213
|
+
...MESSAGE,
|
|
214
|
+
subject: "Scheduled maintenance window",
|
|
215
|
+
text: "Our service will be unavailable on Sunday",
|
|
216
|
+
}),
|
|
217
|
+
applied,
|
|
218
|
+
),
|
|
219
|
+
MESSAGE,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
assert.deepEqual(applied, []);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("buildReadPathFilterConfig", () => {
|
|
227
|
+
it("keeps filters off when the message-management queue is unset", async () => {
|
|
228
|
+
const previous = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
229
|
+
delete process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
230
|
+
try {
|
|
231
|
+
assert.equal(
|
|
232
|
+
buildReadPathFilterConfig(
|
|
233
|
+
repositoriesWithAnchor(await anchorFor(MESSAGE)),
|
|
234
|
+
),
|
|
235
|
+
undefined,
|
|
236
|
+
);
|
|
237
|
+
} finally {
|
|
238
|
+
if (previous !== undefined) {
|
|
239
|
+
process.env.SQS_QUEUE_URL_MESSAGE_MGMT = previous;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { logger } from "@remit/logger-lambda";
|
|
2
|
+
import {
|
|
3
|
+
_resetEmbeddingCapabilityForTest,
|
|
4
|
+
isEmbeddingCapabilityAbsence,
|
|
5
|
+
isEmbeddingCapabilityUnavailable,
|
|
6
|
+
recordEmbeddingCapabilityAbsence,
|
|
7
|
+
} from "@remit/search-service/capability";
|
|
2
8
|
import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
3
9
|
|
|
4
10
|
/**
|
|
@@ -47,23 +53,20 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
47
53
|
* instead was considered and rejected — it would fabricate relevance scores and
|
|
48
54
|
* matched-chunk labels for the "Related" UI, and cannot serve the cross-account
|
|
49
55
|
* queries the daily brief issues.
|
|
56
|
+
*
|
|
57
|
+
* The error vocabulary and the memoized absence itself live in
|
|
58
|
+
* `@remit/search-service/capability`, shared with the filter pipeline's semantic
|
|
59
|
+
* skip: whichever path embeds first is the probe, and the other stops trying.
|
|
60
|
+
* This module adds the self-host scoping and the operator-facing log.
|
|
50
61
|
*/
|
|
51
62
|
|
|
52
|
-
const CAPABILITY_ABSENCE_CODES = new Set([
|
|
53
|
-
"ERR_MODULE_NOT_FOUND",
|
|
54
|
-
"MODULE_NOT_FOUND",
|
|
55
|
-
"ERR_DLOPEN_FAILED",
|
|
56
|
-
"ERR_EMBEDDING_MODEL_UNAVAILABLE",
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
let semanticUnavailable = false;
|
|
60
|
-
|
|
61
63
|
/** Test-only reset for the memoized absence. */
|
|
62
64
|
export const _resetSemanticCapabilityForTest = (): void => {
|
|
63
|
-
|
|
65
|
+
_resetEmbeddingCapabilityForTest();
|
|
64
66
|
};
|
|
65
67
|
|
|
66
|
-
export const isSemanticSearchUnavailable = (): boolean =>
|
|
68
|
+
export const isSemanticSearchUnavailable = (): boolean =>
|
|
69
|
+
isEmbeddingCapabilityUnavailable();
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
72
|
* Whether a failure is this deployment simply not carrying the semantic
|
|
@@ -71,11 +74,8 @@ export const isSemanticSearchUnavailable = (): boolean => semanticUnavailable;
|
|
|
71
74
|
* Records nothing, for the caller that must degrade one operation without
|
|
72
75
|
* disabling `/search/semantic` process-wide.
|
|
73
76
|
*/
|
|
74
|
-
export const isSemanticCapabilityAbsence = (error: unknown): boolean =>
|
|
75
|
-
|
|
76
|
-
const code = (error as { code?: unknown } | null)?.code;
|
|
77
|
-
return typeof code === "string" && CAPABILITY_ABSENCE_CODES.has(code);
|
|
78
|
-
};
|
|
77
|
+
export const isSemanticCapabilityAbsence = (error: unknown): boolean =>
|
|
78
|
+
isSelfHostSqlBackend() && isEmbeddingCapabilityAbsence(error);
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Classify a semantic-search failure. Returns true — and remembers the
|
|
@@ -84,12 +84,12 @@ export const isSemanticCapabilityAbsence = (error: unknown): boolean => {
|
|
|
84
84
|
*/
|
|
85
85
|
export const noteSemanticCapabilityAbsence = (error: unknown): boolean => {
|
|
86
86
|
if (!isSemanticCapabilityAbsence(error)) return false;
|
|
87
|
-
if (!
|
|
87
|
+
if (!isEmbeddingCapabilityUnavailable()) {
|
|
88
88
|
logger.warn(
|
|
89
89
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
90
90
|
"Semantic search pipeline unavailable in this deployment — serving empty semantic results (see deploy/vps/README.md)",
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
recordEmbeddingCapabilityAbsence(error);
|
|
94
94
|
return true;
|
|
95
95
|
};
|