@remit/web-client 0.0.64 → 0.0.65

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/web-client",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -89,6 +89,7 @@
89
89
  "react-i18next": "^15",
90
90
  "react-resizable-panels": "^2.1.9",
91
91
  "tailwind-merge": "^2",
92
+ "tldts": "^7.4.9",
92
93
  "vaul": "^1.1.2",
93
94
  "zod": "^3",
94
95
  "@types/dompurify": "*"
@@ -76,18 +76,18 @@ describe("OrganizePanel", () => {
76
76
  const html = render({
77
77
  matchedCount: 128,
78
78
  semanticUnavailable: true,
79
- senders: ["npm@github.com", "notifications@github.com"],
79
+ senders: ["npm@github.com", "digest@substack.com"],
80
80
  matchPredicate: {
81
81
  matchOperator: "Or",
82
82
  literalClauses: [
83
83
  { field: "From", value: "npm@github.com" },
84
- { field: "From", value: "notifications@github.com" },
84
+ { field: "From", value: "digest@substack.com" },
85
85
  ],
86
86
  },
87
87
  });
88
88
  assert.match(html, /matching all mail from/);
89
89
  assert.match(html, /npm@github\.com/);
90
- assert.match(html, /notifications@github\.com/);
90
+ assert.match(html, /digest@substack\.com/);
91
91
  assert.match(html, /128 matches/);
92
92
  // Never claims the matched set is semantically similar (only denies that
93
93
  // similar-mail matching is available).
@@ -98,6 +98,25 @@ describe("OrganizePanel", () => {
98
98
  assert.match(html, /All like these/);
99
99
  });
100
100
 
101
+ it("names the shared domain when every fallback sender shares one (RFC 038 D2 collapse)", () => {
102
+ const html = render({
103
+ matchedCount: 342,
104
+ semanticUnavailable: true,
105
+ senders: [
106
+ "npm@github.com",
107
+ "notifications@github.com",
108
+ "ci@sub.github.com",
109
+ ],
110
+ matchPredicate: {
111
+ matchOperator: "Or",
112
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
113
+ },
114
+ });
115
+ assert.match(html, /matching all mail from anyone at github\.com/);
116
+ assert.match(html, /342 matches/);
117
+ assert.doesNotMatch(html, /similar message/i);
118
+ });
119
+
101
120
  it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
102
121
  const html = render({ initialScope: "standing" });
103
122
  // The standing scope's "Always keep" phrasing only renders when it is active.
@@ -227,10 +227,11 @@ export const SemanticUnavailable: Story = {
227
227
 
228
228
  /**
229
229
  * No vector pipeline, but the selection has senders to match on — the widen
230
- * fell back to matching all mail from those senders. The heading names the
231
- * senders and states the real semantics, and every commit scope (including the
232
- * standing filter) carries the sender `From` clauses, so it reaches the widened
233
- * set and keeps working on future mail.
230
+ * fell back to matching all mail from those senders. The senders span different
231
+ * domains, so the fallback keeps one `From` clause per address; the heading
232
+ * names the senders and states the real semantics, and every commit scope
233
+ * (including the standing filter) carries those clauses, so it reaches the
234
+ * widened set and keeps working on future mail.
234
235
  */
235
236
  export const SenderFallback: Story = {
236
237
  name: "Sender Fallback",
@@ -244,12 +245,44 @@ export const SenderFallback: Story = {
244
245
  matchOperator: "Or",
245
246
  literalClauses: [
246
247
  { field: "From", value: "npm@github.com" },
247
- { field: "From", value: "notifications@github.com" },
248
+ { field: "From", value: "noreply@medium.com" },
248
249
  ],
249
250
  }}
250
251
  matchedCount={128}
251
252
  semanticUnavailable
252
- senders={["npm@github.com", "notifications@github.com"]}
253
+ senders={["npm@github.com", "noreply@medium.com"]}
254
+ onClose={() => undefined}
255
+ />
256
+ </SheetStage>
257
+ ),
258
+ };
259
+
260
+ /**
261
+ * The sender fallback where every selected sender shares one registrable domain
262
+ * (RFC 038 D2): the per-address `From` chips collapse to a single `FromDomain`
263
+ * clause, and the heading names the domain — "anyone at github.com" — rather
264
+ * than listing addresses. Matches `sub.github.com` too, never a look-alike like
265
+ * `github.com.evil.example`.
266
+ */
267
+ export const SenderFallbackDomain: Story = {
268
+ name: "Sender Fallback (domain)",
269
+ render: () => (
270
+ <SheetStage>
271
+ <OrganizePanel
272
+ accountId={ACCOUNT_ID}
273
+ mailboxId="mbx-inbox"
274
+ selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
275
+ matchPredicate={{
276
+ matchOperator: "Or",
277
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
278
+ }}
279
+ matchedCount={342}
280
+ semanticUnavailable
281
+ senders={[
282
+ "npm@github.com",
283
+ "notifications@github.com",
284
+ "ci@sub.github.com",
285
+ ]}
253
286
  onClose={() => undefined}
254
287
  />
255
288
  </SheetStage>
@@ -131,4 +131,12 @@ describe("senderFallbackSummary", () => {
131
131
  assert.match(summary, /isn't available on this server/);
132
132
  assert.match(summary, /matching all mail from npm@github\.com instead/);
133
133
  });
134
+
135
+ it("names the shared domain when the senders collapse to one FromDomain", () => {
136
+ const summary = senderFallbackSummary(["npm@github.com", "ci@github.com"]);
137
+ assert.match(
138
+ summary,
139
+ /matching all mail from anyone at github\.com instead/,
140
+ );
141
+ });
134
142
  });
@@ -1,5 +1,6 @@
1
1
  import type { OrganizeScope } from "./organize-model";
2
2
  import { hasCommittableAction, type OrganizeDraft } from "./organize-model";
3
+ import { collapsibleDomain } from "./sender-fallback";
3
4
 
4
5
  export interface CommitContext {
5
6
  draft: OrganizeDraft;
@@ -82,9 +83,13 @@ export const formatSenderList = (senders: readonly string[]): string => {
82
83
  /**
83
84
  * The heading when the widen fell back to sender matching (no vector pipeline on
84
85
  * this server). States the actual semantics — matching every mail from these
85
- * senders and never claims semantic similarity.
86
+ * senders, or from anyone at their shared domain when the clauses collapsed to a
87
+ * single `FromDomain` (RFC 038 D2) — and never claims semantic similarity.
86
88
  */
87
- export const senderFallbackSummary = (senders: readonly string[]): string =>
88
- `Similar-mail matching isn't available on this server — matching all mail from ${formatSenderList(
89
- senders,
90
- )} instead.`;
89
+ export const senderFallbackSummary = (senders: readonly string[]): string => {
90
+ const domain = collapsibleDomain(senders);
91
+ const target = domain
92
+ ? `anyone at ${domain}`
93
+ : `${formatSenderList(senders)}`;
94
+ return `Similar-mail matching isn't available on this server — matching all mail from ${target} instead.`;
95
+ };
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
4
  buildSenderFallbackDraft,
5
+ collapsibleDomain,
5
6
  deriveSenderClauses,
6
7
  distinctSenders,
7
8
  } from "./sender-fallback";
@@ -27,8 +28,36 @@ describe("distinctSenders", () => {
27
28
  });
28
29
  });
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
+
30
59
  describe("deriveSenderClauses", () => {
31
- it("emits one From clause per distinct sender", () => {
60
+ it("emits one From clause per distinct sender when domains differ", () => {
32
61
  assert.deepEqual(
33
62
  deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
34
63
  [
@@ -38,6 +67,32 @@ describe("deriveSenderClauses", () => {
38
67
  );
39
68
  });
40
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
+
41
96
  it("is empty when no sender survives", () => {
42
97
  assert.deepEqual(deriveSenderClauses(["", " "]), []);
43
98
  });
@@ -53,4 +108,12 @@ describe("buildSenderFallbackDraft", () => {
53
108
  { field: "From", value: "a@x.com" },
54
109
  ]);
55
110
  });
111
+
112
+ it("carries a single FromDomain clause when the senders share a domain", () => {
113
+ const draft = buildSenderFallbackDraft(["npm@github.com", "ci@github.com"]);
114
+ assert.equal(draft.matchOperator, "Or");
115
+ assert.deepEqual(draft.literalClauses, [
116
+ { field: "FromDomain", value: "github.com" },
117
+ ]);
118
+ });
56
119
  });
@@ -2,6 +2,7 @@ import type {
2
2
  RemitImapFilterClause,
3
3
  RemitImapOrganizeInput,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
+ import { getDomain } from "tldts";
5
6
  import type { OrganizeDraft } from "./organize-model";
6
7
 
7
8
  /**
@@ -31,15 +32,47 @@ export const distinctSenders = (senders: readonly string[]): string[] => {
31
32
  return out;
32
33
  };
33
34
 
35
+ const hostOf = (address: string): string => {
36
+ const at = address.lastIndexOf("@");
37
+ return at >= 0 ? address.slice(at + 1) : address;
38
+ };
39
+
34
40
  /**
35
- * One `From` literal clause per distinct sender address. A `From` clause matches
36
- * the sender address or display name (match.ts `clauseMatches`), so the address
37
- * is the precise, stable key.
41
+ * The single registrable domain the whole selection collapses to, or `null` when
42
+ * it does not collapse. A collapse needs at least two distinct senders that all
43
+ * resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
44
+ * — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
45
+ * `From` clause rather than widening a single address to its whole domain, and a
46
+ * sender whose domain can't be resolved blocks the collapse.
47
+ */
48
+ export const collapsibleDomain = (
49
+ senders: readonly string[],
50
+ ): string | null => {
51
+ const distinct = distinctSenders(senders);
52
+ if (distinct.length < 2) return null;
53
+ let shared: string | null = null;
54
+ for (const sender of distinct) {
55
+ const domain = getDomain(hostOf(sender));
56
+ if (domain === null) return null;
57
+ if (shared === null) shared = domain;
58
+ else if (shared !== domain) return null;
59
+ }
60
+ return shared;
61
+ };
62
+
63
+ /**
64
+ * The literal clauses standing in for the selection. When every sender shares one
65
+ * registrable domain, a single `FromDomain` clause replaces the per-address `From`
66
+ * chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
67
+ * matching the sender address or display name (match.ts `clauseMatches`).
38
68
  */
39
69
  export const deriveSenderClauses = (
40
70
  senders: readonly string[],
41
- ): RemitImapFilterClause[] =>
42
- distinctSenders(senders).map((value) => ({ field: "From", value }));
71
+ ): RemitImapFilterClause[] => {
72
+ const domain = collapsibleDomain(senders);
73
+ if (domain !== null) return [{ field: "FromDomain", value: domain }];
74
+ return distinctSenders(senders).map((value) => ({ field: "From", value }));
75
+ };
43
76
 
44
77
  /**
45
78
  * The literal predicate that stands in for the semantic anchor: the sender `From`