@remit/web-client 0.0.119 → 0.0.121
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/settings/FilterEditor.render.test.ts +2 -2
- package/src/components/settings/FilterEditor.tsx +8 -4
- package/src/components/settings/FilterEditorSurface.tsx +5 -2
- package/src/components/settings/settings-filter.stories.tsx +15 -6
- package/src/hooks/useCreateMailbox.render.test.ts +4 -4
- package/src/hooks/useIntelligenceData.test.ts +102 -0
- package/src/hooks/useIntelligenceData.ts +75 -4
- package/src/routes/settings/-filters.test.ts +115 -20
- package/src/routes/settings/filters.tsx +20 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
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": {
|
|
@@ -35,8 +35,8 @@ afterEach(() => {
|
|
|
35
35
|
const ACCOUNT_ID = "acc-1";
|
|
36
36
|
|
|
37
37
|
const FOLDERS = [
|
|
38
|
-
{ id: "mbx-receipts", label: "Receipts" },
|
|
39
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
38
|
+
{ id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
|
|
39
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
40
40
|
];
|
|
41
41
|
|
|
42
42
|
const filterFixture = (
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ClauseField,
|
|
6
6
|
type FilterRule,
|
|
7
7
|
FilterRuleEditor,
|
|
8
|
-
type
|
|
8
|
+
type FolderTreeNode,
|
|
9
9
|
type LabelOption,
|
|
10
10
|
type MatchOperator,
|
|
11
11
|
previewCountSummary,
|
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
interface FilterEditorProps {
|
|
35
35
|
accountId: string;
|
|
36
36
|
filter: RemitImapFilterResponse;
|
|
37
|
-
folders:
|
|
37
|
+
folders: readonly FolderTreeNode[];
|
|
38
|
+
/** The provider hierarchy separator the destination tree nests on. */
|
|
39
|
+
delimiter?: string;
|
|
38
40
|
labels: LabelOption[];
|
|
39
41
|
/**
|
|
40
42
|
* This deployment ships no vector pipeline, so a semantic anchor cannot be
|
|
@@ -61,6 +63,7 @@ export function FilterEditor({
|
|
|
61
63
|
accountId,
|
|
62
64
|
filter,
|
|
63
65
|
folders,
|
|
66
|
+
delimiter,
|
|
64
67
|
labels,
|
|
65
68
|
semanticUnavailable = false,
|
|
66
69
|
onClose,
|
|
@@ -77,7 +80,7 @@ export function FilterEditor({
|
|
|
77
80
|
const { count: preview } = useRulePreview(accountId, rulePredicate(rule));
|
|
78
81
|
const update = useUpdateFilter(accountId, filter.filterId);
|
|
79
82
|
const organizeJob = useOrganizeJob(accountId);
|
|
80
|
-
const {
|
|
83
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
81
84
|
const { createLabel } = useCreateLabel(accountId);
|
|
82
85
|
const onCreateLabel = async (name: string): Promise<LabelOption> => {
|
|
83
86
|
const label = await createLabel(name);
|
|
@@ -219,6 +222,7 @@ export function FilterEditor({
|
|
|
219
222
|
<FilterRuleEditor
|
|
220
223
|
rule={rule}
|
|
221
224
|
folders={folders}
|
|
225
|
+
delimiter={delimiter}
|
|
222
226
|
labels={labels}
|
|
223
227
|
preview={preview}
|
|
224
228
|
// The update endpoint carries no anchor field at all (reader #266), so a
|
|
@@ -240,7 +244,7 @@ export function FilterEditor({
|
|
|
240
244
|
onCancelClause={() => setClauseEdit(undefined)}
|
|
241
245
|
onChangeMatchOperator={changeMatchOperator}
|
|
242
246
|
onChangeMove={changeMove}
|
|
243
|
-
onCreateFolder={
|
|
247
|
+
onCreateFolder={createFolderIn}
|
|
244
248
|
onChangeLabel={changeLabel}
|
|
245
249
|
onCreateLabel={onCreateLabel}
|
|
246
250
|
onChangeName={changeName}
|
|
@@ -2,7 +2,7 @@ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.t
|
|
|
2
2
|
import {
|
|
3
3
|
BottomSheet,
|
|
4
4
|
Dialog,
|
|
5
|
-
type
|
|
5
|
+
type FolderTreeNode,
|
|
6
6
|
type LabelOption,
|
|
7
7
|
} from "@remit/ui";
|
|
8
8
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
@@ -11,7 +11,8 @@ import { FilterEditor } from "./FilterEditor";
|
|
|
11
11
|
interface FilterEditorSurfaceProps {
|
|
12
12
|
accountId: string;
|
|
13
13
|
filter: RemitImapFilterResponse;
|
|
14
|
-
folders:
|
|
14
|
+
folders: readonly FolderTreeNode[];
|
|
15
|
+
delimiter?: string;
|
|
15
16
|
labels: LabelOption[];
|
|
16
17
|
semanticUnavailable?: boolean;
|
|
17
18
|
onClose: () => void;
|
|
@@ -27,6 +28,7 @@ export function FilterEditorSurface({
|
|
|
27
28
|
accountId,
|
|
28
29
|
filter,
|
|
29
30
|
folders,
|
|
31
|
+
delimiter,
|
|
30
32
|
labels,
|
|
31
33
|
semanticUnavailable,
|
|
32
34
|
onClose,
|
|
@@ -38,6 +40,7 @@ export function FilterEditorSurface({
|
|
|
38
40
|
accountId={accountId}
|
|
39
41
|
filter={filter}
|
|
40
42
|
folders={folders}
|
|
43
|
+
delimiter={delimiter}
|
|
41
44
|
labels={labels}
|
|
42
45
|
semanticUnavailable={semanticUnavailable}
|
|
43
46
|
onClose={onClose}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import { BottomSheet, type
|
|
2
|
+
import { BottomSheet, type FolderTreeNode, type LabelOption } from "@remit/ui";
|
|
3
3
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
4
4
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
5
|
import type { ReactNode } from "react";
|
|
@@ -17,11 +17,20 @@ import { FilterEditor } from "./FilterEditor";
|
|
|
17
17
|
|
|
18
18
|
const ACCOUNT_ID = "acc-1";
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{ id: "mbx-
|
|
20
|
+
// Two folders named Receipts at different depths, and a Trash the account has
|
|
21
|
+
// renamed: the destination reads as the user names it and nests where the
|
|
22
|
+
// provider puts it.
|
|
23
|
+
const FOLDERS: FolderTreeNode[] = [
|
|
24
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
25
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
26
|
+
{ id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
|
|
27
|
+
{ id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
|
|
28
|
+
{
|
|
29
|
+
id: "mbx-travel-receipts",
|
|
30
|
+
label: "Receipts",
|
|
31
|
+
path: "INBOX/Travel/Receipts",
|
|
32
|
+
},
|
|
33
|
+
{ id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
|
|
25
34
|
];
|
|
26
35
|
|
|
27
36
|
const LABELS: LabelOption[] = [
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
RemitImapMailboxResponse,
|
|
17
17
|
} from "@remit/api-http-client/types.gen.ts";
|
|
18
18
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
19
|
-
import type {
|
|
19
|
+
import type { FolderTreeNode } from "@remit/ui";
|
|
20
20
|
import { act, createElement } from "react";
|
|
21
21
|
import { MAILBOX_SYNC_FAILED_MESSAGE } from "../lib/mailbox-sync-wait";
|
|
22
22
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
@@ -28,7 +28,7 @@ const ACCOUNT = "acc-1";
|
|
|
28
28
|
let harness: DomHarness | undefined;
|
|
29
29
|
let http: HttpMock | undefined;
|
|
30
30
|
let createFolder:
|
|
31
|
-
| ((name: string, signal?: AbortSignal) => Promise<
|
|
31
|
+
| ((name: string, signal?: AbortSignal) => Promise<FolderTreeNode>)
|
|
32
32
|
| undefined;
|
|
33
33
|
|
|
34
34
|
afterEach(() => {
|
|
@@ -132,7 +132,7 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
132
132
|
|
|
133
133
|
it("passes a valid name through and resolves once the folder is confirmed synced", async () => {
|
|
134
134
|
mount([mailbox("INBOX", "/")]);
|
|
135
|
-
let result:
|
|
135
|
+
let result: FolderTreeNode | undefined;
|
|
136
136
|
await act(async () => {
|
|
137
137
|
result = await createFolder?.("Taxes");
|
|
138
138
|
});
|
|
@@ -202,7 +202,7 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
202
202
|
|
|
203
203
|
// The server confirms; the user presses "Create folder" again, same name.
|
|
204
204
|
status = MailboxSyncStatus.synced;
|
|
205
|
-
let result:
|
|
205
|
+
let result: FolderTreeNode | undefined;
|
|
206
206
|
await act(async () => {
|
|
207
207
|
result = await createFolder?.("Taxes");
|
|
208
208
|
});
|
|
@@ -243,6 +243,108 @@ describe("buildAuthenticityIntel", () => {
|
|
|
243
243
|
}
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
describe("a passing signature over a claim that does not hold", () => {
|
|
247
|
+
// The InfoMedics invoice phish: an attacker's own free Atlassian tenant,
|
|
248
|
+
// so SPF/DKIM/DMARC genuinely pass for a domain nobody recognises, and the
|
|
249
|
+
// provider's own filter already called it spam.
|
|
250
|
+
const infoMedics = makeThread({
|
|
251
|
+
fromEmail: "jira@serviceupdatebank.atlassian.net",
|
|
252
|
+
fromName: "InfoMedics",
|
|
253
|
+
subject: "Vordering",
|
|
254
|
+
authenticity: {
|
|
255
|
+
fromDomain: "serviceupdatebank.atlassian.net",
|
|
256
|
+
dkimDomain: "custmx.one.com",
|
|
257
|
+
dkimMismatch: false,
|
|
258
|
+
displayNameCorrespondence: "Unrelated",
|
|
259
|
+
offDomainLinkDomains: ["betaal-vordering.example"],
|
|
260
|
+
},
|
|
261
|
+
} as Partial<RemitImapThreadMessageResponse>);
|
|
262
|
+
|
|
263
|
+
test("is never presented as verified", () => {
|
|
264
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
265
|
+
assert.notEqual(result.verdict, "aligned");
|
|
266
|
+
assert.doesNotMatch(result.summary, /We verified/i);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("names the display name and the link destination", () => {
|
|
270
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
271
|
+
assert.equal(result.verdict, "caution");
|
|
272
|
+
assert.match(result.summary, /really was sent by/);
|
|
273
|
+
assert.match(result.summary, /"InfoMedics"/);
|
|
274
|
+
assert.match(result.summary, /betaal-vordering\.example/);
|
|
275
|
+
assert.doesNotMatch(result.summary, /DKIM|SPF|DMARC/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a lookalike display name reads as an imitation", () => {
|
|
279
|
+
const result = buildAuthenticityIntel(
|
|
280
|
+
makeThread({
|
|
281
|
+
fromEmail: "billing@1nfomedics.nl",
|
|
282
|
+
fromName: "InfoMedics",
|
|
283
|
+
authenticity: {
|
|
284
|
+
fromDomain: "1nfomedics.nl",
|
|
285
|
+
dkimDomain: "1nfomedics.nl",
|
|
286
|
+
dkimMismatch: false,
|
|
287
|
+
displayNameCorrespondence: "Lookalike",
|
|
288
|
+
offDomainLinkDomains: [],
|
|
289
|
+
},
|
|
290
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
291
|
+
0,
|
|
292
|
+
);
|
|
293
|
+
assert.equal(result.verdict, "caution");
|
|
294
|
+
assert.match(result.summary, /only looks like/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("stays verified when the comparisons agreed", () => {
|
|
298
|
+
const result = buildAuthenticityIntel(
|
|
299
|
+
makeThread({
|
|
300
|
+
fromEmail: "notifications@notifications.github.com",
|
|
301
|
+
fromName: "GitHub",
|
|
302
|
+
authenticity: {
|
|
303
|
+
fromDomain: "notifications.github.com",
|
|
304
|
+
dkimDomain: "github.com",
|
|
305
|
+
dkimMismatch: false,
|
|
306
|
+
displayNameCorrespondence: "Corresponds",
|
|
307
|
+
offDomainLinkDomains: [],
|
|
308
|
+
},
|
|
309
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
310
|
+
0,
|
|
311
|
+
);
|
|
312
|
+
assert.equal(result.verdict, "aligned");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("stays verified when nothing was compared", () => {
|
|
316
|
+
const result = buildAuthenticityIntel(
|
|
317
|
+
makeThread({
|
|
318
|
+
fromEmail: "alice@example.com",
|
|
319
|
+
authenticity: {
|
|
320
|
+
fromDomain: "example.com",
|
|
321
|
+
dkimDomain: "example.com",
|
|
322
|
+
dkimMismatch: false,
|
|
323
|
+
},
|
|
324
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
325
|
+
0,
|
|
326
|
+
);
|
|
327
|
+
assert.equal(result.verdict, "aligned");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a drifted link list carries no destination to name", () => {
|
|
331
|
+
const result = buildAuthenticityIntel(
|
|
332
|
+
makeThread({
|
|
333
|
+
fromEmail: "alice@example.com",
|
|
334
|
+
authenticity: {
|
|
335
|
+
fromDomain: "example.com",
|
|
336
|
+
dkimDomain: "example.com",
|
|
337
|
+
dkimMismatch: false,
|
|
338
|
+
displayNameCorrespondence: "Corresponds",
|
|
339
|
+
offDomainLinkDomains: "elsewhere.example",
|
|
340
|
+
},
|
|
341
|
+
} as unknown as Partial<RemitImapThreadMessageResponse>),
|
|
342
|
+
0,
|
|
343
|
+
);
|
|
344
|
+
assert.equal(result.verdict, "aligned");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
246
348
|
describe("unparseable sender drives the red tier", () => {
|
|
247
349
|
test("mismatch + addressUnreadable when the domain has no dot", () => {
|
|
248
350
|
const thread = makeThread({
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapAddressResponse,
|
|
7
7
|
RemitImapThreadMessageResponse,
|
|
8
8
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
9
10
|
import type {
|
|
10
11
|
AuthenticityIntel,
|
|
11
12
|
IntelligenceData,
|
|
@@ -97,6 +98,65 @@ function buildSenderFlags(
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** The brand the display name asserts, or `undefined` when it asserts none. */
|
|
102
|
+
function claimedBrandOf(
|
|
103
|
+
thread: RemitImapThreadMessageResponse,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
if (!thread.fromName) return undefined;
|
|
106
|
+
if (thread.fromName === thread.fromEmail) return undefined;
|
|
107
|
+
return thread.fromName;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function joinDomains(domains: readonly string[]): string {
|
|
111
|
+
if (domains.length === 1) return domains[0];
|
|
112
|
+
return `${domains.slice(0, -1).join(", ")} and ${domains[domains.length - 1]}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The clauses naming what does not line up on a message whose signature checks
|
|
117
|
+
* out. Empty when everything the backend compared agreed — including when it
|
|
118
|
+
* compared nothing, which is every message the provider's filter did not
|
|
119
|
+
* already call spam.
|
|
120
|
+
*/
|
|
121
|
+
function describeSenderMismatch(
|
|
122
|
+
auth: NonNullable<RemitImapThreadMessageResponse["authenticity"]>,
|
|
123
|
+
claimedBrand: string | undefined,
|
|
124
|
+
): string[] {
|
|
125
|
+
const clauses: string[] = [];
|
|
126
|
+
const correspondence = auth.displayNameCorrespondence;
|
|
127
|
+
|
|
128
|
+
if (claimedBrand) {
|
|
129
|
+
if (correspondence === DisplayNameCorrespondence.Unrelated) {
|
|
130
|
+
clauses.push(
|
|
131
|
+
`The name it shows, "${claimedBrand}", has nothing to do with that domain.`,
|
|
132
|
+
);
|
|
133
|
+
} else if (correspondence === DisplayNameCorrespondence.Lookalike) {
|
|
134
|
+
clauses.push(
|
|
135
|
+
`The name it shows, "${claimedBrand}", only looks like that domain.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const linkDomains = readDomainList(auth.offDomainLinkDomains);
|
|
141
|
+
if (linkDomains.length > 0) {
|
|
142
|
+
clauses.push(`Its links go to ${joinDomains(linkDomains.slice(0, 3))}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return clauses;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The field is a JSON blob on the message row, so a value written by an older
|
|
150
|
+
* or drifted writer reaches here as whatever it happens to be. Anything that is
|
|
151
|
+
* not a list of non-empty strings carries no destination to name.
|
|
152
|
+
*/
|
|
153
|
+
function readDomainList(value: unknown): string[] {
|
|
154
|
+
if (!Array.isArray(value)) return [];
|
|
155
|
+
return value.filter(
|
|
156
|
+
(entry): entry is string => typeof entry === "string" && entry.length > 0,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
100
160
|
/**
|
|
101
161
|
* Build authenticity intel from the thread message's authenticity field.
|
|
102
162
|
*
|
|
@@ -130,6 +190,20 @@ export function buildAuthenticityIntel(
|
|
|
130
190
|
};
|
|
131
191
|
}
|
|
132
192
|
if (!auth.dkimMismatch) {
|
|
193
|
+
const claimed = claimedBrandOf(thread);
|
|
194
|
+
const unlike = describeSenderMismatch(auth, claimed);
|
|
195
|
+
if (unlike.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
verdict: "caution",
|
|
198
|
+
fromDomain: auth.fromDomain,
|
|
199
|
+
dkimDomain: auth.dkimDomain,
|
|
200
|
+
claimedBrand: claimed,
|
|
201
|
+
summary: [
|
|
202
|
+
`This message really was sent by ${auth.fromDomain}.`,
|
|
203
|
+
...unlike,
|
|
204
|
+
].join(" "),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
133
207
|
return {
|
|
134
208
|
verdict: "aligned",
|
|
135
209
|
fromDomain: auth.fromDomain,
|
|
@@ -142,10 +216,7 @@ export function buildAuthenticityIntel(
|
|
|
142
216
|
|
|
143
217
|
const fromDomain = auth.fromDomain;
|
|
144
218
|
const dkimDomain = auth.dkimDomain;
|
|
145
|
-
const claimedBrand =
|
|
146
|
-
thread.fromName && thread.fromName !== thread.fromEmail
|
|
147
|
-
? thread.fromName
|
|
148
|
-
: undefined;
|
|
219
|
+
const claimedBrand = claimedBrandOf(thread);
|
|
149
220
|
const summary = claimedBrand
|
|
150
221
|
? `The display name claims "${claimedBrand}", but this message was actually sent from ${dkimDomain ?? "another sender"} — not ${fromDomain}. Real senders use their own address.`
|
|
151
222
|
: `This message claims to be from ${fromDomain}, but it was actually sent from ${dkimDomain ?? "a different sender"}.`;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { afterEach, describe, it } from "node:test";
|
|
4
4
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import { createElement } from "react";
|
|
6
7
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
7
8
|
import { makeAccount, makeMailbox } from "../../test-support/fixtures";
|
|
@@ -31,15 +32,32 @@ const account = makeAccount({
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
// Dutch leaf names for the appointed Drafts and Sent: only the appointment can
|
|
34
|
-
// exclude them, so a name-based filter would not pass this.
|
|
35
|
+
// exclude them, so a name-based filter would not pass this. `Receipts` renamed
|
|
36
|
+
// to `Bonnetjes`, and a second `Receipts` inside Travel, are what the
|
|
37
|
+
// destination has to read and nest correctly.
|
|
35
38
|
const mailboxes = [
|
|
36
|
-
makeMailbox({
|
|
39
|
+
makeMailbox({
|
|
40
|
+
mailboxId: "mbx-receipts",
|
|
41
|
+
fullPath: "INBOX/Receipts",
|
|
42
|
+
displayNameOverride: "Bonnetjes",
|
|
43
|
+
}),
|
|
37
44
|
makeMailbox({ mailboxId: "mbx-concepten", fullPath: "INBOX/Concepten" }),
|
|
38
45
|
makeMailbox({ mailboxId: "mbx-verzonden", fullPath: "INBOX/Verzonden" }),
|
|
39
46
|
makeMailbox({ mailboxId: "mbx-archief", fullPath: "INBOX/Archief" }),
|
|
40
47
|
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
48
|
+
makeMailbox({ mailboxId: "mbx-travel", fullPath: "INBOX/Travel" }),
|
|
49
|
+
makeMailbox({
|
|
50
|
+
mailboxId: "mbx-travel-receipts",
|
|
51
|
+
fullPath: "INBOX/Travel/Receipts",
|
|
52
|
+
}),
|
|
41
53
|
];
|
|
42
54
|
|
|
55
|
+
const createdFolder = makeMailbox({
|
|
56
|
+
mailboxId: "mbx-created",
|
|
57
|
+
fullPath: "INBOX/Travel/Hotels",
|
|
58
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
59
|
+
});
|
|
60
|
+
|
|
43
61
|
const filter: RemitImapFilterResponse = {
|
|
44
62
|
filterId: "f-1",
|
|
45
63
|
accountConfigId: ACCOUNT_ID,
|
|
@@ -74,9 +92,46 @@ const settleUntil = async (
|
|
|
74
92
|
}
|
|
75
93
|
};
|
|
76
94
|
|
|
77
|
-
const
|
|
95
|
+
const rowLabels = (dom: DomHarness): string[] =>
|
|
96
|
+
dom
|
|
97
|
+
.queryAll("[role=treeitem]")
|
|
98
|
+
.map((row) => row.getAttribute("aria-label") ?? "");
|
|
99
|
+
|
|
100
|
+
const buttonWithText = (
|
|
101
|
+
dom: DomHarness,
|
|
102
|
+
text: string,
|
|
103
|
+
): HTMLElement | undefined =>
|
|
104
|
+
dom
|
|
105
|
+
.queryAll<HTMLButtonElement>("button")
|
|
106
|
+
.find((button) => button.textContent?.trim() === text);
|
|
107
|
+
|
|
108
|
+
/** The create form's name field, found by the label the kit gives it. */
|
|
109
|
+
const folderNameField = (dom: DomHarness): HTMLInputElement | undefined => {
|
|
110
|
+
const label = dom
|
|
111
|
+
.queryAll<HTMLLabelElement>("label")
|
|
112
|
+
.find((node) => node.textContent?.trim() === "Folder name");
|
|
113
|
+
const id = label?.getAttribute("for");
|
|
114
|
+
return id
|
|
115
|
+
? ((dom.query(`input[id="${id}"]`) as HTMLInputElement | null) ?? undefined)
|
|
116
|
+
: undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Opens the filter for editing and asks its destination field for the tree. The
|
|
121
|
+
* filter already moves matches into `INBOX/Receipts`, so the tree opens on the
|
|
122
|
+
* branch holding it.
|
|
123
|
+
*/
|
|
124
|
+
const openDestinationTree = async (
|
|
125
|
+
posted?: Record<string, unknown>[],
|
|
126
|
+
): Promise<DomHarness> => {
|
|
127
|
+
const live = [...mailboxes];
|
|
78
128
|
http = mockFetch((call) => {
|
|
79
|
-
if (call.path.endsWith("/mailboxes")
|
|
129
|
+
if (call.path.endsWith("/mailboxes") && call.method === "POST") {
|
|
130
|
+
posted?.push(call.body ?? {});
|
|
131
|
+
live.push(createdFolder);
|
|
132
|
+
return createdFolder;
|
|
133
|
+
}
|
|
134
|
+
if (call.path.endsWith("/mailboxes")) return { items: live };
|
|
80
135
|
if (call.path.endsWith("/filters")) return { items: [filter] };
|
|
81
136
|
if (call.path.endsWith("/labels")) return { items: [] };
|
|
82
137
|
if (call.path.endsWith("/organize/preview"))
|
|
@@ -91,24 +146,64 @@ const offeredDestinations = async (): Promise<string[]> => {
|
|
|
91
146
|
() => !!dom.query('[aria-label="Edit filter Receipts"]'),
|
|
92
147
|
);
|
|
93
148
|
dom.click(dom.byLabel("Edit filter Receipts"));
|
|
94
|
-
await settleUntil(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
149
|
+
await settleUntil(dom, () => !!buttonWithText(dom, "Choose a folder"));
|
|
150
|
+
const choose = buttonWithText(dom, "Choose a folder");
|
|
151
|
+
assert.ok(choose, "the destination field offers the folder tree");
|
|
152
|
+
dom.click(choose);
|
|
153
|
+
await settleUntil(dom, () => rowLabels(dom).length > 0);
|
|
154
|
+
return dom;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const openFolder = async (dom: DomHarness, label: string): Promise<void> => {
|
|
158
|
+
dom.click(dom.byLabel(`Move to ${label}`));
|
|
159
|
+
await dom.flush();
|
|
104
160
|
};
|
|
105
161
|
|
|
106
|
-
describe("Settings › Filters — move
|
|
107
|
-
it("offers every folder but the appointed Drafts and Sent
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
"
|
|
162
|
+
describe("Settings › Filters — move destination (#236, #540, #549)", () => {
|
|
163
|
+
it("offers every folder but the appointed Drafts and Sent", async () => {
|
|
164
|
+
const dom = await openDestinationTree();
|
|
165
|
+
assert.deepEqual(rowLabels(dom), [
|
|
166
|
+
"Move to INBOX",
|
|
167
|
+
"Move to Archief",
|
|
168
|
+
"Move to Bonnetjes",
|
|
169
|
+
"Move to Travel",
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("reads a renamed folder as the name the account gave it", async () => {
|
|
174
|
+
const dom = await openDestinationTree();
|
|
175
|
+
assert.ok(rowLabels(dom).includes("Move to Bonnetjes"));
|
|
176
|
+
assert.ok(
|
|
177
|
+
!rowLabels(dom).some((label) => label.includes("Receipts")),
|
|
178
|
+
"the provider leaf is never what the row reads as",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("nests a folder under the one holding it", async () => {
|
|
183
|
+
const dom = await openDestinationTree();
|
|
184
|
+
await openFolder(dom, "Travel");
|
|
185
|
+
const nested = dom
|
|
186
|
+
.queryAll("[role=treeitem]")
|
|
187
|
+
.filter((row) => row.getAttribute("aria-label") === "Move to Receipts");
|
|
188
|
+
assert.equal(nested.length, 1);
|
|
189
|
+
assert.equal(nested[0].getAttribute("aria-level"), "3");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("creates a folder inside the one the tree is looking at", async () => {
|
|
193
|
+
const posted: Record<string, unknown>[] = [];
|
|
194
|
+
const dom = await openDestinationTree(posted);
|
|
195
|
+
await openFolder(dom, "Travel");
|
|
196
|
+
dom.click(dom.byLabel("New folder inside Travel"));
|
|
197
|
+
await dom.flush();
|
|
198
|
+
const name = folderNameField(dom);
|
|
199
|
+
assert.ok(name, "the folder name field is on screen");
|
|
200
|
+
dom.type(name, "Hotels");
|
|
201
|
+
const create = buttonWithText(dom, "Create folder");
|
|
202
|
+
assert.ok(create, "the create button is on screen");
|
|
203
|
+
dom.click(create);
|
|
204
|
+
await settleUntil(dom, () => posted.length > 0);
|
|
205
|
+
assert.deepEqual(posted, [
|
|
206
|
+
{ fullPath: "INBOX/Travel/Hotels", namespaceType: "personal" },
|
|
112
207
|
]);
|
|
113
208
|
});
|
|
114
209
|
});
|
|
@@ -11,9 +11,10 @@ import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
|
|
|
11
11
|
import { FiltersList } from "@/components/settings/FiltersList";
|
|
12
12
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
13
13
|
import { useDeleteFilter, useFilterList } from "@/hooks/useFilters";
|
|
14
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
14
15
|
import { useLabelList } from "@/hooks/useLabels";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
17
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
17
18
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
18
19
|
|
|
19
20
|
export const Route = createFileRoute("/settings/filters")({
|
|
@@ -50,27 +51,33 @@ export function AccountFilters({
|
|
|
50
51
|
staleTime: Infinity,
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
const translator = useFolderLabelTranslator();
|
|
55
|
+
const roleMap = useMemo(
|
|
56
|
+
() => buildMailboxRoleMap(account.folderAppointments),
|
|
57
|
+
[account.folderAppointments],
|
|
58
|
+
);
|
|
59
|
+
|
|
53
60
|
const mailboxName = useCallback(
|
|
54
61
|
(mailboxId: string): string | undefined => {
|
|
55
62
|
const mailbox = mailboxesData?.items.find(
|
|
56
63
|
(item) => item.mailboxId === mailboxId,
|
|
57
64
|
);
|
|
58
|
-
|
|
65
|
+
if (!mailbox) return undefined;
|
|
66
|
+
return labelForMailbox(mailbox, roleMap.get(mailboxId), translator);
|
|
59
67
|
},
|
|
60
|
-
[mailboxesData?.items],
|
|
68
|
+
[mailboxesData?.items, roleMap, translator],
|
|
61
69
|
);
|
|
62
70
|
|
|
63
71
|
const folders = useMemo(
|
|
64
72
|
() =>
|
|
65
|
-
|
|
66
|
-
mailboxesData?.items ?? [],
|
|
67
|
-
account.folderAppointments,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})),
|
|
72
|
-
[mailboxesData?.items, account.folderAppointments],
|
|
73
|
+
buildMoveOptions({
|
|
74
|
+
mailboxes: mailboxesData?.items ?? [],
|
|
75
|
+
folderAppointments: account.folderAppointments,
|
|
76
|
+
translator,
|
|
77
|
+
}),
|
|
78
|
+
[mailboxesData?.items, account.folderAppointments, translator],
|
|
73
79
|
);
|
|
80
|
+
const delimiter = folderDelimiter(mailboxesData?.items ?? []);
|
|
74
81
|
|
|
75
82
|
const { labels: labelItems } = useLabelList(accountId);
|
|
76
83
|
const labels: LabelOption[] = useMemo(
|
|
@@ -99,6 +106,7 @@ export function AccountFilters({
|
|
|
99
106
|
accountId={accountId}
|
|
100
107
|
filter={editingFilter}
|
|
101
108
|
folders={folders}
|
|
109
|
+
delimiter={delimiter}
|
|
102
110
|
labels={labels}
|
|
103
111
|
onClose={() => setEditingFilterId(undefined)}
|
|
104
112
|
/>
|