@remit/web-client 0.0.190 → 0.0.191
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/DeleteConfirmDialog.render.test.ts +208 -26
- package/src/components/mail/DeleteConfirmDialog.tsx +53 -17
- package/src/components/mail/MessageList.tsx +71 -62
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +292 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +246 -0
- package/src/components/mail/ThreadListInteraction.test.ts +30 -21
- package/src/components/mail/ThreadListInteraction.tsx +18 -8
- package/src/components/ui/folder-role-refusal.test.ts +94 -0
- package/src/components/ui/folder-role-refusal.ts +81 -0
- package/src/hooks/useArchiveMailbox.ts +10 -5
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/lib/format.test.ts +133 -11
- package/src/lib/format.ts +95 -17
- package/src/lib/move-options.ts +5 -0
- package/src/routes/__root.tsx +14 -11
- package/src/routes/settings/folders.tsx +12 -3
- package/src/test-support/dom.ts +6 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
isFolderRoleRefusal,
|
|
5
|
+
isMailboxNotSettledRefusal,
|
|
6
|
+
} from "./folder-role-refusal.js";
|
|
7
|
+
|
|
8
|
+
const refusal = {
|
|
9
|
+
code: "folder_role_unresolved",
|
|
10
|
+
message: "No folder is appointed as Trash",
|
|
11
|
+
details: { role: "Trash", reason: "none", accountId: "acct-1" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("isFolderRoleRefusal", () => {
|
|
15
|
+
it("carries the account and the reason the prompt needs", () => {
|
|
16
|
+
assert.deepEqual(isFolderRoleRefusal(refusal), {
|
|
17
|
+
reason: "none",
|
|
18
|
+
role: "Trash",
|
|
19
|
+
accountId: "acct-1",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reads every reason the API declares", () => {
|
|
24
|
+
for (const reason of ["none", "stale", "unconfirmed"]) {
|
|
25
|
+
assert.equal(
|
|
26
|
+
isFolderRoleRefusal({
|
|
27
|
+
...refusal,
|
|
28
|
+
details: { ...refusal.details, reason },
|
|
29
|
+
})?.reason,
|
|
30
|
+
reason,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not open the prompt for a 409 without the code", () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
isFolderRoleRefusal({
|
|
38
|
+
message: "No folder is appointed as Trash",
|
|
39
|
+
details: refusal.details,
|
|
40
|
+
}),
|
|
41
|
+
undefined,
|
|
42
|
+
);
|
|
43
|
+
assert.equal(
|
|
44
|
+
isFolderRoleRefusal({ ...refusal, code: "mailbox_not_settled" }),
|
|
45
|
+
undefined,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never guesses at a message string", () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
isFolderRoleRefusal(new Error("folder_role_unresolved: Trash")),
|
|
52
|
+
undefined,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuses a body missing anything the prompt has to have", () => {
|
|
57
|
+
assert.equal(isFolderRoleRefusal({ ...refusal, details: {} }), undefined);
|
|
58
|
+
assert.equal(
|
|
59
|
+
isFolderRoleRefusal({
|
|
60
|
+
...refusal,
|
|
61
|
+
details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
|
|
62
|
+
}),
|
|
63
|
+
undefined,
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
isFolderRoleRefusal({
|
|
67
|
+
...refusal,
|
|
68
|
+
details: { role: "Trash", reason: "none" },
|
|
69
|
+
}),
|
|
70
|
+
undefined,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("survives anything a network layer might throw", () => {
|
|
75
|
+
for (const value of [undefined, null, "boom", 409, []]) {
|
|
76
|
+
assert.equal(isFolderRoleRefusal(value), undefined);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("isMailboxNotSettledRefusal", () => {
|
|
82
|
+
it("matches only the appointment write's own refusal", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
isMailboxNotSettledRefusal({
|
|
85
|
+
code: "mailbox_not_settled",
|
|
86
|
+
message: "Mailbox is still being created",
|
|
87
|
+
details: { mailboxId: "mbx-1", syncStatus: "pending" },
|
|
88
|
+
}),
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
assert.equal(isMailboxNotSettledRefusal(refusal), false);
|
|
92
|
+
assert.equal(isMailboxNotSettledRefusal(new Error("pending")), false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coded 409 a destructive action is refused with when the folder role it
|
|
3
|
+
* needs is unsettled (#887). Read the `code`, never the message: the copy is
|
|
4
|
+
* free to change and a message-string match would silently start opening the
|
|
5
|
+
* appointment prompt over an unrelated conflict. A 409 without one of these
|
|
6
|
+
* codes is somebody else's error and keeps today's banner.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
ApiError,
|
|
10
|
+
RemitImapCanonicalMailboxRole,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
13
|
+
|
|
14
|
+
/** Why the role is unresolved, as the API's `details.reason` spells it. */
|
|
15
|
+
export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
|
|
16
|
+
|
|
17
|
+
/** `FolderRoleConflict`'s `details`, narrowed to the values the prompt needs. */
|
|
18
|
+
export interface FolderRoleRefusal {
|
|
19
|
+
reason: FolderRoleRefusalReason;
|
|
20
|
+
role: RemitImapCanonicalMailboxRole;
|
|
21
|
+
accountId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
|
|
25
|
+
"none",
|
|
26
|
+
"stale",
|
|
27
|
+
"unconfirmed",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The wire body as `handleError` emits it — flat, so `code` and `details` sit
|
|
34
|
+
* at the top level. Everything is re-checked at runtime: this is a network
|
|
35
|
+
* boundary, and the type only says what the contract promises.
|
|
36
|
+
*/
|
|
37
|
+
const bodyOf = (error: unknown): Partial<ApiError> | undefined =>
|
|
38
|
+
typeof error === "object" && error !== null
|
|
39
|
+
? (error as Partial<ApiError>)
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const stringAt = (
|
|
43
|
+
details: ApiError["details"],
|
|
44
|
+
key: string,
|
|
45
|
+
): string | undefined => {
|
|
46
|
+
const value = details?.[key];
|
|
47
|
+
return typeof value === "string" ? value : undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The refusal, or `undefined` for every other failure. Every fact the prompt
|
|
52
|
+
* needs travels with it: the account to appoint on (the delete endpoint's body
|
|
53
|
+
* carries none), the role, and the reason, which decides the framing.
|
|
54
|
+
*/
|
|
55
|
+
export const isFolderRoleRefusal = (
|
|
56
|
+
error: unknown,
|
|
57
|
+
): FolderRoleRefusal | undefined => {
|
|
58
|
+
const body = bodyOf(error);
|
|
59
|
+
if (body?.code !== "folder_role_unresolved") return undefined;
|
|
60
|
+
const { details } = body;
|
|
61
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
62
|
+
const reason = stringAt(details, "reason");
|
|
63
|
+
const role = stringAt(details, "role");
|
|
64
|
+
const accountId = stringAt(details, "accountId");
|
|
65
|
+
if (!reason || !REASONS.has(reason)) return undefined;
|
|
66
|
+
if (!role || !ROLES.has(role) || !accountId) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
reason: reason as FolderRoleRefusalReason,
|
|
69
|
+
role: role as RemitImapCanonicalMailboxRole,
|
|
70
|
+
accountId,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The appointment write's own refusal: the mailbox is still being created or
|
|
76
|
+
* deleted on the mail server, so it cannot hold a role yet. A different
|
|
77
|
+
* sentence with a different remedy from a network failure — waiting fixes this
|
|
78
|
+
* one, retrying does not.
|
|
79
|
+
*/
|
|
80
|
+
export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
|
|
81
|
+
bodyOf(error)?.code === "mailbox_not_settled";
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "@remit/api-http-client/types.gen.ts";
|
|
6
6
|
import { useQuery } from "@tanstack/react-query";
|
|
7
7
|
import { useMemo } from "react";
|
|
8
|
+
import type { TrashResolution } from "@/lib/format";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* RFC 032 exclusive-folder-appointment (#976): every "which mailbox plays
|
|
@@ -99,7 +100,7 @@ export const useJunkMailbox = (
|
|
|
99
100
|
* move over an expunge that would replay on reconnect.
|
|
100
101
|
*/
|
|
101
102
|
export const useTrashByAccount = (): {
|
|
102
|
-
trashByAccount: ReadonlyMap<string,
|
|
103
|
+
trashByAccount: ReadonlyMap<string, TrashResolution>;
|
|
103
104
|
hasAppointments: boolean;
|
|
104
105
|
isError: boolean;
|
|
105
106
|
} => {
|
|
@@ -109,12 +110,16 @@ export const useTrashByAccount = (): {
|
|
|
109
110
|
});
|
|
110
111
|
|
|
111
112
|
const trashByAccount = useMemo(() => {
|
|
112
|
-
const byAccount = new Map<string,
|
|
113
|
+
const byAccount = new Map<string, TrashResolution>();
|
|
113
114
|
for (const account of config?.accounts ?? []) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
account.folderAppointments.find((fa) => fa.role === "Trash")?.mailboxId,
|
|
115
|
+
const trash = account.folderAppointments.find(
|
|
116
|
+
(appointment) => appointment.role === "Trash",
|
|
117
117
|
);
|
|
118
|
+
byAccount.set(account.accountId, {
|
|
119
|
+
mailboxId: trash?.mailboxId,
|
|
120
|
+
source: trash?.source ?? "None",
|
|
121
|
+
staleFolderPath: trash?.staleAppointmentPath,
|
|
122
|
+
});
|
|
118
123
|
}
|
|
119
124
|
return byAccount;
|
|
120
125
|
}, [config]);
|
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
6
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import { useCallback } from "react";
|
|
8
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
9
|
+
import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
9
10
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
11
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
12
|
+
import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
|
|
11
13
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
12
14
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
13
15
|
import {
|
|
@@ -75,6 +77,14 @@ export const useDeleteMessages = ({
|
|
|
75
77
|
}: UseDeleteMessagesOptions) => {
|
|
76
78
|
const queryClient = useQueryClient();
|
|
77
79
|
const { pushError } = useErrorBanners();
|
|
80
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
81
|
+
|
|
82
|
+
// The refused chunk is not what the user asked for. The whole selection is
|
|
83
|
+
// held here so the appointment's confirm replays all of it (#887).
|
|
84
|
+
const selectionRef = useRef<string[]>([]);
|
|
85
|
+
const runRef = useRef<(messageIds: string[]) => Promise<void>>(
|
|
86
|
+
async () => {},
|
|
87
|
+
);
|
|
78
88
|
|
|
79
89
|
const { mutateAsync, isPending } = useMutation({
|
|
80
90
|
...messageBulkOperationsDeleteMessagesMutation(),
|
|
@@ -149,6 +159,21 @@ export const useDeleteMessages = ({
|
|
|
149
159
|
}
|
|
150
160
|
restoreThreadListQueries(queryClient, context.previousThreadsList);
|
|
151
161
|
}
|
|
162
|
+
// A provenance refusal is answered by the prompt, not by a banner: the
|
|
163
|
+
// generic banner is suppressed for this one error, and every other
|
|
164
|
+
// failure keeps today's.
|
|
165
|
+
const refusal = isFolderRoleRefusal(err);
|
|
166
|
+
if (refusal) {
|
|
167
|
+
const replay = selectionRef.current;
|
|
168
|
+
requestAppointment({
|
|
169
|
+
accountId: refusal.accountId,
|
|
170
|
+
role: refusal.role,
|
|
171
|
+
reason: refusal.reason,
|
|
172
|
+
action: { kind: "delete", count: replay.length },
|
|
173
|
+
onAppointed: () => runRef.current(replay),
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
152
177
|
const count = vars.body.messageIds?.length ?? 0;
|
|
153
178
|
pushError({
|
|
154
179
|
title:
|
|
@@ -177,14 +202,25 @@ export const useDeleteMessages = ({
|
|
|
177
202
|
},
|
|
178
203
|
});
|
|
179
204
|
|
|
205
|
+
const runSelection = useCallback(
|
|
206
|
+
(messageIds: string[]): Promise<void> =>
|
|
207
|
+
runChunkedMutation(messageIds, (chunk) =>
|
|
208
|
+
mutateAsync({ body: { messageIds: chunk } }),
|
|
209
|
+
),
|
|
210
|
+
[mutateAsync],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
runRef.current = runSelection;
|
|
215
|
+
}, [runSelection]);
|
|
216
|
+
|
|
180
217
|
const deleteMessages = useCallback(
|
|
181
218
|
(messageIds: string[]) => {
|
|
182
219
|
if (messageIds.length === 0) return;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
220
|
+
selectionRef.current = messageIds;
|
|
221
|
+
void runSelection(messageIds);
|
|
186
222
|
},
|
|
187
|
-
[
|
|
223
|
+
[runSelection],
|
|
188
224
|
);
|
|
189
225
|
|
|
190
226
|
return { deleteMessages, isPending };
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* from several mailboxes and several accounts at once.
|
|
12
12
|
*
|
|
13
13
|
* The decision itself is `deleteOutcomeFor`, kept pure in `lib/format`; this is
|
|
14
|
-
* only the read that feeds it.
|
|
14
|
+
* only the read that feeds it. The two facts beside it are what the copy needs
|
|
15
|
+
* to name a folder: whether the Trash it resolved is a name match nobody
|
|
16
|
+
* confirmed (D4a), and the folder a stale appointment lost.
|
|
15
17
|
*/
|
|
16
18
|
import { useMemo } from "react";
|
|
17
19
|
import {
|
|
@@ -21,15 +23,43 @@ import {
|
|
|
21
23
|
} from "@/lib/format";
|
|
22
24
|
import { useTrashByAccount } from "./useArchiveMailbox";
|
|
23
25
|
|
|
26
|
+
export interface DeleteOutcomeResult {
|
|
27
|
+
outcome: DeleteOutcome;
|
|
28
|
+
/** The Trash these rows resolve to was matched by name, never confirmed. */
|
|
29
|
+
trashIsUnconfirmed: boolean;
|
|
30
|
+
/** The folder the user appointed, when it is gone from the mail server. */
|
|
31
|
+
staleFolderLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/** The outcome of deleting `targets`. */
|
|
25
35
|
export const useDeleteOutcome = (
|
|
26
36
|
targets: readonly DeleteTarget[],
|
|
27
|
-
):
|
|
37
|
+
): DeleteOutcomeResult => {
|
|
28
38
|
const { trashByAccount, hasAppointments, isError } = useTrashByAccount();
|
|
29
39
|
|
|
30
|
-
return useMemo(
|
|
31
|
-
(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
return useMemo(() => {
|
|
41
|
+
const outcome = deleteOutcomeFor({
|
|
42
|
+
targets,
|
|
43
|
+
trashByAccount,
|
|
44
|
+
hasAppointments,
|
|
45
|
+
isError,
|
|
46
|
+
});
|
|
47
|
+
const trashFor = (target: DeleteTarget) =>
|
|
48
|
+
target.accountId ? trashByAccount.get(target.accountId) : undefined;
|
|
49
|
+
return {
|
|
50
|
+
outcome,
|
|
51
|
+
// Only the rows the delete would actually expunge — a row filed
|
|
52
|
+
// somewhere else says nothing about the folder it is moving into.
|
|
53
|
+
trashIsUnconfirmed: targets.some((target) => {
|
|
54
|
+
const trash = trashFor(target);
|
|
55
|
+
return (
|
|
56
|
+
trash?.source === "Proposed" && trash.mailboxId === target.mailboxId
|
|
57
|
+
);
|
|
58
|
+
}),
|
|
59
|
+
// The account `deleteOutcomeFor` refused on, found the way it found it.
|
|
60
|
+
staleFolderLabel: targets
|
|
61
|
+
.map(trashFor)
|
|
62
|
+
.find((trash) => trash?.source === "Stale")?.staleFolderPath,
|
|
63
|
+
};
|
|
64
|
+
}, [targets, trashByAccount, hasAppointments, isError]);
|
|
35
65
|
};
|
package/src/lib/format.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import type { DeleteTarget } from "./format.js";
|
|
3
|
+
import type { DeleteTarget, TrashResolution } from "./format.js";
|
|
4
4
|
import {
|
|
5
5
|
deleteConfirmationCopy,
|
|
6
6
|
deleteOutcomeFor,
|
|
@@ -166,9 +166,9 @@ describe("deleteConfirmationCopy", () => {
|
|
|
166
166
|
* the error path.
|
|
167
167
|
*/
|
|
168
168
|
describe("deleteOutcomeFor", () => {
|
|
169
|
-
const trashByAccount = new Map([
|
|
170
|
-
["acct-1", "mbx-trash"],
|
|
171
|
-
["acct-2", undefined],
|
|
169
|
+
const trashByAccount = new Map<string, TrashResolution>([
|
|
170
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Appointed" }],
|
|
171
|
+
["acct-2", { mailboxId: undefined, source: "None" }],
|
|
172
172
|
]);
|
|
173
173
|
const settled = { trashByAccount, hasAppointments: true, isError: false };
|
|
174
174
|
// No default for the account: a default parameter is applied to an explicit
|
|
@@ -234,9 +234,9 @@ describe("deleteOutcomeFor", () => {
|
|
|
234
234
|
assert.strictEqual(
|
|
235
235
|
deleteOutcomeFor({
|
|
236
236
|
...settled,
|
|
237
|
-
trashByAccount: new Map([
|
|
238
|
-
["acct-1", "mbx-trash"],
|
|
239
|
-
["acct-2", "mbx-other-trash"],
|
|
237
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
238
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Appointed" }],
|
|
239
|
+
["acct-2", { mailboxId: "mbx-other-trash", source: "Appointed" }],
|
|
240
240
|
]),
|
|
241
241
|
targets: [target("mbx-trash", "acct-2")],
|
|
242
242
|
}),
|
|
@@ -306,6 +306,67 @@ describe("deleteOutcomeFor", () => {
|
|
|
306
306
|
"unknown",
|
|
307
307
|
);
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test("a Trash the account lost is a repair, not a missing appointment", () => {
|
|
311
|
+
assert.strictEqual(
|
|
312
|
+
deleteOutcomeFor({
|
|
313
|
+
...settled,
|
|
314
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
315
|
+
[
|
|
316
|
+
"acct-1",
|
|
317
|
+
{
|
|
318
|
+
mailboxId: "mbx-fallback",
|
|
319
|
+
source: "Stale",
|
|
320
|
+
staleFolderPath: "INBOX/Prullenbak",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
]),
|
|
324
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
325
|
+
}),
|
|
326
|
+
"staleTrash",
|
|
327
|
+
"the folder the user chose is gone; a fallback is not their choice",
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a Trash matched only by name still takes an ordinary delete", () => {
|
|
332
|
+
assert.strictEqual(
|
|
333
|
+
deleteOutcomeFor({
|
|
334
|
+
...settled,
|
|
335
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
336
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
|
|
337
|
+
]),
|
|
338
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
339
|
+
}),
|
|
340
|
+
"trash",
|
|
341
|
+
"only Empty Trash demands a confirmed appointment (D4)",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("never answers `unconfirmed`, whatever the rows say", () => {
|
|
346
|
+
const sources: TrashResolution["source"][] = [
|
|
347
|
+
"Appointed",
|
|
348
|
+
"Flagged",
|
|
349
|
+
"Reserved",
|
|
350
|
+
"Proposed",
|
|
351
|
+
"Stale",
|
|
352
|
+
"None",
|
|
353
|
+
];
|
|
354
|
+
for (const source of sources) {
|
|
355
|
+
for (const mailboxId of ["mbx-trash", "mbx-inbox", undefined]) {
|
|
356
|
+
assert.notStrictEqual(
|
|
357
|
+
deleteOutcomeFor({
|
|
358
|
+
...settled,
|
|
359
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
360
|
+
["acct-1", { mailboxId, source }],
|
|
361
|
+
]),
|
|
362
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
363
|
+
}),
|
|
364
|
+
"unconfirmed",
|
|
365
|
+
"the targets of a delete say nothing about a whole folder",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
309
370
|
});
|
|
310
371
|
|
|
311
372
|
describe("deleteConfirmationCopy — the refusal", () => {
|
|
@@ -324,11 +385,14 @@ describe("deleteConfirmationCopy — the refusal", () => {
|
|
|
324
385
|
assert.ok(!copy.description.includes("restore"));
|
|
325
386
|
});
|
|
326
387
|
|
|
327
|
-
test("
|
|
388
|
+
test("answers a missing Trash where the refusal happened", () => {
|
|
328
389
|
const copy = deleteConfirmationCopy(3, "noTrash");
|
|
329
|
-
assert.strictEqual(copy.title, "Can't delete 3 messages");
|
|
330
|
-
assert.
|
|
331
|
-
|
|
390
|
+
assert.strictEqual(copy.title, "Can't delete 3 messages yet");
|
|
391
|
+
assert.strictEqual(
|
|
392
|
+
copy.description,
|
|
393
|
+
"No folder on this account is set as Trash, so there is nowhere to move the mail. Nothing has been deleted.",
|
|
394
|
+
);
|
|
395
|
+
assert.strictEqual(copy.confirmLabel, "Pick a Trash folder");
|
|
332
396
|
});
|
|
333
397
|
|
|
334
398
|
test("never promises a restore when no Trash is appointed", () => {
|
|
@@ -336,4 +400,62 @@ describe("deleteConfirmationCopy — the refusal", () => {
|
|
|
336
400
|
assert.ok(!copy.title.includes("Move"));
|
|
337
401
|
assert.ok(!copy.description.includes("restore"));
|
|
338
402
|
});
|
|
403
|
+
|
|
404
|
+
test("names the folder that vanished, and drops the clause without one", () => {
|
|
405
|
+
const named = deleteConfirmationCopy(3, "staleTrash", {
|
|
406
|
+
staleFolderLabel: "INBOX/Prullenbak",
|
|
407
|
+
});
|
|
408
|
+
assert.strictEqual(named.title, "Can't delete 3 messages yet");
|
|
409
|
+
assert.strictEqual(
|
|
410
|
+
named.description,
|
|
411
|
+
"The folder you set as this account's Trash — INBOX/Prullenbak — is gone from the mail server. Nothing has been deleted.",
|
|
412
|
+
);
|
|
413
|
+
assert.strictEqual(named.confirmLabel, "Pick another folder");
|
|
414
|
+
assert.strictEqual(
|
|
415
|
+
deleteConfirmationCopy(3, "staleTrash").description,
|
|
416
|
+
"The folder you set as this account's Trash is gone from the mail server. Nothing has been deleted.",
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("names the guess and the irreversibility before an Empty Trash", () => {
|
|
421
|
+
const copy = deleteConfirmationCopy(0, "unconfirmed", {
|
|
422
|
+
trashFolderLabel: "Deleted Messages",
|
|
423
|
+
});
|
|
424
|
+
assert.strictEqual(copy.title, "Confirm this account's Trash folder");
|
|
425
|
+
assert.strictEqual(
|
|
426
|
+
copy.description,
|
|
427
|
+
"reader files this account's deleted mail in Deleted Messages because of its name — nobody confirmed it. Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.",
|
|
428
|
+
);
|
|
429
|
+
assert.strictEqual(copy.confirmLabel, "Confirm the folder");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("keeps today's words for an expunge inside a confirmed Trash", () => {
|
|
433
|
+
assert.deepStrictEqual(deleteConfirmationCopy(2, "permanent"), {
|
|
434
|
+
title: "Permanently delete 2 messages?",
|
|
435
|
+
description:
|
|
436
|
+
"They are erased from the mail server and cannot be restored.",
|
|
437
|
+
confirmLabel: "Delete permanently",
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("names the folder before an expunge inside a Trash nobody confirmed", () => {
|
|
442
|
+
const copy = deleteConfirmationCopy(2, "permanent", {
|
|
443
|
+
trashFolderLabel: "Deleted Messages",
|
|
444
|
+
trashIsUnconfirmed: true,
|
|
445
|
+
});
|
|
446
|
+
assert.strictEqual(copy.title, "Permanently delete 2 messages?");
|
|
447
|
+
assert.strictEqual(
|
|
448
|
+
copy.description,
|
|
449
|
+
"They are in Deleted Messages, which reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.",
|
|
450
|
+
);
|
|
451
|
+
assert.strictEqual(copy.confirmLabel, "Delete permanently");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("drops the name clause when the caller holds no folder name", () => {
|
|
455
|
+
const copy = deleteConfirmationCopy(2, "permanent", {
|
|
456
|
+
trashIsUnconfirmed: true,
|
|
457
|
+
});
|
|
458
|
+
assert.match(copy.description, /because of its name — nobody confirmed it/);
|
|
459
|
+
assert.ok(!copy.description.includes("undefined"));
|
|
460
|
+
});
|
|
339
461
|
});
|