@remit/web-client 0.0.190 → 0.0.192

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,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
3
+
4
+ /**
5
+ * Emptying Trash from the mailbox pane, and the three refusals the server can
6
+ * answer with (#847).
7
+ *
8
+ * The strip sits where the Spam rescue banner sits — above the list, in flow —
9
+ * and the button always acts. Nothing here is pre-refused from what the client
10
+ * believes the Trash appointment is: the press reaches the server and the 409
11
+ * is the authority, so a folder nobody has tried to empty carries no warning.
12
+ * A refusal is a standing fact about the account rather than an event, so it
13
+ * lands under the button and stays there until the user repairs it.
14
+ */
15
+ const meta: Meta<typeof EmptyTrashBar> = {
16
+ title: "Flows/Mail/Empty Trash",
17
+ component: EmptyTrashBar,
18
+ parameters: { layout: "fullscreen" },
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof EmptyTrashBar>;
23
+
24
+ const noop = () => {};
25
+
26
+ const TrashList = () => (
27
+ <ul className="divide-y divide-line">
28
+ {[
29
+ ["Re: invoice 4421", "Bookkeeping"],
30
+ ["Your parcel could not be delivered", "PostNL"],
31
+ ["Weekly digest", "Hacker Newsletter"],
32
+ ].map(([subject, from]) => (
33
+ <li key={subject} className="px-row-inset py-3">
34
+ <p className="text-sm text-fg">{subject}</p>
35
+ <p className="text-2xs text-fg-subtle">{from}</p>
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ );
40
+
41
+ const base = {
42
+ messageCount: 128,
43
+ isEmptying: false,
44
+ trashFolderLabel: "Deleted Items",
45
+ onEmpty: noop,
46
+ onRepair: noop,
47
+ children: <TrashList />,
48
+ };
49
+
50
+ const Frame = ({ children }: { children: React.ReactNode }) => (
51
+ <div className="h-[520px] w-full max-w-2xl overflow-hidden border border-line bg-canvas">
52
+ {children}
53
+ </div>
54
+ );
55
+
56
+ /** The folder holds mail, so the verb is offered — quietly, and to the right. */
57
+ export const Idle: Story = {
58
+ render: () => (
59
+ <Frame>
60
+ <EmptyTrashBar {...base} />
61
+ </Frame>
62
+ ),
63
+ };
64
+
65
+ /** The press is in flight: the button says so and refuses a second one. */
66
+ export const Emptying: Story = {
67
+ render: () => (
68
+ <Frame>
69
+ <EmptyTrashBar {...base} isEmptying />
70
+ </Frame>
71
+ ),
72
+ };
73
+
74
+ /**
75
+ * What the run reported, straight from the service. A second press re-marks
76
+ * the same rows and honestly reports the same N — never 0 over an expunge.
77
+ */
78
+ export const Emptied: Story = {
79
+ render: () => (
80
+ <Frame>
81
+ <EmptyTrashBar {...base} deletedCount={128} />
82
+ </Frame>
83
+ ),
84
+ };
85
+
86
+ /**
87
+ * reader matched the folder by name and nobody confirmed it. The words are
88
+ * `deleteConfirmationCopy`'s, so the dialog and this strip say the same thing.
89
+ */
90
+ export const RefusedUnconfirmed: Story = {
91
+ name: "Refused (unconfirmed)",
92
+ render: () => (
93
+ <Frame>
94
+ <EmptyTrashBar {...base} refusalReason="unconfirmed" />
95
+ </Frame>
96
+ ),
97
+ };
98
+
99
+ /** The folder the user chose is gone from the mail server, and is named. */
100
+ export const RefusedStale: Story = {
101
+ name: "Refused (stale)",
102
+ render: () => (
103
+ <Frame>
104
+ <EmptyTrashBar
105
+ {...base}
106
+ refusalReason="stale"
107
+ staleFolderLabel="Archive/Bin"
108
+ />
109
+ </Frame>
110
+ ),
111
+ };
112
+
113
+ /** Nothing on this account is set as Trash, so there is nothing to empty. */
114
+ export const RefusedNone: Story = {
115
+ name: "Refused (none)",
116
+ render: () => (
117
+ <Frame>
118
+ <EmptyTrashBar {...base} refusalReason="none" />
119
+ </Frame>
120
+ ),
121
+ };
@@ -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
@@ -80,6 +81,17 @@ export const useJunkMailbox = (
80
81
  return { junkMailboxId: mailboxId, isLoading };
81
82
  };
82
83
 
84
+ /**
85
+ * Returns the account's appointed Trash mailbox id, which is how the mailbox
86
+ * pane tells it is looking at Trash and may offer to empty it (#847).
87
+ */
88
+ export const useTrashMailbox = (
89
+ accountId: string | undefined,
90
+ ): { trashMailboxId: string | undefined; isLoading: boolean } => {
91
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Trash");
92
+ return { trashMailboxId: mailboxId, isLoading };
93
+ };
94
+
83
95
  /**
84
96
  * Each account's appointed Trash mailbox, keyed by account. The delete
85
97
  * confirmation needs it to tell a move-to-Trash apart from a delete inside
@@ -99,7 +111,7 @@ export const useJunkMailbox = (
99
111
  * move over an expunge that would replay on reconnect.
100
112
  */
101
113
  export const useTrashByAccount = (): {
102
- trashByAccount: ReadonlyMap<string, string | undefined>;
114
+ trashByAccount: ReadonlyMap<string, TrashResolution>;
103
115
  hasAppointments: boolean;
104
116
  isError: boolean;
105
117
  } => {
@@ -109,12 +121,16 @@ export const useTrashByAccount = (): {
109
121
  });
110
122
 
111
123
  const trashByAccount = useMemo(() => {
112
- const byAccount = new Map<string, string | undefined>();
124
+ const byAccount = new Map<string, TrashResolution>();
113
125
  for (const account of config?.accounts ?? []) {
114
- byAccount.set(
115
- account.accountId,
116
- account.folderAppointments.find((fa) => fa.role === "Trash")?.mailboxId,
126
+ const trash = account.folderAppointments.find(
127
+ (appointment) => appointment.role === "Trash",
117
128
  );
129
+ byAccount.set(account.accountId, {
130
+ mailboxId: trash?.mailboxId,
131
+ source: trash?.source ?? "None",
132
+ staleFolderPath: trash?.staleAppointmentPath,
133
+ });
118
134
  }
119
135
  return byAccount;
120
136
  }, [config]);
@@ -97,3 +97,36 @@ export const useCurrentMailboxUnseenCount = ({
97
97
 
98
98
  return null;
99
99
  };
100
+
101
+ /**
102
+ * The current mailbox's total message count, from the same warm-cache mailbox
103
+ * query. `0` while it is unresolvable, so a surface gated on "holds at least
104
+ * one message" — Empty Trash (#847) — stays down rather than offering a verb
105
+ * over a folder nothing is known about yet.
106
+ */
107
+ export const useCurrentMailboxMessageCount = ({
108
+ accounts,
109
+ }: UseCurrentMailboxNameOptions): number => {
110
+ const params = useParams({ strict: false });
111
+ const mailboxId = (params as { mailboxId?: string }).mailboxId;
112
+
113
+ const queries = useQueries({
114
+ queries: accounts.map((account) => ({
115
+ ...mailboxOperationsListMailboxesOptions({
116
+ path: { accountId: account.accountId },
117
+ }),
118
+ staleTime: Infinity,
119
+ })),
120
+ });
121
+
122
+ if (!mailboxId) return 0;
123
+
124
+ for (const query of queries) {
125
+ const items = query.data?.items;
126
+ if (!items) continue;
127
+ const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
128
+ if (match) return match.messageCount;
129
+ }
130
+
131
+ return 0;
132
+ };
@@ -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
  };