@remit/web-client 0.0.277 → 0.0.279
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/IntelligencePane.tsx +27 -1
- package/src/components/mail/SelectionWizardHost.tsx +8 -6
- package/src/components/mail/reclassify-remove-override.render.test.ts +142 -0
- package/src/hooks/useOrganizeWiden.test.ts +71 -5
- package/src/hooks/useOrganizeWiden.ts +24 -15
- package/src/hooks/useUpdateAddressFlags.ts +35 -10
- package/src/lib/organize/sender-fallback.test.ts +31 -2
- package/src/lib/organize/sender-fallback.ts +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.279",
|
|
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": {
|
|
@@ -159,16 +159,25 @@ function IntelligenceSkeleton() {
|
|
|
159
159
|
/**
|
|
160
160
|
* Reclassify picker: a small modal listing the category-override options. On
|
|
161
161
|
* selection it PATCHes `AddressFlags.category` for the sender.
|
|
162
|
+
*
|
|
163
|
+
* A sender that already carries an override also gets the way back out. The
|
|
164
|
+
* override is an enum with no false-equivalent member, so removal is not a
|
|
165
|
+
* value to send — it is `clearFlags: ["category"]`, the contract's removal
|
|
166
|
+
* form for every flag (#615).
|
|
162
167
|
*/
|
|
163
168
|
function ReclassifyDialog({
|
|
164
169
|
isOpen,
|
|
165
170
|
current,
|
|
171
|
+
hasOverride,
|
|
166
172
|
onSelect,
|
|
173
|
+
onClear,
|
|
167
174
|
onCancel,
|
|
168
175
|
}: {
|
|
169
176
|
isOpen: boolean;
|
|
170
177
|
current: string;
|
|
178
|
+
hasOverride: boolean;
|
|
171
179
|
onSelect: (category: CategoryOverride) => void;
|
|
180
|
+
onClear: () => void;
|
|
172
181
|
onCancel: () => void;
|
|
173
182
|
}) {
|
|
174
183
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
@@ -210,6 +219,15 @@ function ReclassifyDialog({
|
|
|
210
219
|
)}
|
|
211
220
|
</button>
|
|
212
221
|
))}
|
|
222
|
+
{hasOverride && (
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={onClear}
|
|
226
|
+
className="mt-1 flex min-h-11 items-center rounded border-t border-line px-3 text-left text-sm text-fg transition-colors hover:bg-surface-raised"
|
|
227
|
+
>
|
|
228
|
+
Remove override — classify automatically again
|
|
229
|
+
</button>
|
|
230
|
+
)}
|
|
213
231
|
</div>
|
|
214
232
|
<div className="mt-6 flex justify-end">
|
|
215
233
|
<button
|
|
@@ -247,6 +265,7 @@ function WiredPanel({
|
|
|
247
265
|
}: WiredPanelProps) {
|
|
248
266
|
const {
|
|
249
267
|
data,
|
|
268
|
+
address,
|
|
250
269
|
addressId,
|
|
251
270
|
isSimilarLoading,
|
|
252
271
|
similarError,
|
|
@@ -256,7 +275,7 @@ function WiredPanel({
|
|
|
256
275
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
257
276
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
258
277
|
|
|
259
|
-
const { updateFlags } = useUpdateAddressFlags({
|
|
278
|
+
const { updateFlags, clearFlags } = useUpdateAddressFlags({
|
|
260
279
|
addressId,
|
|
261
280
|
senderEmail,
|
|
262
281
|
});
|
|
@@ -315,6 +334,11 @@ function WiredPanel({
|
|
|
315
334
|
[updateFlags],
|
|
316
335
|
);
|
|
317
336
|
|
|
337
|
+
const handleReclassifyClear = useCallback(() => {
|
|
338
|
+
setReclassifyOpen(false);
|
|
339
|
+
clearFlags(["category"]);
|
|
340
|
+
}, [clearFlags]);
|
|
341
|
+
|
|
318
342
|
// Every per-sender flag toggle PATCHes the sender's address row, so none of
|
|
319
343
|
// them can be serviced until that row resolves. Leave them unwired until it
|
|
320
344
|
// does: the panel renders an unwired quick action as disabled, so the flow is
|
|
@@ -383,7 +407,9 @@ function WiredPanel({
|
|
|
383
407
|
<ReclassifyDialog
|
|
384
408
|
isOpen={reclassifyOpen}
|
|
385
409
|
current={data.category.value}
|
|
410
|
+
hasOverride={address?.flags?.category?.value != null}
|
|
386
411
|
onSelect={handleReclassifySelect}
|
|
412
|
+
onClear={handleReclassifyClear}
|
|
387
413
|
onCancel={() => setReclassifyOpen(false)}
|
|
388
414
|
/>
|
|
389
415
|
</>
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
type ClauseDraft,
|
|
5
5
|
type ClauseEditState,
|
|
6
6
|
derivePropertyClauses,
|
|
7
|
-
deriveSenderClauses,
|
|
8
7
|
dominantSender,
|
|
9
8
|
type FolderTreeNode,
|
|
10
9
|
type MatchCount,
|
|
@@ -403,6 +402,7 @@ function SelectionWizardSession({
|
|
|
403
402
|
escalated ? undefined : accountId,
|
|
404
403
|
anchorMessageId,
|
|
405
404
|
senders,
|
|
405
|
+
subjects,
|
|
406
406
|
);
|
|
407
407
|
const { preview: probeWiden } = widen;
|
|
408
408
|
// The similar door has to know before it is pressed whether it can run, so
|
|
@@ -581,20 +581,22 @@ function SelectionWizardSession({
|
|
|
581
581
|
[anchorMessageId, selection.length, seedPropertyClauses],
|
|
582
582
|
);
|
|
583
583
|
|
|
584
|
-
// The dimmed similar door, pressed.
|
|
585
|
-
//
|
|
586
|
-
//
|
|
584
|
+
// The dimmed similar door, pressed. What the widen would have fallen back to
|
|
585
|
+
// is filled in on the property step instead of standing in for the semantic
|
|
586
|
+
// match without saying so (#477 3.6) — the same agreement the property step
|
|
587
|
+
// itself seeds from (#458), not senders alone: a selection with nothing in
|
|
588
|
+
// common on sender still shares a subject worth matching on.
|
|
587
589
|
const takeSemanticFallback = useCallback(() => {
|
|
588
590
|
setSemanticFallbackTaken(true);
|
|
589
591
|
setDoor("properties");
|
|
590
592
|
setDraft((held) => ({
|
|
591
593
|
...held,
|
|
592
594
|
widen: undefined,
|
|
593
|
-
clauses:
|
|
595
|
+
clauses: seedPropertyClauses(),
|
|
594
596
|
matchOperator: "any",
|
|
595
597
|
}));
|
|
596
598
|
goToStep("properties");
|
|
597
|
-
}, [
|
|
599
|
+
}, [seedPropertyClauses, goToStep]);
|
|
598
600
|
|
|
599
601
|
// The probe lands after the door is on screen, so it can report the widen
|
|
600
602
|
// unavailable while the user is already holding it. The fallback is taken then
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing a sender's category override (#615).
|
|
3
|
+
*
|
|
4
|
+
* A sender flag is removed by naming its key in `clearFlags`. `category` is the
|
|
5
|
+
* flag that has no other way out: its value is a `MessageCategory` enum with no
|
|
6
|
+
* false-equivalent member, so the `{ value: false }` form the boolean flags use
|
|
7
|
+
* cannot express "no override", and the `null` form the schema documents is
|
|
8
|
+
* rejected by the request validator. Before this the reclassify picker was
|
|
9
|
+
* one-way — a reader could set an override and never take it back.
|
|
10
|
+
*
|
|
11
|
+
* Drives the real chain: the real `IntelligencePane`, the real
|
|
12
|
+
* `useUpdateAddressFlags` mutation, and a fetch mock that answers the way the
|
|
13
|
+
* server does. Both halves are asserted — the body that goes on the wire, and
|
|
14
|
+
* that the override is gone from what the panel renders.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { afterEach, describe, it } from "node:test";
|
|
19
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
20
|
+
import { createElement } from "react";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
22
|
+
import {
|
|
23
|
+
makeAccount,
|
|
24
|
+
makeConfig,
|
|
25
|
+
makeThreadMessage,
|
|
26
|
+
} from "@/test-support/fixtures";
|
|
27
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
28
|
+
import { IntelligencePane } from "./IntelligencePane";
|
|
29
|
+
|
|
30
|
+
const thread = makeThreadMessage({ messageId: "msg-1", category: "personal" });
|
|
31
|
+
|
|
32
|
+
const addressWith = (
|
|
33
|
+
flags: RemitImapAddressResponse["flags"],
|
|
34
|
+
): RemitImapAddressResponse => ({
|
|
35
|
+
addressId: "addr-1",
|
|
36
|
+
accountConfigId: "cfg-1",
|
|
37
|
+
displayName: "Alice",
|
|
38
|
+
localPart: "alice",
|
|
39
|
+
domain: "example.com",
|
|
40
|
+
normalizedEmail: "alice@example.com",
|
|
41
|
+
flags,
|
|
42
|
+
inboundCount: 6,
|
|
43
|
+
outboundCount: 1,
|
|
44
|
+
replyCount: 2,
|
|
45
|
+
lastInboundAt: 1_000,
|
|
46
|
+
lastReplyAt: 900,
|
|
47
|
+
createdAt: 0,
|
|
48
|
+
updatedAt: 0,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let harness: DomHarness | undefined;
|
|
52
|
+
let http: HttpMock;
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
harness?.close();
|
|
56
|
+
harness = undefined;
|
|
57
|
+
http.restore();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("the reclassify picker on a sender that already carries an override", () => {
|
|
61
|
+
it("removes the override with clearFlags, and the panel stops claiming one", async () => {
|
|
62
|
+
let stored: RemitImapAddressResponse = addressWith({
|
|
63
|
+
category: { value: "newsletter", setAt: 10 },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
http = mockFetch((call) => {
|
|
67
|
+
if (call.path.endsWith("/config")) {
|
|
68
|
+
return makeConfig([makeAccount({ accountId: "acc-1" })], {
|
|
69
|
+
semanticSearchEnabled: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (call.path.endsWith("/addresses/search")) {
|
|
73
|
+
return { items: [stored] };
|
|
74
|
+
}
|
|
75
|
+
if (call.method === "PATCH" && call.path.endsWith("/addresses/addr-1")) {
|
|
76
|
+
stored = addressWith({});
|
|
77
|
+
return stored;
|
|
78
|
+
}
|
|
79
|
+
return { items: [] };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
harness = createDomHarness();
|
|
83
|
+
harness.renderApp(
|
|
84
|
+
createElement(IntelligencePane, { thread, onClose: () => {} }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await harness.waitFor(
|
|
88
|
+
() => harness?.text().includes("your override") === true,
|
|
89
|
+
"the panel never showed the sender's category override",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
harness.click(harness.byText("button", "reclassify"));
|
|
93
|
+
harness.click(
|
|
94
|
+
harness.byText(
|
|
95
|
+
"button",
|
|
96
|
+
"Remove override — classify automatically again",
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await harness.waitFor(
|
|
101
|
+
() => http.to("/addresses/addr-1").length === 1,
|
|
102
|
+
"the remove control sent no request",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(http.to("/addresses/addr-1")[0].body, {
|
|
106
|
+
clearFlags: ["category"],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await harness.waitFor(
|
|
110
|
+
() => harness?.text().includes("your override") === false,
|
|
111
|
+
"the override survived its own removal",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("offers no removal on a sender that carries no override", async () => {
|
|
116
|
+
http = mockFetch((call) => {
|
|
117
|
+
if (call.path.endsWith("/config")) {
|
|
118
|
+
return makeConfig([makeAccount({ accountId: "acc-1" })], {
|
|
119
|
+
semanticSearchEnabled: false,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (call.path.endsWith("/addresses/search")) {
|
|
123
|
+
return { items: [addressWith({})] };
|
|
124
|
+
}
|
|
125
|
+
return { items: [] };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
harness = createDomHarness();
|
|
129
|
+
harness.renderApp(
|
|
130
|
+
createElement(IntelligencePane, { thread, onClose: () => {} }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await harness.waitFor(
|
|
134
|
+
() => harness?.text().includes("reclassify") === true,
|
|
135
|
+
"the panel never rendered the category section",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
harness.click(harness.byText("button", "reclassify"));
|
|
139
|
+
|
|
140
|
+
assert.equal(harness.text().includes("Remove override"), false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* The widen step's two-phase preview. On a semantic-capable deployment it runs
|
|
3
3
|
* the anchor preview once and stops. On a deployment that reports
|
|
4
4
|
* `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
|
|
5
|
-
* re-previews with
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* re-previews with what the whole selection agrees on (#458) — one `From` or
|
|
6
|
+
* `FromDomain` clause when senders agree, the shared subject when they don't,
|
|
7
|
+
* combined with `Or`, no anchor — and reports the literal match count, so
|
|
8
|
+
* every commit scope acts on the same set the fallback derived.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import assert from "node:assert/strict";
|
|
@@ -29,12 +30,16 @@ type Responder = Parameters<typeof mockFetch>[0];
|
|
|
29
30
|
|
|
30
31
|
const PREVIEW_PATH = "/organize/preview";
|
|
31
32
|
|
|
32
|
-
const mount = (
|
|
33
|
+
const mount = (
|
|
34
|
+
senders: string[],
|
|
35
|
+
responder: Responder,
|
|
36
|
+
subjects: string[] = [],
|
|
37
|
+
) => {
|
|
33
38
|
http = mockFetch(responder);
|
|
34
39
|
harness = createDomHarness();
|
|
35
40
|
const holder: { current: Widen | undefined } = { current: undefined };
|
|
36
41
|
function Probe() {
|
|
37
|
-
const widen = useOrganizeWiden("acc-1", "msg-1", senders);
|
|
42
|
+
const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
|
|
38
43
|
holder.current = widen;
|
|
39
44
|
const { preview } = widen;
|
|
40
45
|
useEffect(() => {
|
|
@@ -130,6 +135,39 @@ describe("useOrganizeWiden — no vector pipeline, senders present", () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
138
|
+
describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
|
|
139
|
+
it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
|
|
140
|
+
const holder = mount(
|
|
141
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
142
|
+
(call) =>
|
|
143
|
+
call.body?.anchorMessageId
|
|
144
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
145
|
+
: {
|
|
146
|
+
matchedCount: 64,
|
|
147
|
+
messageIds: ["m"],
|
|
148
|
+
semanticUnavailable: false,
|
|
149
|
+
},
|
|
150
|
+
["Invoice 1841", "Invoice 1902"],
|
|
151
|
+
);
|
|
152
|
+
await settle(2);
|
|
153
|
+
|
|
154
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
155
|
+
assert.equal(calls.length, 2);
|
|
156
|
+
const second = calls[1];
|
|
157
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
158
|
+
assert.equal(second.body?.matchOperator, "Or");
|
|
159
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
160
|
+
{ field: "Subject", value: "Invoice" },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const widen = holder.current;
|
|
164
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
165
|
+
matchOperator: "Or",
|
|
166
|
+
literalClauses: [{ field: "Subject", value: "Invoice" }],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
133
171
|
describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
134
172
|
it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
|
|
135
173
|
const holder = mount([], () => ({
|
|
@@ -148,4 +186,32 @@ describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
|
148
186
|
assert.deepEqual(widen?.senders, []);
|
|
149
187
|
assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
|
|
150
188
|
});
|
|
189
|
+
|
|
190
|
+
it("still re-previews on a shared subject alone (#458)", async () => {
|
|
191
|
+
const holder = mount(
|
|
192
|
+
[],
|
|
193
|
+
(call) =>
|
|
194
|
+
call.body?.anchorMessageId
|
|
195
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
196
|
+
: {
|
|
197
|
+
matchedCount: 12,
|
|
198
|
+
messageIds: ["m"],
|
|
199
|
+
semanticUnavailable: false,
|
|
200
|
+
},
|
|
201
|
+
["Invoice 1841", "Invoice 1902"],
|
|
202
|
+
);
|
|
203
|
+
await settle(2);
|
|
204
|
+
|
|
205
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
206
|
+
assert.equal(calls.length, 2);
|
|
207
|
+
const second = calls[1];
|
|
208
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
209
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
210
|
+
{ field: "Subject", value: "Invoice" },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
const widen = holder.current;
|
|
214
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
215
|
+
assert.equal(widen?.senderFallback, true);
|
|
216
|
+
});
|
|
151
217
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { derivePropertyClauses, distinctSenders } from "@remit/ui";
|
|
3
3
|
import { useMutation } from "@tanstack/react-query";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
@@ -9,16 +9,17 @@ import {
|
|
|
9
9
|
} from "@/lib/organize/sender-fallback";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* "Select similar messages" (the widen step) with a
|
|
13
|
-
* deployments without the vector pipeline.
|
|
12
|
+
* "Select similar messages" (the widen step) with a selection-agreement
|
|
13
|
+
* fallback for deployments without the vector pipeline.
|
|
14
14
|
*
|
|
15
15
|
* The first preview runs the semantic anchor (POST /organize/preview). When the
|
|
16
16
|
* server reports `semanticUnavailable` — no vector pipeline on this deployment
|
|
17
17
|
* (semantic-capability.ts) — the widen re-previews with the literal vocabulary
|
|
18
|
-
* that matches vector-free:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* that matches vector-free: what the whole selection agrees on (#458) — one
|
|
19
|
+
* sender/domain clause, or the subject the selection shares when senders don't
|
|
20
|
+
* — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
|
|
21
|
+
* and any standing filter then all carry that same literal predicate, so the
|
|
22
|
+
* previewed set equals the set every commit scope acts on.
|
|
22
23
|
*
|
|
23
24
|
* A semantic-capable deployment never fires the second preview and keeps exactly
|
|
24
25
|
* the anchor behaviour.
|
|
@@ -27,12 +28,17 @@ export const useOrganizeWiden = (
|
|
|
27
28
|
accountId: string | undefined,
|
|
28
29
|
anchorMessageId: string | undefined,
|
|
29
30
|
senders: readonly string[],
|
|
31
|
+
subjects: readonly string[],
|
|
30
32
|
) => {
|
|
31
33
|
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
32
34
|
const { mutate, reset: mutationReset } = mutation;
|
|
33
35
|
const [fellBack, setFellBack] = useState(false);
|
|
34
36
|
|
|
35
37
|
const distinct = useMemo(() => distinctSenders(senders), [senders]);
|
|
38
|
+
const agreementClauses = useMemo(
|
|
39
|
+
() => derivePropertyClauses(distinct, subjects),
|
|
40
|
+
[distinct, subjects],
|
|
41
|
+
);
|
|
36
42
|
|
|
37
43
|
const preview = useCallback(() => {
|
|
38
44
|
if (!accountId || !anchorMessageId) return;
|
|
@@ -53,21 +59,24 @@ export const useOrganizeWiden = (
|
|
|
53
59
|
}, [mutationReset]);
|
|
54
60
|
|
|
55
61
|
const data = mutation.data;
|
|
56
|
-
// The first preview came back capability-absent and
|
|
57
|
-
// match on
|
|
58
|
-
//
|
|
59
|
-
//
|
|
62
|
+
// The first preview came back capability-absent and the selection agrees on
|
|
63
|
+
// something to match on — a sender, a domain, or just a shared subject, not
|
|
64
|
+
// senders alone (#458): re-preview with the literal clauses before showing
|
|
65
|
+
// any count, so the count the user sees is the agreement's match count,
|
|
66
|
+
// never a flash of the empty semantic result.
|
|
60
67
|
const willFallBack =
|
|
61
|
-
data?.semanticUnavailable === true &&
|
|
68
|
+
data?.semanticUnavailable === true &&
|
|
69
|
+
!fellBack &&
|
|
70
|
+
agreementClauses.length > 0;
|
|
62
71
|
|
|
63
72
|
useEffect(() => {
|
|
64
73
|
if (!willFallBack || !accountId) return;
|
|
65
74
|
setFellBack(true);
|
|
66
75
|
mutate({
|
|
67
76
|
path: { accountId },
|
|
68
|
-
body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
|
|
77
|
+
body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
|
|
69
78
|
});
|
|
70
|
-
}, [willFallBack, accountId, distinct, mutate]);
|
|
79
|
+
}, [willFallBack, accountId, distinct, subjects, mutate]);
|
|
71
80
|
|
|
72
81
|
// The literal re-preview has no anchor, so its response reports
|
|
73
82
|
// `semanticUnavailable: false`; `fellBack` is what remembers the capability
|
|
@@ -76,7 +85,7 @@ export const useOrganizeWiden = (
|
|
|
76
85
|
const isPending = mutation.isPending || willFallBack;
|
|
77
86
|
|
|
78
87
|
const matchPredicate: OrganizeMatchPredicate = fellBack
|
|
79
|
-
? { matchOperator: "Or", literalClauses:
|
|
88
|
+
? { matchOperator: "Or", literalClauses: agreementClauses }
|
|
80
89
|
: {
|
|
81
90
|
...(anchorMessageId ? { anchorMessageId } : {}),
|
|
82
91
|
matchOperator: "And",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import type {
|
|
6
6
|
AddressOperationsSearchAddressesResponse,
|
|
7
|
+
RemitImapAddressFlagKey,
|
|
7
8
|
RemitImapAddressFlags,
|
|
8
9
|
RemitImapUpdateAddressFlagsInput,
|
|
9
10
|
} from "@remit/api-http-client/types.gen.ts";
|
|
@@ -25,15 +26,17 @@ interface MutationContext {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Apply a flag
|
|
29
|
-
* `patch` is a flag update object
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Apply a flag update onto an existing AddressFlags object. Each key in
|
|
30
|
+
* `patch` is a flag update object; each key in `clear` is removed outright,
|
|
31
|
+
* after the patch, matching the server's ordering. Mirrors the server-side
|
|
32
|
+
* merge semantics so the optimistic cache matches what the API returns. Typed
|
|
33
|
+
* loosely (the per-key flag value types differ — boolean for most, a category
|
|
34
|
+
* enum for `category`) and re-narrowed at the return.
|
|
33
35
|
*/
|
|
34
36
|
function applyFlagPatch(
|
|
35
37
|
current: RemitImapAddressFlags | undefined,
|
|
36
38
|
patch: RemitImapUpdateAddressFlagsInput,
|
|
39
|
+
clear: readonly RemitImapAddressFlagKey[] = [],
|
|
37
40
|
): RemitImapAddressFlags {
|
|
38
41
|
const next: Record<string, unknown> = { ...(current ?? {}) };
|
|
39
42
|
for (const [key, update] of Object.entries(patch)) {
|
|
@@ -45,6 +48,9 @@ function applyFlagPatch(
|
|
|
45
48
|
next[key] = { ...update, setAt: update.setAt ?? Date.now() };
|
|
46
49
|
}
|
|
47
50
|
}
|
|
51
|
+
for (const key of clear) {
|
|
52
|
+
delete next[key];
|
|
53
|
+
}
|
|
48
54
|
return next as RemitImapAddressFlags;
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -69,6 +75,7 @@ export function useUpdateAddressFlags({
|
|
|
69
75
|
...addressDetailOperationsUpdateAddressMutation(),
|
|
70
76
|
onMutate: async (vars): Promise<MutationContext> => {
|
|
71
77
|
const patch = vars.body.flags ?? {};
|
|
78
|
+
const clear = vars.body.clearFlags ?? [];
|
|
72
79
|
await queryClient.cancelQueries({ queryKey: addressCacheKey });
|
|
73
80
|
|
|
74
81
|
const previous =
|
|
@@ -84,7 +91,7 @@ export function useUpdateAddressFlags({
|
|
|
84
91
|
...old,
|
|
85
92
|
items: old.items.map((addr) =>
|
|
86
93
|
addr.addressId === vars.path.addressId
|
|
87
|
-
? { ...addr, flags: applyFlagPatch(addr.flags, patch) }
|
|
94
|
+
? { ...addr, flags: applyFlagPatch(addr.flags, patch, clear) }
|
|
88
95
|
: addr,
|
|
89
96
|
),
|
|
90
97
|
};
|
|
@@ -108,8 +115,11 @@ export function useUpdateAddressFlags({
|
|
|
108
115
|
},
|
|
109
116
|
});
|
|
110
117
|
|
|
111
|
-
const
|
|
112
|
-
(
|
|
118
|
+
const submit = useCallback(
|
|
119
|
+
(body: {
|
|
120
|
+
flags?: RemitImapUpdateAddressFlagsInput;
|
|
121
|
+
clearFlags?: RemitImapAddressFlagKey[];
|
|
122
|
+
}) => {
|
|
113
123
|
if (!addressId) {
|
|
114
124
|
reportFatalError(
|
|
115
125
|
new Error(
|
|
@@ -118,10 +128,25 @@ export function useUpdateAddressFlags({
|
|
|
118
128
|
);
|
|
119
129
|
return;
|
|
120
130
|
}
|
|
121
|
-
mutate({ path: { addressId }, body
|
|
131
|
+
mutate({ path: { addressId }, body });
|
|
122
132
|
},
|
|
123
133
|
[addressId, mutate, senderEmail],
|
|
124
134
|
);
|
|
125
135
|
|
|
126
|
-
|
|
136
|
+
const updateFlags = useCallback(
|
|
137
|
+
(flags: RemitImapUpdateAddressFlagsInput) => submit({ flags }),
|
|
138
|
+
[submit],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remove flags outright. This is the removal form for every flag whatever
|
|
143
|
+
* its value type: `category` holds an enum with no false-equivalent member,
|
|
144
|
+
* so `{ value: false }` cannot express "no override" for it.
|
|
145
|
+
*/
|
|
146
|
+
const clearFlags = useCallback(
|
|
147
|
+
(keys: RemitImapAddressFlagKey[]) => submit({ clearFlags: keys }),
|
|
148
|
+
[submit],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return { updateFlags, clearFlags, isPending };
|
|
127
152
|
}
|
|
@@ -4,7 +4,10 @@ import { buildSenderFallbackDraft } from "./sender-fallback";
|
|
|
4
4
|
|
|
5
5
|
describe("buildSenderFallbackDraft", () => {
|
|
6
6
|
it("combines the sender clauses with Or and carries no anchor", () => {
|
|
7
|
-
const draft = buildSenderFallbackDraft(
|
|
7
|
+
const draft = buildSenderFallbackDraft(
|
|
8
|
+
["npm@github.com", "a@x.com"],
|
|
9
|
+
["Deploy failed", "Standup notes"],
|
|
10
|
+
);
|
|
8
11
|
assert.equal(draft.matchOperator, "Or");
|
|
9
12
|
assert.equal(draft.anchorMessageId, undefined);
|
|
10
13
|
assert.deepEqual(draft.literalClauses, [
|
|
@@ -14,10 +17,36 @@ describe("buildSenderFallbackDraft", () => {
|
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
it("carries a single FromDomain clause when the senders share a domain", () => {
|
|
17
|
-
const draft = buildSenderFallbackDraft(
|
|
20
|
+
const draft = buildSenderFallbackDraft(
|
|
21
|
+
["npm@github.com", "ci@github.com"],
|
|
22
|
+
["Deploy failed", "Standup notes"],
|
|
23
|
+
);
|
|
18
24
|
assert.equal(draft.matchOperator, "Or");
|
|
19
25
|
assert.deepEqual(draft.literalClauses, [
|
|
20
26
|
{ field: "FromDomain", value: "github.com" },
|
|
21
27
|
]);
|
|
22
28
|
});
|
|
29
|
+
|
|
30
|
+
it("falls back to a shared subject when mixed senders share no domain (#458)", () => {
|
|
31
|
+
const draft = buildSenderFallbackDraft(
|
|
32
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
33
|
+
["Invoice 1841", "Invoice 1902"],
|
|
34
|
+
);
|
|
35
|
+
assert.equal(draft.matchOperator, "Or");
|
|
36
|
+
assert.deepEqual(draft.literalClauses, [
|
|
37
|
+
{ field: "Subject", value: "Invoice" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("offers one sender chip each when mixed senders share no subject either", () => {
|
|
42
|
+
const draft = buildSenderFallbackDraft(
|
|
43
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
44
|
+
["Invoice 1841", "Standup notes"],
|
|
45
|
+
);
|
|
46
|
+
assert.equal(draft.matchOperator, "Or");
|
|
47
|
+
assert.deepEqual(draft.literalClauses, [
|
|
48
|
+
{ field: "From", value: "billing@acme.test" },
|
|
49
|
+
{ field: "From", value: "accounts@globex.test" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
23
52
|
});
|
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import type { RemitImapOrganizeInput } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { derivePropertyClauses } from "@remit/ui";
|
|
3
3
|
import type { OrganizeDraft } from "./organize-model";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The widen fallback for a deployment that ships no vector pipeline (self-host
|
|
7
7
|
* sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
|
|
8
8
|
* so a widen degrades to the literal vocabulary RFC 031 already matches
|
|
9
|
-
* vector-free: the
|
|
10
|
-
* anchor
|
|
11
|
-
*
|
|
9
|
+
* vector-free: what the whole selection agrees on, combined with `Or`, no
|
|
10
|
+
* anchor (#458) — the same evidence order the properties door prefills from,
|
|
11
|
+
* so a selection with nothing in common on sender still falls back to a
|
|
12
|
+
* subject it shares rather than one `From` chip per message. The same
|
|
13
|
+
* predicate matches at index time (RFC 034), so a standing filter built from
|
|
14
|
+
* it keeps working on future mail.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
* The literal predicate that stands in for the semantic anchor: the
|
|
16
|
-
*
|
|
17
|
-
* back-apply, and the standing filter all carry exactly this.
|
|
18
|
+
* The literal predicate that stands in for the semantic anchor: the clauses
|
|
19
|
+
* the selection agrees on, combined with `Or` and no anchor. The preview, the
|
|
20
|
+
* one-time back-apply, and the standing filter all carry exactly this.
|
|
18
21
|
*/
|
|
19
22
|
export const buildSenderFallbackDraft = (
|
|
20
23
|
senders: readonly string[],
|
|
24
|
+
subjects: readonly string[],
|
|
21
25
|
): OrganizeDraft => ({
|
|
22
26
|
matchOperator: "Or",
|
|
23
|
-
literalClauses:
|
|
27
|
+
literalClauses: derivePropertyClauses(senders, subjects),
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
/**
|