@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.
|
|
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
|
+
});
|