@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 +1 -1
- package/src/filters/match.test.ts +104 -0
- package/src/filters/match.ts +86 -1
- package/src/index.ts +7 -1
- package/src/placement-settled.ts +6 -4
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/filters/match.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
254
|
+
export {
|
|
255
|
+
isPlacementUnsettled,
|
|
256
|
+
type PlacementBinding,
|
|
257
|
+
placementBindingOf,
|
|
258
|
+
} from "./placement-settled.js";
|
|
253
259
|
export {
|
|
254
260
|
type QuarantineContext,
|
|
255
261
|
QuarantinedUids,
|
package/src/placement-settled.ts
CHANGED
|
@@ -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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|