@remit/web-client 0.0.239 → 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 +1 -1
- package/src/auth/auth-interceptor.test.ts +41 -38
- package/src/components/compose/ComposeForm.tsx +51 -2
- package/src/components/compose/attachment-only-source.render.test.ts +233 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +7 -3
- package/src/components/settings/QuarantinePanel.delimiter.test.ts +112 -0
- package/src/components/settings/QuarantinePanel.tsx +38 -15
- package/src/components/ui/folder-role-refusal.test.ts +56 -26
- package/src/components/ui/folder-role-refusal.ts +6 -18
- package/src/components/ui/placement-refusal.test.ts +14 -6
- package/src/components/ui/placement-refusal.ts +5 -6
- package/src/hooks/refusal-escalation.render.test.ts +187 -0
- package/src/hooks/use-system-update.test.ts +104 -0
- package/src/hooks/use-system-update.ts +6 -0
- package/src/hooks/useDeleteMessages.ts +5 -0
- package/src/hooks/useEmptyTrash.ts +4 -0
- package/src/hooks/useMessageBodyContent.ts +7 -0
- package/src/hooks/useResultFolderIndex.ts +39 -9
- package/src/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/lib/quarantine-entries.ts +5 -3
- package/src/routes/mail.tsx +1 -1
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
- package/src/hooks/useAccountDelimiters.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
44
|
-
assert.
|
|
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
|
-
() =>
|
|
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(
|
|
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
|
-
|
|
75
|
-
assert.
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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]);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quarantine row names the folder its message sits in, which means cutting a
|
|
3
|
+
* provider path into a leaf — and that cut is only correct on the account's own
|
|
4
|
+
* hierarchy separator. The separator comes from a second read (the account's
|
|
5
|
+
* mailbox list), so the pane must never paint a row before that read lands:
|
|
6
|
+
* `INBOX.Projects.Q3` cut on a slash is one segment, and the row would show the
|
|
7
|
+
* whole path as if it were a folder name. See #900.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { createElement } from "react";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
14
|
+
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
15
|
+
import { QuarantinePanel } from "./QuarantinePanel";
|
|
16
|
+
|
|
17
|
+
const ACCOUNT_ID = "acct-1";
|
|
18
|
+
const MAILBOX_ID = "mbx-q3";
|
|
19
|
+
const FULL_PATH = "INBOX.Projects.Q3";
|
|
20
|
+
|
|
21
|
+
const CONFIG = {
|
|
22
|
+
accounts: [{ accountId: ACCOUNT_ID, folderAppointments: [] }],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAILBOXES = {
|
|
26
|
+
items: [
|
|
27
|
+
{
|
|
28
|
+
mailboxId: MAILBOX_ID,
|
|
29
|
+
fullPath: FULL_PATH,
|
|
30
|
+
hierarchyDelimiter: ".",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const QUARANTINE = {
|
|
36
|
+
entries: [
|
|
37
|
+
{
|
|
38
|
+
quarantineId: "q-1",
|
|
39
|
+
accountId: ACCOUNT_ID,
|
|
40
|
+
mailboxId: MAILBOX_ID,
|
|
41
|
+
uidValidity: 1_712_000_000,
|
|
42
|
+
uid: 40251,
|
|
43
|
+
mailboxPath: FULL_PATH,
|
|
44
|
+
failureStage: "BodyParse",
|
|
45
|
+
failureCode: "UnreadableBody",
|
|
46
|
+
failureMessage: "stream ended before the declared body length",
|
|
47
|
+
quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
|
|
48
|
+
attempts: 1,
|
|
49
|
+
sizeBytes: 2_140,
|
|
50
|
+
structure: [],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let harness: DomHarness | undefined;
|
|
56
|
+
let http: HttpMock | undefined;
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
harness?.close();
|
|
60
|
+
harness = undefined;
|
|
61
|
+
http?.restore();
|
|
62
|
+
http = undefined;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const mount = async (mailboxes: () => unknown): Promise<DomHarness> => {
|
|
66
|
+
http = mockFetch((call) => {
|
|
67
|
+
if (call.path.endsWith("/config")) return CONFIG;
|
|
68
|
+
if (call.path.endsWith("/mailboxes")) return mailboxes();
|
|
69
|
+
if (call.path.endsWith("/quarantine")) return QUARANTINE;
|
|
70
|
+
return {};
|
|
71
|
+
});
|
|
72
|
+
harness = createDomHarness();
|
|
73
|
+
harness.renderApp(createElement(QuarantinePanel));
|
|
74
|
+
// Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
|
|
75
|
+
// returns, so it cannot even start until that first read has settled.
|
|
76
|
+
await harness.flush();
|
|
77
|
+
await harness.wait(0);
|
|
78
|
+
await harness.flush();
|
|
79
|
+
return harness;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("QuarantinePanel folder names", () => {
|
|
83
|
+
it("waits for the account's delimiter rather than cutting on a guess", async () => {
|
|
84
|
+
const view = await mount(() => new Promise(() => {}));
|
|
85
|
+
|
|
86
|
+
assert.match(view.text(), /Checking for messages set aside/);
|
|
87
|
+
assert.doesNotMatch(
|
|
88
|
+
view.text(),
|
|
89
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("names the folder by its leaf once the delimiter has arrived", async () => {
|
|
94
|
+
const view = await mount(() => MAILBOXES);
|
|
95
|
+
|
|
96
|
+
assert.match(view.text(), /Q3/);
|
|
97
|
+
assert.doesNotMatch(
|
|
98
|
+
view.text(),
|
|
99
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reports a failed mailbox read instead of naming folders on a guess", async () => {
|
|
104
|
+
const view = await mount(() => httpError(500));
|
|
105
|
+
|
|
106
|
+
assert.match(view.html(), /role="alert"/);
|
|
107
|
+
assert.doesNotMatch(
|
|
108
|
+
view.text(),
|
|
109
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|