@remit/web-client 0.0.240 → 0.0.241

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.240",
3
+ "version": "0.0.241",
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": {
@@ -1,15 +1,19 @@
1
1
  import assert from "node:assert";
2
- import { describe, test } from "node:test";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import { client } from "@remit/api-http-client/client.gen.ts";
4
+ import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
5
+ import { type HttpMock, mockFetch } from "../test-support/http";
3
6
  import { AuthTokenError } from "./better-auth-config";
4
7
  import { type AuthProvider, noneAuthProvider } from "./provider";
5
8
 
6
- type RequestFn = (req: Request) => Promise<Request>;
7
-
8
- declare global {
9
- var __REMIT_CLIENT_MOCKS__: { requestFns: RequestFn[] } | undefined;
10
- }
11
-
12
9
  let cacheBust = 1000;
10
+ let http: HttpMock | undefined;
11
+
12
+ afterEach(() => {
13
+ http?.restore();
14
+ http = undefined;
15
+ client.interceptors.request.clear();
16
+ });
13
17
 
14
18
  const providerWithToken = (
15
19
  getToken: AuthProvider["getToken"],
@@ -17,15 +21,30 @@ const providerWithToken = (
17
21
 
18
22
  const loadInterceptor = async () => {
19
23
  cacheBust += 1;
20
- globalThis.__REMIT_CLIENT_MOCKS__ = { requestFns: [] };
24
+ client.interceptors.request.clear();
21
25
  return import(`./auth-interceptor.ts?v=${cacheBust}`);
22
26
  };
23
27
 
28
+ const send = (headers?: Record<string, string>): Promise<unknown> => {
29
+ http = mockFetch();
30
+ return trashOperationsEmptyTrash({
31
+ path: { accountId: "acc-1" },
32
+ headers,
33
+ throwOnError: true,
34
+ });
35
+ };
36
+
37
+ const sentHeaders = (): Record<string, string> => {
38
+ assert.ok(http, "expected a request to have been sent");
39
+ assert.equal(http.calls.length, 1);
40
+ return http.calls[0].headers;
41
+ };
42
+
24
43
  describe("installAuthInterceptor", () => {
25
44
  test("registers exactly one request interceptor", async () => {
26
45
  const mod = await loadInterceptor();
27
46
  mod.installAuthInterceptor(providerWithToken(async () => null));
28
- assert.equal(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
47
+ assert.equal(client.interceptors.request.fns.length, 1);
29
48
  });
30
49
 
31
50
  test("is idempotent — subsequent calls do not register additional interceptors", async () => {
@@ -34,17 +53,14 @@ describe("installAuthInterceptor", () => {
34
53
  mod.installAuthInterceptor(provider);
35
54
  mod.installAuthInterceptor(provider);
36
55
  mod.installAuthInterceptor(provider);
37
- assert.equal(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
56
+ assert.equal(client.interceptors.request.fns.length, 1);
38
57
  });
39
58
 
40
59
  test("attaches the provider's Bearer token when one exists", async () => {
41
60
  const mod = await loadInterceptor();
42
61
  mod.installAuthInterceptor(providerWithToken(async () => "ID-TOKEN-123"));
43
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
44
- assert.ok(fn, "expected interceptor fn to be registered");
45
- const req = new Request("https://api.example.com/thing");
46
- const out = await fn(req);
47
- assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
62
+ await send();
63
+ assert.equal(sentHeaders().authorization, "Bearer ID-TOKEN-123");
48
64
  });
49
65
 
50
66
  test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
@@ -54,42 +70,32 @@ describe("installAuthInterceptor", () => {
54
70
  throw new AuthTokenError("Could not mint a session token: 429", 429);
55
71
  }),
56
72
  );
57
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
58
- assert.ok(fn);
59
- const req = new Request("https://api.example.com/thing");
60
73
  await assert.rejects(
61
- () => fn(req),
74
+ () => send(),
62
75
  (error: unknown) => {
63
76
  assert.ok(error instanceof AuthTokenError);
64
77
  assert.equal(error.status, 429);
65
78
  return true;
66
79
  },
67
80
  );
68
- assert.equal(req.headers.get("Authorization"), null);
81
+ assert.equal(http?.calls.length, 0);
69
82
  });
70
83
 
71
84
  test("omits Authorization header when the deployment presents no identity", async () => {
72
85
  const mod = await loadInterceptor();
73
86
  mod.installAuthInterceptor(providerWithToken(async () => null));
74
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
75
- assert.ok(fn);
76
- const req = new Request("https://api.example.com/thing");
77
- const out = await fn(req);
78
- assert.equal(out.headers.get("Authorization"), null);
87
+ await send();
88
+ assert.equal(sentHeaders().authorization, undefined);
79
89
  });
80
90
 
81
91
  test("preserves caller-supplied headers when adding Authorization", async () => {
82
92
  const mod = await loadInterceptor();
83
93
  mod.installAuthInterceptor(providerWithToken(async () => "TOK"));
84
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
85
- assert.ok(fn);
86
- const req = new Request("https://api.example.com/thing", {
87
- headers: { "X-Trace-Id": "abc-123", "Content-Type": "application/json" },
88
- });
89
- const out = await fn(req);
90
- assert.equal(out.headers.get("X-Trace-Id"), "abc-123");
91
- assert.equal(out.headers.get("Content-Type"), "application/json");
92
- assert.equal(out.headers.get("Authorization"), "Bearer TOK");
94
+ await send({ "X-Trace-Id": "abc-123", "X-Client": "reader" });
95
+ const headers = sentHeaders();
96
+ assert.equal(headers["x-trace-id"], "abc-123");
97
+ assert.equal(headers["x-client"], "reader");
98
+ assert.equal(headers.authorization, "Bearer TOK");
93
99
  });
94
100
 
95
101
  test("propagates errors when the provider's getToken rejects (let it crash)", async () => {
@@ -99,9 +105,6 @@ describe("installAuthInterceptor", () => {
99
105
  throw new Error("session fetch failed");
100
106
  }),
101
107
  );
102
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
103
- assert.ok(fn);
104
- const req = new Request("https://api.example.com/thing");
105
- await assert.rejects(() => fn(req), /session fetch failed/);
108
+ await assert.rejects(() => send(), /session fetch failed/);
106
109
  });
107
110
  });
@@ -14,12 +14,15 @@ import {
14
14
  ComposeBodySkeleton,
15
15
  ComposeFormShell,
16
16
  ComposeHeader,
17
+ ComposeQuoteMissingBanner,
18
+ type ComposeQuoteMode,
17
19
  type ComposeSendState,
18
20
  type ComposeShellLayout,
19
21
  ComposeSubjectField,
20
22
  composeHeaderSummary,
21
23
  defaultComposeLanguages,
22
24
  modeOfDraft,
25
+ NO_QUOTABLE_BODY_FORWARD_MESSAGE,
23
26
  QuotedText,
24
27
  type RichTextValue,
25
28
  SMTP_MISSING_MESSAGE,
@@ -678,6 +681,7 @@ export const ComposeForm = ({
678
681
  data: sourceBody,
679
682
  isLoading: quoteIsLoading,
680
683
  isError: quoteFailed,
684
+ hasNoRenderablePart: sourceHasNoQuotableBody,
681
685
  refetch: refetchQuote,
682
686
  } = useMessageBodyContent({
683
687
  messageId: sourceMessage?.message.messageId,
@@ -685,6 +689,31 @@ export const ComposeForm = ({
685
689
  enabled: isQuoting && !!sourceMessage,
686
690
  });
687
691
 
692
+ const quoteMode: ComposeQuoteMode = mode === "forward" ? "forward" : "reply";
693
+
694
+ /**
695
+ * The message being answered has not arrived yet, so the quote's own fetch
696
+ * cannot have started — it picks its part out of what has not landed. A send
697
+ * pressed in this window went out carrying none of the original and was
698
+ * refused by nothing, because "no part picked" reads the same here as "this
699
+ * message has no part to pick" (#1030).
700
+ */
701
+ const quoteSourceIsLoading = isQuoting && !sourceMessage;
702
+
703
+ /**
704
+ * The message being answered arrived carrying attachments and no text. A
705
+ * forward of it is refused: the composer sends a text body and an html body
706
+ * and no attachments, so the forward would arrive holding nothing of the
707
+ * original at all. A reply goes out — it carries the answer and the thread's
708
+ * references either way — and says the original is not quoted in it.
709
+ *
710
+ * Not stated for a resumed draft, whose body already holds whatever quote it
711
+ * was saved with; this composer is not the one that assembles it.
712
+ */
713
+ const quoteHasNoBody =
714
+ isQuoting && !documentHoldsQuote && sourceHasNoQuotableBody;
715
+ const nothingToForward = quoteHasNoBody && quoteMode === "forward";
716
+
688
717
  /**
689
718
  * The original as it will be sent, and as it is shown while the answer is
690
719
  * written — one value for both, because the two disagreeing is the whole of
@@ -818,7 +847,7 @@ export const ComposeForm = ({
818
847
  if (selectedAccountMissingSmtp) {
819
848
  return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
820
849
  }
821
- if (quoteIsLoading) {
850
+ if (quoteIsLoading || quoteSourceIsLoading) {
822
851
  return { status: "blocked", reason: QUOTE_LOADING_MESSAGE };
823
852
  }
824
853
  if (unparsed) {
@@ -827,9 +856,26 @@ export const ComposeForm = ({
827
856
  if (toCount === 0) {
828
857
  return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
829
858
  }
859
+ // Last of the refusals: the others name something the writer can go and
860
+ // fix, and this one names something about the message being forwarded
861
+ // that they cannot. The banner above states it the whole time either
862
+ // way, so the press is better spent on the fixable thing.
863
+ if (nothingToForward) {
864
+ return {
865
+ status: "blocked",
866
+ reason: NO_QUOTABLE_BODY_FORWARD_MESSAGE,
867
+ };
868
+ }
830
869
  return { status: "ready", accountId: selectedAccountId };
831
870
  },
832
- [isSending, selectedAccountId, selectedAccountMissingSmtp, quoteIsLoading],
871
+ [
872
+ isSending,
873
+ selectedAccountId,
874
+ selectedAccountMissingSmtp,
875
+ quoteIsLoading,
876
+ quoteSourceIsLoading,
877
+ nothingToForward,
878
+ ],
833
879
  );
834
880
  const sendReadiness = useMemo<SendReadiness>(
835
881
  () =>
@@ -1092,6 +1138,9 @@ export const ComposeForm = ({
1092
1138
  configureRef={smtpConfigureRef}
1093
1139
  />
1094
1140
  ) : null}
1141
+ {quoteHasNoBody ? (
1142
+ <ComposeQuoteMissingBanner mode={quoteMode} />
1143
+ ) : null}
1095
1144
  {quoteFailed ? (
1096
1145
  <Banner tone="warning" data-testid="compose-quote-failed">
1097
1146
  <span>{QUOTE_FAILED_MESSAGE}</span>{" "}
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Issue #1030: a source with no text or html part — a message that is
3
+ * attachments and nothing else — left the quote hook disabled, so nothing was
4
+ * loading, nothing had failed, and the composer sent a forward carrying none of
5
+ * the original at all.
6
+ *
7
+ * The composer sends a text body and an html body and no attachments, so that
8
+ * forward has nothing to arrive with: it is refused, in words. A reply still
9
+ * carries the answer and the thread's references, so it goes out and says the
10
+ * original is not quoted in it. And a source that has not arrived yet is a
11
+ * third thing again — still loading, not a message with nothing in it.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import type {
17
+ RemitImapAccountResponse,
18
+ RemitImapDescribeMessageResponse,
19
+ } from "@remit/api-http-client/types.gen.ts";
20
+ import { NO_QUOTABLE_BODY_FORWARD_MESSAGE } from "@remit/ui";
21
+ import {
22
+ type AnyRouter,
23
+ createMemoryHistory,
24
+ createRootRoute,
25
+ createRoute,
26
+ createRouter,
27
+ RouterContextProvider,
28
+ } from "@tanstack/react-router";
29
+ import { createElement, useState } from "react";
30
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
31
+ import { type HttpMock, mockFetch } from "../../test-support/http";
32
+ import { ComposeForm } from "./ComposeForm";
33
+ import type { ComposeMode } from "./ComposeProvider";
34
+ import { ComposeProvider } from "./ComposeProvider";
35
+
36
+ const ACCOUNT_ID = "acc-1";
37
+
38
+ const account = {
39
+ accountId: ACCOUNT_ID,
40
+ email: "me@example.com",
41
+ smtpEnabled: true,
42
+ } as unknown as RemitImapAccountResponse;
43
+
44
+ /** Two scans and a cover sheet — no text part anywhere in it. */
45
+ const attachmentOnlySource = {
46
+ message: { messageId: "msg-1" },
47
+ envelope: {
48
+ subject: "Scans",
49
+ date: Date.UTC(2026, 5, 24, 9, 14),
50
+ messageIdValue: "<m1@example.com>",
51
+ from: [{ normalizedEmail: "dana@example.com", displayName: "Dana" }],
52
+ replyTo: [],
53
+ to: [{ normalizedEmail: "me@example.com" }],
54
+ cc: [],
55
+ },
56
+ references: [],
57
+ bodyParts: [
58
+ {
59
+ mediaType: "MULTIPART",
60
+ mediaSubtype: "MIXED",
61
+ contentUrl: "",
62
+ isMultipart: true,
63
+ },
64
+ {
65
+ mediaType: "IMAGE",
66
+ mediaSubtype: "JPEG",
67
+ disposition: "attachment",
68
+ contentUrl: "/content/msg-1/2.jpg",
69
+ isMultipart: false,
70
+ },
71
+ ],
72
+ } as unknown as RemitImapDescribeMessageResponse;
73
+
74
+ const outboxEntry = () => ({
75
+ outboxMessageId: "ob-1030",
76
+ accountId: ACCOUNT_ID,
77
+ fromAddress: account.email,
78
+ toAddresses: ["dana@example.com"],
79
+ ccAddresses: [],
80
+ bccAddresses: [],
81
+ references: [],
82
+ subject: "Fwd: Scans",
83
+ status: "draft",
84
+ });
85
+
86
+ let harness: DomHarness | undefined;
87
+ let http: HttpMock | undefined;
88
+
89
+ afterEach(() => {
90
+ harness?.close();
91
+ harness = undefined;
92
+ http?.restore();
93
+ http = undefined;
94
+ });
95
+
96
+ const Opened = ({
97
+ mode,
98
+ sourceMessage,
99
+ }: {
100
+ mode: ComposeMode;
101
+ sourceMessage?: RemitImapDescribeMessageResponse;
102
+ }) => {
103
+ const [draftId, setDraftId] = useState<string | undefined>(undefined);
104
+
105
+ return createElement(ComposeForm, {
106
+ mode,
107
+ account,
108
+ sourceMessage,
109
+ outboxMessageId: draftId,
110
+ onDraftCreated: setDraftId,
111
+ onClose: () => {},
112
+ });
113
+ };
114
+
115
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
116
+
117
+ const rootRoute = createRootRoute();
118
+ const mailboxRoute = createRoute({
119
+ getParentRoute: () => rootRoute,
120
+ path: "/mail/$mailboxId",
121
+ validateSearch: (search: Record<string, unknown>) => search,
122
+ });
123
+
124
+ const testRouter = (): AnyRouter =>
125
+ createRouter({
126
+ routeTree: rootRoute.addChildren([mailboxRoute]),
127
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
128
+ }) as unknown as AnyRouter;
129
+
130
+ const mount = async (options: {
131
+ mode: ComposeMode;
132
+ /** Left out for the deep link that opened before the source landed. */
133
+ sourceMessage?: RemitImapDescribeMessageResponse;
134
+ }): Promise<void> => {
135
+ http = mockFetch(async (call) => {
136
+ if (call.path.endsWith("/config")) return { accounts: [account] };
137
+ return outboxEntry();
138
+ });
139
+
140
+ harness = createDomHarness();
141
+ harness.renderApp(
142
+ createElement(RouterContextProvider, {
143
+ router: testRouter(),
144
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
145
+ children: createElement(
146
+ ComposeProvider,
147
+ null,
148
+ createElement(Opened, {
149
+ mode: options.mode,
150
+ sourceMessage: options.sourceMessage,
151
+ }),
152
+ ),
153
+ }),
154
+ );
155
+ await harness.flush();
156
+ await harness.wait(100);
157
+ await harness.flush();
158
+ };
159
+
160
+ const sendButton = (): HTMLElement => {
161
+ const button = harness?.byText("button", "Send");
162
+ if (!button) throw new Error("the compose send button is not mounted");
163
+ return button;
164
+ };
165
+
166
+ const addRecipient = (email: string): void => {
167
+ const field = harness?.query<HTMLInputElement>("#address-field-To");
168
+ if (!field) throw new Error("the To field is not mounted");
169
+ harness?.type(field, email);
170
+ };
171
+
172
+ const dispatches = (): number =>
173
+ (http?.calls ?? []).filter((call) => call.path.endsWith("/send")).length;
174
+
175
+ const press = async (): Promise<void> => {
176
+ harness?.click(sendButton());
177
+ await harness?.flush();
178
+ await harness?.wait(100);
179
+ };
180
+
181
+ describe("a source with nothing to quote (#1030)", () => {
182
+ it("refuses a forward of a message that is attachments only", async () => {
183
+ await mount({ mode: "forward", sourceMessage: attachmentOnlySource });
184
+
185
+ addRecipient("bob@example.com");
186
+ await press();
187
+
188
+ assert.equal(
189
+ dispatches(),
190
+ 0,
191
+ "a forward left with nothing of the message it forwards",
192
+ );
193
+ assert.ok(
194
+ (harness?.text() ?? "").includes(NO_QUOTABLE_BODY_FORWARD_MESSAGE),
195
+ "the refusal was not stated",
196
+ );
197
+ });
198
+
199
+ it("says so on screen before the forward is even written", async () => {
200
+ await mount({ mode: "forward", sourceMessage: attachmentOnlySource });
201
+
202
+ const banner = harness?.query("[data-testid=compose-quote-missing]");
203
+ assert.ok(banner, "nothing said the message had no body to quote");
204
+ });
205
+
206
+ it("sends a reply to it, saying the original is not quoted", async () => {
207
+ await mount({ mode: "reply", sourceMessage: attachmentOnlySource });
208
+
209
+ assert.ok(
210
+ harness?.query("[data-testid=compose-quote-missing]"),
211
+ "nothing said the original would not be quoted",
212
+ );
213
+
214
+ await press();
215
+
216
+ assert.equal(dispatches(), 1, "the reply was not sent");
217
+ });
218
+
219
+ it("refuses while the source is still on its way, without claiming it is empty", async () => {
220
+ await mount({ mode: "forward" });
221
+
222
+ addRecipient("bob@example.com");
223
+ await press();
224
+
225
+ assert.equal(dispatches(), 0, "a forward left before its source arrived");
226
+ assert.match(harness?.text() ?? "", /Loading the message you're quoting/);
227
+ assert.equal(
228
+ harness?.query("[data-testid=compose-quote-missing]"),
229
+ null,
230
+ "a source still arriving was reported as a message with no body",
231
+ );
232
+ });
233
+ });
@@ -24,6 +24,7 @@ import {
24
24
  import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal";
25
25
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
26
26
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
27
+ import { softErrorStatuses } from "@/lib/error-classifier";
27
28
  import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
28
29
  import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
29
30
 
@@ -90,9 +91,12 @@ export const RoleAppointmentPromptProvider = ({
90
91
  enabled: !!accountId,
91
92
  });
92
93
  const { createFolderIn } = useCreateMailbox(accountId);
93
- const appointMutation = useMutation(
94
- folderRoleOperationsAppointFolderRoleMutation(),
95
- );
94
+ const appointMutation = useMutation({
95
+ ...folderRoleOperationsAppointFolderRoleMutation(),
96
+ // A mailbox still settling answers 409, and the prompt states that itself
97
+ // in `appoint-failed`. Escalating would take the screen the prompt is on.
98
+ meta: softErrorStatuses(409),
99
+ });
96
100
 
97
101
  const account = config?.accounts.find((one) => one.accountId === accountId);
98
102
  const mailboxes = useMemo(() => mailboxData?.items ?? [], [mailboxData]);
@@ -1,19 +1,26 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { wrapHttpFailure } from "@/lib/api";
3
4
  import {
4
5
  isFolderRoleRefusal,
5
6
  isMailboxNotSettledRefusal,
6
7
  } from "./folder-role-refusal.js";
7
8
 
8
- const refusal = {
9
+ const wireBody = {
9
10
  code: "folder_role_unresolved",
10
11
  message: "No folder is appointed as Trash",
11
12
  details: { role: "Trash", reason: "none", accountId: "acct-1" },
12
13
  };
13
14
 
15
+ // Every call site takes the error interceptor, so what reaches these readers is
16
+ // always the `ApiError` `wrapHttpFailure` builds — never the flat wire body it
17
+ // carries at `.body` (#1004).
18
+ const refused = (body: Record<string, unknown> = wireBody) =>
19
+ wrapHttpFailure(body, 409);
20
+
14
21
  describe("isFolderRoleRefusal", () => {
15
22
  it("carries the account and the reason the prompt needs", () => {
16
- assert.deepEqual(isFolderRoleRefusal(refusal), {
23
+ assert.deepEqual(isFolderRoleRefusal(refused()), {
17
24
  reason: "none",
18
25
  role: "Trash",
19
26
  accountId: "acct-1",
@@ -23,10 +30,9 @@ describe("isFolderRoleRefusal", () => {
23
30
  it("reads every reason the API declares", () => {
24
31
  for (const reason of ["none", "stale", "unconfirmed"]) {
25
32
  assert.equal(
26
- isFolderRoleRefusal({
27
- ...refusal,
28
- details: { ...refusal.details, reason },
29
- })?.reason,
33
+ isFolderRoleRefusal(
34
+ refused({ ...wireBody, details: { ...wireBody.details, reason } }),
35
+ )?.reason,
30
36
  reason,
31
37
  );
32
38
  }
@@ -34,14 +40,18 @@ describe("isFolderRoleRefusal", () => {
34
40
 
35
41
  it("does not open the prompt for a 409 without the code", () => {
36
42
  assert.equal(
37
- isFolderRoleRefusal({
38
- message: "No folder is appointed as Trash",
39
- details: refusal.details,
40
- }),
43
+ isFolderRoleRefusal(
44
+ refused({
45
+ message: "No folder is appointed as Trash",
46
+ details: wireBody.details,
47
+ }),
48
+ ),
41
49
  undefined,
42
50
  );
43
51
  assert.equal(
44
- isFolderRoleRefusal({ ...refusal, code: "mailbox_not_settled" }),
52
+ isFolderRoleRefusal(
53
+ refused({ ...wireBody, code: "mailbox_not_settled" }),
54
+ ),
45
55
  undefined,
46
56
  );
47
57
  });
@@ -51,22 +61,32 @@ describe("isFolderRoleRefusal", () => {
51
61
  isFolderRoleRefusal(new Error("folder_role_unresolved: Trash")),
52
62
  undefined,
53
63
  );
64
+ assert.equal(
65
+ isFolderRoleRefusal(
66
+ wrapHttpFailure({ message: "folder_role_unresolved" }, 409),
67
+ ),
68
+ undefined,
69
+ );
54
70
  });
55
71
 
56
72
  it("refuses a body missing anything the prompt has to have", () => {
57
- assert.equal(isFolderRoleRefusal({ ...refusal, details: {} }), undefined);
58
73
  assert.equal(
59
- isFolderRoleRefusal({
60
- ...refusal,
61
- details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
62
- }),
74
+ isFolderRoleRefusal(refused({ ...wireBody, details: {} })),
75
+ undefined,
76
+ );
77
+ assert.equal(
78
+ isFolderRoleRefusal(
79
+ refused({
80
+ ...wireBody,
81
+ details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
82
+ }),
83
+ ),
63
84
  undefined,
64
85
  );
65
86
  assert.equal(
66
- isFolderRoleRefusal({
67
- ...refusal,
68
- details: { role: "Trash", reason: "none" },
69
- }),
87
+ isFolderRoleRefusal(
88
+ refused({ ...wireBody, details: { role: "Trash", reason: "none" } }),
89
+ ),
70
90
  undefined,
71
91
  );
72
92
  });
@@ -75,20 +95,30 @@ describe("isFolderRoleRefusal", () => {
75
95
  for (const value of [undefined, null, "boom", 409, []]) {
76
96
  assert.equal(isFolderRoleRefusal(value), undefined);
77
97
  }
98
+ assert.equal(
99
+ isFolderRoleRefusal(wrapHttpFailure(undefined, 500)),
100
+ undefined,
101
+ );
102
+ });
103
+
104
+ it("still reads a bare wire body", () => {
105
+ assert.equal(isFolderRoleRefusal(wireBody)?.accountId, "acct-1");
78
106
  });
79
107
  });
80
108
 
81
109
  describe("isMailboxNotSettledRefusal", () => {
82
110
  it("matches only the appointment write's own refusal", () => {
83
111
  assert.equal(
84
- isMailboxNotSettledRefusal({
85
- code: "mailbox_not_settled",
86
- message: "Mailbox is still being created",
87
- details: { mailboxId: "mbx-1", syncStatus: "pending" },
88
- }),
112
+ isMailboxNotSettledRefusal(
113
+ refused({
114
+ code: "mailbox_not_settled",
115
+ message: "Mailbox is still being created",
116
+ details: { mailboxId: "mbx-1", syncStatus: "pending" },
117
+ }),
118
+ ),
89
119
  true,
90
120
  );
91
- assert.equal(isMailboxNotSettledRefusal(refusal), false);
121
+ assert.equal(isMailboxNotSettledRefusal(refused()), false);
92
122
  assert.equal(isMailboxNotSettledRefusal(new Error("pending")), false);
93
123
  });
94
124
  });
@@ -5,11 +5,9 @@
5
5
  * appointment prompt over an unrelated conflict. A 409 without one of these
6
6
  * codes is somebody else's error and keeps today's banner.
7
7
  */
8
- import type {
9
- ApiError,
10
- RemitImapCanonicalMailboxRole,
11
- } from "@remit/api-http-client/types.gen.ts";
8
+ import type { RemitImapCanonicalMailboxRole } from "@remit/api-http-client/types.gen.ts";
12
9
  import { CanonicalMailboxRole } from "@remit/domain-enums";
10
+ import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
13
11
 
14
12
  /** Why the role is unresolved, as the API's `details.reason` spells it. */
15
13
  export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
@@ -29,18 +27,8 @@ const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
29
27
 
30
28
  const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
31
29
 
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
30
  const stringAt = (
43
- details: ApiError["details"],
31
+ details: CodedApiErrorBody["details"],
44
32
  key: string,
45
33
  ): string | undefined => {
46
34
  const value = details?.[key];
@@ -55,10 +43,10 @@ const stringAt = (
55
43
  export const isFolderRoleRefusal = (
56
44
  error: unknown,
57
45
  ): FolderRoleRefusal | undefined => {
58
- const body = bodyOf(error);
46
+ const body = codedApiErrorBody(error);
59
47
  if (body?.code !== "folder_role_unresolved") return undefined;
60
48
  const { details } = body;
61
- if (typeof details !== "object" || details === null) return undefined;
49
+ if (!details) return undefined;
62
50
  const reason = stringAt(details, "reason");
63
51
  const role = stringAt(details, "role");
64
52
  const accountId = stringAt(details, "accountId");
@@ -78,4 +66,4 @@ export const isFolderRoleRefusal = (
78
66
  * one, retrying does not.
79
67
  */
80
68
  export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
81
- bodyOf(error)?.code === "mailbox_not_settled";
69
+ codedApiErrorBody(error)?.code === "mailbox_not_settled";
@@ -1,15 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { wrapHttpFailure } from "@/lib/api";
3
4
  import {
4
5
  isPlacementRefusal,
5
6
  placementRefusalBanner,
6
7
  } from "./placement-refusal.js";
7
8
 
8
- const refusal = (reason: string) => ({
9
- message: "Message 4f1 was not acted on: its folder and uid do not name...",
10
- code: "message_placement_unsettled",
11
- details: { accountId: "acc-1", messageId: "msg-1", reason },
12
- });
9
+ // The reader is handed what the error interceptor produces, not the flat wire
10
+ // body it carries at `.body` (#1004).
11
+ const refusal = (reason: string) =>
12
+ wrapHttpFailure(
13
+ {
14
+ message:
15
+ "Message 4f1 was not acted on: its folder and uid do not name...",
16
+ code: "message_placement_unsettled",
17
+ details: { accountId: "acc-1", messageId: "msg-1", reason },
18
+ },
19
+ 409,
20
+ );
13
21
 
14
22
  describe("isPlacementRefusal", () => {
15
23
  it("reads the reason and the message off the coded body", () => {
@@ -23,7 +31,7 @@ describe("isPlacementRefusal", () => {
23
31
  assert.equal(isPlacementRefusal(undefined), undefined);
24
32
  assert.equal(isPlacementRefusal(new Error("network")), undefined);
25
33
  assert.equal(
26
- isPlacementRefusal({ ...refusal("in_flight"), code: "other" }),
34
+ isPlacementRefusal(wrapHttpFailure({ code: "other" }, 409)),
27
35
  undefined,
28
36
  );
29
37
  assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
@@ -4,8 +4,8 @@
4
4
  * server's sentence names a uuid and no remedy, and a message-string match
5
5
  * would start firing on an unrelated conflict the moment the copy changes.
6
6
  */
7
- import type { ApiError } from "@remit/api-http-client/types.gen.ts";
8
7
  import type { PushErrorInput } from "@/components/ui/error-banners";
8
+ import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
9
9
 
10
10
  /**
11
11
  * `in_flight` clears on its own once the mail server confirms the move;
@@ -25,7 +25,7 @@ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
25
25
  ]);
26
26
 
27
27
  const stringAt = (
28
- details: ApiError["details"],
28
+ details: CodedApiErrorBody["details"],
29
29
  key: string,
30
30
  ): string | undefined => {
31
31
  const value = details?.[key];
@@ -35,11 +35,10 @@ const stringAt = (
35
35
  export const isPlacementRefusal = (
36
36
  error: unknown,
37
37
  ): PlacementRefusal | undefined => {
38
- if (typeof error !== "object" || error === null) return undefined;
39
- const body = error as Partial<ApiError>;
40
- if (body.code !== "message_placement_unsettled") return undefined;
38
+ const body = codedApiErrorBody(error);
39
+ if (body?.code !== "message_placement_unsettled") return undefined;
41
40
  const { details } = body;
42
- if (typeof details !== "object" || details === null) return undefined;
41
+ if (!details) return undefined;
43
42
  const reason = stringAt(details, "reason");
44
43
  const messageId = stringAt(details, "messageId");
45
44
  if (!reason || !REASONS.has(reason) || !messageId) return undefined;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * A coded 409 must reach the surface that answers it and nothing else (#1004).
3
+ *
4
+ * Both delete paths raise through `useMutation`, and the app wires the global
5
+ * fail-fast sink on the `MutationCache` (`shell/index.tsx`), where a 409 with
6
+ * no `meta` escalates to the full-screen fatal overlay. The prompt then opens
7
+ * underneath a red page nobody can see past — the refusal is handled and still
8
+ * never reaches the user. Every other test mounts on the harness's plain
9
+ * `QueryClient`, which has no such sink, so none of them can see this.
10
+ *
11
+ * Mounted here on the client the app actually builds, with the error
12
+ * interceptor registered.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import {
18
+ MutationCache,
19
+ QueryCache,
20
+ QueryClient,
21
+ QueryClientProvider,
22
+ } from "@tanstack/react-query";
23
+ import { act, createElement } from "react";
24
+ import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
25
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
26
+ import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
27
+ import {
28
+ handleMutationCacheError,
29
+ handleQueryCacheError,
30
+ } from "@/lib/query-error-handler";
31
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
32
+ import { type HttpMock, mockFetch } from "../test-support/http";
33
+ import { useDeleteMessages } from "./useDeleteMessages";
34
+ import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
35
+ import "@/lib/client";
36
+
37
+ const ACCOUNT = "acc-1";
38
+ const INBOX = "mbx-inbox";
39
+ const TRASH = "mbx-trash";
40
+
41
+ let harness: DomHarness | undefined;
42
+ let http: HttpMock | undefined;
43
+ let deleteMessages: ((messageIds: string[]) => void) | undefined;
44
+ let trash: EmptyTrashState | undefined;
45
+
46
+ afterEach(() => {
47
+ harness?.close();
48
+ harness = undefined;
49
+ http?.restore();
50
+ http = undefined;
51
+ deleteMessages = undefined;
52
+ trash = undefined;
53
+ __resetFatalError();
54
+ });
55
+
56
+ const CONFIG = {
57
+ accounts: [
58
+ {
59
+ accountId: ACCOUNT,
60
+ email: "you@example.com",
61
+ folderAppointments: [
62
+ { role: "Trash", mailboxId: TRASH, source: "Proposed" },
63
+ ],
64
+ },
65
+ ],
66
+ };
67
+
68
+ /** Fresh every call — a `Response` body can only be read once. */
69
+ const refusal = (): Response =>
70
+ new Response(
71
+ JSON.stringify({
72
+ status: 409,
73
+ message: "Nobody has confirmed which folder is this account's Trash",
74
+ code: "folder_role_unresolved",
75
+ details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
76
+ }),
77
+ { status: 409, headers: { "content-type": "application/json" } },
78
+ );
79
+
80
+ const broken = (): Response =>
81
+ new Response(JSON.stringify({ message: "boom" }), {
82
+ status: 500,
83
+ headers: { "content-type": "application/json" },
84
+ });
85
+
86
+ const Probe = () => {
87
+ deleteMessages = useDeleteMessages({
88
+ mailboxId: INBOX,
89
+ accountId: ACCOUNT,
90
+ }).deleteMessages;
91
+ trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
92
+ return null;
93
+ };
94
+
95
+ /** The client `shell/index.tsx` builds: the real global escalation sink. */
96
+ const appQueryClient = (): QueryClient =>
97
+ new QueryClient({
98
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
99
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
100
+ defaultOptions: {
101
+ queries: { retry: false },
102
+ mutations: { retry: false },
103
+ },
104
+ });
105
+
106
+ const settle = async (): Promise<void> => {
107
+ if (!harness) throw new Error("nothing mounted");
108
+ for (let round = 0; round < 40; round += 1) {
109
+ await harness.flush();
110
+ await harness.wait(0);
111
+ }
112
+ };
113
+
114
+ const mount = async (respond: (path: string) => unknown): Promise<void> => {
115
+ http = mockFetch((call) => respond(call.path));
116
+ harness = createDomHarness({ queryClient: appQueryClient() });
117
+ await harness.renderAsync(
118
+ createElement(
119
+ QueryClientProvider,
120
+ { client: harness.queryClient as never },
121
+ createElement(
122
+ ErrorBannerProvider,
123
+ null,
124
+ createElement(
125
+ RoleAppointmentPromptProvider,
126
+ null,
127
+ createElement(Probe),
128
+ ),
129
+ ),
130
+ ),
131
+ );
132
+ await settle();
133
+ };
134
+
135
+ const responder =
136
+ (endpoint: string, answer: () => Response) =>
137
+ (path: string): unknown => {
138
+ if (path.endsWith("/config")) return CONFIG;
139
+ if (path.endsWith(endpoint)) return answer();
140
+ return {};
141
+ };
142
+
143
+ describe("a delete refused for its folder role", () => {
144
+ it("opens the appointment prompt without taking the screen", async () => {
145
+ await mount(responder("/messages/delete", refusal));
146
+
147
+ await act(async () => {
148
+ deleteMessages?.(["msg-1"]);
149
+ });
150
+ await settle();
151
+
152
+ assert.match(harness?.text() ?? "", /Confirm this account's Trash folder/);
153
+ assert.equal(
154
+ getCurrentFatalError(),
155
+ null,
156
+ "a refusal the prompt answers is not a fatal error",
157
+ );
158
+ });
159
+
160
+ it("still escalates a 500 on the same delete", async () => {
161
+ await mount(responder("/messages/delete", broken));
162
+
163
+ await act(async () => {
164
+ deleteMessages?.(["msg-1"]);
165
+ });
166
+ await settle();
167
+
168
+ assert.ok(
169
+ getCurrentFatalError(),
170
+ "the 409 opt-out must not cover anything else",
171
+ );
172
+ });
173
+ });
174
+
175
+ describe("an empty Trash refused for its folder role", () => {
176
+ it("states the refusal in the pane without taking the screen", async () => {
177
+ await mount(responder("/trash/empty", refusal));
178
+
179
+ await act(async () => {
180
+ trash?.emptyTrash();
181
+ });
182
+ await settle();
183
+
184
+ assert.equal(trash?.refusal?.reason, "unconfirmed");
185
+ assert.equal(getCurrentFatalError(), null);
186
+ });
187
+ });
@@ -558,6 +558,110 @@ describe("useSystemUpdate — actions", () => {
558
558
  }
559
559
  });
560
560
 
561
+ test("checking from the success row reaches the pane, not a dead button (#1015)", async () => {
562
+ // An undismissed run outcome wins over any check in flight, so a press
563
+ // made while the success banner holds the pane would leave the screen
564
+ // exactly as it was. The row's control retires the outcome first.
565
+ const succeeded = {
566
+ currentVersion: "0.9.4",
567
+ check: { status: "ok", updateAvailable: false },
568
+ run: run({ outcome: "succeeded" }),
569
+ };
570
+ const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
571
+ await settle(dom);
572
+ assert.match(dom.html(), /Updated to Remit 0\.9\.4/);
573
+
574
+ dom.click(dom.byText("button", "Check for updates"));
575
+ await waitFor(
576
+ dom,
577
+ () => /Looking for a newer version/.test(dom.html()),
578
+ "the pressed check to reach the pane",
579
+ );
580
+ assert.doesNotMatch(dom.html(), /Updated to Remit/);
581
+ });
582
+
583
+ test("a check pressed from the success row fails in the open, not behind the banner (#1015)", async () => {
584
+ const succeeded = {
585
+ currentVersion: "0.9.4",
586
+ check: { status: "ok", updateAvailable: false },
587
+ run: run({ outcome: "succeeded" }),
588
+ };
589
+ const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
590
+ await settle(dom);
591
+
592
+ const control = dom.byText("button", "Check for updates");
593
+ const realNow = Date.now;
594
+ try {
595
+ await act(async () => {
596
+ // Dispatched inside this `act` rather than through `dom.click`, so
597
+ // the press is stamped off the real clock and the wait it arms is
598
+ // measured against one already past the budget.
599
+ control.dispatchEvent(
600
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
601
+ );
602
+ Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
603
+ await dom.flush();
604
+ });
605
+ await dom.wait(5);
606
+ await dom.flush();
607
+
608
+ assert.match(dom.html(), /The updater did not answer/);
609
+ assert.doesNotMatch(dom.html(), /Updated to Remit/);
610
+ } finally {
611
+ Date.now = realNow;
612
+ }
613
+ });
614
+
615
+ test("dismissing an outcome does not uncover the check verdict it hid", async () => {
616
+ // The failure belongs to a press made before the run, on a pane the user
617
+ // has not been able to see since. Serving it as the current verdict claims
618
+ // a check failed that may well have succeeded.
619
+ let response: unknown = available;
620
+ const { dom, api } = mountApi(
621
+ () => response,
622
+ createElement(SelfUpdatePanel),
623
+ );
624
+ await settle(dom);
625
+
626
+ const realNow = Date.now;
627
+ try {
628
+ await act(async () => {
629
+ api().onCheck();
630
+ Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
631
+ await dom.flush();
632
+ });
633
+ await dom.wait(5);
634
+ await dom.flush();
635
+ assert.match(dom.html(), /The updater did not answer/);
636
+ } finally {
637
+ Date.now = realNow;
638
+ }
639
+
640
+ response = {
641
+ currentVersion: "0.9.4",
642
+ check: { status: "ok", updateAvailable: false },
643
+ run: run({ outcome: "succeeded" }),
644
+ };
645
+ await retryConnection(dom, api);
646
+ await waitFor(
647
+ dom,
648
+ () => /Updated to Remit 0\.9\.4/.test(dom.html()),
649
+ "the finished run to reach the pane",
650
+ );
651
+
652
+ await act(async () => {
653
+ api().onDismissResult();
654
+ await dom.flush();
655
+ });
656
+ await waitFor(
657
+ dom,
658
+ () => !/Updated to Remit/.test(dom.html()),
659
+ "the outcome to be retired",
660
+ );
661
+ assert.doesNotMatch(dom.html(), /The updater did not answer/);
662
+ assert.match(dom.html(), /0\.9\.4 is the latest version/);
663
+ });
664
+
561
665
  test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
562
666
  let refused = false;
563
667
  const { dom, api } = mountApi(
@@ -249,6 +249,12 @@ export function useSystemUpdate(): SelfUpdateApi {
249
249
  if (shownRunIdRef.current !== null)
250
250
  setDismissedRunId(shownRunIdRef.current);
251
251
  setHeld(null);
252
+ // Retiring the outcome uncovers the check verdict beneath it. A failure
253
+ // recorded while the outcome held the pane describes a press the user can
254
+ // no longer see, and an update run of its own supersedes anything an
255
+ // earlier check concluded — either way it must not resurface here.
256
+ setCheckPress(null);
257
+ setCheckFailure(null);
252
258
  }, []);
253
259
 
254
260
  return {
@@ -16,6 +16,7 @@ import {
16
16
  } from "@/components/ui/placement-refusal";
17
17
  import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
18
18
  import { runChunkedMutation } from "@/lib/bulk-actions";
19
+ import { softErrorStatuses } from "@/lib/error-classifier";
19
20
  import {
20
21
  cancelThreadListQueries,
21
22
  invalidateThreadListQueries,
@@ -92,6 +93,10 @@ export const useDeleteMessages = ({
92
93
 
93
94
  const { mutateAsync, isPending } = useMutation({
94
95
  ...messageBulkOperationsDeleteMessagesMutation(),
96
+ // A coded 409 is answered here — the prompt, or a banner — so it must not
97
+ // also take the whole screen. Only 409: a 401, 403 or 5xx on a delete is
98
+ // still the fatal page's (#1059).
99
+ meta: softErrorStatuses(409),
95
100
  onMutate: async (variables): Promise<ThreadMutationContext> => {
96
101
  const messageIds = new Set(variables.body.messageIds ?? []);
97
102
 
@@ -22,6 +22,7 @@ import {
22
22
  type FolderRoleRefusal,
23
23
  isFolderRoleRefusal,
24
24
  } from "@/components/ui/folder-role-refusal";
25
+ import { softErrorStatuses } from "@/lib/error-classifier";
25
26
  import {
26
27
  invalidateThreadListQueries,
27
28
  threadListCacheKeys,
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
109
110
 
110
111
  const { mutateAsync, isPending } = useMutation({
111
112
  ...trashOperationsEmptyTrashMutation(),
113
+ // The refusal is rendered in the pane, so it must not also take the whole
114
+ // screen. Only 409 — every other status is still the fatal page's (#1059).
115
+ meta: softErrorStatuses(409),
112
116
  onMutate: (): EmptyTrashContext => {
113
117
  inFlight.current = scope;
114
118
  return { scope, listMailboxId: listMailboxIdNow() };
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
351
351
 
352
352
  const isLoading = query.isLoading && !!picked;
353
353
 
354
+ // `picked` is null for two different reasons, and a caller that treats them
355
+ // alike is wrong about one of them: the message has not arrived yet, or it
356
+ // arrived carrying nothing renderable. Only the second is a fact about the
357
+ // message, and only it may be stated to the reader (#1030).
358
+ const hasNoRenderablePart = bodyParts !== undefined && picked === null;
359
+
354
360
  useEffect(() => {
355
361
  if (isLoading) {
356
362
  loadStartRef.current = performance.now();
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
374
380
  picked,
375
381
  data,
376
382
  isLoading,
383
+ hasNoRenderablePart,
377
384
  isError: query.isError,
378
385
  error: query.error,
379
386
  refetch: query.refetch,
package/src/lib/api.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ApiError as ApiErrorWireBody } from "@remit/api-http-client/types.gen.ts";
1
2
  import { taggedFetch } from "./network-error";
2
3
 
3
4
  export class ApiError extends Error {
@@ -11,6 +12,46 @@ export class ApiError extends Error {
11
12
  }
12
13
  }
13
14
 
15
+ /**
16
+ * The wire body's coded half, re-checked at runtime. `ApiErrorWireBody` is the
17
+ * generated flat shape the API sends; `ApiError` is the class every HTTP
18
+ * failure arrives in, carrying that body at `.body`. Reading `code` off the
19
+ * class is the bug #1004 is about, so nothing outside this module reaches for
20
+ * either shape directly.
21
+ */
22
+ export interface CodedApiErrorBody {
23
+ code: ApiErrorWireBody["code"];
24
+ details: Record<string, unknown> | undefined;
25
+ }
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === "object" && value !== null && !Array.isArray(value);
29
+
30
+ export const codedApiErrorBody = (
31
+ error: unknown,
32
+ ): CodedApiErrorBody | undefined => {
33
+ const body = error instanceof ApiError ? error.body : error;
34
+ if (!isRecord(body)) return undefined;
35
+ const { code, details } = body;
36
+ if (typeof code !== "string") return undefined;
37
+ return { code, details: isRecord(details) ? details : undefined };
38
+ };
39
+
40
+ const failureMessage = (body: unknown, status: number): string => {
41
+ if (isRecord(body) && body.message !== undefined && body.message !== null) {
42
+ const message = String(body.message);
43
+ if (message) return message;
44
+ }
45
+ return `Request failed with status ${status}`;
46
+ };
47
+
48
+ /**
49
+ * The one place an HTTP failure becomes an `ApiError`, so a test can hand a
50
+ * consumer the exact value the error interceptor produces.
51
+ */
52
+ export const wrapHttpFailure = (body: unknown, status: number): ApiError =>
53
+ new ApiError(failureMessage(body, status), status, body);
54
+
14
55
  interface RequestOptions extends Omit<RequestInit, "body"> {
15
56
  params?: Record<string, string | number | boolean | undefined>;
16
57
  body?: unknown;
@@ -52,11 +93,7 @@ const request = async <T>(
52
93
 
53
94
  if (!response.ok) {
54
95
  const errorBody = await response.json().catch(() => undefined);
55
- throw new ApiError(
56
- errorBody?.message || `Request failed with status ${response.status}`,
57
- response.status,
58
- errorBody,
59
- );
96
+ throw wrapHttpFailure(errorBody, response.status);
60
97
  }
61
98
 
62
99
  if (response.status === 204) {
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
4
+ import { type HttpMock, mockFetch } from "../test-support/http";
5
+ import { ApiError } from "./api";
6
+ import "./client";
7
+
8
+ let http: HttpMock | undefined;
9
+
10
+ afterEach(() => {
11
+ http?.restore();
12
+ http = undefined;
13
+ });
14
+
15
+ const answer = (status: number, body: unknown): void => {
16
+ http = mockFetch(
17
+ () =>
18
+ new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "content-type": "application/json" },
21
+ }),
22
+ );
23
+ };
24
+
25
+ const empty = (): Promise<unknown> =>
26
+ trashOperationsEmptyTrash({
27
+ path: { accountId: "acc-1" },
28
+ throwOnError: true,
29
+ });
30
+
31
+ describe("the error interceptor", () => {
32
+ it("hands the app an ApiError carrying the status and the wire body", async () => {
33
+ answer(409, {
34
+ code: "folder_role_unresolved",
35
+ message: "Nobody has confirmed which folder is this account's Trash",
36
+ details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
37
+ });
38
+
39
+ const error = await empty().then(
40
+ () => undefined,
41
+ (reason: unknown) => reason,
42
+ );
43
+
44
+ assert.ok(error instanceof ApiError);
45
+ assert.equal(error.status, 409);
46
+ assert.equal(
47
+ error.message,
48
+ "Nobody has confirmed which folder is this account's Trash",
49
+ );
50
+ assert.deepEqual(error.body, {
51
+ code: "folder_role_unresolved",
52
+ message: "Nobody has confirmed which folder is this account's Trash",
53
+ details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
54
+ });
55
+ });
56
+
57
+ it("falls back to the status when the body states no message", async () => {
58
+ answer(503, {});
59
+
60
+ const error = await empty().then(
61
+ () => undefined,
62
+ (reason: unknown) => reason,
63
+ );
64
+
65
+ assert.ok(error instanceof ApiError);
66
+ assert.equal(error.message, "Request failed with status 503");
67
+ });
68
+ });
package/src/lib/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { client } from "@remit/api-http-client/client.gen.ts";
2
2
  import { getRuntimeConfig } from "../runtime-config";
3
- import { ApiError } from "./api";
3
+ import { ApiError, wrapHttpFailure } from "./api";
4
4
  import { taggedFetch } from "./network-error";
5
5
 
6
6
  // In production, the config.js apiUrl points at the deployed API Gateway.
@@ -27,13 +27,7 @@ client.interceptors.error.use((error, response) => {
27
27
  if (error instanceof ApiError) return error;
28
28
  if (!response) return error;
29
29
 
30
- const body = error;
31
- const message =
32
- body && typeof body === "object" && "message" in body
33
- ? String((body as { message: unknown }).message)
34
- : `Request failed with status ${response.status}`;
35
-
36
- return new ApiError(message, response.status, body);
30
+ return wrapHttpFailure(error, response.status);
37
31
  });
38
32
 
39
33
  export { client };
@@ -31,6 +31,7 @@ import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal"
31
31
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
32
32
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
33
33
  import { guardFolderDeletion } from "@/lib/delete-folder";
34
+ import { softErrorStatuses } from "@/lib/error-classifier";
34
35
  import {
35
36
  buildMailboxRoleMap,
36
37
  CANONICAL_TO_NAV_ROLE,
@@ -57,6 +58,9 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
57
58
 
58
59
  const appointMutation = useMutation({
59
60
  ...folderRoleOperationsAppointFolderRoleMutation(),
61
+ // The pane states a still-settling mailbox inline; see the same meta on
62
+ // the prompt's own write.
63
+ meta: softErrorStatuses(409),
60
64
  onSuccess: () => {
61
65
  queryClient.invalidateQueries({
62
66
  queryKey: configOperationsGetConfigQueryKey(),
@@ -71,8 +71,9 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
71
71
 
72
72
  /**
73
73
  * A failed request. The status travels in the body as well as on the response:
74
- * the generated client throws the parsed body, and the app's error classifier
75
- * reads the status off it.
74
+ * the generated client throws the parsed body, which the error interceptor
75
+ * re-wraps as an `ApiError` carrying the response status. Anything read off the
76
+ * body — a `code`, its `details` — sits at `.body`, never at the top level.
76
77
  */
77
78
  export const httpError = (status: number, message = "boom"): Response =>
78
79
  new Response(JSON.stringify({ status, message }), {