@remit/web-client 0.0.301 → 0.0.302

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.301",
3
+ "version": "0.0.302",
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": {
@@ -64,6 +64,7 @@ import type {
64
64
  import { AddressField } from "./AddressField";
65
65
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
66
66
  import { composeSpellcheck } from "./compose-spellcheck.js";
67
+ import { mailSyncOffReason } from "./mail-sync-off-refusal.js";
67
68
  import {
68
69
  buildQuotedBlock,
69
70
  outgoingBody,
@@ -910,6 +911,13 @@ export const ComposeForm = ({
910
911
  const selectedAccountMissingSmtp = selectedAccount
911
912
  ? accountIsMissingSmtp(selectedAccount)
912
913
  : false;
914
+ const [mailOffRefusal, setMailOffRefusal] = useState<
915
+ { accountId: string; reason: string } | undefined
916
+ >();
917
+ const selectedAccountMailOffReason =
918
+ mailOffRefusal && mailOffRefusal.accountId === selectedAccountId
919
+ ? mailOffRefusal.reason
920
+ : undefined;
913
921
 
914
922
  // An account that has never been to the language setting falls back to what
915
923
  // the browser already knows the user reads, which is an ordered answer.
@@ -942,6 +950,9 @@ export const ComposeForm = ({
942
950
  if (!selectedAccountId) {
943
951
  return { status: "blocked", reason: "Choose an account to send from." };
944
952
  }
953
+ if (selectedAccountMailOffReason) {
954
+ return { status: "blocked", reason: selectedAccountMailOffReason };
955
+ }
945
956
  if (selectedAccountMissingSmtp) {
946
957
  return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
947
958
  }
@@ -972,6 +983,7 @@ export const ComposeForm = ({
972
983
  [
973
984
  isSending,
974
985
  selectedAccountId,
986
+ selectedAccountMailOffReason,
975
987
  selectedAccountMissingSmtp,
976
988
  quoteIsLoading,
977
989
  quoteSourceIsLoading,
@@ -1116,6 +1128,8 @@ export const ComposeForm = ({
1116
1128
  path: { outboxMessageId: messageId },
1117
1129
  })
1118
1130
  .catch((error: unknown) => {
1131
+ const reason = mailSyncOffReason(error);
1132
+ if (reason) setMailOffRefusal({ accountId, reason });
1119
1133
  pushError({
1120
1134
  title: "Couldn't send message",
1121
1135
  detail:
@@ -0,0 +1,7 @@
1
+ import { codedApiErrorBody } from "@/lib/api";
2
+ import { formatErrorDetail } from "../ui/error-banners.js";
3
+
4
+ export const mailSyncOffReason = (error: unknown): string | undefined => {
5
+ if (codedApiErrorBody(error)?.code !== "mail_sync_off") return undefined;
6
+ return formatErrorDetail(error);
7
+ };
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapAccountResponse,
5
+ RemitImapDescribeMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ type AnyRouter,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ RouterContextProvider,
14
+ } from "@tanstack/react-router";
15
+ import { createElement, useState } from "react";
16
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
17
+ import { type HttpMock, mockFetch } from "../../test-support/http";
18
+ import { ComposeForm } from "./ComposeForm";
19
+ import { ComposeProvider } from "./ComposeProvider";
20
+
21
+ const ACCOUNT_ID = "acc-1181";
22
+ const OUTBOX_MESSAGE_ID = "ob-1181";
23
+ const SERVER_REFUSAL =
24
+ "Mail is turned off for this account, so it cannot send. Turn Mail back on for this account in Settings to send from it.";
25
+
26
+ const sourceMessage = {
27
+ message: { messageId: "msg-1" },
28
+ envelope: {
29
+ subject: "Lunch",
30
+ messageIdValue: "<m1@example.com>",
31
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
32
+ replyTo: [],
33
+ to: [],
34
+ cc: [],
35
+ },
36
+ references: [],
37
+ bodyParts: [],
38
+ } as unknown as RemitImapDescribeMessageResponse;
39
+
40
+ const outboxEntry = {
41
+ attachments: [],
42
+ outboxMessageId: OUTBOX_MESSAGE_ID,
43
+ accountId: ACCOUNT_ID,
44
+ fromAddress: "me@example.com",
45
+ toAddresses: ["them@example.com"],
46
+ ccAddresses: [],
47
+ bccAddresses: [],
48
+ references: [],
49
+ subject: "Re: Lunch",
50
+ textBody: "yes",
51
+ status: "draft",
52
+ };
53
+
54
+ let harness: DomHarness | undefined;
55
+ let http: HttpMock | undefined;
56
+ let closed = 0;
57
+
58
+ afterEach(() => {
59
+ harness?.close();
60
+ harness = undefined;
61
+ http?.restore();
62
+ http = undefined;
63
+ closed = 0;
64
+ });
65
+
66
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
67
+
68
+ const rootRoute = createRootRoute();
69
+ const mailboxRoute = createRoute({
70
+ getParentRoute: () => rootRoute,
71
+ path: "/mail/$mailboxId",
72
+ validateSearch: (search: Record<string, unknown>) => search,
73
+ });
74
+
75
+ const testRouter = (): AnyRouter =>
76
+ createRouter({
77
+ routeTree: rootRoute.addChildren([mailboxRoute]),
78
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
79
+ }) as unknown as AnyRouter;
80
+
81
+ const mailSyncOff = (): Response =>
82
+ new Response(
83
+ JSON.stringify({
84
+ status: 400,
85
+ code: "mail_sync_off",
86
+ message: SERVER_REFUSAL,
87
+ details: { accountId: ACCOUNT_ID },
88
+ }),
89
+ { status: 400, headers: { "content-type": "application/json" } },
90
+ );
91
+
92
+ const mount = async (options: {
93
+ syncedServices: string[];
94
+ serverRefusesSend: boolean;
95
+ }): Promise<void> => {
96
+ const account = {
97
+ accountId: ACCOUNT_ID,
98
+ email: "me@example.com",
99
+ smtpEnabled: true,
100
+ syncedServices: options.syncedServices,
101
+ } as unknown as RemitImapAccountResponse;
102
+
103
+ http = mockFetch(async (call) => {
104
+ if (call.path.endsWith("/config")) return { accounts: [account] };
105
+ if (call.path.endsWith("/send") && options.serverRefusesSend) {
106
+ return mailSyncOff();
107
+ }
108
+ return outboxEntry;
109
+ });
110
+
111
+ const Opened = () => {
112
+ const [draftId, setDraftId] = useState<string | undefined>(
113
+ OUTBOX_MESSAGE_ID,
114
+ );
115
+ return createElement(ComposeForm, {
116
+ mode: "reply",
117
+ account,
118
+ sourceMessage,
119
+ outboxMessageId: draftId,
120
+ onDraftCreated: setDraftId,
121
+ onClose: () => {
122
+ closed += 1;
123
+ },
124
+ });
125
+ };
126
+
127
+ harness = createDomHarness();
128
+ harness.renderApp(
129
+ createElement(RouterContextProvider, {
130
+ router: testRouter(),
131
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
132
+ children: createElement(ComposeProvider, null, createElement(Opened)),
133
+ }),
134
+ );
135
+ await harness.flush();
136
+ await harness.wait(50);
137
+ };
138
+
139
+ const pressSend = async (): Promise<void> => {
140
+ const button = harness?.byText("button", "Send");
141
+ if (!button) throw new Error("the compose send button is not mounted");
142
+ harness?.click(button);
143
+ await harness?.flush();
144
+ await harness?.wait(100);
145
+ };
146
+
147
+ const sends = (): number =>
148
+ (http?.calls ?? []).filter((call) => call.path.endsWith("/send")).length;
149
+
150
+ describe("sending from an account with mail off (#1181)", () => {
151
+ it("shows the server's refusal in the composer and keeps it open", async () => {
152
+ await mount({ syncedServices: ["Calendar"], serverRefusesSend: true });
153
+
154
+ await pressSend();
155
+
156
+ assert.equal(sends(), 1);
157
+ assert.equal(closed, 0);
158
+ assert.ok((harness?.text() ?? "").includes(SERVER_REFUSAL));
159
+ });
160
+
161
+ it("holds Send blocked on that refusal instead of asking the server again", async () => {
162
+ await mount({ syncedServices: ["Calendar"], serverRefusesSend: true });
163
+
164
+ await pressSend();
165
+ await pressSend();
166
+
167
+ assert.equal(sends(), 1);
168
+ assert.equal(
169
+ harness?.byText("button", "Send")?.getAttribute("title"),
170
+ SERVER_REFUSAL,
171
+ );
172
+ });
173
+ });