@remit/ui 0.0.154 → 0.0.155
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/folder-role.test.ts +59 -7
- package/src/components/folder-role.tsx +22 -19
- package/src/components/mobile-search-view.stories.tsx +1 -1
- package/src/components/quarantine-entry-row.tsx +3 -2
- package/src/components/quarantine-fixtures.ts +6 -1
- package/src/components/quarantine-report.ts +6 -0
- package/src/components/quarantine-section.render.test.ts +13 -0
- package/src/components/search-results.render.test.ts +8 -2
- package/src/components/search-results.stories.tsx +5 -2
- package/src/index.ts +0 -1
package/package.json
CHANGED
|
@@ -10,18 +10,55 @@ describe("provenanceFolderLabel", () => {
|
|
|
10
10
|
|
|
11
11
|
it("reads a junk appointment as Spam whatever the server calls it", () => {
|
|
12
12
|
assert.equal(
|
|
13
|
-
provenanceFolderLabel({
|
|
13
|
+
provenanceFolderLabel({
|
|
14
|
+
role: "junk",
|
|
15
|
+
providerPath: "Bulk Mail",
|
|
16
|
+
hierarchyDelimiter: "/",
|
|
17
|
+
}),
|
|
14
18
|
"Spam",
|
|
15
19
|
);
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
it("falls back to the leaf of a folder nobody appointed", () => {
|
|
19
23
|
assert.equal(
|
|
20
|
-
provenanceFolderLabel({
|
|
24
|
+
provenanceFolderLabel({
|
|
25
|
+
providerPath: "Projects/Bookkeeping",
|
|
26
|
+
hierarchyDelimiter: "/",
|
|
27
|
+
}),
|
|
21
28
|
"Bookkeeping",
|
|
22
29
|
);
|
|
23
30
|
});
|
|
24
31
|
|
|
32
|
+
it("cuts the path on the delimiter the server reports, not on a slash", () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
provenanceFolderLabel({
|
|
35
|
+
providerPath: "INBOX.Projects.Q3",
|
|
36
|
+
hierarchyDelimiter: ".",
|
|
37
|
+
}),
|
|
38
|
+
"Q3",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("leaves a slash alone in a folder name on a dotted server", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
provenanceFolderLabel({
|
|
45
|
+
providerPath: "INBOX.Reading/Writing",
|
|
46
|
+
hierarchyDelimiter: ".",
|
|
47
|
+
}),
|
|
48
|
+
"Reading/Writing",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats a flat namespace as one folder name", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
provenanceFolderLabel({
|
|
55
|
+
providerPath: "Projects/Q3",
|
|
56
|
+
hierarchyDelimiter: "",
|
|
57
|
+
}),
|
|
58
|
+
"Projects/Q3",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
25
62
|
it("refuses to label a view rather than a place", () => {
|
|
26
63
|
assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
|
|
27
64
|
assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
|
|
@@ -29,28 +66,43 @@ describe("provenanceFolderLabel", () => {
|
|
|
29
66
|
|
|
30
67
|
it("refuses to label Gmail's own reserved namespace", () => {
|
|
31
68
|
assert.equal(
|
|
32
|
-
provenanceFolderLabel({
|
|
69
|
+
provenanceFolderLabel({
|
|
70
|
+
providerPath: "[Gmail]/All Mail",
|
|
71
|
+
hierarchyDelimiter: "/",
|
|
72
|
+
}),
|
|
33
73
|
undefined,
|
|
34
74
|
);
|
|
35
75
|
assert.equal(
|
|
36
|
-
provenanceFolderLabel({
|
|
76
|
+
provenanceFolderLabel({
|
|
77
|
+
providerPath: "[Gmail]/Starred",
|
|
78
|
+
hierarchyDelimiter: "/",
|
|
79
|
+
}),
|
|
37
80
|
undefined,
|
|
38
81
|
);
|
|
39
82
|
});
|
|
40
83
|
|
|
41
84
|
it("refuses the googlemail.com spelling of the same namespace", () => {
|
|
42
85
|
assert.equal(
|
|
43
|
-
provenanceFolderLabel({
|
|
86
|
+
provenanceFolderLabel({
|
|
87
|
+
providerPath: "[Google Mail]/All Mail",
|
|
88
|
+
hierarchyDelimiter: "/",
|
|
89
|
+
}),
|
|
44
90
|
undefined,
|
|
45
91
|
);
|
|
46
92
|
assert.equal(
|
|
47
|
-
provenanceFolderLabel({
|
|
93
|
+
provenanceFolderLabel({
|
|
94
|
+
providerPath: "[Google Mail]/Starred",
|
|
95
|
+
hierarchyDelimiter: "/",
|
|
96
|
+
}),
|
|
48
97
|
undefined,
|
|
49
98
|
);
|
|
50
99
|
});
|
|
51
100
|
|
|
52
101
|
it("labels a user folder that merely mentions Gmail", () => {
|
|
53
|
-
assert.equal(
|
|
102
|
+
assert.equal(
|
|
103
|
+
provenanceFolderLabel({ providerPath: "Gmail", hierarchyDelimiter: "/" }),
|
|
104
|
+
"Gmail",
|
|
105
|
+
);
|
|
54
106
|
});
|
|
55
107
|
|
|
56
108
|
it("has nothing to say about a folder it knows nothing about", () => {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Trash2,
|
|
10
10
|
} from "lucide-react";
|
|
11
11
|
import type { ReactNode } from "react";
|
|
12
|
+
import { folderLeaf, folderPathSegments } from "../lib/folder-tree.js";
|
|
12
13
|
|
|
13
14
|
/* ------------------------------------------------------------------ */
|
|
14
15
|
/* Folder role vocabulary (RFC 032 exclusive-folder-appointment, */
|
|
@@ -48,30 +49,28 @@ export function canonicalRoleLabel(role: FolderRole): string {
|
|
|
48
49
|
return ROLE_LABEL[role];
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
-
* Leaf segment of a provider path (`INBOX/Spam` → `Spam`). Only for the two
|
|
53
|
-
* surfaces whose wire payload carries no `hierarchyDelimiter` — quarantine
|
|
54
|
-
* entries and search-result provenance. Everything else: `folderLeaf`.
|
|
55
|
-
*/
|
|
56
|
-
export function providerLeaf(providerPath: string): string {
|
|
57
|
-
const parts = providerPath.split("/");
|
|
58
|
-
return parts[parts.length - 1] || providerPath;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
52
|
/* ------------------------------------------------------------------ */
|
|
62
53
|
/* Provenance: where a search result actually lives */
|
|
63
54
|
/* ------------------------------------------------------------------ */
|
|
64
55
|
|
|
65
56
|
/**
|
|
66
57
|
* The folder a search result was read from. `role` is the account's IMAP
|
|
67
|
-
* special-use appointment (`junk` is `\Junk`);
|
|
68
|
-
*
|
|
58
|
+
* special-use appointment (`junk` is `\Junk`); a folder nobody appointed a role
|
|
59
|
+
* to carries only its path.
|
|
60
|
+
*
|
|
61
|
+
* The path and the separator its own server cuts it on travel together or not
|
|
62
|
+
* at all: `INBOX.Projects.Q3` is three segments on a `.`-delimited server and a
|
|
63
|
+
* single folder name on a `/`-delimited one, so a path alone cannot be cut.
|
|
69
64
|
*/
|
|
70
|
-
export
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
export type ResultFolder =
|
|
66
|
+
| {
|
|
67
|
+
role?: FolderRole;
|
|
68
|
+
/** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
|
|
69
|
+
providerPath: string;
|
|
70
|
+
/** The account's own hierarchy separator; `""` means a flat namespace. */
|
|
71
|
+
hierarchyDelimiter: string;
|
|
72
|
+
}
|
|
73
|
+
| { role?: FolderRole; providerPath?: never; hierarchyDelimiter?: never };
|
|
75
74
|
|
|
76
75
|
/**
|
|
77
76
|
* Roles that name a view rather than a place a message is filed. A message in
|
|
@@ -113,8 +112,12 @@ export function provenanceFolderLabel(
|
|
|
113
112
|
return canonicalRoleLabel(folder.role);
|
|
114
113
|
}
|
|
115
114
|
if (!folder.providerPath) return undefined;
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
const segments = folderPathSegments(
|
|
116
|
+
folder.providerPath,
|
|
117
|
+
folder.hierarchyDelimiter,
|
|
118
|
+
);
|
|
119
|
+
if (GMAIL_NAMESPACES.has(segments[0])) return undefined;
|
|
120
|
+
return folderLeaf(folder.providerPath, folder.hierarchyDelimiter);
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export function roleIcon(role: FolderRole): ReactNode {
|
|
@@ -123,7 +123,7 @@ const crossFolderMatches: SearchResult[] = [
|
|
|
123
123
|
subject: "Invoices for the quarter",
|
|
124
124
|
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
125
125
|
date: "Jan 30",
|
|
126
|
-
folder: { providerPath: "Projects/Bookkeeping" },
|
|
126
|
+
folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
|
|
127
127
|
},
|
|
128
128
|
];
|
|
129
129
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Bug } from "lucide-react";
|
|
2
|
+
import { folderLeaf } from "../lib/folder-tree.js";
|
|
2
3
|
import { Badge } from "./badge.js";
|
|
3
4
|
import { Button } from "./button.js";
|
|
4
|
-
import { canonicalRoleLabel
|
|
5
|
+
import { canonicalRoleLabel } from "./folder-role.js";
|
|
5
6
|
import {
|
|
6
7
|
type QuarantineEntry,
|
|
7
8
|
quarantineSummary,
|
|
@@ -47,7 +48,7 @@ export function QuarantineEntryRow({
|
|
|
47
48
|
</Badge>
|
|
48
49
|
)}
|
|
49
50
|
<span className="truncate" title={entry.mailboxPath}>
|
|
50
|
-
{
|
|
51
|
+
{folderLeaf(entry.mailboxPath, entry.mailboxDelimiter)}
|
|
51
52
|
</span>
|
|
52
53
|
<span aria-hidden>·</span>
|
|
53
54
|
<span>{`uid ${entry.uid}`}</span>
|
|
@@ -14,6 +14,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
14
14
|
uid: 40217,
|
|
15
15
|
mailboxRole: "inbox",
|
|
16
16
|
mailboxPath: "INBOX",
|
|
17
|
+
mailboxDelimiter: "/",
|
|
17
18
|
failureStage: "BodyParse",
|
|
18
19
|
failureCode: "UnreadableBody",
|
|
19
20
|
failureMessage: "multipart boundary was never closed",
|
|
@@ -42,6 +43,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
42
43
|
uid: 40219,
|
|
43
44
|
mailboxRole: "archive",
|
|
44
45
|
mailboxPath: "Archive/2026",
|
|
46
|
+
mailboxDelimiter: "/",
|
|
45
47
|
failureStage: "BodyParse",
|
|
46
48
|
failureCode: "UnknownCharset",
|
|
47
49
|
failureMessage: "declared charset is not a known encoding",
|
|
@@ -65,7 +67,9 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
65
67
|
// A folder the user never appointed a role to — the ordinary state of a
|
|
66
68
|
// plain folder, and the reason mailboxRole is optional.
|
|
67
69
|
mailboxRole: undefined,
|
|
68
|
-
mailboxPath: "Clients
|
|
70
|
+
mailboxPath: "INBOX.Clients.Acme Holdings",
|
|
71
|
+
// A `.`-delimited server: the row still reads the leaf, not the path.
|
|
72
|
+
mailboxDelimiter: ".",
|
|
69
73
|
failureStage: "BodyParse",
|
|
70
74
|
failureCode: "UnreadableBody",
|
|
71
75
|
failureMessage: "stream ended before the declared body length",
|
|
@@ -91,6 +95,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
91
95
|
uid: 40263,
|
|
92
96
|
mailboxRole: "inbox",
|
|
93
97
|
mailboxPath: "INBOX",
|
|
98
|
+
mailboxDelimiter: "/",
|
|
94
99
|
failureStage: "BodyParse",
|
|
95
100
|
failureCode: "UnreadableBody",
|
|
96
101
|
failureMessage: "connection closed mid-body",
|
|
@@ -78,6 +78,12 @@ export interface QuarantineEntry {
|
|
|
78
78
|
mailboxRole?: FolderRole;
|
|
79
79
|
/** The user's own folder name. Shown on screen, withheld from the report. */
|
|
80
80
|
mailboxPath: string;
|
|
81
|
+
/**
|
|
82
|
+
* The separator that folder's own server cuts its paths on, so the row can
|
|
83
|
+
* show the leaf. Resolved from the account's mailbox list — the quarantine
|
|
84
|
+
* record itself carries no delimiter. `""` means a flat namespace.
|
|
85
|
+
*/
|
|
86
|
+
mailboxDelimiter: string;
|
|
81
87
|
failureStage: QuarantineFailureStage;
|
|
82
88
|
failureCode: QuarantineFailureCode;
|
|
83
89
|
/** Parser error text. Shown on screen, never in the report. */
|
|
@@ -44,6 +44,19 @@ describe("QuarantineSection", () => {
|
|
|
44
44
|
assert.match(html, /Acme Holdings/);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it("shows the folder name of a dotted path, not the whole path", () => {
|
|
48
|
+
const html = render([
|
|
49
|
+
{
|
|
50
|
+
...base,
|
|
51
|
+
mailboxPath: "INBOX.Projects.Q3",
|
|
52
|
+
mailboxDelimiter: ".",
|
|
53
|
+
mailboxRole: undefined,
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
assert.match(html, />Q3</);
|
|
57
|
+
assert.doesNotMatch(html, />INBOX\.Projects\.Q3</);
|
|
58
|
+
});
|
|
59
|
+
|
|
47
60
|
it("offers reporting as the only per-row action", () => {
|
|
48
61
|
const html = render([base]);
|
|
49
62
|
assert.match(html, /Cut a bug/);
|
|
@@ -235,7 +235,10 @@ describe("SearchResults provenance labels", () => {
|
|
|
235
235
|
{
|
|
236
236
|
...result,
|
|
237
237
|
id: "c1",
|
|
238
|
-
folder: {
|
|
238
|
+
folder: {
|
|
239
|
+
providerPath: "Projects/Books",
|
|
240
|
+
hierarchyDelimiter: "/",
|
|
241
|
+
},
|
|
239
242
|
},
|
|
240
243
|
],
|
|
241
244
|
},
|
|
@@ -274,7 +277,10 @@ describe("SearchResults provenance labels", () => {
|
|
|
274
277
|
{
|
|
275
278
|
...result,
|
|
276
279
|
id: "v3",
|
|
277
|
-
folder: {
|
|
280
|
+
folder: {
|
|
281
|
+
providerPath: "[Gmail]/Important",
|
|
282
|
+
hierarchyDelimiter: "/",
|
|
283
|
+
},
|
|
278
284
|
},
|
|
279
285
|
],
|
|
280
286
|
},
|
|
@@ -112,7 +112,7 @@ const crossFolderMatches: SearchResult[] = [
|
|
|
112
112
|
subject: "Invoices for the quarter",
|
|
113
113
|
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
114
114
|
date: "Jan 30",
|
|
115
|
-
folder: { providerPath: "Projects/Bookkeeping" },
|
|
115
|
+
folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
|
|
116
116
|
},
|
|
117
117
|
];
|
|
118
118
|
|
|
@@ -355,7 +355,10 @@ export const VirtualFoldersGoUnlabelled: Story = {
|
|
|
355
355
|
{
|
|
356
356
|
...topMatches[2],
|
|
357
357
|
id: "v3",
|
|
358
|
-
folder: {
|
|
358
|
+
folder: {
|
|
359
|
+
providerPath: "[Gmail]/Important",
|
|
360
|
+
hierarchyDelimiter: "/",
|
|
361
|
+
},
|
|
359
362
|
},
|
|
360
363
|
...crossFolderMatches.slice(0, 1),
|
|
361
364
|
],
|