@remit/backend 0.0.28 → 0.0.30

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/backend",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -213,7 +213,14 @@ const candidate = (
213
213
  over: Partial<OrganizeCandidate["message"]> = {},
214
214
  ): OrganizeCandidate => ({
215
215
  messageId,
216
- message: { from: "", fromName: "", subject: "", text: "", ...over },
216
+ message: {
217
+ from: "",
218
+ fromName: "",
219
+ subject: "",
220
+ text: "",
221
+ listId: "",
222
+ ...over,
223
+ },
217
224
  });
218
225
 
219
226
  describe("matchOrganize", () => {
@@ -597,3 +604,115 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
597
604
  );
598
605
  });
599
606
  });
607
+
608
+ describe("matchOrganize with ListId and FromDomain clauses", () => {
609
+ const senderChunk = (
610
+ messageId: string,
611
+ fromEmail: string,
612
+ over: Partial<ChunkMetadata> = {},
613
+ ): VectorRecord => ({
614
+ chunkId: `${messageId}#sender`,
615
+ vector: ANCHOR_VECTOR,
616
+ metadata: metadata({
617
+ messageId,
618
+ chunkType: "sender",
619
+ textPreview: fromEmail,
620
+ ...over,
621
+ }),
622
+ });
623
+
624
+ it("matches a ListId clause exactly off the vector-free projection", async () => {
625
+ const deps = vectorlessDeps([
626
+ candidate("msg-1", { listId: "weekly.news.example.com" }),
627
+ candidate("msg-2", { listId: "news.example.com" }),
628
+ candidate("msg-3", {}),
629
+ ]);
630
+
631
+ const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
632
+ ...predicate(),
633
+ anchorMessageId: "None",
634
+ literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
635
+ });
636
+
637
+ assert.deepEqual(messageIds, ["msg-1"]);
638
+ });
639
+
640
+ it("matches a FromDomain clause public-suffix aware off the vector-free projection", async () => {
641
+ const deps = vectorlessDeps([
642
+ candidate("msg-1", { from: "notifications@github.com" }),
643
+ candidate("msg-2", { from: "ci@sub.github.com" }),
644
+ candidate("msg-3", { from: "attacker@github.com.evil.example" }),
645
+ ]);
646
+
647
+ const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
648
+ ...predicate(),
649
+ anchorMessageId: "None",
650
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
651
+ });
652
+
653
+ assert.deepEqual(messageIds, ["msg-1", "msg-2"]);
654
+ });
655
+
656
+ it("round-trips ListId and FromDomain through the semantic-arm chunk projection", async () => {
657
+ const store = createMemoryVectorStore();
658
+ await store.upsert([
659
+ senderChunk("msg-1", "ci@github.com", {
660
+ listId: "actions.github.com",
661
+ }),
662
+ bodyChunk("msg-1", ANCHOR_VECTOR, { listId: "actions.github.com" }),
663
+ senderChunk("msg-2", "hi@othersender.example", {
664
+ listId: "other.list.example",
665
+ }),
666
+ bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
667
+ ]);
668
+
669
+ const byListId = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
670
+ ...predicate(),
671
+ literalClauses: [{ field: "ListId", value: "actions.github.com" }],
672
+ });
673
+ assert.deepEqual(byListId.messageIds, ["msg-1"]);
674
+
675
+ const byFromDomain = await matchOrganize(
676
+ matchDeps(store),
677
+ ACCOUNT_CONFIG_ID,
678
+ {
679
+ ...predicate(),
680
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
681
+ },
682
+ );
683
+ assert.deepEqual(byFromDomain.messageIds, ["msg-1"]);
684
+ });
685
+
686
+ it("applies a ListId predicate to exactly the previewed set (preview == apply)", async () => {
687
+ const deps = vectorlessDeps([
688
+ candidate("msg-1", { listId: "weekly.news.example.com" }),
689
+ candidate("msg-2", { listId: "weekly.news.example.com" }),
690
+ candidate("msg-3", { listId: "other.example.com" }),
691
+ ]);
692
+ const p = predicate({
693
+ anchorMessageId: "None",
694
+ actionLabelId: "lbl-list",
695
+ literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
696
+ });
697
+
698
+ const previewed = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
699
+ const applied = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
700
+ assert.deepEqual(previewed, applied);
701
+ assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
702
+
703
+ const tracked = trackingClient();
704
+ const result = await applyOrganize(
705
+ { client: tracked.client },
706
+ ACCOUNT_CONFIG_ID,
707
+ applied.messageIds,
708
+ p,
709
+ );
710
+
711
+ assert.equal(result.applied, 2);
712
+ assert.equal(result.failed, 0);
713
+ assert.deepEqual(tracked.labeled.map((row) => row.messageId).sort(), [
714
+ "msg-1",
715
+ "msg-2",
716
+ ]);
717
+ });
718
+ });
@@ -131,11 +131,13 @@ const filterMessageFromChunks = (
131
131
  let subject = "";
132
132
  let fromName = "";
133
133
  let from = "";
134
+ let listId = "";
134
135
  const textParts: string[] = [];
135
136
  for (const record of records) {
136
137
  const meta = record.metadata;
137
138
  if (meta.subject && !subject) subject = meta.subject;
138
139
  if (meta.fromName && !fromName) fromName = meta.fromName;
140
+ if (meta.listId && !listId) listId = meta.listId;
139
141
  if (meta.chunkType === "sender" && meta.textPreview && !from) {
140
142
  from = meta.textPreview;
141
143
  }
@@ -146,7 +148,7 @@ const filterMessageFromChunks = (
146
148
  textParts.push(meta.textPreview);
147
149
  }
148
150
  }
149
- return { from, fromName, subject, text: textParts.join("\n") };
151
+ return { from, fromName, subject, listId, text: textParts.join("\n") };
150
152
  };
151
153
 
152
154
  /**
@@ -355,8 +357,10 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
355
357
  * deployment that ships no vector pipeline. Deduped by message id — the same
356
358
  * mail filed in two folders is one candidate.
357
359
  *
358
- * `From`/`Subject` come from the row verbatim (full fidelity). `text` (the body)
359
- * is left empty: the thread row carries only a truncated `snippet`, and matching
360
+ * `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
361
+ * `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
362
+ * does at index time. `text` (the body) is left empty: the thread row carries
363
+ * only a truncated `snippet`, and matching
360
364
  * a body-content clause against a preview would silently diverge from the live
361
365
  * index-time filter's full-body match. Body-content (`HasWords`) clauses are
362
366
  * rejected before this is read (see {@link assertNoBodyContentClause}), so the
@@ -384,6 +388,7 @@ const listAccountFilterMessagesFromClient =
384
388
  fromName: row.fromName ?? "",
385
389
  subject: row.subject ?? "",
386
390
  text: "",
391
+ listId: row.listId ?? "",
387
392
  },
388
393
  });
389
394
  if (candidates.length >= limit) return candidates;
@@ -4,14 +4,23 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
4
4
  /**
5
5
  * Semantic-search capability gate for the self-host compose profiles.
6
6
  *
7
- * The backend container image ships neither `@huggingface/transformers` (query
8
- * embedding) nor `sqlite-vec` (the vector store's loadable extension, glibc-only
9
- * on top of that see npm-scripts/docker-bundle.mjs and deploy/vps/README.md).
10
- * Both are reached lazily through `runtimeImport`, so the first semantic query —
11
- * not startup hits ERR_MODULE_NOT_FOUND. Without this gate every search the
12
- * web client issues fires a `/search/semantic` request that 500s (the client
13
- * fetches semantic hits alongside every literal search), retried by the client
14
- * on top.
7
+ * This gates the free-text `/search/semantic` path only. Answering that query
8
+ * means embedding it in-process, and the backend container image ships without
9
+ * `@huggingface/transformers` (the model plus its dependencies would roughly
10
+ * quadruple the image see npm-scripts/docker-bundle.mjs and
11
+ * deploy/vps/README.md). The embedder is reached lazily through `runtimeImport`,
12
+ * so the first semantic query not startup hits ERR_MODULE_NOT_FOUND. Without
13
+ * this gate every search the web client issues fires a `/search/semantic`
14
+ * request that 500s (the client fetches semantic hits alongside every literal
15
+ * search), retried by the client on top.
16
+ *
17
+ * The vector STORE is a separate capability and is NOT gated here. The Organize
18
+ * anchor widen (organize.ts matchSemantic) pools an anchor message's already
19
+ * stored chunk vectors and runs a KNN read over the vector store — no embedding
20
+ * model involved — so it needs only `sqlite-vec`, which the backend image now
21
+ * carries as a musl build (Dockerfile sqlite-vec-musl stage,
22
+ * SQLITE_VEC_EXTENSION_PATH). matchOrganize never consults the memoized flag
23
+ * below; a missing embedder therefore never disables the widen.
15
24
  *
16
25
  * The e2e-dev lane runs this backend from source rather than the container, so
17
26
  * `@huggingface/transformers` IS present and the local embedder instead tries to