@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.
@@ -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, string | undefined>;
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, string | undefined>();
113
+ const byAccount = new Map<string, TrashResolution>();
113
114
  for (const account of config?.accounts ?? []) {
114
- byAccount.set(
115
- account.accountId,
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
- void runChunkedMutation(messageIds, (chunk) =>
184
- mutateAsync({ body: { messageIds: chunk } }),
185
- );
220
+ selectionRef.current = messageIds;
221
+ void runSelection(messageIds);
186
222
  },
187
- [mutateAsync],
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
- ): DeleteOutcome => {
37
+ ): DeleteOutcomeResult => {
28
38
  const { trashByAccount, hasAppointments, isError } = useTrashByAccount();
29
39
 
30
- return useMemo(
31
- () =>
32
- deleteOutcomeFor({ targets, trashByAccount, hasAppointments, isError }),
33
- [targets, trashByAccount, hasAppointments, isError],
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
  };
@@ -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("sends an unappointed Trash to the screen that appoints one", () => {
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.match(copy.description, /appointed as Trash/);
331
- assert.strictEqual(copy.confirmLabel, "Open folder settings");
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
  });