@remit/mailbox-service 0.0.86 → 0.0.88

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/mailbox-service",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -8,6 +8,7 @@ import {
8
8
  cosineSimilarity,
9
9
  type FilterMessage,
10
10
  literalClausesMatch,
11
+ literalClauseTerms,
11
12
  selectMoveWinner,
12
13
  } from "./match.js";
13
14
 
@@ -287,3 +288,106 @@ describe("buildMatchText", () => {
287
288
  assert.equal(text.length, 512);
288
289
  });
289
290
  });
291
+
292
+ describe("literalClauseTerms (reader #459)", () => {
293
+ it("narrows From and Subject onto the columns that carry them", () => {
294
+ assert.deepEqual(
295
+ literalClauseTerms(
296
+ [
297
+ clause(FilterClauseField.From, " alice@acme.example "),
298
+ clause(FilterClauseField.Subject, "invoice"),
299
+ ],
300
+ FilterMatchOperator.And,
301
+ ),
302
+ {
303
+ terms: [
304
+ { field: "sender", contains: "alice@acme.example" },
305
+ { field: "subject", contains: "invoice" },
306
+ ],
307
+ operator: "and",
308
+ },
309
+ );
310
+ });
311
+
312
+ it("narrows FromDomain to the registrable domain, which the sender always contains", () => {
313
+ const narrowing = literalClauseTerms(
314
+ [clause(FilterClauseField.FromDomain, "billing@mail.acme.example")],
315
+ FilterMatchOperator.And,
316
+ );
317
+
318
+ assert.deepEqual(narrowing?.terms, [
319
+ { field: "sender", contains: "acme.example" },
320
+ ]);
321
+ assert.equal(
322
+ clauseMatches(
323
+ clause(FilterClauseField.FromDomain, "billing@mail.acme.example"),
324
+ message(),
325
+ ),
326
+ true,
327
+ );
328
+ });
329
+
330
+ it("narrows ListId to its canonical form", () => {
331
+ assert.deepEqual(
332
+ literalClauseTerms(
333
+ [clause(FilterClauseField.ListId, "News <Weekly.News.Example.com>")],
334
+ FilterMatchOperator.And,
335
+ )?.terms,
336
+ [{ field: "listId", contains: "weekly.news.example.com" }],
337
+ );
338
+ });
339
+
340
+ it("returns null for clauses nothing can satisfy, so no query is worth running", () => {
341
+ assert.equal(
342
+ literalClauseTerms(
343
+ [clause(FilterClauseField.From, " ")],
344
+ FilterMatchOperator.And,
345
+ ),
346
+ null,
347
+ );
348
+ assert.equal(
349
+ literalClauseTerms(
350
+ [clause(FilterClauseField.FromDomain, "not a domain")],
351
+ FilterMatchOperator.Or,
352
+ ),
353
+ null,
354
+ );
355
+ });
356
+
357
+ it("drops an unsatisfiable clause from an Or instead of losing the rest", () => {
358
+ assert.deepEqual(
359
+ literalClauseTerms(
360
+ [
361
+ clause(FilterClauseField.From, ""),
362
+ clause(FilterClauseField.Subject, "invoice"),
363
+ ],
364
+ FilterMatchOperator.Or,
365
+ ),
366
+ { terms: [{ field: "subject", contains: "invoice" }], operator: "or" },
367
+ );
368
+ });
369
+
370
+ it("narrows nothing when an Or reaches a clause no column carries", () => {
371
+ assert.deepEqual(
372
+ literalClauseTerms(
373
+ [
374
+ clause(FilterClauseField.HasWords, "quarter"),
375
+ clause(FilterClauseField.Subject, "invoice"),
376
+ ],
377
+ FilterMatchOperator.Or,
378
+ ),
379
+ { terms: [], operator: "or" },
380
+ );
381
+ });
382
+
383
+ it("narrows nothing for no clauses, matching the vacuous pass of literalClausesMatch", () => {
384
+ assert.deepEqual(literalClauseTerms([], FilterMatchOperator.And), {
385
+ terms: [],
386
+ operator: "and",
387
+ });
388
+ assert.equal(
389
+ literalClausesMatch([], FilterMatchOperator.And, message()),
390
+ true,
391
+ );
392
+ });
393
+ });
@@ -1,4 +1,8 @@
1
- import { FILTER_NO_ACTION, type FilterItem } from "@remit/data-ports";
1
+ import {
2
+ FILTER_NO_ACTION,
3
+ type FilterItem,
4
+ type ThreadMessageFieldTerm,
5
+ } from "@remit/data-ports";
2
6
  import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
3
7
  import { getDomain } from "tldts";
4
8
  import { normalizeListId } from "./list-id.js";
@@ -108,6 +112,87 @@ export const literalClausesMatch = (
108
112
  : clauses.every((clause) => clauseMatches(clause, msg));
109
113
  };
110
114
 
115
+ /**
116
+ * What a store can be asked for on behalf of one clause: a column term, or the
117
+ * two answers a term cannot express — a clause nothing can satisfy, and a
118
+ * clause selecting on something no ThreadMessage column carries.
119
+ */
120
+ type ClauseNarrowing =
121
+ | { kind: "Term"; term: ThreadMessageFieldTerm }
122
+ | { kind: "NeverMatches" }
123
+ | { kind: "Unnarrowable" };
124
+
125
+ const clauseNarrowing = (clause: FilterClause): ClauseNarrowing => {
126
+ const value = clause.value.trim();
127
+ if (value === "") return { kind: "NeverMatches" };
128
+ switch (clause.field) {
129
+ case FilterClauseField.From:
130
+ return { kind: "Term", term: { field: "sender", contains: value } };
131
+ case FilterClauseField.Subject:
132
+ return { kind: "Term", term: { field: "subject", contains: value } };
133
+ case FilterClauseField.ListId: {
134
+ const target = normalizeListId(value);
135
+ if (target === "") return { kind: "NeverMatches" };
136
+ return { kind: "Term", term: { field: "listId", contains: target } };
137
+ }
138
+ case FilterClauseField.FromDomain: {
139
+ const target = registrableDomain(value);
140
+ if (target === null) return { kind: "NeverMatches" };
141
+ return { kind: "Term", term: { field: "sender", contains: target } };
142
+ }
143
+ default:
144
+ return { kind: "Unnarrowable" };
145
+ }
146
+ };
147
+
148
+ /**
149
+ * The terms to query with, and how to combine them. Empty terms narrow nothing
150
+ * — the caller reads the whole corpus and refines it.
151
+ */
152
+ export interface LiteralClauseNarrowing {
153
+ terms: ThreadMessageFieldTerm[];
154
+ operator: "and" | "or";
155
+ }
156
+
157
+ /**
158
+ * The store-side half of {@link literalClausesMatch}: the column terms a query
159
+ * can evaluate on behalf of these clauses, or `null` when they can match
160
+ * nothing at all and there is no query worth running.
161
+ *
162
+ * A term is deliberately WIDER than the clause it stands for — a `FromDomain`
163
+ * clause narrows to the registrable domain appearing anywhere in the sender,
164
+ * so `github.com.evil.example` still comes back — and a `HasWords` clause
165
+ * cannot be narrowed at all, since no row column carries the body. The rows a
166
+ * term selects are candidates; `literalClausesMatch` still decides. Pushing the
167
+ * terms down is what makes the caller's bound a bound on RESULTS: filtering a
168
+ * date-ordered page instead answers "matches among the newest N messages"
169
+ * (#459).
170
+ */
171
+ export const literalClauseTerms = (
172
+ clauses: readonly FilterClause[],
173
+ operator: FilterItem["matchOperator"],
174
+ ): LiteralClauseNarrowing | null => {
175
+ const combine = operator === FilterMatchOperator.Or ? "or" : "and";
176
+ if (clauses.length === 0) return { terms: [], operator: combine };
177
+ const narrowings = clauses.map(clauseNarrowing);
178
+ const terms = narrowings.flatMap((narrowing) =>
179
+ narrowing.kind === "Term" ? [narrowing.term] : [],
180
+ );
181
+ if (combine === "and") {
182
+ if (narrowings.some((narrowing) => narrowing.kind === "NeverMatches")) {
183
+ return null;
184
+ }
185
+ return { terms, operator: "and" };
186
+ }
187
+ if (narrowings.every((narrowing) => narrowing.kind === "NeverMatches")) {
188
+ return null;
189
+ }
190
+ if (narrowings.some((narrowing) => narrowing.kind === "Unnarrowable")) {
191
+ return { terms: [], operator: "or" };
192
+ }
193
+ return { terms, operator: "or" };
194
+ };
195
+
111
196
  /**
112
197
  * Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
113
198
  * rather than silently scoring incomparable vectors — a mismatch means a stale
package/src/index.ts CHANGED
@@ -76,7 +76,9 @@ export {
76
76
  cosineSimilarity,
77
77
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
78
78
  type FilterMessage,
79
+ type LiteralClauseNarrowing,
79
80
  literalClausesMatch,
81
+ literalClauseTerms,
80
82
  NO_ACTION,
81
83
  selectMoveWinner,
82
84
  } from "./filters/match.js";
@@ -249,7 +251,11 @@ export {
249
251
  type ResolveExhaustedPlacementMoveResult,
250
252
  resolveExhaustedPlacementMoveFailure,
251
253
  } from "./placement-move-terminal.js";
252
- export { isPlacementUnsettled } from "./placement-settled.js";
254
+ export {
255
+ isPlacementUnsettled,
256
+ type PlacementBinding,
257
+ placementBindingOf,
258
+ } from "./placement-settled.js";
253
259
  export {
254
260
  type QuarantineContext,
255
261
  QuarantinedUids,
@@ -44,10 +44,12 @@ export const bindsForeignUid = (
44
44
 
45
45
  /**
46
46
  * `in_flight` — a mover is still working on the row, so waiting resolves it.
47
- * `abandoned` — the mover gave up without confirming (`syncStatus: failed`,
48
- * written by every handler's give-up path while `status` stays `moving`, since
49
- * only `updateUid` clears it). Nothing routine settles such a row, so waiting
50
- * on one is a wait that never ends, and the pair stays a lie either way.
47
+ * `abandoned` — the last attempt failed (`syncStatus: failed`) and the row is
48
+ * still `moving`: either a redelivery is pending, or the record dead-lettered
49
+ * and nothing is coming for it. The two are indistinguishable from the row
50
+ * (`message-settlement.ts`), and the pair is a lie either way, so neither is
51
+ * worth spending the settle ceiling on. A mover that exhausts its budget
52
+ * settles the row against IMAP instead of leaving this state behind (#1005).
51
53
  */
52
54
  export type PlacementBinding = "consistent" | "in_flight" | "abandoned";
53
55