@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 +2 -1
- package/src/components/mail/organize/OrganizePanel.render.test.ts +22 -3
- package/src/components/mail/organize/smart-organize.stories.tsx +39 -6
- package/src/lib/organize/organize-copy.test.ts +8 -0
- package/src/lib/organize/organize-copy.ts +10 -5
- package/src/lib/organize/sender-fallback.test.ts +64 -1
- package/src/lib/organize/sender-fallback.ts +38 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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", "
|
|
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: "
|
|
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, /
|
|
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
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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: "
|
|
248
|
+
{ field: "From", value: "noreply@medium.com" },
|
|
248
249
|
],
|
|
249
250
|
}}
|
|
250
251
|
matchedCount={128}
|
|
251
252
|
semanticUnavailable
|
|
252
|
-
senders={["npm@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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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`
|