@remit/web-client 0.0.215 → 0.0.217

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.215",
3
+ "version": "0.0.217",
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": {
@@ -144,7 +144,6 @@ const mount = (options: {
144
144
  accountId?: string;
145
145
  trashFolderLabel?: string;
146
146
  staleFolderLabel?: string;
147
- trashIsUnconfirmed?: boolean;
148
147
  authProvider?: AuthProvider;
149
148
  onConfirm?: (messageIds: string[]) => void;
150
149
  }) => {
@@ -174,7 +173,6 @@ const mount = (options: {
174
173
  accountId: options.accountId,
175
174
  trashFolderLabel: options.trashFolderLabel,
176
175
  staleFolderLabel: options.staleFolderLabel,
177
- trashIsUnconfirmed: options.trashIsUnconfirmed,
178
176
  isDeleting: options.isDeleting,
179
177
  onConfirm,
180
178
  onCancel: () => undefined,
@@ -325,6 +323,40 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
325
323
  assert.match(view.text(), /The Trash folder you chose is gone/);
326
324
  });
327
325
 
326
+ // #876: a row already inside the folder reader only matched by name refuses
327
+ // like Empty Trash's own "unconfirmed" — not a plain "Delete permanently"
328
+ // the server would 409 on.
329
+ it("asks to confirm the guessed folder before an expunge nobody confirmed", async () => {
330
+ const view = mount({
331
+ outcome: "unconfirmed",
332
+ count: 3,
333
+ accountId: ACCOUNT,
334
+ trashFolderLabel: "Deleted Messages",
335
+ });
336
+ const text = view.text();
337
+ assert.match(text, /Confirm this account's Trash folder/);
338
+ assert.match(text, /Deleted Messages/);
339
+ assert.doesNotMatch(text, /Permanently delete 3 messages\?/);
340
+ const confirm = view.button("Confirm the folder");
341
+ assert.ok(confirm, "the remedy the copy names is on screen");
342
+ act(() => confirm?.click());
343
+ // DeleteConfirmDialog's own "unconfirmed" copy shares its title with the
344
+ // appointment prompt's, so the title alone cannot tell the two dialogs
345
+ // apart — matching only that string would pass whether or not the
346
+ // prompt actually replaced the dialog. The picker prompt is the
347
+ // prompt's own wording and never appears in DeleteConfirmDialog's copy.
348
+ assert.match(
349
+ view.text(),
350
+ /Confirm Deleted Messages, or pick the folder this account really uses\./,
351
+ );
352
+ await settle();
353
+ act(() => view.byLabel("Set Prullenbak, 3 messages, as Trash")?.click());
354
+ assert.ok(
355
+ view.button("Set as Trash and delete 3 messages"),
356
+ "the folder picker and the delete-specific confirm label are the prompt's own, not the dialog's",
357
+ );
358
+ });
359
+
328
360
  it("still acts when no single account owns the rows", () => {
329
361
  const confirmed: string[][] = [];
330
362
  const view = mount({
@@ -369,28 +401,12 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
369
401
  });
370
402
  });
371
403
 
372
- /**
373
- * D4a: an expunge inside a Trash reader only matched by name still goes
374
- * through — the user asked for these specific rows — but they are told which
375
- * folder that is, and that nobody chose it, before it happens.
376
- */
377
- describe("DeleteConfirmDialog — an expunge inside a Trash nobody confirmed", () => {
378
- it("keeps today's words for a confirmed Trash", () => {
404
+ describe("DeleteConfirmDialog — an expunge inside a confirmed Trash", () => {
405
+ it("asks for the expunge itself, with no folder to confirm first", () => {
379
406
  const view = mount({ outcome: "permanent", count: 3 });
380
- assert.doesNotMatch(view.text(), /nobody confirmed it/);
381
- });
382
-
383
- it("names the folder and says nobody chose it", () => {
384
- const view = mount({
385
- outcome: "permanent",
386
- count: 3,
387
- trashFolderLabel: "Deleted Messages",
388
- trashIsUnconfirmed: true,
389
- });
390
407
  const text = view.text();
391
408
  assert.match(text, /Permanently delete 3 messages\?/);
392
- assert.match(text, /They are in Deleted Messages/);
393
- assert.match(text, /nobody confirmed it/);
409
+ assert.doesNotMatch(text, /nobody confirmed it/);
394
410
  assert.equal(view.button("Delete permanently")?.disabled, false);
395
411
  });
396
412
  });
@@ -5,14 +5,15 @@
5
5
  * and Flagged — so the wording and the refusal cannot drift apart again the way
6
6
  * they did between #845 and #855.
7
7
  *
8
- * Three of the outcomes are not confirmations at all. When the account has no
9
- * Trash, or the folder it appointed is gone, the server refuses the delete
10
- * outright; when its folder settings could not be read reader cannot say
11
- * whether a delete moves the mail or erases it. All three refuse and the
12
- * caller's `onConfirm` is never reached directly. The first two are answered
13
- * in place — the affirmative control opens the appointment prompt, whose own
14
- * confirm appoints the folder and then runs this delete. None of them may
15
- * render as "this folder is not Trash".
8
+ * Four of the outcomes are not confirmations at all. When the account has no
9
+ * Trash, the folder it appointed is gone, or the row would expunge inside a
10
+ * Trash nobody ever confirmed, the server refuses the delete outright; when
11
+ * its folder settings could not be read reader cannot say whether a delete
12
+ * moves the mail or erases it. All four refuse and the caller's `onConfirm` is
13
+ * never reached directly. The first three are answered in place — the
14
+ * affirmative control opens the appointment prompt, whose own confirm appoints
15
+ * the folder and then runs this delete. None of them may render as "this
16
+ * folder is not Trash".
16
17
  */
17
18
  import { ConfirmDialog } from "@remit/ui";
18
19
  import { useAuthProvider } from "@/auth/provider";
@@ -34,8 +35,8 @@ interface DeleteConfirmDialogProps {
34
35
  trashFolderLabel?: string;
35
36
  /** The folder the user appointed, now gone from the mail server. */
36
37
  staleFolderLabel?: string;
37
- /** That folder is a name match nobody ever confirmed. */
38
- trashIsUnconfirmed?: boolean;
38
+ /** `unconfirmed`: that guessed folder by id, so the prompt opens on it. */
39
+ guessedMailboxId?: string;
39
40
  /** A delete is already in flight, so the confirm cannot be pressed again. */
40
41
  isDeleting?: boolean;
41
42
  onConfirm: (messageIds: string[]) => void;
@@ -49,7 +50,7 @@ export const DeleteConfirmDialog = ({
49
50
  accountId,
50
51
  trashFolderLabel,
51
52
  staleFolderLabel,
52
- trashIsUnconfirmed = false,
53
+ guessedMailboxId,
53
54
  isDeleting = false,
54
55
  onConfirm,
55
56
  onCancel,
@@ -61,10 +62,19 @@ export const DeleteConfirmDialog = ({
61
62
  const copy = deleteConfirmationCopy(count, outcome, {
62
63
  trashFolderLabel,
63
64
  staleFolderLabel,
64
- trashIsUnconfirmed,
65
65
  });
66
66
 
67
- if (outcome === "noTrash" || outcome === "staleTrash") {
67
+ if (
68
+ outcome === "noTrash" ||
69
+ outcome === "staleTrash" ||
70
+ outcome === "unconfirmed"
71
+ ) {
72
+ const reason =
73
+ outcome === "noTrash"
74
+ ? "none"
75
+ : outcome === "staleTrash"
76
+ ? "stale"
77
+ : "unconfirmed";
68
78
  return (
69
79
  <ConfirmDialog
70
80
  isOpen={isOpen}
@@ -79,9 +89,11 @@ export const DeleteConfirmDialog = ({
79
89
  requestAppointment({
80
90
  accountId,
81
91
  role: "Trash",
82
- reason: outcome === "noTrash" ? "none" : "stale",
92
+ reason,
83
93
  action: { kind: "delete", count },
84
94
  staleFolderLabel,
95
+ trashFolderLabel,
96
+ guessedMailboxId,
85
97
  onAppointed: async () => onConfirm(replay),
86
98
  });
87
99
  }}
@@ -5,6 +5,7 @@ import { describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { runEndingBanner } from "@/lib/bulk-action-copy";
7
7
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
8
+ import { type DeleteOutcome, deleteExpunges } from "@/lib/format";
8
9
 
9
10
  /**
10
11
  * A run states how it ended, to whoever is still there to read it (#521). The
@@ -28,6 +29,8 @@ import type { BulkRunOutcome } from "@/lib/bulk-actions";
28
29
  const here = dirname(fileURLToPath(import.meta.url));
29
30
  const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
30
31
 
32
+ const expunging: DeleteOutcome[] = ["permanent", "unconfirmed"];
33
+
31
34
  const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
32
35
  done: 100,
33
36
  failedIds: [],
@@ -92,18 +95,24 @@ describe("reporting how a run ended", () => {
92
95
  );
93
96
  });
94
97
 
95
- it("names an expunge as an expunge, however the run ended", () => {
96
- assert.match(
97
- runEndingBanner("delete", 100, ended(), "permanent")?.title ?? "",
98
- /^100 permanently deleted\./,
99
- );
100
- assert.match(
101
- runEndingBanner("delete", 3000, ended({ cancelled: true }), "permanent")
102
- ?.detail ?? "",
103
- /100 of 3,000 permanently deleted\./,
104
- "the half that ran is erased whether or not the rest did",
105
- );
106
- });
98
+ // Both outcomes a row already inside Trash produces, because both erase it.
99
+ // `unconfirmed` is only ever reached that way (#876), and a ternary that knew
100
+ // only `permanent` reported an expunge as "moved to Trash".
101
+ for (const outcome of expunging) {
102
+ it(`names a ${outcome} delete as an expunge, however the run ended`, () => {
103
+ assert.ok(deleteExpunges(outcome), "the copy reads this predicate");
104
+ assert.match(
105
+ runEndingBanner("delete", 100, ended(), outcome)?.title ?? "",
106
+ /^100 permanently deleted\./,
107
+ );
108
+ assert.match(
109
+ runEndingBanner("delete", 3000, ended({ cancelled: true }), outcome)
110
+ ?.detail ?? "",
111
+ /100 of 3,000 permanently deleted\./,
112
+ "the half that ran is erased whether or not the rest did",
113
+ );
114
+ });
115
+ }
107
116
  });
108
117
 
109
118
  describe("the run the wizard leaves behind", () => {
@@ -326,8 +326,8 @@ export const MessageList = ({
326
326
  );
327
327
  const {
328
328
  outcome: deleteOutcome,
329
- trashIsUnconfirmed,
330
329
  staleFolderLabel,
330
+ guessedMailboxId,
331
331
  } = useDeleteOutcome(deleteScope);
332
332
 
333
333
  // Selection state
@@ -1352,7 +1352,7 @@ export const MessageList = ({
1352
1352
  // mailbox is the Trash the copy has to name.
1353
1353
  trashFolderLabel={listTitle}
1354
1354
  staleFolderLabel={staleFolderLabel}
1355
- trashIsUnconfirmed={trashIsUnconfirmed}
1355
+ guessedMailboxId={guessedMailboxId}
1356
1356
  isDeleting={isDeleting}
1357
1357
  onConfirm={handleConfirmDelete}
1358
1358
  onCancel={handleCancelDelete}
@@ -212,6 +212,7 @@ export const RoleAppointmentPromptProvider = ({
212
212
  folders={folders}
213
213
  delimiter={folderDelimiter(mailboxes)}
214
214
  trashFolderLabel={trashFolderLabel}
215
+ guessedMailboxId={guessedMailboxId}
215
216
  staleFolderLabel={
216
217
  request.staleFolderLabel ?? trash?.staleAppointmentPath
217
218
  }
@@ -287,8 +287,8 @@ export function ThreadListInteraction({
287
287
  );
288
288
  const {
289
289
  outcome: deleteOutcome,
290
- trashIsUnconfirmed,
291
290
  staleFolderLabel,
291
+ guessedMailboxId,
292
292
  } = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
293
293
 
294
294
  // A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
@@ -482,7 +482,7 @@ export function ThreadListInteraction({
482
482
  outcome={deleteOutcome}
483
483
  accountId={pendingDelete?.targets[0]?.accountId}
484
484
  staleFolderLabel={staleFolderLabel}
485
- trashIsUnconfirmed={trashIsUnconfirmed}
485
+ guessedMailboxId={guessedMailboxId}
486
486
  isDeleting={isDeleting}
487
487
  onConfirm={confirmDelete}
488
488
  onCancel={cancelDelete}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Deleting the open message leaves the reader on the list they are browsing
3
+ * (#798, #946).
4
+ *
5
+ * The row vanishes optimistically, so the address that names it has to move
6
+ * before the server answers. It moves up to the list underneath — the brief,
7
+ * Flagged, the folder — and never to the deleted message's own home folder,
8
+ * which ejected a reader out of the brief into a folder they had not opened.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import {
14
+ type AnyRouter,
15
+ createMemoryHistory,
16
+ createRootRoute,
17
+ createRoute,
18
+ createRouter,
19
+ Outlet,
20
+ RouterProvider,
21
+ } from "@tanstack/react-router";
22
+ import { createElement } from "react";
23
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
24
+ import { type HttpMock, mockFetch } from "@/test-support/http";
25
+ import { MessageActionMenu } from "./MessageActionMenu";
26
+
27
+ const THREAD_ID = "thread-1";
28
+ const MESSAGE_ID = "msg-1";
29
+ const MAILBOX_ID = "mbx-inbox";
30
+
31
+ let harness: DomHarness | undefined;
32
+ let http: HttpMock | undefined;
33
+
34
+ afterEach(() => {
35
+ harness?.close();
36
+ harness = undefined;
37
+ http?.restore();
38
+ http = undefined;
39
+ });
40
+
41
+ // The router reads `self` at construction; the shared jsdom globals stop at
42
+ // `window`.
43
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
44
+
45
+ /**
46
+ * The three lists a conversation can be opened from, each with the thread and
47
+ * the message under it. The menu is what the message route mounts, so the
48
+ * mailbox it is handed is the message's own — which on the brief and on Flagged
49
+ * is not the list being browsed.
50
+ */
51
+ const testRouter = (href: string): AnyRouter => {
52
+ const rootRoute = createRootRoute({ component: Outlet });
53
+ const mailRoute = createRoute({
54
+ getParentRoute: () => rootRoute,
55
+ path: "/mail",
56
+ validateSearch: (search: Record<string, unknown>) => search,
57
+ component: Outlet,
58
+ });
59
+ const listRoute = (path: string) => {
60
+ const list = createRoute({
61
+ getParentRoute: () => mailRoute,
62
+ path,
63
+ component: Outlet,
64
+ });
65
+ const thread = createRoute({
66
+ getParentRoute: () => list,
67
+ path: "$threadId",
68
+ component: Outlet,
69
+ });
70
+ const message = createRoute({
71
+ getParentRoute: () => thread,
72
+ path: "$messageId",
73
+ component: () =>
74
+ createElement(MessageActionMenu, {
75
+ messageId: MESSAGE_ID,
76
+ threadId: THREAD_ID,
77
+ mailboxId: MAILBOX_ID,
78
+ isRead: true,
79
+ }),
80
+ });
81
+ return list.addChildren([thread.addChildren([message])]);
82
+ };
83
+ const routeTree = rootRoute.addChildren([
84
+ mailRoute.addChildren([
85
+ listRoute("/brief"),
86
+ listRoute("/flagged"),
87
+ listRoute("/$mailboxId"),
88
+ ]),
89
+ ]);
90
+ return createRouter({
91
+ routeTree,
92
+ history: createMemoryHistory({ initialEntries: [href] }),
93
+ }) as unknown as AnyRouter;
94
+ };
95
+
96
+ const settle = async (mounted: DomHarness): Promise<void> => {
97
+ await mounted.flush();
98
+ await mounted.wait(20);
99
+ await mounted.flush();
100
+ };
101
+
102
+ const mountAt = async (href: string): Promise<[DomHarness, AnyRouter]> => {
103
+ http = mockFetch(() => ({}));
104
+ const router = testRouter(href);
105
+ await router.load();
106
+ const mounted = createDomHarness();
107
+ harness = mounted;
108
+ mounted.renderApp(createElement(RouterProvider, { router }));
109
+ await settle(mounted);
110
+ return [mounted, router];
111
+ };
112
+
113
+ /** The overflow menu on the open message, and the Delete inside it. */
114
+ const pressDelete = async (mounted: DomHarness): Promise<void> => {
115
+ const overflow = mounted.query("button");
116
+ assert.ok(overflow, "the message carries an overflow menu");
117
+ mounted.click(overflow);
118
+ await mounted.flush();
119
+
120
+ mounted.click(mounted.byText("button", "Delete"));
121
+ await settle(mounted);
122
+ };
123
+
124
+ describe("deleting the open message keeps the list it was opened from", () => {
125
+ it("stays on the brief", async () => {
126
+ const [mounted, router] = await mountAt(
127
+ `/mail/brief/${THREAD_ID}/${MESSAGE_ID}`,
128
+ );
129
+
130
+ await pressDelete(mounted);
131
+
132
+ assert.equal(router.state.location.pathname, "/mail/brief");
133
+ });
134
+
135
+ it("stays on the flagged list", async () => {
136
+ const [mounted, router] = await mountAt(
137
+ `/mail/flagged/${THREAD_ID}/${MESSAGE_ID}`,
138
+ );
139
+
140
+ await pressDelete(mounted);
141
+
142
+ assert.equal(router.state.location.pathname, "/mail/flagged");
143
+ });
144
+
145
+ it("stays in the folder being browsed", async () => {
146
+ const [mounted, router] = await mountAt(
147
+ `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`,
148
+ );
149
+
150
+ await pressDelete(mounted);
151
+
152
+ assert.equal(router.state.location.pathname, `/mail/${MAILBOX_ID}`);
153
+ });
154
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A select-all delete refused for its folder role is answered by the
3
+ * appointment prompt, not by a banner (#876).
4
+ *
5
+ * The escalated path had no branch for the coded 409, so the refusal arrived as
6
+ * the API's own sentence — "Appoint one under Settings › Folder roles" — under a
7
+ * "Couldn't delete these messages" banner. That asks a user who has just
8
+ * selected three thousand messages to go to another screen and rebuild the
9
+ * selection, which is the outcome the prompt exists to remove; the single-row
10
+ * path has answered it in place since #887.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { afterEach, describe, it } from "node:test";
15
+ import { QueryClientProvider } from "@tanstack/react-query";
16
+ import { act, createElement } from "react";
17
+ import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
18
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
19
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
20
+ import { type HttpMock, mockFetch } from "../test-support/http";
21
+ import {
22
+ type UseEscalatedActionsResult,
23
+ useEscalatedActions,
24
+ } from "./useEscalatedActions";
25
+
26
+ const INBOX = "mbx-inbox";
27
+ const ACCOUNT = "acc-1";
28
+ const TRASH = "mbx-trash";
29
+
30
+ let harness: DomHarness | undefined;
31
+ let http: HttpMock | undefined;
32
+ let hook: UseEscalatedActionsResult | undefined;
33
+
34
+ afterEach(() => {
35
+ harness?.close();
36
+ harness = undefined;
37
+ http?.restore();
38
+ http = undefined;
39
+ hook = undefined;
40
+ });
41
+
42
+ const CONFIG = {
43
+ accounts: [
44
+ {
45
+ accountId: ACCOUNT,
46
+ email: "you@example.com",
47
+ folderAppointments: [
48
+ { role: "Trash", mailboxId: TRASH, source: "Proposed" },
49
+ ],
50
+ },
51
+ ],
52
+ };
53
+
54
+ /** Fresh every call — a `Response` body can only be read once. */
55
+ const refusal = (): Response =>
56
+ new Response(
57
+ JSON.stringify({
58
+ status: 409,
59
+ message: "Nobody has confirmed which folder is this account's Trash",
60
+ code: "folder_role_unresolved",
61
+ details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
62
+ }),
63
+ { status: 409, headers: { "content-type": "application/json" } },
64
+ );
65
+
66
+ const Probe = () => {
67
+ hook = useEscalatedActions({
68
+ mailboxId: INBOX,
69
+ accountId: ACCOUNT,
70
+ enabled: true,
71
+ predicateKey: "npm",
72
+ searchQuery: { query: "npm" },
73
+ });
74
+ return null;
75
+ };
76
+
77
+ const settle = async (): Promise<void> => {
78
+ if (!harness) throw new Error("nothing mounted");
79
+ for (let round = 0; round < 40; round += 1) {
80
+ await harness.flush();
81
+ await harness.wait(0);
82
+ }
83
+ };
84
+
85
+ const mount = async (respond: (path: string) => unknown): Promise<void> => {
86
+ http = mockFetch((call) => respond(call.path));
87
+ harness = createDomHarness();
88
+ await harness.renderAsync(
89
+ createElement(
90
+ QueryClientProvider,
91
+ { client: harness.queryClient as never },
92
+ createElement(
93
+ ErrorBannerProvider,
94
+ null,
95
+ createElement(
96
+ RoleAppointmentPromptProvider,
97
+ null,
98
+ createElement(Probe),
99
+ ),
100
+ ),
101
+ ),
102
+ );
103
+ await settle();
104
+ };
105
+
106
+ const runDelete = async (): Promise<void> => {
107
+ await act(async () => {
108
+ await hook?.runAction({ kind: "delete" }, [
109
+ { id: "msg-1", accountId: ACCOUNT },
110
+ ]);
111
+ });
112
+ await settle();
113
+ };
114
+
115
+ describe("a bulk delete refused for its folder role", () => {
116
+ it("opens the appointment prompt instead of bannering the API's sentence", async () => {
117
+ await mount((path) => {
118
+ if (path.endsWith("/config")) return CONFIG;
119
+ if (path.endsWith("/messages/delete")) return refusal();
120
+ return {};
121
+ });
122
+
123
+ await runDelete();
124
+
125
+ const text = harness?.text() ?? "";
126
+ assert.match(text, /Confirm this account's Trash folder/);
127
+ assert.doesNotMatch(text, /Couldn't delete these messages/);
128
+ assert.doesNotMatch(
129
+ text,
130
+ /Settings › Folder roles/,
131
+ "the raw API sentence is what the prompt replaces",
132
+ );
133
+ });
134
+
135
+ it("keeps today's banner for a 409 that is somebody else's", async () => {
136
+ await mount((path) => {
137
+ if (path.endsWith("/config")) return CONFIG;
138
+ if (path.endsWith("/messages/delete"))
139
+ return new Response(
140
+ JSON.stringify({
141
+ status: 409,
142
+ message: "boom",
143
+ code: "mailbox_not_settled",
144
+ }),
145
+ { status: 409, headers: { "content-type": "application/json" } },
146
+ );
147
+ return {};
148
+ });
149
+
150
+ await runDelete();
151
+
152
+ const text = harness?.text() ?? "";
153
+ assert.match(text, /Couldn't delete these messages/);
154
+ assert.doesNotMatch(text, /Confirm this account's Trash folder/);
155
+ });
156
+ });
@@ -11,9 +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. 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.
14
+ * only the read that feeds it. The two facts beside it are the folders the
15
+ * refusal has to name: the one a stale appointment lost, and the one reader
16
+ * guessed and is asking the user to confirm.
17
17
  */
18
18
  import { useMemo } from "react";
19
19
  import {
@@ -25,10 +25,14 @@ import { useTrashByAccount } from "./useArchiveMailbox";
25
25
 
26
26
  export interface DeleteOutcomeResult {
27
27
  outcome: DeleteOutcome;
28
- /** The Trash these rows resolve to was matched by name, never confirmed. */
29
- trashIsUnconfirmed: boolean;
30
28
  /** The folder the user appointed, when it is gone from the mail server. */
31
29
  staleFolderLabel?: string;
30
+ /**
31
+ * `unconfirmed`: the folder reader matched by name, which the rows are
32
+ * already inside. The prompt opens with it chosen, so confirming the guess is
33
+ * one tap.
34
+ */
35
+ guessedMailboxId?: string;
32
36
  }
33
37
 
34
38
  /** The outcome of deleting `targets`. */
@@ -48,18 +52,19 @@ export const useDeleteOutcome = (
48
52
  target.accountId ? trashByAccount.get(target.accountId) : undefined;
49
53
  return {
50
54
  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
55
  // The account `deleteOutcomeFor` refused on, found the way it found it.
60
56
  staleFolderLabel: targets
61
57
  .map(trashFor)
62
58
  .find((trash) => trash?.source === "Stale")?.staleFolderPath,
59
+ // The folder that produced `unconfirmed`: a row's own mailbox, which is
60
+ // also its account's guessed Trash. Only that row says which folder is
61
+ // being asked about — a row filed elsewhere is moving, not expunging.
62
+ guessedMailboxId:
63
+ outcome === "unconfirmed"
64
+ ? targets.find(
65
+ (target) => trashFor(target)?.mailboxId === target.mailboxId,
66
+ )?.mailboxId
67
+ : undefined,
63
68
  };
64
69
  }, [targets, trashByAccount, hasAppointments, isError]);
65
70
  };
@@ -8,8 +8,10 @@ import {
8
8
  import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
9
9
  import { useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
11
12
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
13
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
+ import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
13
15
  import {
14
16
  bulkActionFailureDetail,
15
17
  bulkActionFailureTitle,
@@ -152,6 +154,14 @@ export const useEscalatedActions = ({
152
154
  const runningRef = useRef(false);
153
155
  const queryClient = useQueryClient();
154
156
  const { pushError } = useErrorBanners();
157
+ const { requestAppointment } = useRoleAppointmentPrompt();
158
+ // The run replays itself once a folder is appointed, so it needs a handle on
159
+ // itself that does not make `runAction` its own dependency.
160
+ const runRef = useRef<UseEscalatedActionsResult["runAction"]>(async () => ({
161
+ done: 0,
162
+ failedIds: [],
163
+ cancelled: false,
164
+ }));
155
165
 
156
166
  // A different search (or leaving search/desktop) makes any in-flight
157
167
  // escalation meaningless — it would otherwise keep counting or offering to
@@ -298,6 +308,10 @@ export const useEscalatedActions = ({
298
308
  cancelRef.current = false;
299
309
  runningRef.current = true;
300
310
  setRunningAction(action);
311
+ // Read before the run clears the phase below, so a refusal can say how
312
+ // many messages the appointment's replay is about.
313
+ const matched =
314
+ targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
301
315
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
302
316
  // predicate can match more by the time the run pages it than the count
303
317
  // saw, and the bar must never show more done than out of.
@@ -337,7 +351,25 @@ export const useEscalatedActions = ({
337
351
  setProgress(undefined);
338
352
  setPhase({ kind: "idle" });
339
353
 
340
- if (outcome.error) {
354
+ // A provenance refusal is answered by the appointment prompt here exactly
355
+ // as it is on the single-row path (#887, #876). Left to the banner it
356
+ // arrives as the API's own sentence — "Appoint one under Settings ›
357
+ // Folder roles" — which asks the user to reassemble a select-all they
358
+ // have already made.
359
+ const refusal = outcome.error
360
+ ? isFolderRoleRefusal(outcome.error)
361
+ : undefined;
362
+ if (refusal) {
363
+ requestAppointment({
364
+ accountId: refusal.accountId,
365
+ role: refusal.role,
366
+ reason: refusal.reason,
367
+ action: { kind: "delete", count: matched },
368
+ onAppointed: async () => {
369
+ await runRef.current(action, targets);
370
+ },
371
+ });
372
+ } else if (outcome.error) {
341
373
  pushError(
342
374
  buildMutationErrorBanner(
343
375
  bulkActionFailureTitle(action.kind, outcome.done),
@@ -351,8 +383,16 @@ export const useEscalatedActions = ({
351
383
  }
352
384
  return outcome;
353
385
  },
354
- [applyBatchFor, fetchPagesOf, phase, pushError, invalidateAfterRun],
386
+ [
387
+ applyBatchFor,
388
+ fetchPagesOf,
389
+ phase,
390
+ pushError,
391
+ invalidateAfterRun,
392
+ requestAppointment,
393
+ ],
355
394
  );
395
+ runRef.current = runAction;
356
396
 
357
397
  return {
358
398
  phase,
@@ -1,6 +1,6 @@
1
1
  import type { ErrorBannerSeverity } from "@/components/ui/error-banners";
2
2
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
3
- import { type DeleteOutcome, formatNumber } from "@/lib/format";
3
+ import { type DeleteOutcome, deleteExpunges, formatNumber } from "@/lib/format";
4
4
 
5
5
  /** A run ending, as the list banners it. */
6
6
  export interface RunEndingBanner {
@@ -38,7 +38,7 @@ const pastTense: Record<BulkActionKind, string> = {
38
38
  * it does for one that finished — the half that ran is still erased.
39
39
  */
40
40
  const pastTenseFor = (kind: BulkActionKind, outcome: DeleteOutcome): string =>
41
- kind === "delete" && outcome === "permanent"
41
+ kind === "delete" && deleteExpunges(outcome)
42
42
  ? "permanently deleted"
43
43
  : pastTense[kind];
44
44
 
@@ -1,5 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import { trashSourceMeetsAssurance } from "@remit/data-ports/folder-role";
4
+ import { FolderAppointmentSource } from "@remit/domain-enums";
3
5
  import type { DeleteTarget, TrashResolution } from "./format.js";
4
6
  import {
5
7
  deleteConfirmationCopy,
@@ -338,33 +340,55 @@ describe("deleteOutcomeFor", () => {
338
340
  targets: [target("mbx-inbox", "acct-1")],
339
341
  }),
340
342
  "trash",
341
- "only Empty Trash demands a confirmed appointment (D4)",
343
+ "filing mail into a name guess is reversible; only an expunge needs more",
342
344
  );
343
345
  });
344
346
 
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
- }
347
+ test("a row already inside a Trash resolved only by name refuses as unconfirmed (#876)", () => {
348
+ assert.strictEqual(
349
+ deleteOutcomeFor({
350
+ ...settled,
351
+ trashByAccount: new Map<string, TrashResolution>([
352
+ ["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
353
+ ]),
354
+ targets: [target("mbx-trash", "acct-1")],
355
+ }),
356
+ "unconfirmed",
357
+ "deleting a row already in the name-guessed folder expunges it — the same act Empty Trash refuses on this evidence",
358
+ );
359
+ });
360
+
361
+ test("one row expunging inside an unconfirmed Trash outranks a plain expunge", () => {
362
+ assert.strictEqual(
363
+ deleteOutcomeFor({
364
+ ...settled,
365
+ trashByAccount: new Map<string, TrashResolution>([
366
+ ["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
367
+ ]),
368
+ targets: [target("mbx-inbox", "acct-1"), target("mbx-trash", "acct-1")],
369
+ }),
370
+ "unconfirmed",
371
+ );
372
+ });
373
+
374
+ test("words an expunge from the same evidence the service gates it on", () => {
375
+ // Derived from the shared gate rather than listed here: a hand-written
376
+ // list is how the dialog came to offer a confirm the server 409s on.
377
+ // `Stale` is excluded because it refuses under its own outcome first.
378
+ for (const source of Object.values(FolderAppointmentSource)) {
379
+ if (source === FolderAppointmentSource.Stale) continue;
380
+ const outcome = deleteOutcomeFor({
381
+ ...settled,
382
+ trashByAccount: new Map<string, TrashResolution>([
383
+ ["acct-1", { mailboxId: "mbx-trash", source }],
384
+ ]),
385
+ targets: [target("mbx-trash", "acct-1")],
386
+ });
387
+ assert.strictEqual(
388
+ outcome === "unconfirmed",
389
+ !trashSourceMeetsAssurance(source, "confirmed"),
390
+ `${source} must ask for what the service demands before an expunge`,
391
+ );
368
392
  }
369
393
  });
370
394
  });
@@ -438,24 +462,9 @@ describe("deleteConfirmationCopy — the refusal", () => {
438
462
  });
439
463
  });
440
464
 
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
465
  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/);
466
+ const copy = deleteConfirmationCopy(3, "unconfirmed");
467
+ assert.match(copy.description, /matched by name — nobody confirmed it/);
459
468
  assert.ok(!copy.description.includes("undefined"));
460
469
  });
461
470
  });
package/src/lib/format.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ type FolderAppointmentSourceValue,
3
+ trashSourceMeetsAssurance,
4
+ } from "@remit/data-ports/folder-role";
5
+
1
6
  /**
2
7
  * Get the user's preferred locale from browser settings.
3
8
  * Falls back to 'en-US' if unavailable.
@@ -178,8 +183,6 @@ export interface DeleteConfirmationContext {
178
183
  trashFolderLabel?: string;
179
184
  /** The folder the user appointed, now gone from the mail server. */
180
185
  staleFolderLabel?: string;
181
- /** That folder is a name match nobody ever confirmed. */
182
- trashIsUnconfirmed?: boolean;
183
186
  }
184
187
 
185
188
  /**
@@ -207,10 +210,11 @@ export interface DeleteConfirmationContext {
207
210
  * single `noTrash` member cannot — this function only ever sees the member.
208
211
  *
209
212
  * `unconfirmed` is a folder reader matched by name that nobody ever confirmed.
210
- * Only Empty Trash refuses on it (D4); `deleteOutcomeFor` never produces it,
211
- * because the targets an ordinary delete is about say nothing about a whole
212
- * folder. The Empty Trash surface derives it from the server's own 409, and the
213
- * branch lives here so both surfaces word it identically.
213
+ * Empty Trash refuses on it for the whole folder (D4); `deleteOutcomeFor`
214
+ * produces it too, for the narrower case of a row that is already inside that
215
+ * folder — deleting it expunges on the same name guess, and the guess is never
216
+ * enough to destroy mail (#876). Both surfaces derive it from the server's own
217
+ * 409, and the branch lives here so they word it identically.
214
218
  */
215
219
  export type DeleteOutcome =
216
220
  | "trash"
@@ -222,13 +226,7 @@ export type DeleteOutcome =
222
226
  | "unavailable";
223
227
 
224
228
  /** Where an account's Trash answer came from (`FolderAppointmentSource`). */
225
- export type TrashSource =
226
- | "Appointed"
227
- | "Flagged"
228
- | "Reserved"
229
- | "Proposed"
230
- | "Stale"
231
- | "None";
229
+ export type TrashSource = FolderAppointmentSourceValue;
232
230
 
233
231
  /** One account's Trash, as `/config` resolved it. */
234
232
  export interface TrashResolution {
@@ -268,8 +266,10 @@ export interface DeleteOutcomeInput {
268
266
  * One row bound for an expunge makes the whole delete unrecoverable, so a mixed
269
267
  * set is permanent: the wording may overstate what is destroyed, never what is
270
268
  * kept. One row on an account with no Trash refuses the whole call, so that
271
- * outranks both. Pure, so every branch — the failure ones above all — is
272
- * testable without a DOM.
269
+ * outranks both. An expunge on a Trash nobody confirmed outranks a plain
270
+ * expunge in turn — the server refuses it (#876), so the dialog asks for the
271
+ * same confirmation up front rather than offering a confirm that 409s. Pure,
272
+ * so every branch — the failure ones above all — is testable without a DOM.
273
273
  */
274
274
  export const deleteOutcomeFor = ({
275
275
  targets,
@@ -282,17 +282,33 @@ export const deleteOutcomeFor = ({
282
282
  if (targets.length === 0) return "unknown";
283
283
 
284
284
  let expunges = false;
285
+ let expungesUnconfirmed = false;
285
286
  for (const target of targets) {
286
287
  if (target.accountId === undefined) return "unknown";
287
288
  const trash = trashByAccount.get(target.accountId);
288
289
  if (trash === undefined) return "unknown";
289
290
  if (trash.source === "Stale") return "staleTrash";
290
291
  if (trash.mailboxId === undefined) return "noTrash";
291
- if (trash.mailboxId === target.mailboxId) expunges = true;
292
+ if (trash.mailboxId === target.mailboxId) {
293
+ expunges = true;
294
+ if (!trashSourceMeetsAssurance(trash.source, "confirmed"))
295
+ expungesUnconfirmed = true;
296
+ }
292
297
  }
298
+ if (expungesUnconfirmed) return "unconfirmed";
293
299
  return expunges ? "permanent" : "trash";
294
300
  };
295
301
 
302
+ /**
303
+ * The delete erases mail rather than filing it. Both outcomes a row already
304
+ * inside Trash produces say so — `unconfirmed` is only ever reached that way
305
+ * (#876) — and anything that reports what a delete did reads this rather than
306
+ * testing for `permanent`, which is how a run of expunged mail came to be
307
+ * announced as "moved to Trash".
308
+ */
309
+ export const deleteExpunges = (outcome: DeleteOutcome): boolean =>
310
+ outcome === "permanent" || outcome === "unconfirmed";
311
+
296
312
  /**
297
313
  * The confirmation for a delete, worded for what the delete actually does.
298
314
  * Deleting mail that already sits in Trash expunges it on the server and
@@ -315,7 +331,7 @@ export const deleteConfirmationCopy = (
315
331
  ): DeleteConfirmationCopy => {
316
332
  const quantity = count === 1 ? "1" : formatNumber(count);
317
333
  const noun = count === 1 ? "message" : "messages";
318
- const { trashFolderLabel, staleFolderLabel, trashIsUnconfirmed } = context;
334
+ const { trashFolderLabel, staleFolderLabel } = context;
319
335
 
320
336
  if (outcome === "noTrash") {
321
337
  return {
@@ -335,11 +351,18 @@ export const deleteConfirmationCopy = (
335
351
  };
336
352
  }
337
353
  if (outcome === "unconfirmed") {
354
+ const folderClause = trashFolderLabel
355
+ ? `reader files this account's deleted mail in ${trashFolderLabel} because of its name — nobody confirmed it.`
356
+ : "reader files this account's deleted mail in a folder it matched by name — nobody confirmed it.";
357
+ // count === 0: Empty Trash, acting on everything the folder holds. A
358
+ // positive count: a delete whose rows already sit in that folder, so
359
+ // this expunges them specifically, not the folder's whole contents.
338
360
  return {
339
361
  title: "Confirm this account's Trash folder",
340
- description: trashFolderLabel
341
- ? `reader files this account's deleted mail in ${trashFolderLabel} 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.`
342
- : "reader files this account's deleted mail in a folder it matched by name — nobody confirmed it. Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.",
362
+ description:
363
+ count > 0
364
+ ? `${folderClause} Deleting ${quantity} ${noun} there erases them from the mail server, and that cannot be restored. Nothing has been deleted.`
365
+ : `${folderClause} Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.`,
343
366
  confirmLabel: "Confirm the folder",
344
367
  };
345
368
  }
@@ -359,18 +382,6 @@ export const deleteConfirmationCopy = (
359
382
  };
360
383
  }
361
384
  if (outcome === "permanent") {
362
- // D4a: the expunge still goes through on a Trash nobody confirmed — the
363
- // user asked for these specific rows — but they are told which folder
364
- // reader treats as Trash, and that nobody chose it, before it happens.
365
- if (trashIsUnconfirmed) {
366
- return {
367
- title: `Permanently delete ${quantity} ${noun}?`,
368
- description: trashFolderLabel
369
- ? `They are in ${trashFolderLabel}, 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.`
370
- : "They are in a folder 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.",
371
- confirmLabel: "Delete permanently",
372
- };
373
- }
374
385
  return {
375
386
  title: `Permanently delete ${quantity} ${noun}?`,
376
387
  description: