@remit/web-client 0.0.86 → 0.0.87
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/auth/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +155 -34
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +90 -0
- package/src/lib/organize/property-prefill.test.ts +173 -0
- package/src/lib/organize/property-prefill.ts +151 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +110 -31
- package/src/lib/organize/search-to-rule.ts +12 -4
- package/src/lib/organize/sender-fallback.ts +11 -1
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
|
@@ -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";
|
|
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 { 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
|
+
};
|
|
@@ -11,6 +11,7 @@ import { buildOrganizeInput } from "./organize-model";
|
|
|
11
11
|
import {
|
|
12
12
|
buildInitialRule,
|
|
13
13
|
derivePreview,
|
|
14
|
+
isEvaluablePredicate,
|
|
14
15
|
normalizeClauseValue,
|
|
15
16
|
normalizeListId,
|
|
16
17
|
predicateSignature,
|
|
@@ -300,3 +301,59 @@ describe("derivePreview", () => {
|
|
|
300
301
|
);
|
|
301
302
|
});
|
|
302
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("isEvaluablePredicate", () => {
|
|
306
|
+
it("accepts literal clauses the vector-free matcher can read off the index", () => {
|
|
307
|
+
assert.equal(
|
|
308
|
+
isEvaluablePredicate({
|
|
309
|
+
matchOperator: "And",
|
|
310
|
+
literalClauses: [
|
|
311
|
+
{ field: "From", value: "npm@github.com" },
|
|
312
|
+
{ field: "Subject", value: "Invoice" },
|
|
313
|
+
],
|
|
314
|
+
}),
|
|
315
|
+
true,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("rejects a body clause with no anchor — the shape that answered 500", () => {
|
|
320
|
+
assert.equal(
|
|
321
|
+
isEvaluablePredicate({
|
|
322
|
+
matchOperator: "And",
|
|
323
|
+
literalClauses: [{ field: "HasWords", value: "receipt" }],
|
|
324
|
+
}),
|
|
325
|
+
false,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects it however it is mixed in with clauses that are evaluable", () => {
|
|
330
|
+
assert.equal(
|
|
331
|
+
isEvaluablePredicate({
|
|
332
|
+
matchOperator: "Or",
|
|
333
|
+
literalClauses: [
|
|
334
|
+
{ field: "From", value: "npm@github.com" },
|
|
335
|
+
{ field: "HasWords", value: "receipt" },
|
|
336
|
+
],
|
|
337
|
+
}),
|
|
338
|
+
false,
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("accepts a body clause once an anchor puts the matcher on the semantic arm", () => {
|
|
343
|
+
assert.equal(
|
|
344
|
+
isEvaluablePredicate({
|
|
345
|
+
anchorMessageId: "msg-1",
|
|
346
|
+
matchOperator: "And",
|
|
347
|
+
literalClauses: [{ field: "HasWords", value: "receipt" }],
|
|
348
|
+
}),
|
|
349
|
+
true,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("accepts an empty predicate", () => {
|
|
354
|
+
assert.equal(
|
|
355
|
+
isEvaluablePredicate({ matchOperator: "And", literalClauses: [] }),
|
|
356
|
+
true,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -4,14 +4,13 @@ import type {
|
|
|
4
4
|
MatchOperator,
|
|
5
5
|
PreviewCount,
|
|
6
6
|
RuleClause,
|
|
7
|
+
RuleMatchMode,
|
|
7
8
|
RuleScope,
|
|
8
9
|
} from "@remit/ui";
|
|
9
10
|
import { pickedDateToExpiresAt } from "./filter-status";
|
|
10
11
|
import type { OrganizeDraft } from "./organize-model";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
type OrganizeMatchPredicate,
|
|
14
|
-
} from "./sender-fallback";
|
|
12
|
+
import { derivePropertyClauses } from "./property-prefill";
|
|
13
|
+
import type { OrganizeMatchPredicate } from "./sender-fallback";
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
|
|
@@ -55,11 +54,19 @@ export interface InitialRuleInput {
|
|
|
55
54
|
anchorMessageId?: string;
|
|
56
55
|
/**
|
|
57
56
|
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
58
|
-
* opens on the
|
|
57
|
+
* opens on the property-derived literal clauses instead of an anchor.
|
|
59
58
|
*/
|
|
60
59
|
semanticUnavailable: boolean;
|
|
61
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* How the rule opens. Defaults to `properties` when the deployment cannot
|
|
62
|
+
* serve the widen, `similar` otherwise — semantic stays the default wherever
|
|
63
|
+
* it can run.
|
|
64
|
+
*/
|
|
65
|
+
matchMode?: RuleMatchMode;
|
|
66
|
+
/** Distinct sender addresses of the selection, for the derived clauses. */
|
|
62
67
|
senders: readonly string[];
|
|
68
|
+
/** Subjects of the selected messages, for the shared-subject prefill. */
|
|
69
|
+
subjects?: readonly string[];
|
|
63
70
|
/** How many messages the selection holds — the widen's anchor count. */
|
|
64
71
|
selectionCount: number;
|
|
65
72
|
/** A folder a "Something else" shortcut pre-picked. */
|
|
@@ -68,41 +75,96 @@ export interface InitialRuleInput {
|
|
|
68
75
|
seedScope?: RuleScope;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
export const defaultMatchMode = (
|
|
79
|
+
semanticUnavailable: boolean,
|
|
80
|
+
): RuleMatchMode => (semanticUnavailable ? "properties" : "similar");
|
|
81
|
+
|
|
82
|
+
/** The property prefill as editor chips, flagged as derived so a mode switch
|
|
83
|
+
* can take back its own guesses without touching a clause the user wrote. */
|
|
84
|
+
const derivedClauses = (
|
|
85
|
+
senders: readonly string[],
|
|
86
|
+
subjects: readonly string[],
|
|
87
|
+
): RuleClause[] =>
|
|
88
|
+
derivePropertyClauses(senders, subjects).map((clause, index) => ({
|
|
89
|
+
id: `derived-${index}`,
|
|
90
|
+
field: clause.field,
|
|
91
|
+
value: clause.value,
|
|
92
|
+
derived: true,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
export interface RuleMatchers {
|
|
96
|
+
clauses: RuleClause[];
|
|
97
|
+
matchOperator: MatchOperator;
|
|
98
|
+
widen?: FilterRule["widen"];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface MatchersInput {
|
|
102
|
+
anchorMessageId?: string;
|
|
103
|
+
senders: readonly string[];
|
|
104
|
+
subjects: readonly string[];
|
|
105
|
+
selectionCount: number;
|
|
106
|
+
/** Clauses the user wrote, carried across a mode switch untouched. */
|
|
107
|
+
keepClauses?: RuleClause[];
|
|
108
|
+
/** The operator to keep when the user already has clauses of their own. */
|
|
109
|
+
currentOperator?: MatchOperator;
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
113
|
+
* What a rule matches on in a given mode, with the action and scope left alone.
|
|
114
|
+
*
|
|
115
|
+
* - `similar` — the semantic widen chip, the anchor doing the work.
|
|
116
|
+
* - `properties` — no anchor at all: the selection's own sender or shared
|
|
117
|
+
* subject as ordinary literal chips ({@link derivePropertyClauses}).
|
|
118
|
+
*
|
|
119
|
+
* Both keep clauses the user added by hand, so switching mode is never
|
|
120
|
+
* destructive of their own edits.
|
|
77
121
|
*/
|
|
78
|
-
export const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
(clause
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}),
|
|
122
|
+
export const matchersForMode = (
|
|
123
|
+
mode: RuleMatchMode,
|
|
124
|
+
input: MatchersInput,
|
|
125
|
+
): RuleMatchers => {
|
|
126
|
+
const kept = input.keepClauses ?? [];
|
|
127
|
+
if (mode === "properties") {
|
|
128
|
+
const prefill = derivedClauses(input.senders, input.subjects).filter(
|
|
129
|
+
(clause) =>
|
|
130
|
+
!kept.some(
|
|
131
|
+
(existing) =>
|
|
132
|
+
existing.field === clause.field && existing.value === clause.value,
|
|
133
|
+
),
|
|
91
134
|
);
|
|
92
135
|
return {
|
|
93
|
-
clauses,
|
|
94
|
-
matchOperator: "any",
|
|
95
|
-
|
|
96
|
-
scope,
|
|
97
|
-
name: "",
|
|
136
|
+
clauses: [...kept, ...prefill],
|
|
137
|
+
matchOperator: kept.length > 0 ? (input.currentOperator ?? "any") : "any",
|
|
138
|
+
widen: undefined,
|
|
98
139
|
};
|
|
99
140
|
}
|
|
100
141
|
return {
|
|
101
|
-
clauses:
|
|
102
|
-
matchOperator: "all",
|
|
142
|
+
clauses: kept,
|
|
143
|
+
matchOperator: kept.length > 0 ? (input.currentOperator ?? "all") : "all",
|
|
103
144
|
widen: input.anchorMessageId
|
|
104
145
|
? { anchorCount: Math.max(input.selectionCount, 1) }
|
|
105
146
|
: undefined,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The rule the editor opens on, built from the widen probe. A semantic-capable
|
|
152
|
+
* deployment opens on the widen chip (the anchor, no literal clauses); one
|
|
153
|
+
* without the vector pipeline, or a user who picked properties matching, opens
|
|
154
|
+
* on the derived `From` / `FromDomain` / `Subject` chips (#251), visible and
|
|
155
|
+
* editable, matched with `Or`. Either way the move destination and scope come
|
|
156
|
+
* from any "Something else" seed.
|
|
157
|
+
*/
|
|
158
|
+
export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
|
|
159
|
+
const scope = input.seedScope ?? "once";
|
|
160
|
+
const mode = input.matchMode ?? defaultMatchMode(input.semanticUnavailable);
|
|
161
|
+
return {
|
|
162
|
+
...matchersForMode(mode, {
|
|
163
|
+
anchorMessageId: input.anchorMessageId,
|
|
164
|
+
senders: input.senders,
|
|
165
|
+
subjects: input.subjects ?? [],
|
|
166
|
+
selectionCount: input.selectionCount,
|
|
167
|
+
}),
|
|
106
168
|
moveMailboxId: input.seedMailboxId,
|
|
107
169
|
scope,
|
|
108
170
|
name: "",
|
|
@@ -132,6 +194,23 @@ export const rulePredicate = (
|
|
|
132
194
|
};
|
|
133
195
|
};
|
|
134
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Whether the matcher behind `/organize/preview` can evaluate this predicate at
|
|
199
|
+
* all. Without an anchor it takes the vector-free literal arm, which reads no
|
|
200
|
+
* message body and rejects a `HasWords` clause rather than narrowing the match
|
|
201
|
+
* silently (`assertNoBodyContentClause`, backend/service/organize.ts). Asking it
|
|
202
|
+
* anyway is a 500, so the caller must not ask.
|
|
203
|
+
*/
|
|
204
|
+
export const isEvaluablePredicate = (
|
|
205
|
+
predicate: OrganizeMatchPredicate,
|
|
206
|
+
): boolean =>
|
|
207
|
+
predicate.anchorMessageId !== undefined ||
|
|
208
|
+
!predicate.literalClauses.some((clause) => clause.field === "HasWords");
|
|
209
|
+
|
|
210
|
+
/** What the count says when the predicate cannot be counted at all. */
|
|
211
|
+
export const UNCOUNTABLE_PREDICATE_REASON =
|
|
212
|
+
"Can't count matches — “has the words” reads message bodies, which only a saved rule does.";
|
|
213
|
+
|
|
135
214
|
/**
|
|
136
215
|
* A stable key for a predicate's match set. Two predicates with the same key
|
|
137
216
|
* match the same messages; a change to the key is what marks the live count
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
* The mapping is honest about what a filter cannot carry. A facet with no clause
|
|
19
19
|
* equivalent is never silently folded into the rule: a folder scope is reported
|
|
20
20
|
* as dropped-and-kept-out (the filter matches everywhere, not just there), the
|
|
21
|
-
* attribute facets (attachment /
|
|
21
|
+
* attribute facets (attachment / read state / starred / category / date) are
|
|
22
|
+
* reported as left out, and a
|
|
22
23
|
* free-text query kept as a literal `HasWords` clause is reported as having lost
|
|
23
24
|
* its semantic "similar mail" reach on a deployment that cannot embed the query
|
|
24
25
|
* (D5). Pure functions only — the capability is injected, not probed here.
|
|
@@ -72,15 +73,18 @@ interface ConvertOptions {
|
|
|
72
73
|
const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> = new Set([
|
|
73
74
|
"hasAttachment",
|
|
74
75
|
"isUnread",
|
|
76
|
+
"isRead",
|
|
77
|
+
"isStarred",
|
|
78
|
+
"category",
|
|
75
79
|
"before",
|
|
76
80
|
"after",
|
|
77
81
|
]);
|
|
78
82
|
|
|
79
83
|
/**
|
|
80
84
|
* Convert the current search into a rule's clauses and a record of what could not
|
|
81
|
-
* be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause
|
|
82
|
-
*
|
|
83
|
-
* attribute facets are dropped. The search's terms are ANDed with its facets, so
|
|
85
|
+
* be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause
|
|
86
|
+
* and a `subject:` facet a `Subject` clause; an `in:` facet a kept-out folder
|
|
87
|
+
* scope; `account:` the target account; the attribute facets are dropped. The search's terms are ANDed with its facets, so
|
|
84
88
|
* the rule matches all of them (`all`).
|
|
85
89
|
*/
|
|
86
90
|
export const convertSearchToRule = (
|
|
@@ -97,6 +101,10 @@ export const convertSearchToRule = (
|
|
|
97
101
|
clauses.push({ field: "From", value: token.value });
|
|
98
102
|
continue;
|
|
99
103
|
}
|
|
104
|
+
if (token.type === "subject") {
|
|
105
|
+
clauses.push({ field: "Subject", value: token.value });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
100
108
|
if (token.type === "in") {
|
|
101
109
|
scopedOut = { mailboxId: token.mailboxId, label: token.value };
|
|
102
110
|
continue;
|
|
@@ -37,6 +37,16 @@ const hostOf = (address: string): string => {
|
|
|
37
37
|
return at >= 0 ? address.slice(at + 1) : address;
|
|
38
38
|
};
|
|
39
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.
|
|
46
|
+
*/
|
|
47
|
+
export const senderDomain = (address: string): string | null =>
|
|
48
|
+
getDomain(hostOf(address.trim()));
|
|
49
|
+
|
|
40
50
|
/**
|
|
41
51
|
* The single registrable domain the whole selection collapses to, or `null` when
|
|
42
52
|
* it does not collapse. A collapse needs at least two distinct senders that all
|
|
@@ -52,7 +62,7 @@ export const collapsibleDomain = (
|
|
|
52
62
|
if (distinct.length < 2) return null;
|
|
53
63
|
let shared: string | null = null;
|
|
54
64
|
for (const sender of distinct) {
|
|
55
|
-
const domain =
|
|
65
|
+
const domain = senderDomain(sender);
|
|
56
66
|
if (domain === null) return null;
|
|
57
67
|
if (shared === null) shared = domain;
|
|
58
68
|
else if (shared !== domain) return null;
|
package/src/lib/search-result.ts
CHANGED
|
@@ -44,6 +44,7 @@ export function threadToSearchResult(
|
|
|
44
44
|
return {
|
|
45
45
|
id: thread.messageId,
|
|
46
46
|
sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
47
|
+
...(thread.fromEmail ? { senderEmail: thread.fromEmail } : {}),
|
|
47
48
|
subject: thread.subject ?? "(No subject)",
|
|
48
49
|
snippet: thread.snippet ?? "",
|
|
49
50
|
date: formatEmailDate(thread.sentDate),
|
|
@@ -66,6 +67,7 @@ export function rowToSearchResult(
|
|
|
66
67
|
return {
|
|
67
68
|
id: row.id,
|
|
68
69
|
sender: row.fromName,
|
|
70
|
+
...(row.fromEmail ? { senderEmail: row.fromEmail } : {}),
|
|
69
71
|
subject: row.subject,
|
|
70
72
|
snippet: row.snippet,
|
|
71
73
|
date: row.timeLabel,
|