@remit/web-client 0.0.87 → 0.0.88
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 +1 -1
- package/src/components/mail/MailListHeader.tsx +2 -4
- package/src/components/mail/organize/SearchFilterDialog.tsx +2 -5
- package/src/components/mail/organize/SearchFilterEditor.tsx +2 -4
- package/src/hooks/useOrganizeWiden.ts +1 -2
- package/src/lib/organize/clause-suggestions.ts +1 -2
- package/src/lib/organize/rule-model.ts +9 -9
- package/src/lib/organize/search-to-rule.test.ts +8 -28
- package/src/lib/organize/search-to-rule.ts +25 -89
- package/src/lib/organize/sender-fallback.test.ts +1 -97
- package/src/lib/organize/sender-fallback.ts +8 -81
- package/src/lib/organize/property-prefill.test.ts +0 -173
- package/src/lib/organize/property-prefill.ts +0 -151
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.88",
|
|
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": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import {
|
|
42
42
|
type FilterSheetProps,
|
|
43
|
+
isConvertible,
|
|
43
44
|
MailHeader,
|
|
44
45
|
MakeFilterAction,
|
|
45
46
|
MobileSearchView,
|
|
@@ -68,10 +69,7 @@ import { useSearchScope } from "@/hooks/useSearchScope";
|
|
|
68
69
|
import { useSearchSuggestions } from "@/hooks/useSearchSuggestions";
|
|
69
70
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
70
71
|
import { useMailContext } from "@/lib/mail-context";
|
|
71
|
-
import {
|
|
72
|
-
convertSearchToRule,
|
|
73
|
-
isConvertible,
|
|
74
|
-
} from "@/lib/organize/search-to-rule";
|
|
72
|
+
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
75
73
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
76
74
|
import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
|
|
77
75
|
import { applySearchSuggestion } from "@/lib/search-suggestions";
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import { Button, Dialog } from "@remit/ui";
|
|
1
|
+
import { Button, buildSearchRule, Dialog } from "@remit/ui";
|
|
2
2
|
import { Loader2 } from "lucide-react";
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import { useSearchFilterSeed } from "@/hooks/useSearchFilterSeed";
|
|
5
5
|
import { rulePredicate } from "@/lib/organize/rule-model";
|
|
6
|
-
import {
|
|
7
|
-
buildSearchRule,
|
|
8
|
-
convertSearchToRule,
|
|
9
|
-
} from "@/lib/organize/search-to-rule";
|
|
6
|
+
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
10
7
|
import type { ParsedSearchQuery } from "@/lib/search-tokens";
|
|
11
8
|
import { SearchFilterEditor } from "./SearchFilterEditor";
|
|
12
9
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
2
|
import {
|
|
3
|
+
buildSearchRule,
|
|
3
4
|
type FilterRule,
|
|
4
5
|
FilterRuleEditor,
|
|
5
6
|
type FolderOption,
|
|
7
|
+
type SearchConversion,
|
|
6
8
|
SearchConversionNoticeView,
|
|
7
9
|
} from "@remit/ui";
|
|
8
10
|
import { useQuery } from "@tanstack/react-query";
|
|
@@ -18,10 +20,6 @@ import {
|
|
|
18
20
|
ruleToDraft,
|
|
19
21
|
SUPPORTED_CLAUSE_FIELDS,
|
|
20
22
|
} from "@/lib/organize/rule-model";
|
|
21
|
-
import {
|
|
22
|
-
buildSearchRule,
|
|
23
|
-
type SearchConversion,
|
|
24
|
-
} from "@/lib/organize/search-to-rule";
|
|
25
23
|
import {
|
|
26
24
|
CommitError,
|
|
27
25
|
FilterSaved,
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { deriveSenderClauses, distinctSenders } from "@remit/ui";
|
|
2
3
|
import { useMutation } from "@tanstack/react-query";
|
|
3
4
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
5
|
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
5
6
|
import {
|
|
6
7
|
buildSenderFallbackDraft,
|
|
7
|
-
deriveSenderClauses,
|
|
8
|
-
distinctSenders,
|
|
9
8
|
type OrganizeMatchPredicate,
|
|
10
9
|
} from "@/lib/organize/sender-fallback";
|
|
11
10
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { senderDomain } from "./sender-fallback";
|
|
1
|
+
import { type ClauseField, type Suggestion, senderDomain } from "@remit/ui";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Values worth offering while a clause is being typed. The rule editor's value
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
ClauseField,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import {
|
|
2
|
+
type ClauseField,
|
|
3
|
+
derivePropertyClauses,
|
|
4
|
+
type FilterRule,
|
|
5
|
+
type MatchOperator,
|
|
6
|
+
type PreviewCount,
|
|
7
|
+
type RuleClause,
|
|
8
|
+
type RuleMatchMode,
|
|
9
|
+
type RuleScope,
|
|
9
10
|
} from "@remit/ui";
|
|
10
11
|
import { pickedDateToExpiresAt } from "./filter-status";
|
|
11
12
|
import type { OrganizeDraft } from "./organize-model";
|
|
12
|
-
import { derivePropertyClauses } from "./property-prefill";
|
|
13
13
|
import type { OrganizeMatchPredicate } from "./sender-fallback";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { isConvertible } from "@remit/ui";
|
|
3
4
|
import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
|
|
4
|
-
import {
|
|
5
|
-
buildSearchRule,
|
|
6
|
-
convertSearchToRule,
|
|
7
|
-
isConvertible,
|
|
8
|
-
} from "./search-to-rule";
|
|
5
|
+
import { convertSearchToRule } from "./search-to-rule";
|
|
9
6
|
|
|
10
7
|
const CONTEXT: SearchTokenContext = {
|
|
11
8
|
mailboxesByName: new Map([["archive", "mbx-archive"]]),
|
|
@@ -114,33 +111,16 @@ describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
|
|
|
114
111
|
});
|
|
115
112
|
});
|
|
116
113
|
|
|
117
|
-
describe("
|
|
118
|
-
it("
|
|
114
|
+
describe("convertSearchToRule — nothing left to make a filter from", () => {
|
|
115
|
+
it("yields no clause for a query of only dropped facets", () => {
|
|
119
116
|
assert.equal(isConvertible(convert("has:attachment")), false);
|
|
120
|
-
assert.equal(isConvertible(convert("in:archive")), false);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("is convertible once a term or sender is present", () => {
|
|
124
|
-
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
125
117
|
});
|
|
126
118
|
|
|
127
|
-
it("
|
|
128
|
-
|
|
129
|
-
assert.equal(rule.scope, "standing");
|
|
130
|
-
assert.equal(rule.widen, undefined);
|
|
131
|
-
assert.equal(rule.name, "");
|
|
132
|
-
assert.deepEqual(
|
|
133
|
-
rule.clauses.map((clause) => clause.id),
|
|
134
|
-
["search-0", "search-1"],
|
|
135
|
-
);
|
|
119
|
+
it("yields no clause for a bare folder scope", () => {
|
|
120
|
+
assert.equal(isConvertible(convert("in:archive")), false);
|
|
136
121
|
});
|
|
137
122
|
|
|
138
|
-
it("
|
|
139
|
-
|
|
140
|
-
scope: "once",
|
|
141
|
-
moveMailboxId: "mbx-archive",
|
|
142
|
-
});
|
|
143
|
-
assert.equal(rule.scope, "once");
|
|
144
|
-
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
123
|
+
it("yields a clause once a term rides along with the folder scope", () => {
|
|
124
|
+
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
145
125
|
});
|
|
146
126
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
RuleScope,
|
|
2
|
+
DroppedFacet,
|
|
3
|
+
DroppedFacetType,
|
|
4
|
+
SearchConversion,
|
|
6
5
|
} from "@remit/ui";
|
|
7
6
|
import {
|
|
8
7
|
type ParsedSearchQuery,
|
|
@@ -19,47 +18,12 @@ import {
|
|
|
19
18
|
* equivalent is never silently folded into the rule: a folder scope is reported
|
|
20
19
|
* as dropped-and-kept-out (the filter matches everywhere, not just there), the
|
|
21
20
|
* attribute facets (attachment / read state / starred / category / date) are
|
|
22
|
-
* reported as left out, and a
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* reported as left out, and a free-text query kept as a literal `HasWords` clause
|
|
22
|
+
* is reported as having lost its semantic "similar mail" reach on a deployment
|
|
23
|
+
* that cannot embed the query (D5). Pure functions only — the capability is
|
|
24
|
+
* injected, not probed here.
|
|
26
25
|
*/
|
|
27
26
|
|
|
28
|
-
export interface DroppedFacet {
|
|
29
|
-
/** The facet that had no clause equivalent. */
|
|
30
|
-
type: SearchToken["type"];
|
|
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
27
|
interface ConvertOptions {
|
|
64
28
|
/**
|
|
65
29
|
* Whether the current search surfaced semantically-similar mail (a non-empty
|
|
@@ -70,15 +34,22 @@ interface ConvertOptions {
|
|
|
70
34
|
searchHadSemanticReach: boolean;
|
|
71
35
|
}
|
|
72
36
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
37
|
+
type NoClauseFacet = Extract<SearchToken["type"], DroppedFacetType>;
|
|
38
|
+
|
|
39
|
+
const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> =
|
|
40
|
+
new Set<NoClauseFacet>([
|
|
41
|
+
"hasAttachment",
|
|
42
|
+
"isUnread",
|
|
43
|
+
"isRead",
|
|
44
|
+
"isStarred",
|
|
45
|
+
"category",
|
|
46
|
+
"before",
|
|
47
|
+
"after",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const hasNoClauseEquivalent = (
|
|
51
|
+
type: SearchToken["type"],
|
|
52
|
+
): type is NoClauseFacet => FACET_HAS_NO_CLAUSE.has(type);
|
|
82
53
|
|
|
83
54
|
/**
|
|
84
55
|
* Convert the current search into a rule's clauses and a record of what could not
|
|
@@ -93,7 +64,7 @@ export const convertSearchToRule = (
|
|
|
93
64
|
): SearchConversion => {
|
|
94
65
|
const clauses: SearchConversion["clauses"] = [];
|
|
95
66
|
const droppedFacets: DroppedFacet[] = [];
|
|
96
|
-
let scopedOut:
|
|
67
|
+
let scopedOut: SearchConversion["scopedOut"];
|
|
97
68
|
let targetAccountId: string | undefined;
|
|
98
69
|
|
|
99
70
|
for (const token of parsed.tokens) {
|
|
@@ -113,7 +84,7 @@ export const convertSearchToRule = (
|
|
|
113
84
|
targetAccountId = token.accountId;
|
|
114
85
|
continue;
|
|
115
86
|
}
|
|
116
|
-
if (
|
|
87
|
+
if (hasNoClauseEquivalent(token.type)) {
|
|
117
88
|
droppedFacets.push({ type: token.type, label: searchTokenLabel(token) });
|
|
118
89
|
}
|
|
119
90
|
}
|
|
@@ -132,38 +103,3 @@ export const convertSearchToRule = (
|
|
|
132
103
|
droppedSemantic: keptTerms && searchHadSemanticReach,
|
|
133
104
|
};
|
|
134
105
|
};
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Whether the conversion yields a rule with something to match. A search of only
|
|
138
|
-
* dropped facets or a bare folder scope converts to no clauses, so there is no
|
|
139
|
-
* filter to open — the entry point offers nothing rather than an empty editor.
|
|
140
|
-
*/
|
|
141
|
-
export const isConvertible = (conversion: SearchConversion): boolean =>
|
|
142
|
-
conversion.clauses.length > 0;
|
|
143
|
-
|
|
144
|
-
interface BuildRuleOptions {
|
|
145
|
-
scope?: RuleScope;
|
|
146
|
-
moveMailboxId?: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The rule the editor opens on, from a conversion. A search-derived rule defaults
|
|
151
|
-
* to a standing filter — "make this a filter" is a request to keep applying it —
|
|
152
|
-
* and the editor lets the user drop it back to a one-time apply. It carries no
|
|
153
|
-
* widen: a free-text query has no message anchor, so the semantic chip is not
|
|
154
|
-
* offered on this surface (its loss is stated in the conversion notice instead).
|
|
155
|
-
*/
|
|
156
|
-
export const buildSearchRule = (
|
|
157
|
-
conversion: SearchConversion,
|
|
158
|
-
{ scope = "standing", moveMailboxId }: BuildRuleOptions = {},
|
|
159
|
-
): FilterRule => ({
|
|
160
|
-
clauses: conversion.clauses.map((clause, index) => ({
|
|
161
|
-
id: `search-${index}`,
|
|
162
|
-
field: clause.field,
|
|
163
|
-
value: clause.value,
|
|
164
|
-
})),
|
|
165
|
-
matchOperator: conversion.matchOperator,
|
|
166
|
-
moveMailboxId,
|
|
167
|
-
scope,
|
|
168
|
-
name: "",
|
|
169
|
-
});
|
|
@@ -1,102 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
buildSenderFallbackDraft,
|
|
5
|
-
collapsibleDomain,
|
|
6
|
-
deriveSenderClauses,
|
|
7
|
-
distinctSenders,
|
|
8
|
-
} from "./sender-fallback";
|
|
9
|
-
|
|
10
|
-
describe("distinctSenders", () => {
|
|
11
|
-
it("drops empties and blanks, trimming what remains", () => {
|
|
12
|
-
assert.deepEqual(
|
|
13
|
-
distinctSenders([" npm@github.com ", "", " ", "a@x.com"]),
|
|
14
|
-
["npm@github.com", "a@x.com"],
|
|
15
|
-
);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("de-duplicates case-insensitively, keeping first-seen casing and order", () => {
|
|
19
|
-
assert.deepEqual(
|
|
20
|
-
distinctSenders([
|
|
21
|
-
"NPM@github.com",
|
|
22
|
-
"a@x.com",
|
|
23
|
-
"npm@GITHUB.com",
|
|
24
|
-
"a@x.com",
|
|
25
|
-
]),
|
|
26
|
-
["NPM@github.com", "a@x.com"],
|
|
27
|
-
);
|
|
28
|
-
});
|
|
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
|
-
|
|
59
|
-
describe("deriveSenderClauses", () => {
|
|
60
|
-
it("emits one From clause per distinct sender when domains differ", () => {
|
|
61
|
-
assert.deepEqual(
|
|
62
|
-
deriveSenderClauses(["npm@github.com", "npm@github.com", "a@x.com"]),
|
|
63
|
-
[
|
|
64
|
-
{ field: "From", value: "npm@github.com" },
|
|
65
|
-
{ field: "From", value: "a@x.com" },
|
|
66
|
-
],
|
|
67
|
-
);
|
|
68
|
-
});
|
|
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
|
-
|
|
96
|
-
it("is empty when no sender survives", () => {
|
|
97
|
-
assert.deepEqual(deriveSenderClauses(["", " "]), []);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
3
|
+
import { buildSenderFallbackDraft } from "./sender-fallback";
|
|
100
4
|
|
|
101
5
|
describe("buildSenderFallbackDraft", () => {
|
|
102
6
|
it("combines the sender clauses with Or and carries no anchor", () => {
|
|
@@ -1,93 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
RemitImapOrganizeInput,
|
|
4
|
-
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import { getDomain } from "tldts";
|
|
1
|
+
import type { RemitImapOrganizeInput } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { deriveSenderClauses } from "@remit/ui";
|
|
6
3
|
import type { OrganizeDraft } from "./organize-model";
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
6
|
* The widen fallback for a deployment that ships no vector pipeline (self-host
|
|
10
7
|
* sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
|
|
11
8
|
* so a widen degrades to the literal vocabulary RFC 031 already matches
|
|
12
|
-
* vector-free:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Distinct sender addresses from the selection, trimmed, empties dropped, and
|
|
19
|
-
* de-duplicated case-insensitively while preserving first-seen casing and order.
|
|
20
|
-
*/
|
|
21
|
-
export const distinctSenders = (senders: readonly string[]): string[] => {
|
|
22
|
-
const seen = new Set<string>();
|
|
23
|
-
const out: string[] = [];
|
|
24
|
-
for (const raw of senders) {
|
|
25
|
-
const value = raw.trim();
|
|
26
|
-
if (value === "") continue;
|
|
27
|
-
const key = value.toLowerCase();
|
|
28
|
-
if (seen.has(key)) continue;
|
|
29
|
-
seen.add(key);
|
|
30
|
-
out.push(value);
|
|
31
|
-
}
|
|
32
|
-
return out;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const hostOf = (address: string): string => {
|
|
36
|
-
const at = address.lastIndexOf("@");
|
|
37
|
-
return at >= 0 ? address.slice(at + 1) : address;
|
|
38
|
-
};
|
|
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.
|
|
9
|
+
* vector-free: the sender clauses `@remit/ui` derives, combined with `Or`, no
|
|
10
|
+
* anchor. The same predicate matches at index time (RFC 034), so a standing
|
|
11
|
+
* filter built from it keeps working on future mail.
|
|
46
12
|
*/
|
|
47
|
-
export const senderDomain = (address: string): string | null =>
|
|
48
|
-
getDomain(hostOf(address.trim()));
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The single registrable domain the whole selection collapses to, or `null` when
|
|
52
|
-
* it does not collapse. A collapse needs at least two distinct senders that all
|
|
53
|
-
* resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
|
|
54
|
-
* — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
|
|
55
|
-
* `From` clause rather than widening a single address to its whole domain, and a
|
|
56
|
-
* sender whose domain can't be resolved blocks the collapse.
|
|
57
|
-
*/
|
|
58
|
-
export const collapsibleDomain = (
|
|
59
|
-
senders: readonly string[],
|
|
60
|
-
): string | null => {
|
|
61
|
-
const distinct = distinctSenders(senders);
|
|
62
|
-
if (distinct.length < 2) return null;
|
|
63
|
-
let shared: string | null = null;
|
|
64
|
-
for (const sender of distinct) {
|
|
65
|
-
const domain = senderDomain(sender);
|
|
66
|
-
if (domain === null) return null;
|
|
67
|
-
if (shared === null) shared = domain;
|
|
68
|
-
else if (shared !== domain) return null;
|
|
69
|
-
}
|
|
70
|
-
return shared;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The literal clauses standing in for the selection. When every sender shares one
|
|
75
|
-
* registrable domain, a single `FromDomain` clause replaces the per-address `From`
|
|
76
|
-
* chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
|
|
77
|
-
* matching the sender address or display name (match.ts `clauseMatches`).
|
|
78
|
-
*/
|
|
79
|
-
export const deriveSenderClauses = (
|
|
80
|
-
senders: readonly string[],
|
|
81
|
-
): RemitImapFilterClause[] => {
|
|
82
|
-
const domain = collapsibleDomain(senders);
|
|
83
|
-
if (domain !== null) return [{ field: "FromDomain", value: domain }];
|
|
84
|
-
return distinctSenders(senders).map((value) => ({ field: "From", value }));
|
|
85
|
-
};
|
|
86
13
|
|
|
87
14
|
/**
|
|
88
|
-
* The literal predicate that stands in for the semantic anchor: the sender
|
|
89
|
-
* clauses combined with `Or` and no anchor. The preview, the one-time
|
|
90
|
-
* and the standing filter all carry exactly this.
|
|
15
|
+
* The literal predicate that stands in for the semantic anchor: the sender
|
|
16
|
+
* clauses combined with `Or` and no anchor. The preview, the one-time
|
|
17
|
+
* back-apply, and the standing filter all carry exactly this.
|
|
91
18
|
*/
|
|
92
19
|
export const buildSenderFallbackDraft = (
|
|
93
20
|
senders: readonly string[],
|
|
@@ -1,173 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,151 +0,0 @@
|
|
|
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
|
-
};
|