@remit/web-client 0.0.193 → 0.0.195
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 +2 -1
- package/src/hooks/useAutoMovedBadge.ts +2 -2
- package/src/hooks/useCreateMailbox.render.test.ts +2 -0
- package/src/hooks/useCreateMailbox.ts +2 -2
- package/src/lib/delete-folder.test.ts +94 -7
- package/src/lib/delete-folder.ts +47 -9
- package/src/lib/folder-roles.test.ts +59 -16
- package/src/lib/folder-roles.ts +6 -8
- package/src/lib/move-targets.test.ts +19 -0
- package/src/lib/move-targets.ts +7 -11
- package/src/lib/search-token-index.test.ts +29 -0
- package/src/lib/search-token-index.ts +5 -7
- package/src/routes/settings/folders.tsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.195",
|
|
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": {
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@hookform/resolvers": "*",
|
|
77
77
|
"@remit/api-http-client": "*",
|
|
78
|
+
"@remit/data-ports": "*",
|
|
78
79
|
"@remit/domain-enums": "*",
|
|
79
80
|
"@remit/ui": "*",
|
|
80
81
|
"@tanstack/react-query": "^5",
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
RemitImapAutoMovedInfo,
|
|
4
4
|
RemitImapMessageSpamReport,
|
|
5
5
|
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
6
7
|
import { useQuery } from "@tanstack/react-query";
|
|
7
8
|
import { useCallback } from "react";
|
|
8
9
|
import {
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
resolveUndoTargetMailboxId,
|
|
12
13
|
spamReportLabel,
|
|
13
14
|
} from "@/lib/auto-moved";
|
|
14
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
15
15
|
import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
|
|
16
16
|
import { useMoveMessages } from "./useMoveMessages";
|
|
17
17
|
import { useReportSpam } from "./useReportSpam";
|
|
@@ -132,7 +132,7 @@ export const useAutoMovedBadge = ({
|
|
|
132
132
|
const sourceFolderName = autoMoved.fromMailboxId
|
|
133
133
|
? mailboxes?.items
|
|
134
134
|
.filter((mailbox) => mailbox.mailboxId === autoMoved.fromMailboxId)
|
|
135
|
-
.map((mailbox) =>
|
|
135
|
+
.map((mailbox) => mailboxLeafName(mailbox))[0]
|
|
136
136
|
: undefined;
|
|
137
137
|
|
|
138
138
|
return {
|
|
@@ -67,6 +67,7 @@ const mount = (
|
|
|
67
67
|
mailboxId: `mbx-${body.fullPath}`,
|
|
68
68
|
accountId: ACCOUNT,
|
|
69
69
|
fullPath: body.fullPath,
|
|
70
|
+
hierarchyDelimiter: "/",
|
|
70
71
|
syncStatus: createdSyncStatus,
|
|
71
72
|
} as RemitImapMailboxResponse);
|
|
72
73
|
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
@@ -173,6 +174,7 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
173
174
|
mailboxId: `mbx-${body.fullPath}`,
|
|
174
175
|
accountId: ACCOUNT,
|
|
175
176
|
fullPath: body.fullPath,
|
|
177
|
+
hierarchyDelimiter: "/",
|
|
176
178
|
} as RemitImapMailboxResponse);
|
|
177
179
|
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
178
180
|
}
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
mailboxOperationsListMailboxesOptions,
|
|
4
4
|
mailboxOperationsListMailboxesQueryKey,
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
6
7
|
import type { FolderTreeNode } from "@remit/ui";
|
|
7
8
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
9
|
import { useCallback, useRef } from "react";
|
|
9
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
10
|
import { waitForMailboxSynced } from "@/lib/mailbox-sync-wait";
|
|
11
11
|
import {
|
|
12
12
|
composeFolderPath,
|
|
@@ -136,7 +136,7 @@ export function useCreateMailbox(accountId: string | undefined) {
|
|
|
136
136
|
pendingByPath.current.delete(fullPath);
|
|
137
137
|
return {
|
|
138
138
|
id: confirmed.mailboxId,
|
|
139
|
-
label:
|
|
139
|
+
label: mailboxLeafName(confirmed),
|
|
140
140
|
path: confirmed.fullPath,
|
|
141
141
|
};
|
|
142
142
|
},
|
|
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
advanceMove,
|
|
5
|
-
appointedRole,
|
|
6
5
|
beginMove,
|
|
7
6
|
excludeFolder,
|
|
8
7
|
type FolderNode,
|
|
@@ -11,6 +10,7 @@ import {
|
|
|
11
10
|
hasChildFolders,
|
|
12
11
|
initialStage,
|
|
13
12
|
moveProgressLabel,
|
|
13
|
+
vouchedRole,
|
|
14
14
|
} from "./delete-folder.js";
|
|
15
15
|
|
|
16
16
|
const folder = (
|
|
@@ -36,13 +36,74 @@ describe("guardFolderDeletion", () => {
|
|
|
36
36
|
const guard = guardFolderDeletion(
|
|
37
37
|
target,
|
|
38
38
|
[target],
|
|
39
|
-
[{ role: "Archive", mailboxId: "arch" }],
|
|
39
|
+
[{ role: "Archive", source: "Appointed", mailboxId: "arch" }],
|
|
40
40
|
);
|
|
41
41
|
assert.equal(guard.deletable, false);
|
|
42
42
|
assert.equal(guard.reason, "role");
|
|
43
43
|
assert.match(guard.message ?? "", /Archive/);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("blocks a folder the mail server flagged for the role", () => {
|
|
47
|
+
const target = folder({ mailboxId: "sent", fullPath: "Sent" });
|
|
48
|
+
const guard = guardFolderDeletion(
|
|
49
|
+
target,
|
|
50
|
+
[target],
|
|
51
|
+
[{ role: "Sent", source: "Flagged", mailboxId: "sent" }],
|
|
52
|
+
);
|
|
53
|
+
assert.equal(guard.deletable, false);
|
|
54
|
+
assert.equal(guard.reason, "role");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("deletes a folder whose only role claim is a name proposal", () => {
|
|
58
|
+
const target = folder({ mailboxId: "arch", fullPath: "Archive" });
|
|
59
|
+
const guard = guardFolderDeletion(
|
|
60
|
+
target,
|
|
61
|
+
[target],
|
|
62
|
+
[{ role: "Archive", source: "Proposed", mailboxId: "arch" }],
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(guard, { deletable: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("deletes the name-proposed fallback a stale appointment resolves to", () => {
|
|
68
|
+
const target = folder({ mailboxId: "deleted-items", fullPath: "Deleted" });
|
|
69
|
+
const guard = guardFolderDeletion(
|
|
70
|
+
target,
|
|
71
|
+
[target],
|
|
72
|
+
[{ role: "Trash", source: "Stale", mailboxId: "deleted-items" }],
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual(guard, { deletable: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("blocks the flagged folder a stale appointment falls back to", () => {
|
|
78
|
+
const target = folder({
|
|
79
|
+
mailboxId: "server-trash",
|
|
80
|
+
fullPath: "Trash",
|
|
81
|
+
specialUse: ["Trash"],
|
|
82
|
+
});
|
|
83
|
+
const guard = guardFolderDeletion(
|
|
84
|
+
target,
|
|
85
|
+
[target],
|
|
86
|
+
[{ role: "Trash", source: "Stale", mailboxId: "server-trash" }],
|
|
87
|
+
);
|
|
88
|
+
assert.equal(guard.deletable, false);
|
|
89
|
+
assert.equal(guard.reason, "role");
|
|
90
|
+
assert.match(guard.message ?? "", /Trash/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reads the flag for the role the entry names, not any flag the folder has", () => {
|
|
94
|
+
const target = folder({
|
|
95
|
+
mailboxId: "archive",
|
|
96
|
+
fullPath: "Archive",
|
|
97
|
+
specialUse: ["Archive"],
|
|
98
|
+
});
|
|
99
|
+
const guard = guardFolderDeletion(
|
|
100
|
+
target,
|
|
101
|
+
[target],
|
|
102
|
+
[{ role: "Trash", source: "Stale", mailboxId: "archive" }],
|
|
103
|
+
);
|
|
104
|
+
assert.deepEqual(guard, { deletable: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
46
107
|
it("blocks a folder that has subfolders", () => {
|
|
47
108
|
const target = folder({ mailboxId: "work", fullPath: "Work" });
|
|
48
109
|
const child = folder({ mailboxId: "sub", fullPath: "Work/Shop" });
|
|
@@ -62,14 +123,18 @@ describe("guardFolderDeletion", () => {
|
|
|
62
123
|
const guard = guardFolderDeletion(
|
|
63
124
|
target,
|
|
64
125
|
[target, other],
|
|
65
|
-
[{ role: "Archive", mailboxId: "other-box" }],
|
|
126
|
+
[{ role: "Archive", source: "Appointed", mailboxId: "other-box" }],
|
|
66
127
|
);
|
|
67
128
|
assert.deepEqual(guard, { deletable: true });
|
|
68
129
|
});
|
|
69
130
|
|
|
70
131
|
it("ignores an unfilled role appointment with no mailbox", () => {
|
|
71
132
|
const target = folder({ mailboxId: "r", fullPath: "Receipts" });
|
|
72
|
-
const guard = guardFolderDeletion(
|
|
133
|
+
const guard = guardFolderDeletion(
|
|
134
|
+
target,
|
|
135
|
+
[target],
|
|
136
|
+
[{ role: "Junk", source: "None" }],
|
|
137
|
+
);
|
|
73
138
|
assert.equal(guard.deletable, true);
|
|
74
139
|
});
|
|
75
140
|
});
|
|
@@ -106,20 +171,42 @@ describe("hasChildFolders", () => {
|
|
|
106
171
|
});
|
|
107
172
|
});
|
|
108
173
|
|
|
109
|
-
describe("
|
|
174
|
+
describe("vouchedRole", () => {
|
|
110
175
|
it("returns the role a mailbox fills", () => {
|
|
111
176
|
assert.equal(
|
|
112
|
-
|
|
177
|
+
vouchedRole({ mailboxId: "a" }, [
|
|
178
|
+
{ role: "Trash", source: "Appointed", mailboxId: "a" },
|
|
179
|
+
]),
|
|
113
180
|
"Trash",
|
|
114
181
|
);
|
|
115
182
|
});
|
|
116
183
|
|
|
117
184
|
it("returns undefined when the mailbox fills no role", () => {
|
|
118
185
|
assert.equal(
|
|
119
|
-
|
|
186
|
+
vouchedRole({ mailboxId: "a" }, [
|
|
187
|
+
{ role: "Trash", source: "Appointed", mailboxId: "b" },
|
|
188
|
+
]),
|
|
189
|
+
undefined,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns undefined for a role nobody vouched for", () => {
|
|
194
|
+
assert.equal(
|
|
195
|
+
vouchedRole({ mailboxId: "a" }, [
|
|
196
|
+
{ role: "Trash", source: "Proposed", mailboxId: "a" },
|
|
197
|
+
]),
|
|
120
198
|
undefined,
|
|
121
199
|
);
|
|
122
200
|
});
|
|
201
|
+
|
|
202
|
+
it("returns the role a stale entry falls back to when the server flags it", () => {
|
|
203
|
+
assert.equal(
|
|
204
|
+
vouchedRole({ mailboxId: "a", specialUse: ["Trash"] }, [
|
|
205
|
+
{ role: "Trash", source: "Stale", mailboxId: "a" },
|
|
206
|
+
]),
|
|
207
|
+
"Trash",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
123
210
|
});
|
|
124
211
|
|
|
125
212
|
describe("excludeFolder", () => {
|
package/src/lib/delete-folder.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { RemitImapFolderAppointment } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
1
3
|
export const MOVE_BATCH_SIZE = 100;
|
|
2
4
|
|
|
3
5
|
export interface FolderNode {
|
|
@@ -5,10 +7,16 @@ export interface FolderNode {
|
|
|
5
7
|
fullPath: string;
|
|
6
8
|
hierarchyDelimiter: string;
|
|
7
9
|
messageCount: number;
|
|
10
|
+
/** RFC 6154 SPECIAL-USE flags, bare (`Trash`, not `\Trash`). */
|
|
11
|
+
specialUse?: readonly string[];
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
/** Where a role's answer came from. */
|
|
15
|
+
export type RoleAppointmentSource = RemitImapFolderAppointment["source"];
|
|
16
|
+
|
|
10
17
|
export interface RoleAppointment {
|
|
11
18
|
role: string;
|
|
19
|
+
source: RoleAppointmentSource;
|
|
12
20
|
mailboxId?: string | null;
|
|
13
21
|
}
|
|
14
22
|
|
|
@@ -39,18 +47,48 @@ export const hasChildFolders = (
|
|
|
39
47
|
);
|
|
40
48
|
};
|
|
41
49
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Sources that vouch for the mailbox they name: a person chose it, or the
|
|
52
|
+
* server flagged it. `Proposed` is a guess at a folder's name and vouches for
|
|
53
|
+
* nothing. `Reserved` is unreachable behind the inbox branch below and stays
|
|
54
|
+
* anyway — this set is a statement about evidence, not about branch order.
|
|
55
|
+
*/
|
|
56
|
+
const VOUCHED_SOURCES: readonly RoleAppointmentSource[] = [
|
|
57
|
+
"Appointed",
|
|
58
|
+
"Flagged",
|
|
59
|
+
"Reserved",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A `Stale` entry resolves to whatever the account would have had with no
|
|
64
|
+
* appointment at all, and that fallback can be the folder the server itself
|
|
65
|
+
* flags for the role. The entry no longer carries which it was, so read the
|
|
66
|
+
* flag off the folder: the mail server's designation is unchanged by the
|
|
67
|
+
* user's own choice going missing, and nothing behind this guard re-checks it.
|
|
68
|
+
* Flag values are the bare RFC 6154 names, identical to the canonical roles.
|
|
69
|
+
*/
|
|
70
|
+
const vouchesFor = (
|
|
71
|
+
appointment: RoleAppointment,
|
|
72
|
+
folder: Pick<FolderNode, "mailboxId" | "specialUse">,
|
|
73
|
+
): boolean => {
|
|
74
|
+
if (appointment.mailboxId !== folder.mailboxId) return false;
|
|
75
|
+
if (appointment.source === "Stale")
|
|
76
|
+
return folder.specialUse?.includes(appointment.role) ?? false;
|
|
77
|
+
return VOUCHED_SOURCES.includes(appointment.source);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The canonical role a folder is vouched for, or `undefined` when none is. */
|
|
81
|
+
export const vouchedRole = (
|
|
82
|
+
folder: Pick<FolderNode, "mailboxId" | "specialUse">,
|
|
45
83
|
appointments: readonly RoleAppointment[],
|
|
46
|
-
): string | undefined =>
|
|
47
|
-
appointments.find((a) => a.mailboxId != null && a.mailboxId === mailboxId)
|
|
48
|
-
?.role;
|
|
84
|
+
): string | undefined => appointments.find((a) => vouchesFor(a, folder))?.role;
|
|
49
85
|
|
|
50
86
|
/**
|
|
51
87
|
* Why a folder can't be deleted, or `{ deletable: true }` when it can. The
|
|
52
|
-
* inbox is reserved, a folder
|
|
53
|
-
* first, and a folder with subfolders must have them handled before it
|
|
88
|
+
* inbox is reserved, a folder somebody vouched for in a canonical role must be
|
|
89
|
+
* released first, and a folder with subfolders must have them handled before it
|
|
90
|
+
* goes. A role a folder only fills because its name reads that way is no reason
|
|
91
|
+
* to keep the folder: the message would name a remedy that does not apply.
|
|
54
92
|
*/
|
|
55
93
|
export function guardFolderDeletion(
|
|
56
94
|
folder: FolderNode,
|
|
@@ -64,7 +102,7 @@ export function guardFolderDeletion(
|
|
|
64
102
|
message: "The inbox can't be deleted.",
|
|
65
103
|
};
|
|
66
104
|
|
|
67
|
-
const role =
|
|
105
|
+
const role = vouchedRole(folder, appointments);
|
|
68
106
|
if (role)
|
|
69
107
|
return {
|
|
70
108
|
deletable: false,
|
|
@@ -3,7 +3,6 @@ import { describe, test } from "node:test";
|
|
|
3
3
|
import type { RemitImapFolderAppointment } from "@remit/api-http-client/types.gen.ts";
|
|
4
4
|
import {
|
|
5
5
|
buildMailboxRoleMap,
|
|
6
|
-
getMailboxDisplayName,
|
|
7
6
|
labelForMailbox,
|
|
8
7
|
shouldShowUnreadBadgeForRole,
|
|
9
8
|
} from "./folder-roles.js";
|
|
@@ -43,16 +42,6 @@ describe("buildMailboxRoleMap", () => {
|
|
|
43
42
|
});
|
|
44
43
|
});
|
|
45
44
|
|
|
46
|
-
describe("getMailboxDisplayName", () => {
|
|
47
|
-
test("returns the leaf segment of a nested path", () => {
|
|
48
|
-
assert.equal(getMailboxDisplayName("INBOX/Sent Messages"), "Sent Messages");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("returns the whole path when there is no delimiter", () => {
|
|
52
|
-
assert.equal(getMailboxDisplayName("INBOX"), "INBOX");
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
45
|
describe("labelForMailbox", () => {
|
|
57
46
|
const t = (key: string, fallback: string) =>
|
|
58
47
|
key === "sidebar.sent" ? "Verzonden" : fallback;
|
|
@@ -60,7 +49,11 @@ describe("labelForMailbox", () => {
|
|
|
60
49
|
test("a trimmed displayNameOverride wins over everything", () => {
|
|
61
50
|
assert.equal(
|
|
62
51
|
labelForMailbox(
|
|
63
|
-
{
|
|
52
|
+
{
|
|
53
|
+
fullPath: "INBOX/Sent",
|
|
54
|
+
hierarchyDelimiter: "/",
|
|
55
|
+
displayNameOverride: " My Sent ",
|
|
56
|
+
},
|
|
64
57
|
"sent",
|
|
65
58
|
t,
|
|
66
59
|
),
|
|
@@ -70,26 +63,44 @@ describe("labelForMailbox", () => {
|
|
|
70
63
|
|
|
71
64
|
test("falls back to the translated canonical role label", () => {
|
|
72
65
|
assert.equal(
|
|
73
|
-
labelForMailbox(
|
|
66
|
+
labelForMailbox(
|
|
67
|
+
{ fullPath: "INBOX/Verzonden", hierarchyDelimiter: "/" },
|
|
68
|
+
"sent",
|
|
69
|
+
t,
|
|
70
|
+
),
|
|
74
71
|
"Verzonden",
|
|
75
72
|
);
|
|
76
73
|
});
|
|
77
74
|
|
|
78
75
|
test("falls back to the provider leaf when there is no role", () => {
|
|
79
76
|
assert.equal(
|
|
80
|
-
labelForMailbox(
|
|
77
|
+
labelForMailbox(
|
|
78
|
+
{ fullPath: "INBOX/Nieuwsbrieven", hierarchyDelimiter: "/" },
|
|
79
|
+
undefined,
|
|
80
|
+
t,
|
|
81
|
+
),
|
|
81
82
|
"Nieuwsbrieven",
|
|
82
83
|
);
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
test("falls back to the leaf when no translator is supplied", () => {
|
|
86
|
-
assert.equal(
|
|
87
|
+
assert.equal(
|
|
88
|
+
labelForMailbox(
|
|
89
|
+
{ fullPath: "INBOX/Sent", hierarchyDelimiter: "/" },
|
|
90
|
+
"sent",
|
|
91
|
+
),
|
|
92
|
+
"Sent",
|
|
93
|
+
);
|
|
87
94
|
});
|
|
88
95
|
|
|
89
96
|
test("a blank/whitespace override is ignored", () => {
|
|
90
97
|
assert.equal(
|
|
91
98
|
labelForMailbox(
|
|
92
|
-
{
|
|
99
|
+
{
|
|
100
|
+
fullPath: "INBOX/Sent",
|
|
101
|
+
hierarchyDelimiter: "/",
|
|
102
|
+
displayNameOverride: " ",
|
|
103
|
+
},
|
|
93
104
|
"sent",
|
|
94
105
|
t,
|
|
95
106
|
),
|
|
@@ -98,6 +109,38 @@ describe("labelForMailbox", () => {
|
|
|
98
109
|
});
|
|
99
110
|
});
|
|
100
111
|
|
|
112
|
+
describe("labelForMailbox — the server’s own delimiter (#877)", () => {
|
|
113
|
+
test("a dot-delimited nested folder renders its leaf", () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
labelForMailbox(
|
|
116
|
+
{ fullPath: "INBOX.Projects.Q3", hierarchyDelimiter: "." },
|
|
117
|
+
undefined,
|
|
118
|
+
),
|
|
119
|
+
"Q3",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a flat namespace renders the whole name", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
labelForMailbox(
|
|
126
|
+
{ fullPath: "Projects/Q3", hierarchyDelimiter: "" },
|
|
127
|
+
undefined,
|
|
128
|
+
),
|
|
129
|
+
"Projects/Q3",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a slash in a dot-delimited name is part of the name", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
labelForMailbox(
|
|
136
|
+
{ fullPath: "INBOX.Reading/Writing", hierarchyDelimiter: "." },
|
|
137
|
+
undefined,
|
|
138
|
+
),
|
|
139
|
+
"Reading/Writing",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
101
144
|
describe("shouldShowUnreadBadgeForRole", () => {
|
|
102
145
|
test("hides the badge for Sent, Drafts, and Trash", () => {
|
|
103
146
|
assert.equal(shouldShowUnreadBadgeForRole("sent"), false);
|
package/src/lib/folder-roles.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type {
|
|
|
2
2
|
RemitImapCanonicalMailboxRole,
|
|
3
3
|
RemitImapFolderAppointment,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
type MailboxPath,
|
|
7
|
+
mailboxLeafName,
|
|
8
|
+
} from "@remit/data-ports/mailbox-name";
|
|
5
9
|
import type { NavMailboxRole } from "@remit/ui";
|
|
6
10
|
|
|
7
11
|
/**
|
|
@@ -79,12 +83,6 @@ export function buildMailboxRoleMap(
|
|
|
79
83
|
return byMailbox;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
/** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
|
|
83
|
-
export const getMailboxDisplayName = (fullPath: string): string => {
|
|
84
|
-
const parts = fullPath.split("/");
|
|
85
|
-
return parts[parts.length - 1] || fullPath;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
86
|
type Translator = (key: string, fallback: string) => string;
|
|
89
87
|
|
|
90
88
|
/**
|
|
@@ -92,13 +90,13 @@ type Translator = (key: string, fallback: string) => string;
|
|
|
92
90
|
* canonical localized label for the appointed role, else the provider leaf.
|
|
93
91
|
*/
|
|
94
92
|
export function labelForMailbox(
|
|
95
|
-
mailbox:
|
|
93
|
+
mailbox: MailboxPath & { displayNameOverride?: string },
|
|
96
94
|
role: NavMailboxRole | undefined,
|
|
97
95
|
t?: Translator,
|
|
98
96
|
): string {
|
|
99
97
|
const override = mailbox.displayNameOverride?.trim();
|
|
100
98
|
if (override) return override;
|
|
101
|
-
const leaf =
|
|
99
|
+
const leaf = mailboxLeafName(mailbox);
|
|
102
100
|
if (!role || !t) return leaf;
|
|
103
101
|
return t(`sidebar.${role}`, leaf);
|
|
104
102
|
}
|
|
@@ -214,3 +214,22 @@ describe("buildMoveTargets — Outbox locale exclusion (#290)", () => {
|
|
|
214
214
|
assert.deepStrictEqual(ids, ["custom", "inbox", "outboxy", "trash"]);
|
|
215
215
|
});
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
describe("buildMoveTargets — the server’s own delimiter (#877)", () => {
|
|
219
|
+
test("orders and filters by the leaf under a dot-delimited namespace", () => {
|
|
220
|
+
const dotted = (mailboxId: string, fullPath: string) =>
|
|
221
|
+
make({ mailboxId, fullPath, hierarchyDelimiter: "." });
|
|
222
|
+
const result = buildMoveTargets(
|
|
223
|
+
[
|
|
224
|
+
dotted("beta", "INBOX.Projects.Beta"),
|
|
225
|
+
dotted("outbox", "INBOX.Outbox"),
|
|
226
|
+
dotted("alpha", "INBOX.Zoo.Alpha"),
|
|
227
|
+
],
|
|
228
|
+
[],
|
|
229
|
+
);
|
|
230
|
+
assert.deepStrictEqual(
|
|
231
|
+
result.map((mailbox) => mailbox.mailboxId),
|
|
232
|
+
["alpha", "beta"],
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
package/src/lib/move-targets.ts
CHANGED
|
@@ -2,11 +2,8 @@ import type {
|
|
|
2
2
|
RemitImapFolderAppointment,
|
|
3
3
|
RemitImapMailboxResponse,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
getMailboxDisplayName,
|
|
8
|
-
ROLE_PRIORITY,
|
|
9
|
-
} from "./folder-roles.js";
|
|
5
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
6
|
+
import { buildMailboxRoleMap, ROLE_PRIORITY } from "./folder-roles.js";
|
|
10
7
|
|
|
11
8
|
// Outbox has no IMAP special-use flag (RFC 6154 doesn't define one) and isn't
|
|
12
9
|
// a canonical role (RFC 032 exclusive-folder-appointment) either — it's
|
|
@@ -27,7 +24,7 @@ const OUTBOX_LOCALE_NAMES: ReadonlySet<string> = new Set([
|
|
|
27
24
|
]);
|
|
28
25
|
|
|
29
26
|
const isOutboxDestination = (mailbox: RemitImapMailboxResponse): boolean => {
|
|
30
|
-
const leaf =
|
|
27
|
+
const leaf = mailboxLeafName(mailbox).trim().toLowerCase();
|
|
31
28
|
return OUTBOX_LOCALE_NAMES.has(leaf);
|
|
32
29
|
};
|
|
33
30
|
|
|
@@ -65,10 +62,9 @@ export const buildMoveTargets = (
|
|
|
65
62
|
? ROLE_PRIORITY.indexOf(roleMap.get(b.mailboxId) ?? "inbox")
|
|
66
63
|
: NON_SYSTEM_PRIORITY;
|
|
67
64
|
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
65
|
+
return mailboxLeafName(a).localeCompare(mailboxLeafName(b), undefined, {
|
|
66
|
+
sensitivity: "base",
|
|
67
|
+
numeric: true,
|
|
68
|
+
});
|
|
73
69
|
});
|
|
74
70
|
};
|
|
@@ -209,3 +209,32 @@ describe("buildAccountSuggestionValues", () => {
|
|
|
209
209
|
);
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("the server’s own delimiter (#877)", () => {
|
|
214
|
+
it("offers the leaf of a dot-delimited folder as its shortest spelling", () => {
|
|
215
|
+
const values = buildMailboxSuggestionValues([
|
|
216
|
+
[
|
|
217
|
+
mailbox({
|
|
218
|
+
mailboxId: "m1",
|
|
219
|
+
fullPath: "INBOX.Projects.Q3",
|
|
220
|
+
hierarchyDelimiter: ".",
|
|
221
|
+
}),
|
|
222
|
+
],
|
|
223
|
+
]);
|
|
224
|
+
assert.deepEqual(values, [{ value: "Q3", label: "Q3" }]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("resolves in: by the leaf of a dot-delimited folder", () => {
|
|
228
|
+
const index = buildMailboxNameIndex([
|
|
229
|
+
[
|
|
230
|
+
mailbox({
|
|
231
|
+
mailboxId: "m1",
|
|
232
|
+
fullPath: "INBOX.Projects.Q3",
|
|
233
|
+
hierarchyDelimiter: ".",
|
|
234
|
+
}),
|
|
235
|
+
],
|
|
236
|
+
]);
|
|
237
|
+
assert.equal(index.get("q3"), "m1");
|
|
238
|
+
assert.equal(index.get("inbox.projects.q3"), "m1");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
RemitImapAccountResponse,
|
|
14
14
|
RemitImapMailboxResponse,
|
|
15
15
|
} from "@remit/api-http-client/types.gen.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
17
17
|
import type { SearchSuggestionValue } from "./search-suggestions.js";
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -52,11 +52,9 @@ export function buildMailboxNameIndex(
|
|
|
52
52
|
for (const mailbox of mailboxes) {
|
|
53
53
|
const fullPath = mailbox.fullPath?.toLowerCase();
|
|
54
54
|
if (!fullPath) continue;
|
|
55
|
-
const
|
|
55
|
+
const leaf = mailboxLeafName(mailbox).toLowerCase();
|
|
56
56
|
if (!index.has(fullPath)) index.set(fullPath, mailbox.mailboxId);
|
|
57
|
-
if (
|
|
58
|
-
index.set(lastSegment, mailbox.mailboxId);
|
|
59
|
-
}
|
|
57
|
+
if (!index.has(leaf)) index.set(leaf, mailbox.mailboxId);
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
return index;
|
|
@@ -78,14 +76,14 @@ export function buildMailboxSuggestionValues(
|
|
|
78
76
|
const all = mailboxesByAccount.flat().filter((mailbox) => mailbox.fullPath);
|
|
79
77
|
const leafCounts = new Map<string, number>();
|
|
80
78
|
for (const mailbox of all) {
|
|
81
|
-
const leaf =
|
|
79
|
+
const leaf = mailboxLeafName(mailbox).toLowerCase();
|
|
82
80
|
leafCounts.set(leaf, (leafCounts.get(leaf) ?? 0) + 1);
|
|
83
81
|
}
|
|
84
82
|
const emailByAccountId = new Map(
|
|
85
83
|
accounts.map((account) => [account.accountId, account.email]),
|
|
86
84
|
);
|
|
87
85
|
return all.map((mailbox) => {
|
|
88
|
-
const leaf =
|
|
86
|
+
const leaf = mailboxLeafName(mailbox);
|
|
89
87
|
const unique = leafCounts.get(leaf.toLowerCase()) === 1;
|
|
90
88
|
const hint =
|
|
91
89
|
accounts.length > 1 ? emailByAccountId.get(mailbox.accountId) : undefined;
|
|
@@ -166,6 +166,7 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
166
166
|
const candidates: CandidateFolder[] = mailboxes.map((mailbox) => ({
|
|
167
167
|
mailboxId: mailbox.mailboxId,
|
|
168
168
|
providerPath: mailbox.fullPath,
|
|
169
|
+
hierarchyDelimiter: mailbox.hierarchyDelimiter,
|
|
169
170
|
messageCount: mailbox.messageCount,
|
|
170
171
|
}));
|
|
171
172
|
|
|
@@ -241,7 +242,10 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
241
242
|
translator,
|
|
242
243
|
)}
|
|
243
244
|
defaultLabel={labelForMailbox(
|
|
244
|
-
{
|
|
245
|
+
{
|
|
246
|
+
fullPath: renaming.fullPath,
|
|
247
|
+
hierarchyDelimiter: renaming.hierarchyDelimiter,
|
|
248
|
+
},
|
|
245
249
|
roleMap.get(renaming.mailboxId),
|
|
246
250
|
translator,
|
|
247
251
|
)}
|