@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.
Files changed (70) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +157 -38
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +5 -6
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  30. package/src/hooks/useApplyLabel.ts +6 -3
  31. package/src/hooks/useClauseSuggestions.ts +57 -0
  32. package/src/hooks/useDeleteMessages.ts +6 -3
  33. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  34. package/src/hooks/useFollowFocusOpen.ts +81 -0
  35. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  36. package/src/hooks/useInitialSyncProgress.ts +134 -0
  37. package/src/hooks/useListCursor.ts +36 -5
  38. package/src/hooks/useMarkAsRead.ts +6 -3
  39. package/src/hooks/useMoveMessages.ts +6 -3
  40. package/src/hooks/useOrganizeWiden.ts +1 -2
  41. package/src/hooks/useRulePreview.ts +11 -1
  42. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  43. package/src/hooks/useSearchFilterSeed.ts +21 -3
  44. package/src/hooks/useSearchSuggestions.ts +126 -0
  45. package/src/hooks/useSelectedSubjects.ts +0 -0
  46. package/src/hooks/useSemanticSearch.ts +26 -7
  47. package/src/hooks/useThreadActions.ts +11 -2
  48. package/src/lib/brief.test.ts +67 -0
  49. package/src/lib/brief.ts +11 -1
  50. package/src/lib/bulk-actions.ts +29 -0
  51. package/src/lib/drafts.ts +1 -1
  52. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  53. package/src/lib/organize/clause-suggestions.ts +89 -0
  54. package/src/lib/organize/rule-model.test.ts +57 -0
  55. package/src/lib/organize/rule-model.ts +117 -38
  56. package/src/lib/organize/search-to-rule.test.ts +8 -28
  57. package/src/lib/organize/search-to-rule.ts +33 -89
  58. package/src/lib/organize/sender-fallback.test.ts +1 -97
  59. package/src/lib/organize/sender-fallback.ts +8 -71
  60. package/src/lib/search-result.ts +2 -0
  61. package/src/lib/search-suggestions.test.ts +249 -0
  62. package/src/lib/search-suggestions.ts +296 -0
  63. package/src/lib/search-token-index.test.ts +99 -0
  64. package/src/lib/search-token-index.ts +67 -1
  65. package/src/lib/search-tokens.test.ts +236 -0
  66. package/src/lib/search-tokens.ts +303 -36
  67. package/src/lib/thread-cache.ts +1 -1
  68. package/src/lib/thread-search-tokens.test.ts +193 -0
  69. package/src/lib/thread-search-tokens.ts +148 -0
  70. 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 type {
2
- ClauseField,
3
- FilterRule,
4
- MatchOperator,
5
- PreviewCount,
6
- RuleClause,
7
- RuleScope,
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 sender-derived literal clauses instead of an anchor.
57
+ * opens on the property-derived literal clauses instead of an anchor.
59
58
  */
60
59
  semanticUnavailable: boolean;
61
- /** Distinct sender addresses of the selection, for the fallback clauses. */
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
- * The rule the editor opens on, built from the widen probe. A semantic-capable
73
- * deployment opens on the widen chip (the anchor, no literal clauses); one
74
- * without the vector pipeline opens on the sender-fallback `From` chips (#251),
75
- * visible and editable, matched with `Or`. Either way the move destination and
76
- * scope come from any "Something else" seed.
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 buildInitialRule = (input: InitialRuleInput): FilterRule => {
79
- const scope = input.seedScope ?? "once";
80
- if (input.semanticUnavailable) {
81
- // The same derivation the widen fallback runs (#251, #262): one `From`
82
- // clause per sender, or a single `FromDomain` clause when they share a
83
- // registrable domain. Surfaced as ordinary editable chips.
84
- const clauses: RuleClause[] = deriveSenderClauses(input.senders).map(
85
- (clause, index) => ({
86
- id: `derived-${index}`,
87
- field: clause.field,
88
- value: clause.value,
89
- derived: true,
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
- moveMailboxId: input.seedMailboxId,
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("isConvertible / buildSearchRule", () => {
118
- it("is not convertible when the search yields no clause", () => {
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("builds a standing rule with stable clause ids, no widen, empty name", () => {
128
- const rule = buildSearchRule(convert("from:a@b.com nightly"));
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("honors an explicit scope and move target", () => {
139
- const rule = buildSearchRule(convert("nightly"), {
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
- ClauseField,
3
- FilterRule,
4
- MatchOperator,
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 / unread / date) are reported as left out, and a
22
- * free-text query kept as a literal `HasWords` clause is reported as having lost
23
- * its semantic "similar mail" reach on a deployment that cannot embed the query
24
- * (D5). Pure functions only — the capability is injected, not probed here.
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
- const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> = new Set([
73
- "hasAttachment",
74
- "isUnread",
75
- "before",
76
- "after",
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
- * an `in:` facet a kept-out folder scope; `account:` the target account; the
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: ScopedOutFolder | undefined;
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 (FACET_HAS_NO_CLAUSE.has(token.type)) {
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", () => {