@remit/web-client 0.0.214 → 0.0.216

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.
@@ -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,
@@ -13,6 +13,13 @@
13
13
  * It is two hooks because a pane's verbs are aimed at the focused row: the
14
14
  * context comes first, the pane builds its handlers from it, and the keyboard
15
15
  * registration comes last.
16
+ *
17
+ * While a thread is open, `ConversationView` binds its own window listener for
18
+ * the messages inside it, and both used to act on one press (#723). The split
19
+ * is the one `shortcut-tree` already declares: the cursor keys belong to
20
+ * whichever surface has a list on screen, and answering a thread belongs to
21
+ * the open thread — so `hasOpenThread` hands r, a and f over, and the list
22
+ * keeps j/k, Home/End and Enter for as long as it is mounted.
16
23
  */
17
24
  import { type TriageHandlers, useTriageKeyboard } from "@remit/ui";
18
25
  import { type RefObject, useCallback, useRef, useState } from "react";
@@ -83,6 +90,16 @@ interface UseTriageLayerOptions {
83
90
  onClose: () => void;
84
91
  /** The pane's own verbs — reply, star, delete and the rest. */
85
92
  handlers: TriageHandlers;
93
+ /**
94
+ * A thread is open, so `ConversationView` is mounted and answers for it.
95
+ * This layer drops the three verbs aimed at a whole message — `reply`,
96
+ * `replyAll` and `forward` — while that is true, so one press opens one
97
+ * reply (#723) and `a` can never answer a different message than `r` just
98
+ * did. The cursor keys are not in here: a mounted list keeps those at every
99
+ * width, and it is the conversation that stands down from them wherever the
100
+ * list is on screen beside it (`ConversationView.listOnScreen`).
101
+ */
102
+ hasOpenThread?: boolean;
86
103
  }
87
104
 
88
105
  export interface TriageLayer {
@@ -99,6 +116,7 @@ export const useTriageLayer = ({
99
116
  enabled = true,
100
117
  onClose,
101
118
  handlers,
119
+ hasOpenThread = false,
102
120
  }: UseTriageLayerOptions): TriageLayer => {
103
121
  const { listCommandsRef, hasList, blocksKeyboard } = context;
104
122
 
@@ -107,6 +125,12 @@ export const useTriageLayer = ({
107
125
  if (selectedMessageId) onClose();
108
126
  }, [listCommandsRef, selectedMessageId, onClose]);
109
127
 
128
+ // Dropped rather than kept and guarded: an unregistered action is never
129
+ // preventDefault-ed (see useTriageKeyboard), so the keystroke falls through
130
+ // to ConversationView's own listener instead of two handlers racing on one
131
+ // keypress.
132
+ const { reply, replyAll, forward, ...restHandlers } = handlers;
133
+
110
134
  useTriageKeyboard({
111
135
  // A modal owns the keyboard outright. Suspending the layer is what keeps a
112
136
  // second Delete press from reaching a delete while the confirmation for the
@@ -131,7 +155,8 @@ export const useTriageLayer = ({
131
155
  }
132
156
  : {}),
133
157
  back: goBack,
134
- ...handlers,
158
+ ...restHandlers,
159
+ ...(hasOpenThread ? {} : { reply, replyAll, forward }),
135
160
  },
136
161
  });
137
162
 
@@ -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: