@remit/web-client 0.0.85 → 0.0.87

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 (74) 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 +155 -34
  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 +3 -1
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
  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/components/settings/TlsRootCaDownload.render.test.ts +37 -0
  30. package/src/components/settings/TlsRootCaDownload.tsx +33 -0
  31. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  32. package/src/hooks/useApplyLabel.ts +6 -3
  33. package/src/hooks/useClauseSuggestions.ts +57 -0
  34. package/src/hooks/useDeleteMessages.ts +6 -3
  35. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  36. package/src/hooks/useFollowFocusOpen.ts +81 -0
  37. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  38. package/src/hooks/useInitialSyncProgress.ts +134 -0
  39. package/src/hooks/useListCursor.ts +36 -5
  40. package/src/hooks/useMarkAsRead.ts +6 -3
  41. package/src/hooks/useMoveMessages.ts +6 -3
  42. package/src/hooks/useRulePreview.ts +11 -1
  43. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  44. package/src/hooks/useSearchFilterSeed.ts +21 -3
  45. package/src/hooks/useSearchSuggestions.ts +126 -0
  46. package/src/hooks/useSelectedSubjects.ts +0 -0
  47. package/src/hooks/useSemanticSearch.ts +26 -7
  48. package/src/hooks/useThreadActions.ts +11 -2
  49. package/src/lib/brief.test.ts +67 -0
  50. package/src/lib/brief.ts +11 -1
  51. package/src/lib/bulk-actions.ts +29 -0
  52. package/src/lib/drafts.ts +1 -1
  53. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  54. package/src/lib/organize/clause-suggestions.ts +90 -0
  55. package/src/lib/organize/property-prefill.test.ts +173 -0
  56. package/src/lib/organize/property-prefill.ts +151 -0
  57. package/src/lib/organize/rule-model.test.ts +57 -0
  58. package/src/lib/organize/rule-model.ts +110 -31
  59. package/src/lib/organize/search-to-rule.ts +12 -4
  60. package/src/lib/organize/sender-fallback.ts +11 -1
  61. package/src/lib/search-result.ts +2 -0
  62. package/src/lib/search-suggestions.test.ts +249 -0
  63. package/src/lib/search-suggestions.ts +296 -0
  64. package/src/lib/search-token-index.test.ts +99 -0
  65. package/src/lib/search-token-index.ts +67 -1
  66. package/src/lib/search-tokens.test.ts +236 -0
  67. package/src/lib/search-tokens.ts +303 -36
  68. package/src/lib/thread-cache.ts +1 -1
  69. package/src/lib/thread-search-tokens.test.ts +193 -0
  70. package/src/lib/thread-search-tokens.ts +148 -0
  71. package/src/routes/onboarding.tsx +9 -4
  72. package/src/routes/settings/advanced.tsx +2 -0
  73. package/src/runtime-config.test.ts +26 -0
  74. package/src/runtime-config.ts +15 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The opening clauses a properties-only rule offers (RFC 038 D2/D4). The
3
+ * prefill is a starting point the user edits, so the contract worth holding is
4
+ * the order of evidence — sender before subject, and nothing at all rather than
5
+ * a guess that matches half a mailbox.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import {
11
+ derivePropertyClauses,
12
+ normalizeSubject,
13
+ sharedSubjectFragment,
14
+ } from "./property-prefill";
15
+
16
+ describe("normalizeSubject", () => {
17
+ it("strips stacked reply, forward, and list decorations", () => {
18
+ assert.equal(
19
+ normalizeSubject("Re: Fwd: [ops] Invoice 1841"),
20
+ "Invoice 1841",
21
+ );
22
+ assert.equal(normalizeSubject("RE[2]: Invoice 1841"), "Invoice 1841");
23
+ assert.equal(normalizeSubject("AW: SV: Invoice 1841"), "Invoice 1841");
24
+ });
25
+
26
+ it("collapses whitespace and trims", () => {
27
+ assert.equal(normalizeSubject(" Invoice 1841 "), "Invoice 1841");
28
+ });
29
+
30
+ it("leaves a subject that is only decoration empty", () => {
31
+ assert.equal(normalizeSubject("Re: "), "");
32
+ });
33
+ });
34
+
35
+ describe("sharedSubjectFragment", () => {
36
+ it("takes the longest run of whole words every subject carries", () => {
37
+ assert.equal(
38
+ sharedSubjectFragment([
39
+ "Invoice 1841",
40
+ "Invoice 1902",
41
+ "Re: Invoice 2003",
42
+ ]),
43
+ "Invoice",
44
+ );
45
+ });
46
+
47
+ it("matches case-insensitively and answers in the first subject's casing", () => {
48
+ assert.equal(
49
+ sharedSubjectFragment(["Invoice 1841", "INVOICE 1902"]),
50
+ "Invoice",
51
+ );
52
+ });
53
+
54
+ it("compares whole words, never a partial one", () => {
55
+ // "Invoice 18" is a shared character run and not a shared word run.
56
+ assert.equal(
57
+ sharedSubjectFragment(["Invoice 1841", "Invoice 1892"]),
58
+ "Invoice",
59
+ );
60
+ });
61
+
62
+ it("keeps a single subject as its own fragment", () => {
63
+ assert.equal(sharedSubjectFragment(["Invoice 1841"]), "Invoice 1841");
64
+ });
65
+
66
+ it("rejects a single subject that is nothing but filler", () => {
67
+ assert.equal(sharedSubjectFragment(["for you"]), undefined);
68
+ });
69
+
70
+ it("rejects a shared run of filler words", () => {
71
+ assert.equal(
72
+ sharedSubjectFragment(["the report", "the summary"]),
73
+ undefined,
74
+ );
75
+ });
76
+
77
+ it("rejects a shared run too short to be worth matching", () => {
78
+ assert.equal(
79
+ sharedSubjectFragment(["Q3 numbers", "Q3 results"]),
80
+ undefined,
81
+ );
82
+ });
83
+
84
+ it("is undefined when the subjects share nothing", () => {
85
+ assert.equal(
86
+ sharedSubjectFragment(["Invoice 1841", "Standup notes"]),
87
+ undefined,
88
+ );
89
+ });
90
+
91
+ it("is undefined when no subject survives normalizing", () => {
92
+ assert.equal(sharedSubjectFragment(["Re:", " "]), undefined);
93
+ });
94
+ });
95
+
96
+ describe("derivePropertyClauses", () => {
97
+ it("matches on the sender when the whole selection is from one address", () => {
98
+ assert.deepEqual(
99
+ derivePropertyClauses(
100
+ ["npm@github.com", "npm@github.com", "npm@github.com"],
101
+ ["Invoice 1841", "Standup notes", "Deploy failed"],
102
+ ),
103
+ [{ field: "From", value: "npm@github.com" }],
104
+ );
105
+ });
106
+
107
+ it("matches on the domain when several senders share one", () => {
108
+ assert.deepEqual(
109
+ derivePropertyClauses(
110
+ ["npm@github.com", "notifications@github.com", "ci@sub.github.com"],
111
+ ["Invoice 1841", "Standup notes", "Deploy failed"],
112
+ ),
113
+ [{ field: "FromDomain", value: "github.com" }],
114
+ );
115
+ });
116
+
117
+ it("prefers the sender over the subject even when the subjects share a run", () => {
118
+ assert.deepEqual(
119
+ derivePropertyClauses(
120
+ ["npm@github.com", "npm@github.com"],
121
+ ["Invoice 1841", "Invoice 1902"],
122
+ ),
123
+ [{ field: "From", value: "npm@github.com" }],
124
+ );
125
+ });
126
+
127
+ it("falls back to what mixed senders' subjects have in common", () => {
128
+ assert.deepEqual(
129
+ derivePropertyClauses(
130
+ ["billing@acme.test", "accounts@globex.test", "ap@initech.test"],
131
+ ["Invoice 1841", "Invoice 1902", "Re: Invoice 2003"],
132
+ ),
133
+ [{ field: "Subject", value: "Invoice" }],
134
+ );
135
+ });
136
+
137
+ it("offers one sender chip each when mixed senders share no subject either", () => {
138
+ assert.deepEqual(
139
+ derivePropertyClauses(
140
+ ["billing@acme.test", "accounts@globex.test"],
141
+ ["Invoice 1841", "Standup notes"],
142
+ ),
143
+ [
144
+ { field: "From", value: "billing@acme.test" },
145
+ { field: "From", value: "accounts@globex.test" },
146
+ ],
147
+ );
148
+ });
149
+
150
+ it("refuses a filler-only subject run rather than prefilling a wide match", () => {
151
+ assert.deepEqual(
152
+ derivePropertyClauses(
153
+ ["billing@acme.test", "accounts@globex.test"],
154
+ ["the report", "the summary"],
155
+ ),
156
+ [
157
+ { field: "From", value: "billing@acme.test" },
158
+ { field: "From", value: "accounts@globex.test" },
159
+ ],
160
+ );
161
+ });
162
+
163
+ it("reads a single message as its own sender", () => {
164
+ assert.deepEqual(
165
+ derivePropertyClauses(["billing@acme.test"], ["Invoice 1841"]),
166
+ [{ field: "From", value: "billing@acme.test" }],
167
+ );
168
+ });
169
+
170
+ it("offers nothing when the selection carries no sender and no shared subject", () => {
171
+ assert.deepEqual(derivePropertyClauses([], ["Re:", " "]), []);
172
+ });
173
+ });
@@ -0,0 +1,151 @@
1
+ import type { RemitImapFilterClause } from "@remit/api-http-client/types.gen.ts";
2
+ import { deriveSenderClauses, distinctSenders } from "./sender-fallback";
3
+
4
+ /**
5
+ * The opening clauses for a rule matched on properties alone — no semantic
6
+ * anchor (RFC 038 D2/D4). The selection is the only evidence available, so the
7
+ * prefill reads it in the order the evidence is strongest:
8
+ *
9
+ * 1. One sender across the whole selection, or several that collapse to one
10
+ * registrable domain — the sender-fallback derivation (#251, #262) already
11
+ * decides between a `From` and a `FromDomain` clause, and this reuses it
12
+ * rather than deciding again.
13
+ * 2. Mixed senders — what the messages share is their subject, so match on the
14
+ * part the subjects have in common instead.
15
+ * 3. Neither — no clauses. The editor opens empty and asks for one; a wrong
16
+ * guess costs more than an absent one.
17
+ *
18
+ * Every clause it produces is an ordinary chip: visible, editable, removable.
19
+ * It is where the user starts, never what the rule is.
20
+ */
21
+
22
+ /** Reply, forward, and list-tag decorations that carry no meaning for a match. */
23
+ const SUBJECT_PREFIX =
24
+ /^\s*(?:(?:re|aw|fwd?|vs|sv|antw|res|enc|tr)\s*(?:\[\d+\])?\s*:|\[[^\]]{1,32}\])\s*/i;
25
+
26
+ /**
27
+ * A subject reduced to the part worth matching on: reply and forward markers
28
+ * and leading list tags stripped (repeatedly — real threads stack them), and
29
+ * whitespace collapsed.
30
+ */
31
+ export const normalizeSubject = (subject: string): string => {
32
+ let value = subject.replace(/\s+/g, " ").trim();
33
+ for (;;) {
34
+ const stripped = value.replace(SUBJECT_PREFIX, "");
35
+ if (stripped === value) return value.trim();
36
+ value = stripped;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Words too common to be a rule on their own. A single shared word from this
42
+ * list matches half a mailbox, which is worse than offering nothing.
43
+ */
44
+ const WEAK_WORDS = new Set([
45
+ "a",
46
+ "an",
47
+ "and",
48
+ "are",
49
+ "as",
50
+ "at",
51
+ "be",
52
+ "by",
53
+ "for",
54
+ "from",
55
+ "has",
56
+ "have",
57
+ "in",
58
+ "is",
59
+ "it",
60
+ "me",
61
+ "my",
62
+ "new",
63
+ "of",
64
+ "on",
65
+ "or",
66
+ "our",
67
+ "the",
68
+ "this",
69
+ "to",
70
+ "was",
71
+ "we",
72
+ "with",
73
+ "you",
74
+ "your",
75
+ ]);
76
+
77
+ /** The shortest fragment worth prefilling as a `Subject` clause. */
78
+ const MIN_FRAGMENT_LENGTH = 3;
79
+
80
+ const words = (subject: string): string[] =>
81
+ subject.split(" ").filter((word) => word !== "");
82
+
83
+ const isUseful = (fragment: string[]): boolean => {
84
+ if (fragment.length === 0) return false;
85
+ if (fragment.join(" ").length < MIN_FRAGMENT_LENGTH) return false;
86
+ return fragment.some((word) => !WEAK_WORDS.has(word.toLowerCase()));
87
+ };
88
+
89
+ const runAt = (haystack: string[], needle: string[]): number =>
90
+ haystack.findIndex((_, start) =>
91
+ needle.every(
92
+ (word, offset) =>
93
+ haystack[start + offset]?.toLowerCase() === word.toLowerCase(),
94
+ ),
95
+ );
96
+
97
+ /**
98
+ * The longest run of whole words every subject carries, compared
99
+ * case-insensitively and returned in the casing the first subject uses. Whole
100
+ * words rather than raw characters, so "Invoice 1841" and "Invoice 1902" share
101
+ * "Invoice" and not "Invoice 18".
102
+ *
103
+ * `undefined` when the subjects share nothing worth matching on — a single
104
+ * filler word does not count.
105
+ */
106
+ export const sharedSubjectFragment = (
107
+ subjects: readonly string[],
108
+ ): string | undefined => {
109
+ const normalized = subjects
110
+ .map(normalizeSubject)
111
+ .filter((subject) => subject !== "");
112
+ if (normalized.length === 0) return undefined;
113
+ const [first, ...rest] = normalized;
114
+ if (rest.length === 0) return isUseful(words(first)) ? first : undefined;
115
+
116
+ const firstWords = words(first);
117
+ for (let length = firstWords.length; length > 0; length -= 1) {
118
+ for (let start = 0; start + length <= firstWords.length; start += 1) {
119
+ const candidate = firstWords.slice(start, start + length);
120
+ if (!isUseful(candidate)) continue;
121
+ if (rest.every((subject) => runAt(words(subject), candidate) >= 0)) {
122
+ return candidate.join(" ");
123
+ }
124
+ }
125
+ }
126
+ return undefined;
127
+ };
128
+
129
+ /**
130
+ * The clauses a properties-only rule opens on, derived from the selected
131
+ * messages' senders and subjects. Empty when the selection gives nothing to go
132
+ * on — the editor then holds the commit until the user adds a clause, which is
133
+ * the honest state.
134
+ */
135
+ export const derivePropertyClauses = (
136
+ senders: readonly string[],
137
+ subjects: readonly string[],
138
+ ): RemitImapFilterClause[] => {
139
+ const senderClauses = deriveSenderClauses(senders);
140
+ // One sender across the selection, or several on one registrable domain:
141
+ // a single sharp clause, and sharper than any subject fragment.
142
+ if (distinctSenders(senders).length === 1) return senderClauses;
143
+ if (senderClauses.length === 1 && senderClauses[0].field === "FromDomain")
144
+ return senderClauses;
145
+
146
+ const fragment = sharedSubjectFragment(subjects);
147
+ if (fragment !== undefined) return [{ field: "Subject", value: fragment }];
148
+ // Mixed senders with nothing shared in their subjects: one `From` chip each,
149
+ // the widen fallback's own derivation (#251).
150
+ return senderClauses;
151
+ };
@@ -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
+ });
@@ -4,14 +4,13 @@ import type {
4
4
  MatchOperator,
5
5
  PreviewCount,
6
6
  RuleClause,
7
+ RuleMatchMode,
7
8
  RuleScope,
8
9
  } from "@remit/ui";
9
10
  import { pickedDateToExpiresAt } from "./filter-status";
10
11
  import type { OrganizeDraft } from "./organize-model";
11
- import {
12
- deriveSenderClauses,
13
- type OrganizeMatchPredicate,
14
- } from "./sender-fallback";
12
+ import { derivePropertyClauses } from "./property-prefill";
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
@@ -18,7 +18,8 @@ import {
18
18
  * The mapping is honest about what a filter cannot carry. A facet with no clause
19
19
  * equivalent is never silently folded into the rule: a folder scope is reported
20
20
  * 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
21
+ * attribute facets (attachment / read state / starred / category / date) are
22
+ * reported as left out, and a
22
23
  * free-text query kept as a literal `HasWords` clause is reported as having lost
23
24
  * its semantic "similar mail" reach on a deployment that cannot embed the query
24
25
  * (D5). Pure functions only — the capability is injected, not probed here.
@@ -72,15 +73,18 @@ interface ConvertOptions {
72
73
  const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> = new Set([
73
74
  "hasAttachment",
74
75
  "isUnread",
76
+ "isRead",
77
+ "isStarred",
78
+ "category",
75
79
  "before",
76
80
  "after",
77
81
  ]);
78
82
 
79
83
  /**
80
84
  * 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
85
+ * be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause
86
+ * and a `subject:` facet a `Subject` clause; an `in:` facet a kept-out folder
87
+ * scope; `account:` the target account; the attribute facets are dropped. The search's terms are ANDed with its facets, so
84
88
  * the rule matches all of them (`all`).
85
89
  */
86
90
  export const convertSearchToRule = (
@@ -97,6 +101,10 @@ export const convertSearchToRule = (
97
101
  clauses.push({ field: "From", value: token.value });
98
102
  continue;
99
103
  }
104
+ if (token.type === "subject") {
105
+ clauses.push({ field: "Subject", value: token.value });
106
+ continue;
107
+ }
100
108
  if (token.type === "in") {
101
109
  scopedOut = { mailboxId: token.mailboxId, label: token.value };
102
110
  continue;
@@ -37,6 +37,16 @@ const hostOf = (address: string): string => {
37
37
  return at >= 0 ? address.slice(at + 1) : address;
38
38
  };
39
39
 
40
+ /**
41
+ * The registrable domain behind a sender address, public-suffix aware (tldts
42
+ * `getDomain`), or `null` when the address carries none. The one place an
43
+ * address is turned into a `FromDomain` value — a clause the prefill derives and
44
+ * a domain the value field suggests must be the same string, or the suggestion
45
+ * would offer a domain the matcher never produces.
46
+ */
47
+ export const senderDomain = (address: string): string | null =>
48
+ getDomain(hostOf(address.trim()));
49
+
40
50
  /**
41
51
  * The single registrable domain the whole selection collapses to, or `null` when
42
52
  * it does not collapse. A collapse needs at least two distinct senders that all
@@ -52,7 +62,7 @@ export const collapsibleDomain = (
52
62
  if (distinct.length < 2) return null;
53
63
  let shared: string | null = null;
54
64
  for (const sender of distinct) {
55
- const domain = getDomain(hostOf(sender));
65
+ const domain = senderDomain(sender);
56
66
  if (domain === null) return null;
57
67
  if (shared === null) shared = domain;
58
68
  else if (shared !== domain) return null;
@@ -44,6 +44,7 @@ export function threadToSearchResult(
44
44
  return {
45
45
  id: thread.messageId,
46
46
  sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
47
+ ...(thread.fromEmail ? { senderEmail: thread.fromEmail } : {}),
47
48
  subject: thread.subject ?? "(No subject)",
48
49
  snippet: thread.snippet ?? "",
49
50
  date: formatEmailDate(thread.sentDate),
@@ -66,6 +67,7 @@ export function rowToSearchResult(
66
67
  return {
67
68
  id: row.id,
68
69
  sender: row.fromName,
70
+ ...(row.fromEmail ? { senderEmail: row.fromEmail } : {}),
69
71
  subject: row.subject,
70
72
  snippet: row.snippet,
71
73
  date: row.timeLabel,