@remit/ui 0.0.57 → 0.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -26,6 +26,7 @@
26
26
  "react-resizable-panels": "^2.1.9",
27
27
  "react-simple-pull-to-refresh": "^1.3.4",
28
28
  "tailwind-merge": "^2",
29
+ "tldts": "^7.4.9",
29
30
  "@types/dompurify": "*"
30
31
  },
31
32
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -598,6 +598,11 @@ export {
598
598
  labelColorOptions,
599
599
  labelDotClass,
600
600
  } from "./lib/label-color.js";
601
+ export {
602
+ derivePropertyClauses,
603
+ normalizeSubject,
604
+ sharedSubjectFragment,
605
+ } from "./lib/property-prefill.js";
601
606
  export {
602
607
  LIST_ROW_ATTRIBUTE,
603
608
  LIST_ROW_SELECTOR,
@@ -606,6 +611,19 @@ export {
606
611
  type UseRovingFocusOptions,
607
612
  useRovingFocus,
608
613
  } from "./lib/roving-focus.js";
614
+ export {
615
+ buildSearchRule,
616
+ type DroppedFacet,
617
+ type DroppedFacetType,
618
+ isConvertible,
619
+ type SearchConversion,
620
+ } from "./lib/search-rule.js";
621
+ export {
622
+ collapsibleDomain,
623
+ deriveSenderClauses,
624
+ distinctSenders,
625
+ senderDomain,
626
+ } from "./lib/sender-derivation.js";
609
627
  export {
610
628
  type SuggestAction,
611
629
  type SuggestKeyState,
@@ -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.js";
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 { RuleClause } from "../components/filter-rule.js";
2
+ import { deriveSenderClauses, distinctSenders } from "./sender-derivation.js";
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 derivation (#251, #262) already decides
11
+ * between a `From` and a `FromDomain` clause, and this reuses it rather than
12
+ * 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
+ ): Omit<RuleClause, "id">[] => {
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 sender derivation's own answer (#251).
150
+ return senderClauses;
151
+ };
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildSearchRule,
5
+ isConvertible,
6
+ type SearchConversion,
7
+ } from "./search-rule.js";
8
+
9
+ const conversion = (
10
+ overrides: Partial<SearchConversion> = {},
11
+ ): SearchConversion => ({
12
+ clauses: [],
13
+ matchOperator: "all",
14
+ droppedFacets: [],
15
+ keptTerms: false,
16
+ droppedSemantic: false,
17
+ ...overrides,
18
+ });
19
+
20
+ describe("isConvertible", () => {
21
+ it("is false when the search yields no clause", () => {
22
+ assert.equal(
23
+ isConvertible(
24
+ conversion({
25
+ droppedFacets: [{ type: "hasAttachment", label: "Has attachment" }],
26
+ scopedOut: { mailboxId: "mbx-archive", label: "archive" },
27
+ }),
28
+ ),
29
+ false,
30
+ );
31
+ });
32
+
33
+ it("is true once a term or sender is present", () => {
34
+ assert.equal(
35
+ isConvertible(
36
+ conversion({
37
+ clauses: [{ field: "HasWords", value: "receipts" }],
38
+ keptTerms: true,
39
+ }),
40
+ ),
41
+ true,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("buildSearchRule", () => {
47
+ it("builds a standing rule with stable clause ids, no widen, empty name", () => {
48
+ const rule = buildSearchRule(
49
+ conversion({
50
+ clauses: [
51
+ { field: "From", value: "a@b.com" },
52
+ { field: "HasWords", value: "nightly" },
53
+ ],
54
+ }),
55
+ );
56
+ assert.equal(rule.scope, "standing");
57
+ assert.equal(rule.widen, undefined);
58
+ assert.equal(rule.name, "");
59
+ assert.deepEqual(
60
+ rule.clauses.map((clause) => clause.id),
61
+ ["search-0", "search-1"],
62
+ );
63
+ });
64
+
65
+ it("honors an explicit scope and move target", () => {
66
+ const rule = buildSearchRule(
67
+ conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
68
+ { scope: "once", moveMailboxId: "mbx-archive" },
69
+ );
70
+ assert.equal(rule.scope, "once");
71
+ assert.equal(rule.moveMailboxId, "mbx-archive");
72
+ });
73
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What a search converts to, and the rule built from it (RFC 038 D5). The shape
3
+ * carries the clauses alongside everything the search held that a filter cannot,
4
+ * so a conversion can never drop a facet without saying so; `search-conversion.ts`
5
+ * beside it owns the copy that states it.
6
+ */
7
+
8
+ import type {
9
+ ClauseField,
10
+ FilterRule,
11
+ MatchOperator,
12
+ RuleScope,
13
+ } from "../components/filter-rule.js";
14
+
15
+ /**
16
+ * A search facet a filter has no clause for. Attachment, read state, starred,
17
+ * category and the date bounds are attributes of a message, not text a clause
18
+ * matches on.
19
+ */
20
+ export type DroppedFacetType =
21
+ | "hasAttachment"
22
+ | "isUnread"
23
+ | "isRead"
24
+ | "isStarred"
25
+ | "category"
26
+ | "before"
27
+ | "after";
28
+
29
+ export interface DroppedFacet {
30
+ type: DroppedFacetType;
31
+ /** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
32
+ label: string;
33
+ }
34
+
35
+ export interface ScopedOutFolder {
36
+ mailboxId: string;
37
+ /** The folder the search was limited to. */
38
+ label: string;
39
+ }
40
+
41
+ export interface SearchConversion {
42
+ /** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
43
+ clauses: { field: ClauseField; value: string }[];
44
+ matchOperator: MatchOperator;
45
+ /** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
46
+ scopedOut?: ScopedOutFolder;
47
+ /** Facets with no clause equivalent, each named. */
48
+ droppedFacets: DroppedFacet[];
49
+ /** The account an `account:` facet targets — the filter is created for it. */
50
+ targetAccountId?: string;
51
+ /** The search carried free-text terms, kept as a `HasWords` clause. */
52
+ keptTerms: boolean;
53
+ /**
54
+ * The filter this conversion builds is always literal-only — free text has no
55
+ * anchor message for a semantic widen — so the search's semantic "similar mail"
56
+ * reach is dropped whenever the search had one (RFC 038 D5). True exactly when
57
+ * free text was kept AND the search surfaced semantically-similar mail; on a
58
+ * deployment with no semantic reach there was nothing to drop, so no note.
59
+ */
60
+ droppedSemantic: boolean;
61
+ }
62
+
63
+ /**
64
+ * Whether the conversion yields a rule with something to match. A search of only
65
+ * dropped facets or a bare folder scope converts to no clauses, so there is no
66
+ * filter to open — the entry point offers nothing rather than an empty editor.
67
+ */
68
+ export const isConvertible = (conversion: SearchConversion): boolean =>
69
+ conversion.clauses.length > 0;
70
+
71
+ interface BuildRuleOptions {
72
+ scope?: RuleScope;
73
+ moveMailboxId?: string;
74
+ }
75
+
76
+ /**
77
+ * The rule the editor opens on, from a conversion. A search-derived rule defaults
78
+ * to a standing filter — "make this a filter" is a request to keep applying it —
79
+ * and the editor lets the user drop it back to a one-time apply. It carries no
80
+ * widen: a free-text query has no message anchor, so the semantic chip is not
81
+ * offered on this surface (its loss is stated in the conversion notice instead).
82
+ */
83
+ export const buildSearchRule = (
84
+ conversion: SearchConversion,
85
+ { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
86
+ ): FilterRule => ({
87
+ clauses: conversion.clauses.map((clause, index) => ({
88
+ id: `search-${index}`,
89
+ field: clause.field,
90
+ value: clause.value,
91
+ })),
92
+ matchOperator: conversion.matchOperator,
93
+ moveMailboxId,
94
+ scope,
95
+ name: "",
96
+ });
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ collapsibleDomain,
5
+ deriveSenderClauses,
6
+ distinctSenders,
7
+ } from "./sender-derivation.js";
8
+
9
+ describe("distinctSenders", () => {
10
+ it("drops empties and blanks, trimming what remains", () => {
11
+ assert.deepEqual(
12
+ distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
13
+ ["npm@github.com", "a@x.com"],
14
+ );
15
+ });
16
+
17
+ it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
18
+ assert.deepEqual(
19
+ distinctSenders([
20
+ "NPM@github.com",
21
+ "a@x.com",
22
+ "npm@GITHUB.com",
23
+ "a@x.com",
24
+ ]),
25
+ ["NPM@github.com", "a@x.com"],
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("collapsibleDomain", () => {
31
+ it("returns the shared registrable domain when every sender matches it", () => {
32
+ assert.equal(
33
+ collapsibleDomain([
34
+ "npm@github.com",
35
+ "notifications@github.com",
36
+ "ci@sub.github.com",
37
+ ]),
38
+ "github.com",
39
+ );
40
+ });
41
+
42
+ it("does not collapse a single sender to its whole domain", () => {
43
+ assert.equal(collapsibleDomain(["npm@github.com"]), null);
44
+ });
45
+
46
+ it("does not collapse when a sender's domain differs", () => {
47
+ assert.equal(collapsibleDomain(["npm@github.com", "a@x.com"]), null);
48
+ });
49
+
50
+ it("does not collapse when any sender's domain cannot be resolved", () => {
51
+ assert.equal(
52
+ collapsibleDomain(["npm@github.com", "malformed-no-at-sign"]),
53
+ null,
54
+ );
55
+ });
56
+ });
57
+
58
+ describe("deriveSenderClauses", () => {
59
+ it("emits one From clause per distinct sender when domains differ", () => {
60
+ assert.deepEqual(
61
+ deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
62
+ [
63
+ { field: "From", value: "npm@github.com" },
64
+ { field: "From", value: "a@x.com" },
65
+ ],
66
+ );
67
+ });
68
+
69
+ it("collapses to a single FromDomain clause when every sender shares a domain", () => {
70
+ assert.deepEqual(
71
+ deriveSenderClauses([
72
+ "npm@github.com",
73
+ "notifications@github.com",
74
+ "ci@sub.github.com",
75
+ ]),
76
+ [{ field: "FromDomain", value: "github.com" }],
77
+ );
78
+ });
79
+
80
+ it("keeps per-address From clauses for the mixed case", () => {
81
+ assert.deepEqual(
82
+ deriveSenderClauses([
83
+ "npm@github.com",
84
+ "ci@github.com",
85
+ "newsletter@example.org",
86
+ ]),
87
+ [
88
+ { field: "From", value: "npm@github.com" },
89
+ { field: "From", value: "ci@github.com" },
90
+ { field: "From", value: "newsletter@example.org" },
91
+ ],
92
+ );
93
+ });
94
+
95
+ it("is empty when no sender survives", () => {
96
+ assert.deepEqual(deriveSenderClauses(["", " "]), []);
97
+ });
98
+
99
+ it("collapses a multi-label public suffix to the registrable domain", () => {
100
+ // The public-suffix list is what makes this foo.co.uk; the trailing two
101
+ // labels of the host are co.uk, which matches every British domain.
102
+ assert.deepEqual(deriveSenderClauses(["a@foo.co.uk", "b@foo.co.uk"]), [
103
+ { field: "FromDomain", value: "foo.co.uk" },
104
+ ]);
105
+ });
106
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Senders in a selection turned into clauses (RFC 038 D2).
3
+ *
4
+ * The widen fallback for a deployment that ships no vector pipeline reaches for
5
+ * this: the semantic anchor matches nothing there, so a widen degrades to the
6
+ * literal vocabulary RFC 031 already matches vector-free — one `From` clause per
7
+ * distinct sender, or a single `FromDomain` clause when they all sit on one
8
+ * registrable domain. The same predicate matches at index time (RFC 034), so a
9
+ * standing filter built from it keeps working on future mail.
10
+ */
11
+
12
+ import { getDomain } from "tldts";
13
+ import type { RuleClause } from "../components/filter-rule.js";
14
+
15
+ /**
16
+ * Distinct sender addresses from the selection, trimmed, empties dropped, and
17
+ * de-duplicated case-insensitively while preserving first-seen casing and order.
18
+ */
19
+ export const distinctSenders = (senders: readonly string[]): string[] => {
20
+ const seen = new Set<string>();
21
+ const out: string[] = [];
22
+ for (const raw of senders) {
23
+ const value = raw.trim();
24
+ if (value === "") continue;
25
+ const key = value.toLowerCase();
26
+ if (seen.has(key)) continue;
27
+ seen.add(key);
28
+ out.push(value);
29
+ }
30
+ return out;
31
+ };
32
+
33
+ const hostOf = (address: string): string => {
34
+ const at = address.lastIndexOf("@");
35
+ return at >= 0 ? address.slice(at + 1) : address;
36
+ };
37
+
38
+ /**
39
+ * The registrable domain behind a sender address, public-suffix aware (tldts
40
+ * `getDomain`), or `null` when the address carries none. The one place an
41
+ * address is turned into a `FromDomain` value — a clause the prefill derives and
42
+ * a domain the value field suggests must be the same string, or the suggestion
43
+ * would offer a domain the matcher never produces.
44
+ */
45
+ export const senderDomain = (address: string): string | null =>
46
+ getDomain(hostOf(address.trim()));
47
+
48
+ /**
49
+ * The single registrable domain the whole selection collapses to, or `null` when
50
+ * it does not collapse. A collapse needs at least two distinct senders that all
51
+ * resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
52
+ * — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
53
+ * `From` clause rather than widening a single address to its whole domain, and a
54
+ * sender whose domain can't be resolved blocks the collapse.
55
+ */
56
+ export const collapsibleDomain = (
57
+ senders: readonly string[],
58
+ ): string | null => {
59
+ const distinct = distinctSenders(senders);
60
+ if (distinct.length < 2) return null;
61
+ let shared: string | null = null;
62
+ for (const sender of distinct) {
63
+ const domain = senderDomain(sender);
64
+ if (domain === null) return null;
65
+ if (shared === null) shared = domain;
66
+ else if (shared !== domain) return null;
67
+ }
68
+ return shared;
69
+ };
70
+
71
+ /**
72
+ * The literal clauses standing in for the selection. When every sender shares one
73
+ * registrable domain, a single `FromDomain` clause replaces the per-address `From`
74
+ * chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
75
+ * matching the sender address or display name (match.ts `clauseMatches`).
76
+ */
77
+ export const deriveSenderClauses = (
78
+ senders: readonly string[],
79
+ ): Omit<RuleClause, "id">[] => {
80
+ const domain = collapsibleDomain(senders);
81
+ if (domain !== null) return [{ field: "FromDomain", value: domain }];
82
+ return distinctSenders(senders).map(
83
+ (value): Omit<RuleClause, "id"> => ({ field: "From", value }),
84
+ );
85
+ };