@remit/web-client 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/auth/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +157 -38
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +5 -6
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useOrganizeWiden.ts +1 -2
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +89 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +117 -38
- package/src/lib/organize/search-to-rule.test.ts +8 -28
- package/src/lib/organize/search-to-rule.ts +33 -89
- package/src/lib/organize/sender-fallback.test.ts +1 -97
- package/src/lib/organize/sender-fallback.ts +8 -71
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type ClauseField, type Suggestion, senderDomain } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Values worth offering while a clause is being typed. The rule editor's value
|
|
5
|
+
* field used to be bare text entry, so a `From` clause meant recalling an
|
|
6
|
+
* address exactly and a `FromDomain` clause meant knowing what the matcher
|
|
7
|
+
* considers a registrable domain.
|
|
8
|
+
*
|
|
9
|
+
* The offer is a shortcut and never a constraint: nothing here can reject or
|
|
10
|
+
* rewrite what the user typed, and a field with no matches is the plain text box
|
|
11
|
+
* it always was.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** An address a suggestion can be built from. */
|
|
15
|
+
export interface KnownAddress {
|
|
16
|
+
email: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The address came off the messages the user selected, rather than a lookup.
|
|
20
|
+
* These are the likeliest answers, so they lead the list and say where they
|
|
21
|
+
* came from.
|
|
22
|
+
*/
|
|
23
|
+
fromSelection?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How many suggestions the list shows at most. */
|
|
27
|
+
export const CLAUSE_SUGGESTION_LIMIT = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a clause field takes values that come from addresses. `Subject`,
|
|
31
|
+
* `HasWords`, and `ListId` are free text with nothing to draw on, and stay so.
|
|
32
|
+
*/
|
|
33
|
+
export const fieldTakesAddressSuggestions = (field: ClauseField): boolean =>
|
|
34
|
+
field === "From" || field === "FromDomain";
|
|
35
|
+
|
|
36
|
+
const addressSuggestion = (address: KnownAddress): Suggestion => ({
|
|
37
|
+
value: address.email,
|
|
38
|
+
label: address.displayName ?? address.email,
|
|
39
|
+
...(address.displayName ? { hint: address.email } : {}),
|
|
40
|
+
...(address.fromSelection ? { source: "selected" } : {}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const domainSuggestion = (address: KnownAddress): Suggestion | undefined => {
|
|
44
|
+
const domain = senderDomain(address.email);
|
|
45
|
+
if (domain === null) return undefined;
|
|
46
|
+
return {
|
|
47
|
+
value: domain,
|
|
48
|
+
...(address.fromSelection ? { source: "selected" } : {}),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const matches = (suggestion: Suggestion, query: string): boolean =>
|
|
53
|
+
suggestion.value.toLowerCase().includes(query) ||
|
|
54
|
+
(suggestion.label ?? "").toLowerCase().includes(query);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The suggestions for one clause draft. `addresses` is given in priority order —
|
|
58
|
+
* the selection's own senders first — and duplicates collapse onto the first
|
|
59
|
+
* occurrence, so a selected address keeps its "selected" marking even when a
|
|
60
|
+
* lookup returns it too.
|
|
61
|
+
*
|
|
62
|
+
* An empty query offers the lot, which is what makes the selection's addresses
|
|
63
|
+
* available before a single key is pressed. A query that exactly matches an
|
|
64
|
+
* offer drops it: there is nothing left to shortcut.
|
|
65
|
+
*/
|
|
66
|
+
export const buildClauseSuggestions = (
|
|
67
|
+
field: ClauseField,
|
|
68
|
+
query: string,
|
|
69
|
+
addresses: readonly KnownAddress[],
|
|
70
|
+
): Suggestion[] => {
|
|
71
|
+
if (!fieldTakesAddressSuggestions(field)) return [];
|
|
72
|
+
const needle = query.trim().toLowerCase();
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
const out: Suggestion[] = [];
|
|
75
|
+
for (const address of addresses) {
|
|
76
|
+
if (address.email.trim() === "") continue;
|
|
77
|
+
const suggestion =
|
|
78
|
+
field === "From" ? addressSuggestion(address) : domainSuggestion(address);
|
|
79
|
+
if (!suggestion) continue;
|
|
80
|
+
const key = suggestion.value.toLowerCase();
|
|
81
|
+
if (key === "" || seen.has(key)) continue;
|
|
82
|
+
seen.add(key);
|
|
83
|
+
if (key === needle) continue;
|
|
84
|
+
if (needle !== "" && !matches(suggestion, needle)) continue;
|
|
85
|
+
out.push(suggestion);
|
|
86
|
+
if (out.length === CLAUSE_SUGGESTION_LIMIT) break;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
@@ -11,6 +11,7 @@ import { buildOrganizeInput } from "./organize-model";
|
|
|
11
11
|
import {
|
|
12
12
|
buildInitialRule,
|
|
13
13
|
derivePreview,
|
|
14
|
+
isEvaluablePredicate,
|
|
14
15
|
normalizeClauseValue,
|
|
15
16
|
normalizeListId,
|
|
16
17
|
predicateSignature,
|
|
@@ -300,3 +301,59 @@ describe("derivePreview", () => {
|
|
|
300
301
|
);
|
|
301
302
|
});
|
|
302
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("isEvaluablePredicate", () => {
|
|
306
|
+
it("accepts literal clauses the vector-free matcher can read off the index", () => {
|
|
307
|
+
assert.equal(
|
|
308
|
+
isEvaluablePredicate({
|
|
309
|
+
matchOperator: "And",
|
|
310
|
+
literalClauses: [
|
|
311
|
+
{ field: "From", value: "npm@github.com" },
|
|
312
|
+
{ field: "Subject", value: "Invoice" },
|
|
313
|
+
],
|
|
314
|
+
}),
|
|
315
|
+
true,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("rejects a body clause with no anchor — the shape that answered 500", () => {
|
|
320
|
+
assert.equal(
|
|
321
|
+
isEvaluablePredicate({
|
|
322
|
+
matchOperator: "And",
|
|
323
|
+
literalClauses: [{ field: "HasWords", value: "receipt" }],
|
|
324
|
+
}),
|
|
325
|
+
false,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects it however it is mixed in with clauses that are evaluable", () => {
|
|
330
|
+
assert.equal(
|
|
331
|
+
isEvaluablePredicate({
|
|
332
|
+
matchOperator: "Or",
|
|
333
|
+
literalClauses: [
|
|
334
|
+
{ field: "From", value: "npm@github.com" },
|
|
335
|
+
{ field: "HasWords", value: "receipt" },
|
|
336
|
+
],
|
|
337
|
+
}),
|
|
338
|
+
false,
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("accepts a body clause once an anchor puts the matcher on the semantic arm", () => {
|
|
343
|
+
assert.equal(
|
|
344
|
+
isEvaluablePredicate({
|
|
345
|
+
anchorMessageId: "msg-1",
|
|
346
|
+
matchOperator: "And",
|
|
347
|
+
literalClauses: [{ field: "HasWords", value: "receipt" }],
|
|
348
|
+
}),
|
|
349
|
+
true,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("accepts an empty predicate", () => {
|
|
354
|
+
assert.equal(
|
|
355
|
+
isEvaluablePredicate({ matchOperator: "And", literalClauses: [] }),
|
|
356
|
+
true,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
ClauseField,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import {
|
|
2
|
+
type ClauseField,
|
|
3
|
+
derivePropertyClauses,
|
|
4
|
+
type FilterRule,
|
|
5
|
+
type MatchOperator,
|
|
6
|
+
type PreviewCount,
|
|
7
|
+
type RuleClause,
|
|
8
|
+
type RuleMatchMode,
|
|
9
|
+
type RuleScope,
|
|
8
10
|
} from "@remit/ui";
|
|
9
11
|
import { pickedDateToExpiresAt } from "./filter-status";
|
|
10
12
|
import type { OrganizeDraft } from "./organize-model";
|
|
11
|
-
import {
|
|
12
|
-
deriveSenderClauses,
|
|
13
|
-
type OrganizeMatchPredicate,
|
|
14
|
-
} from "./sender-fallback";
|
|
13
|
+
import type { OrganizeMatchPredicate } from "./sender-fallback";
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
|
|
@@ -55,11 +54,19 @@ export interface InitialRuleInput {
|
|
|
55
54
|
anchorMessageId?: string;
|
|
56
55
|
/**
|
|
57
56
|
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
58
|
-
* opens on the
|
|
57
|
+
* opens on the property-derived literal clauses instead of an anchor.
|
|
59
58
|
*/
|
|
60
59
|
semanticUnavailable: boolean;
|
|
61
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* How the rule opens. Defaults to `properties` when the deployment cannot
|
|
62
|
+
* serve the widen, `similar` otherwise — semantic stays the default wherever
|
|
63
|
+
* it can run.
|
|
64
|
+
*/
|
|
65
|
+
matchMode?: RuleMatchMode;
|
|
66
|
+
/** Distinct sender addresses of the selection, for the derived clauses. */
|
|
62
67
|
senders: readonly string[];
|
|
68
|
+
/** Subjects of the selected messages, for the shared-subject prefill. */
|
|
69
|
+
subjects?: readonly string[];
|
|
63
70
|
/** How many messages the selection holds — the widen's anchor count. */
|
|
64
71
|
selectionCount: number;
|
|
65
72
|
/** A folder a "Something else" shortcut pre-picked. */
|
|
@@ -68,41 +75,96 @@ export interface InitialRuleInput {
|
|
|
68
75
|
seedScope?: RuleScope;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
export const defaultMatchMode = (
|
|
79
|
+
semanticUnavailable: boolean,
|
|
80
|
+
): RuleMatchMode => (semanticUnavailable ? "properties" : "similar");
|
|
81
|
+
|
|
82
|
+
/** The property prefill as editor chips, flagged as derived so a mode switch
|
|
83
|
+
* can take back its own guesses without touching a clause the user wrote. */
|
|
84
|
+
const derivedClauses = (
|
|
85
|
+
senders: readonly string[],
|
|
86
|
+
subjects: readonly string[],
|
|
87
|
+
): RuleClause[] =>
|
|
88
|
+
derivePropertyClauses(senders, subjects).map((clause, index) => ({
|
|
89
|
+
id: `derived-${index}`,
|
|
90
|
+
field: clause.field,
|
|
91
|
+
value: clause.value,
|
|
92
|
+
derived: true,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
export interface RuleMatchers {
|
|
96
|
+
clauses: RuleClause[];
|
|
97
|
+
matchOperator: MatchOperator;
|
|
98
|
+
widen?: FilterRule["widen"];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface MatchersInput {
|
|
102
|
+
anchorMessageId?: string;
|
|
103
|
+
senders: readonly string[];
|
|
104
|
+
subjects: readonly string[];
|
|
105
|
+
selectionCount: number;
|
|
106
|
+
/** Clauses the user wrote, carried across a mode switch untouched. */
|
|
107
|
+
keepClauses?: RuleClause[];
|
|
108
|
+
/** The operator to keep when the user already has clauses of their own. */
|
|
109
|
+
currentOperator?: MatchOperator;
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
113
|
+
* What a rule matches on in a given mode, with the action and scope left alone.
|
|
114
|
+
*
|
|
115
|
+
* - `similar` — the semantic widen chip, the anchor doing the work.
|
|
116
|
+
* - `properties` — no anchor at all: the selection's own sender or shared
|
|
117
|
+
* subject as ordinary literal chips ({@link derivePropertyClauses}).
|
|
118
|
+
*
|
|
119
|
+
* Both keep clauses the user added by hand, so switching mode is never
|
|
120
|
+
* destructive of their own edits.
|
|
77
121
|
*/
|
|
78
|
-
export const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
(clause
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}),
|
|
122
|
+
export const matchersForMode = (
|
|
123
|
+
mode: RuleMatchMode,
|
|
124
|
+
input: MatchersInput,
|
|
125
|
+
): RuleMatchers => {
|
|
126
|
+
const kept = input.keepClauses ?? [];
|
|
127
|
+
if (mode === "properties") {
|
|
128
|
+
const prefill = derivedClauses(input.senders, input.subjects).filter(
|
|
129
|
+
(clause) =>
|
|
130
|
+
!kept.some(
|
|
131
|
+
(existing) =>
|
|
132
|
+
existing.field === clause.field && existing.value === clause.value,
|
|
133
|
+
),
|
|
91
134
|
);
|
|
92
135
|
return {
|
|
93
|
-
clauses,
|
|
94
|
-
matchOperator: "any",
|
|
95
|
-
|
|
96
|
-
scope,
|
|
97
|
-
name: "",
|
|
136
|
+
clauses: [...kept, ...prefill],
|
|
137
|
+
matchOperator: kept.length > 0 ? (input.currentOperator ?? "any") : "any",
|
|
138
|
+
widen: undefined,
|
|
98
139
|
};
|
|
99
140
|
}
|
|
100
141
|
return {
|
|
101
|
-
clauses:
|
|
102
|
-
matchOperator: "all",
|
|
142
|
+
clauses: kept,
|
|
143
|
+
matchOperator: kept.length > 0 ? (input.currentOperator ?? "all") : "all",
|
|
103
144
|
widen: input.anchorMessageId
|
|
104
145
|
? { anchorCount: Math.max(input.selectionCount, 1) }
|
|
105
146
|
: undefined,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The rule the editor opens on, built from the widen probe. A semantic-capable
|
|
152
|
+
* deployment opens on the widen chip (the anchor, no literal clauses); one
|
|
153
|
+
* without the vector pipeline, or a user who picked properties matching, opens
|
|
154
|
+
* on the derived `From` / `FromDomain` / `Subject` chips (#251), visible and
|
|
155
|
+
* editable, matched with `Or`. Either way the move destination and scope come
|
|
156
|
+
* from any "Something else" seed.
|
|
157
|
+
*/
|
|
158
|
+
export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
|
|
159
|
+
const scope = input.seedScope ?? "once";
|
|
160
|
+
const mode = input.matchMode ?? defaultMatchMode(input.semanticUnavailable);
|
|
161
|
+
return {
|
|
162
|
+
...matchersForMode(mode, {
|
|
163
|
+
anchorMessageId: input.anchorMessageId,
|
|
164
|
+
senders: input.senders,
|
|
165
|
+
subjects: input.subjects ?? [],
|
|
166
|
+
selectionCount: input.selectionCount,
|
|
167
|
+
}),
|
|
106
168
|
moveMailboxId: input.seedMailboxId,
|
|
107
169
|
scope,
|
|
108
170
|
name: "",
|
|
@@ -132,6 +194,23 @@ export const rulePredicate = (
|
|
|
132
194
|
};
|
|
133
195
|
};
|
|
134
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Whether the matcher behind `/organize/preview` can evaluate this predicate at
|
|
199
|
+
* all. Without an anchor it takes the vector-free literal arm, which reads no
|
|
200
|
+
* message body and rejects a `HasWords` clause rather than narrowing the match
|
|
201
|
+
* silently (`assertNoBodyContentClause`, backend/service/organize.ts). Asking it
|
|
202
|
+
* anyway is a 500, so the caller must not ask.
|
|
203
|
+
*/
|
|
204
|
+
export const isEvaluablePredicate = (
|
|
205
|
+
predicate: OrganizeMatchPredicate,
|
|
206
|
+
): boolean =>
|
|
207
|
+
predicate.anchorMessageId !== undefined ||
|
|
208
|
+
!predicate.literalClauses.some((clause) => clause.field === "HasWords");
|
|
209
|
+
|
|
210
|
+
/** What the count says when the predicate cannot be counted at all. */
|
|
211
|
+
export const UNCOUNTABLE_PREDICATE_REASON =
|
|
212
|
+
"Can't count matches — “has the words” reads message bodies, which only a saved rule does.";
|
|
213
|
+
|
|
135
214
|
/**
|
|
136
215
|
* A stable key for a predicate's match set. Two predicates with the same key
|
|
137
216
|
* match the same messages; a change to the key is what marks the live count
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { isConvertible } from "@remit/ui";
|
|
3
4
|
import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
|
|
4
|
-
import {
|
|
5
|
-
buildSearchRule,
|
|
6
|
-
convertSearchToRule,
|
|
7
|
-
isConvertible,
|
|
8
|
-
} from "./search-to-rule";
|
|
5
|
+
import { convertSearchToRule } from "./search-to-rule";
|
|
9
6
|
|
|
10
7
|
const CONTEXT: SearchTokenContext = {
|
|
11
8
|
mailboxesByName: new Map([["archive", "mbx-archive"]]),
|
|
@@ -114,33 +111,16 @@ describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
|
|
|
114
111
|
});
|
|
115
112
|
});
|
|
116
113
|
|
|
117
|
-
describe("
|
|
118
|
-
it("
|
|
114
|
+
describe("convertSearchToRule — nothing left to make a filter from", () => {
|
|
115
|
+
it("yields no clause for a query of only dropped facets", () => {
|
|
119
116
|
assert.equal(isConvertible(convert("has:attachment")), false);
|
|
120
|
-
assert.equal(isConvertible(convert("in:archive")), false);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("is convertible once a term or sender is present", () => {
|
|
124
|
-
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
125
117
|
});
|
|
126
118
|
|
|
127
|
-
it("
|
|
128
|
-
|
|
129
|
-
assert.equal(rule.scope, "standing");
|
|
130
|
-
assert.equal(rule.widen, undefined);
|
|
131
|
-
assert.equal(rule.name, "");
|
|
132
|
-
assert.deepEqual(
|
|
133
|
-
rule.clauses.map((clause) => clause.id),
|
|
134
|
-
["search-0", "search-1"],
|
|
135
|
-
);
|
|
119
|
+
it("yields no clause for a bare folder scope", () => {
|
|
120
|
+
assert.equal(isConvertible(convert("in:archive")), false);
|
|
136
121
|
});
|
|
137
122
|
|
|
138
|
-
it("
|
|
139
|
-
|
|
140
|
-
scope: "once",
|
|
141
|
-
moveMailboxId: "mbx-archive",
|
|
142
|
-
});
|
|
143
|
-
assert.equal(rule.scope, "once");
|
|
144
|
-
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
123
|
+
it("yields a clause once a term rides along with the folder scope", () => {
|
|
124
|
+
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
145
125
|
});
|
|
146
126
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
RuleScope,
|
|
2
|
+
DroppedFacet,
|
|
3
|
+
DroppedFacetType,
|
|
4
|
+
SearchConversion,
|
|
6
5
|
} from "@remit/ui";
|
|
7
6
|
import {
|
|
8
7
|
type ParsedSearchQuery,
|
|
@@ -18,47 +17,13 @@ import {
|
|
|
18
17
|
* The mapping is honest about what a filter cannot carry. A facet with no clause
|
|
19
18
|
* equivalent is never silently folded into the rule: a folder scope is reported
|
|
20
19
|
* as dropped-and-kept-out (the filter matches everywhere, not just there), the
|
|
21
|
-
* attribute facets (attachment /
|
|
22
|
-
* free-text query kept as a literal `HasWords` clause
|
|
23
|
-
* its semantic "similar mail" reach on a deployment
|
|
24
|
-
* (D5). Pure functions only — the capability is
|
|
20
|
+
* attribute facets (attachment / read state / starred / category / date) are
|
|
21
|
+
* reported as left out, and a free-text query kept as a literal `HasWords` clause
|
|
22
|
+
* is reported as having lost its semantic "similar mail" reach on a deployment
|
|
23
|
+
* that cannot embed the query (D5). Pure functions only — the capability is
|
|
24
|
+
* injected, not probed here.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
export interface DroppedFacet {
|
|
28
|
-
/** The facet that had no clause equivalent. */
|
|
29
|
-
type: SearchToken["type"];
|
|
30
|
-
/** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
|
|
31
|
-
label: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ScopedOutFolder {
|
|
35
|
-
mailboxId: string;
|
|
36
|
-
/** The folder the search was limited to. */
|
|
37
|
-
label: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface SearchConversion {
|
|
41
|
-
/** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
|
|
42
|
-
clauses: { field: ClauseField; value: string }[];
|
|
43
|
-
matchOperator: MatchOperator;
|
|
44
|
-
/** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
|
|
45
|
-
scopedOut?: ScopedOutFolder;
|
|
46
|
-
/** Facets with no clause equivalent, each named. */
|
|
47
|
-
droppedFacets: DroppedFacet[];
|
|
48
|
-
/** The account an `account:` facet targets — the filter is created for it. */
|
|
49
|
-
targetAccountId?: string;
|
|
50
|
-
/** The search carried free-text terms, kept as a `HasWords` clause. */
|
|
51
|
-
keptTerms: boolean;
|
|
52
|
-
/**
|
|
53
|
-
* The filter this conversion builds is always literal-only — free text has no
|
|
54
|
-
* anchor message for a semantic widen — so the search's semantic "similar mail"
|
|
55
|
-
* reach is dropped whenever the search had one (RFC 038 D5). True exactly when
|
|
56
|
-
* free text was kept AND the search surfaced semantically-similar mail; on a
|
|
57
|
-
* deployment with no semantic reach there was nothing to drop, so no note.
|
|
58
|
-
*/
|
|
59
|
-
droppedSemantic: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
27
|
interface ConvertOptions {
|
|
63
28
|
/**
|
|
64
29
|
* Whether the current search surfaced semantically-similar mail (a non-empty
|
|
@@ -69,18 +34,28 @@ interface ConvertOptions {
|
|
|
69
34
|
searchHadSemanticReach: boolean;
|
|
70
35
|
}
|
|
71
36
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
37
|
+
type NoClauseFacet = Extract<SearchToken["type"], DroppedFacetType>;
|
|
38
|
+
|
|
39
|
+
const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> =
|
|
40
|
+
new Set<NoClauseFacet>([
|
|
41
|
+
"hasAttachment",
|
|
42
|
+
"isUnread",
|
|
43
|
+
"isRead",
|
|
44
|
+
"isStarred",
|
|
45
|
+
"category",
|
|
46
|
+
"before",
|
|
47
|
+
"after",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const hasNoClauseEquivalent = (
|
|
51
|
+
type: SearchToken["type"],
|
|
52
|
+
): type is NoClauseFacet => FACET_HAS_NO_CLAUSE.has(type);
|
|
78
53
|
|
|
79
54
|
/**
|
|
80
55
|
* Convert the current search into a rule's clauses and a record of what could not
|
|
81
|
-
* be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause
|
|
82
|
-
*
|
|
83
|
-
* attribute facets are dropped. The search's terms are ANDed with its facets, so
|
|
56
|
+
* be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause
|
|
57
|
+
* and a `subject:` facet a `Subject` clause; an `in:` facet a kept-out folder
|
|
58
|
+
* scope; `account:` the target account; the attribute facets are dropped. The search's terms are ANDed with its facets, so
|
|
84
59
|
* the rule matches all of them (`all`).
|
|
85
60
|
*/
|
|
86
61
|
export const convertSearchToRule = (
|
|
@@ -89,7 +64,7 @@ export const convertSearchToRule = (
|
|
|
89
64
|
): SearchConversion => {
|
|
90
65
|
const clauses: SearchConversion["clauses"] = [];
|
|
91
66
|
const droppedFacets: DroppedFacet[] = [];
|
|
92
|
-
let scopedOut:
|
|
67
|
+
let scopedOut: SearchConversion["scopedOut"];
|
|
93
68
|
let targetAccountId: string | undefined;
|
|
94
69
|
|
|
95
70
|
for (const token of parsed.tokens) {
|
|
@@ -97,6 +72,10 @@ export const convertSearchToRule = (
|
|
|
97
72
|
clauses.push({ field: "From", value: token.value });
|
|
98
73
|
continue;
|
|
99
74
|
}
|
|
75
|
+
if (token.type === "subject") {
|
|
76
|
+
clauses.push({ field: "Subject", value: token.value });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
100
79
|
if (token.type === "in") {
|
|
101
80
|
scopedOut = { mailboxId: token.mailboxId, label: token.value };
|
|
102
81
|
continue;
|
|
@@ -105,7 +84,7 @@ export const convertSearchToRule = (
|
|
|
105
84
|
targetAccountId = token.accountId;
|
|
106
85
|
continue;
|
|
107
86
|
}
|
|
108
|
-
if (
|
|
87
|
+
if (hasNoClauseEquivalent(token.type)) {
|
|
109
88
|
droppedFacets.push({ type: token.type, label: searchTokenLabel(token) });
|
|
110
89
|
}
|
|
111
90
|
}
|
|
@@ -124,38 +103,3 @@ export const convertSearchToRule = (
|
|
|
124
103
|
droppedSemantic: keptTerms && searchHadSemanticReach,
|
|
125
104
|
};
|
|
126
105
|
};
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Whether the conversion yields a rule with something to match. A search of only
|
|
130
|
-
* dropped facets or a bare folder scope converts to no clauses, so there is no
|
|
131
|
-
* filter to open — the entry point offers nothing rather than an empty editor.
|
|
132
|
-
*/
|
|
133
|
-
export const isConvertible = (conversion: SearchConversion): boolean =>
|
|
134
|
-
conversion.clauses.length > 0;
|
|
135
|
-
|
|
136
|
-
interface BuildRuleOptions {
|
|
137
|
-
scope?: RuleScope;
|
|
138
|
-
moveMailboxId?: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The rule the editor opens on, from a conversion. A search-derived rule defaults
|
|
143
|
-
* to a standing filter — "make this a filter" is a request to keep applying it —
|
|
144
|
-
* and the editor lets the user drop it back to a one-time apply. It carries no
|
|
145
|
-
* widen: a free-text query has no message anchor, so the semantic chip is not
|
|
146
|
-
* offered on this surface (its loss is stated in the conversion notice instead).
|
|
147
|
-
*/
|
|
148
|
-
export const buildSearchRule = (
|
|
149
|
-
conversion: SearchConversion,
|
|
150
|
-
{ scope = "standing", moveMailboxId }: BuildRuleOptions = {},
|
|
151
|
-
): FilterRule => ({
|
|
152
|
-
clauses: conversion.clauses.map((clause, index) => ({
|
|
153
|
-
id: `search-${index}`,
|
|
154
|
-
field: clause.field,
|
|
155
|
-
value: clause.value,
|
|
156
|
-
})),
|
|
157
|
-
matchOperator: conversion.matchOperator,
|
|
158
|
-
moveMailboxId,
|
|
159
|
-
scope,
|
|
160
|
-
name: "",
|
|
161
|
-
});
|
|
@@ -1,102 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
buildSenderFallbackDraft,
|
|
5
|
-
collapsibleDomain,
|
|
6
|
-
deriveSenderClauses,
|
|
7
|
-
distinctSenders,
|
|
8
|
-
} from "./sender-fallback";
|
|
9
|
-
|
|
10
|
-
describe("distinctSenders", () => {
|
|
11
|
-
it("drops empties and blanks, trimming what remains", () => {
|
|
12
|
-
assert.deepEqual(
|
|
13
|
-
distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
|
|
14
|
-
["npm@github.com", "a@x.com"],
|
|
15
|
-
);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
|
|
19
|
-
assert.deepEqual(
|
|
20
|
-
distinctSenders([
|
|
21
|
-
"NPM@github.com",
|
|
22
|
-
"a@x.com",
|
|
23
|
-
"npm@GITHUB.com",
|
|
24
|
-
"a@x.com",
|
|
25
|
-
]),
|
|
26
|
-
["NPM@github.com", "a@x.com"],
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("collapsibleDomain", () => {
|
|
32
|
-
it("returns the shared registrable domain when every sender matches it", () => {
|
|
33
|
-
assert.equal(
|
|
34
|
-
collapsibleDomain([
|
|
35
|
-
"npm@github.com",
|
|
36
|
-
"notifications@github.com",
|
|
37
|
-
"ci@sub.github.com",
|
|
38
|
-
]),
|
|
39
|
-
"github.com",
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("does not collapse a single sender to its whole domain", () => {
|
|
44
|
-
assert.equal(collapsibleDomain(["npm@github.com"]), null);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("does not collapse when a sender's domain differs", () => {
|
|
48
|
-
assert.equal(collapsibleDomain(["npm@github.com", "a@x.com"]), null);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("does not collapse when any sender's domain cannot be resolved", () => {
|
|
52
|
-
assert.equal(
|
|
53
|
-
collapsibleDomain(["npm@github.com", "malformed-no-at-sign"]),
|
|
54
|
-
null,
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("deriveSenderClauses", () => {
|
|
60
|
-
it("emits one From clause per distinct sender when domains differ", () => {
|
|
61
|
-
assert.deepEqual(
|
|
62
|
-
deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
|
|
63
|
-
[
|
|
64
|
-
{ field: "From", value: "npm@github.com" },
|
|
65
|
-
{ field: "From", value: "a@x.com" },
|
|
66
|
-
],
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("collapses to a single FromDomain clause when every sender shares a domain", () => {
|
|
71
|
-
assert.deepEqual(
|
|
72
|
-
deriveSenderClauses([
|
|
73
|
-
"npm@github.com",
|
|
74
|
-
"notifications@github.com",
|
|
75
|
-
"ci@sub.github.com",
|
|
76
|
-
]),
|
|
77
|
-
[{ field: "FromDomain", value: "github.com" }],
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("keeps per-address From clauses for the mixed case", () => {
|
|
82
|
-
assert.deepEqual(
|
|
83
|
-
deriveSenderClauses([
|
|
84
|
-
"npm@github.com",
|
|
85
|
-
"ci@github.com",
|
|
86
|
-
"newsletter@example.org",
|
|
87
|
-
]),
|
|
88
|
-
[
|
|
89
|
-
{ field: "From", value: "npm@github.com" },
|
|
90
|
-
{ field: "From", value: "ci@github.com" },
|
|
91
|
-
{ field: "From", value: "newsletter@example.org" },
|
|
92
|
-
],
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("is empty when no sender survives", () => {
|
|
97
|
-
assert.deepEqual(deriveSenderClauses(["", " "]), []);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
3
|
+
import { buildSenderFallbackDraft } from "./sender-fallback";
|
|
100
4
|
|
|
101
5
|
describe("buildSenderFallbackDraft", () => {
|
|
102
6
|
it("combines the sender clauses with Or and carries no anchor", () => {
|