@remit/ui 0.0.57 → 0.0.59

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.
@@ -0,0 +1,159 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ collapsibleDomain,
5
+ deriveSenderClauses,
6
+ distinctSenders,
7
+ dominantSender,
8
+ senderLabel,
9
+ } from "./sender-derivation.js";
10
+
11
+ describe("distinctSenders", () => {
12
+ it("drops empties and blanks, trimming what remains", () => {
13
+ assert.deepEqual(
14
+ distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
15
+ ["npm@github.com", "a@x.com"],
16
+ );
17
+ });
18
+
19
+ it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
20
+ assert.deepEqual(
21
+ distinctSenders([
22
+ "NPM@github.com",
23
+ "a@x.com",
24
+ "npm@GITHUB.com",
25
+ "a@x.com",
26
+ ]),
27
+ ["NPM@github.com", "a@x.com"],
28
+ );
29
+ });
30
+ });
31
+
32
+ describe("collapsibleDomain", () => {
33
+ it("returns the shared registrable domain when every sender matches it", () => {
34
+ assert.equal(
35
+ collapsibleDomain([
36
+ "npm@github.com",
37
+ "notifications@github.com",
38
+ "ci@sub.github.com",
39
+ ]),
40
+ "github.com",
41
+ );
42
+ });
43
+
44
+ it("does not collapse a single sender to its whole domain", () => {
45
+ assert.equal(collapsibleDomain(["npm@github.com"]), null);
46
+ });
47
+
48
+ it("does not collapse when a sender's domain differs", () => {
49
+ assert.equal(collapsibleDomain(["npm@github.com", "a@x.com"]), null);
50
+ });
51
+
52
+ it("does not collapse when any sender's domain cannot be resolved", () => {
53
+ assert.equal(
54
+ collapsibleDomain(["npm@github.com", "malformed-no-at-sign"]),
55
+ null,
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("deriveSenderClauses", () => {
61
+ it("emits one From clause per distinct sender when domains differ", () => {
62
+ assert.deepEqual(
63
+ deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
64
+ [
65
+ { field: "From", value: "npm@github.com" },
66
+ { field: "From", value: "a@x.com" },
67
+ ],
68
+ );
69
+ });
70
+
71
+ it("collapses to a single FromDomain clause when every sender shares a domain", () => {
72
+ assert.deepEqual(
73
+ deriveSenderClauses([
74
+ "npm@github.com",
75
+ "notifications@github.com",
76
+ "ci@sub.github.com",
77
+ ]),
78
+ [{ field: "FromDomain", value: "github.com" }],
79
+ );
80
+ });
81
+
82
+ it("keeps per-address From clauses for the mixed case", () => {
83
+ assert.deepEqual(
84
+ deriveSenderClauses([
85
+ "npm@github.com",
86
+ "ci@github.com",
87
+ "newsletter@example.org",
88
+ ]),
89
+ [
90
+ { field: "From", value: "npm@github.com" },
91
+ { field: "From", value: "ci@github.com" },
92
+ { field: "From", value: "newsletter@example.org" },
93
+ ],
94
+ );
95
+ });
96
+
97
+ it("is empty when no sender survives", () => {
98
+ assert.deepEqual(deriveSenderClauses(["", " "]), []);
99
+ });
100
+
101
+ it("collapses a multi-label public suffix to the registrable domain", () => {
102
+ // The public-suffix list is what makes this foo.co.uk; the trailing two
103
+ // labels of the host are co.uk, which matches every British domain.
104
+ assert.deepEqual(deriveSenderClauses(["a@foo.co.uk", "b@foo.co.uk"]), [
105
+ { field: "FromDomain", value: "foo.co.uk" },
106
+ ]);
107
+ });
108
+ });
109
+
110
+ describe("dominantSender", () => {
111
+ const envelope = (normalizedEmail: string, displayName?: string) => ({
112
+ normalizedEmail,
113
+ displayName,
114
+ });
115
+
116
+ it("takes the sender carrying most of the selection", () => {
117
+ assert.deepEqual(
118
+ dominantSender([
119
+ envelope("noreply@booking.com", "Booking.com"),
120
+ envelope("automated@airbnb.com", "Airbnb"),
121
+ envelope("noreply@booking.com", "Booking.com"),
122
+ ]),
123
+ envelope("noreply@booking.com", "Booking.com"),
124
+ );
125
+ });
126
+
127
+ it("counts one address writing under two names as one sender", () => {
128
+ assert.deepEqual(
129
+ dominantSender([
130
+ envelope("info@klm.com", "KLM"),
131
+ envelope("INFO@klm.com", "KLM Royal Dutch Airlines"),
132
+ envelope("noreply@sixt.com", "Sixt"),
133
+ ]),
134
+ envelope("info@klm.com", "KLM"),
135
+ );
136
+ });
137
+
138
+ it("has no answer for an empty selection", () => {
139
+ assert.equal(dominantSender([]), undefined);
140
+ assert.equal(dominantSender([envelope(" ")]), undefined);
141
+ });
142
+ });
143
+
144
+ describe("senderLabel", () => {
145
+ it("reads the display name, and the address when there is none", () => {
146
+ assert.equal(
147
+ senderLabel({ normalizedEmail: "a@b.example", displayName: "Ada" }),
148
+ "Ada",
149
+ );
150
+ assert.equal(
151
+ senderLabel({ normalizedEmail: "a@b.example", displayName: " " }),
152
+ "a@b.example",
153
+ );
154
+ assert.equal(
155
+ senderLabel({ normalizedEmail: "a@b.example" }),
156
+ "a@b.example",
157
+ );
158
+ });
159
+ });
@@ -0,0 +1,120 @@
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 { EnvelopeAddress } from "../components/address-display.js";
14
+ import type { RuleClause } from "../components/filter-rule.js";
15
+
16
+ /**
17
+ * Distinct sender addresses from the selection, trimmed, empties dropped, and
18
+ * de-duplicated case-insensitively while preserving first-seen casing and order.
19
+ */
20
+ export const distinctSenders = (senders: readonly string[]): string[] => {
21
+ const seen = new Set<string>();
22
+ const out: string[] = [];
23
+ for (const raw of senders) {
24
+ const value = raw.trim();
25
+ if (value === "") continue;
26
+ const key = value.toLowerCase();
27
+ if (seen.has(key)) continue;
28
+ seen.add(key);
29
+ out.push(value);
30
+ }
31
+ return out;
32
+ };
33
+
34
+ const hostOf = (address: string): string => {
35
+ const at = address.lastIndexOf("@");
36
+ return at >= 0 ? address.slice(at + 1) : address;
37
+ };
38
+
39
+ /**
40
+ * The registrable domain behind a sender address, public-suffix aware (tldts
41
+ * `getDomain`), or `null` when the address carries none. The one place an
42
+ * address is turned into a `FromDomain` value — a clause the prefill derives and
43
+ * a domain the value field suggests must be the same string, or the suggestion
44
+ * would offer a domain the matcher never produces.
45
+ */
46
+ export const senderDomain = (address: string): string | null =>
47
+ getDomain(hostOf(address.trim()));
48
+
49
+ /**
50
+ * The single registrable domain the whole selection collapses to, or `null` when
51
+ * it does not collapse. A collapse needs at least two distinct senders that all
52
+ * resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
53
+ * — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
54
+ * `From` clause rather than widening a single address to its whole domain, and a
55
+ * sender whose domain can't be resolved blocks the collapse.
56
+ */
57
+ export const collapsibleDomain = (
58
+ senders: readonly string[],
59
+ ): string | null => {
60
+ const distinct = distinctSenders(senders);
61
+ if (distinct.length < 2) return null;
62
+ let shared: string | null = null;
63
+ for (const sender of distinct) {
64
+ const domain = senderDomain(sender);
65
+ if (domain === null) return null;
66
+ if (shared === null) shared = domain;
67
+ else if (shared !== domain) return null;
68
+ }
69
+ return shared;
70
+ };
71
+
72
+ /**
73
+ * The literal clauses standing in for the selection. When every sender shares one
74
+ * registrable domain, a single `FromDomain` clause replaces the per-address `From`
75
+ * chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
76
+ * matching the sender address or display name (match.ts `clauseMatches`).
77
+ */
78
+ export const deriveSenderClauses = (
79
+ senders: readonly string[],
80
+ ): Omit<RuleClause, "id">[] => {
81
+ const domain = collapsibleDomain(senders);
82
+ if (domain !== null) return [{ field: "FromDomain", value: domain }];
83
+ return distinctSenders(senders).map(
84
+ (value): Omit<RuleClause, "id"> => ({ field: "From", value }),
85
+ );
86
+ };
87
+
88
+ /**
89
+ * The sender carrying most of a selection, or `undefined` when it carries none.
90
+ * Whole envelope addresses rather than bare strings: this reads the display name
91
+ * a person recognises, where the clause derivations above read the address a
92
+ * matcher compares, and the two are not interchangeable.
93
+ *
94
+ * Counted on the normalized address, so one sender writing under two display
95
+ * names is one sender; the answer is the first envelope seen for the winner.
96
+ */
97
+ export const dominantSender = (
98
+ senders: readonly EnvelopeAddress[],
99
+ ): EnvelopeAddress | undefined => {
100
+ const counts = new Map<string, { sender: EnvelopeAddress; count: number }>();
101
+ for (const sender of senders) {
102
+ const key = sender.normalizedEmail.trim().toLowerCase();
103
+ if (key === "") continue;
104
+ const seen = counts.get(key);
105
+ if (seen) seen.count += 1;
106
+ else counts.set(key, { sender, count: 1 });
107
+ }
108
+ let best: EnvelopeAddress | undefined;
109
+ let bestCount = 0;
110
+ for (const { sender, count } of counts.values()) {
111
+ if (count <= bestCount) continue;
112
+ best = sender;
113
+ bestCount = count;
114
+ }
115
+ return best;
116
+ };
117
+
118
+ /** What a sender is called on screen — the display name, or the address when it has none. */
119
+ export const senderLabel = (sender: EnvelopeAddress): string =>
120
+ sender.displayName?.trim() || sender.normalizedEmail;