@remit/web-client 0.0.228 → 0.0.229
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.229",
|
|
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": {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
RemitImapDescribeMessageResponse,
|
|
10
10
|
} from "@remit/api-http-client/types.gen.ts";
|
|
11
11
|
import {
|
|
12
|
+
Banner,
|
|
12
13
|
ComposeActionBar,
|
|
13
14
|
ComposeBodySkeleton,
|
|
14
15
|
ComposeFormShell,
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
SMTP_MISSING_MESSAGE,
|
|
25
26
|
sanitizeQuotedHtml,
|
|
26
27
|
unwrapLanguage,
|
|
27
|
-
wrapWithLanguage,
|
|
28
28
|
} from "@remit/ui";
|
|
29
29
|
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
30
30
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
@@ -56,6 +56,13 @@ import type {
|
|
|
56
56
|
import { AddressField } from "./AddressField";
|
|
57
57
|
import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
|
|
58
58
|
import { composeSpellcheck } from "./compose-spellcheck.js";
|
|
59
|
+
import {
|
|
60
|
+
buildQuotedBlock,
|
|
61
|
+
outgoingBody,
|
|
62
|
+
type QuotedBlock,
|
|
63
|
+
type QuotedSourceBody,
|
|
64
|
+
textToHtml,
|
|
65
|
+
} from "./quoted-message.js";
|
|
59
66
|
|
|
60
67
|
const LazyComposeBody = lazy(() =>
|
|
61
68
|
import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
|
|
@@ -90,19 +97,6 @@ interface ComposeFormProps {
|
|
|
90
97
|
layout?: ComposeShellLayout;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
const escapeHtml = (text: string): string =>
|
|
94
|
-
text
|
|
95
|
-
.replace(/&/g, "&")
|
|
96
|
-
.replace(/</g, "<")
|
|
97
|
-
.replace(/>/g, ">")
|
|
98
|
-
.replace(/"/g, """);
|
|
99
|
-
|
|
100
|
-
const textToHtml = (text: string): string =>
|
|
101
|
-
text
|
|
102
|
-
.split("\n")
|
|
103
|
-
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
|
104
|
-
.join("");
|
|
105
|
-
|
|
106
100
|
const buildInitialHtml = (signaturePlainText: string): string => {
|
|
107
101
|
if (!signaturePlainText) return "";
|
|
108
102
|
return `<p></p><p>-- </p>${textToHtml(signaturePlainText)}`;
|
|
@@ -185,34 +179,6 @@ const getReferences = (
|
|
|
185
179
|
};
|
|
186
180
|
};
|
|
187
181
|
|
|
188
|
-
/**
|
|
189
|
-
* What the two body columns carry for this mode.
|
|
190
|
-
*
|
|
191
|
-
* Plain mode writes the empty string rather than omitting `htmlBody`: absent
|
|
192
|
-
* means "leave alone" at every layer below, so a plain draft that omitted it
|
|
193
|
-
* would send the HTML it was written as before the switch. The empty string is
|
|
194
|
-
* defined, so the repository's update guard clears the column, and nodemailer
|
|
195
|
-
* branches on the value being truthy — an empty one builds no HTML alternative
|
|
196
|
-
* and the message leaves as a single `text/plain` part.
|
|
197
|
-
*
|
|
198
|
-
* Rich mode leaves it alone while it has nothing to say, so the moments before
|
|
199
|
-
* the lazily-loaded editor reports its document cannot write a draft back as
|
|
200
|
-
* plain.
|
|
201
|
-
*/
|
|
202
|
-
const outgoingBody = (
|
|
203
|
-
bodyMode: ComposeBodyMode,
|
|
204
|
-
body: RichTextValue,
|
|
205
|
-
language: string,
|
|
206
|
-
): { textBody: string | undefined; htmlBody: string | undefined } => ({
|
|
207
|
-
textBody: body.text || undefined,
|
|
208
|
-
htmlBody:
|
|
209
|
-
bodyMode === "plain"
|
|
210
|
-
? ""
|
|
211
|
-
: body.html
|
|
212
|
-
? wrapWithLanguage(body.html, language)
|
|
213
|
-
: undefined,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
182
|
type SendReadiness =
|
|
217
183
|
| { status: "sending" }
|
|
218
184
|
| { status: "blocked"; reason: string }
|
|
@@ -243,6 +209,22 @@ interface AddressFieldHandles {
|
|
|
243
209
|
*/
|
|
244
210
|
const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
|
|
245
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Send waits for the message being quoted. Pressed before it lands, the send
|
|
214
|
+
* would go out carrying the answer and nothing of what it answers — the defect
|
|
215
|
+
* this refusal exists to make impossible rather than merely unlikely (#845.5).
|
|
216
|
+
*/
|
|
217
|
+
const QUOTE_LOADING_MESSAGE = "Loading the message you're quoting.";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The quoted original could not be fetched. A forward without it is an empty
|
|
221
|
+
* message and a reply without it drops the thread, so the composer says so
|
|
222
|
+
* where the message is being written instead of sending a message the user
|
|
223
|
+
* believes carries the original.
|
|
224
|
+
*/
|
|
225
|
+
const QUOTE_FAILED_MESSAGE =
|
|
226
|
+
"The message you're quoting couldn't be loaded, so it won't be included.";
|
|
227
|
+
|
|
246
228
|
/** A field holding text that is not an address, and which field it is. */
|
|
247
229
|
interface UnparsedField {
|
|
248
230
|
label: string;
|
|
@@ -456,6 +438,15 @@ export const ComposeForm = ({
|
|
|
456
438
|
* the subject the reader edited.
|
|
457
439
|
*/
|
|
458
440
|
const resumedDraftRef = useRef(outboxMessageId !== undefined);
|
|
441
|
+
/**
|
|
442
|
+
* Whether the document on screen already carries the quoted original. True of
|
|
443
|
+
* exactly one thing: a draft read back from the server, which was saved with
|
|
444
|
+
* the quote in its body. Held as state rather than read off `resumedDraftRef`
|
|
445
|
+
* because the quote is assembled during render.
|
|
446
|
+
*/
|
|
447
|
+
const [documentHoldsQuote, setDocumentHoldsQuote] = useState(
|
|
448
|
+
outboxMessageId !== undefined,
|
|
449
|
+
);
|
|
459
450
|
/**
|
|
460
451
|
* The mode the fields on screen were last written for. A draft that is
|
|
461
452
|
* resumed holds what its reader saved and must not be overwritten when it
|
|
@@ -521,6 +512,7 @@ export const ComposeForm = ({
|
|
|
521
512
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
522
513
|
if (previous === undefined) return;
|
|
523
514
|
resumedDraftRef.current = outboxMessageId !== undefined;
|
|
515
|
+
setDocumentHoldsQuote(outboxMessageId !== undefined);
|
|
524
516
|
seededModeRef.current =
|
|
525
517
|
outboxMessageId !== undefined ? modeRef.current : undefined;
|
|
526
518
|
seededMyEmailRef.current = undefined;
|
|
@@ -679,22 +671,42 @@ export const ComposeForm = ({
|
|
|
679
671
|
}, [mode, sourceMessage, account?.email, clearPendingFields]);
|
|
680
672
|
|
|
681
673
|
// Quoted reply/forward content lives at the per-part `contentUrl` since
|
|
682
|
-
// #224 PR 3 — fetch it via the same hook MessageBody uses
|
|
683
|
-
// to an empty quote when nothing renderable exists (the user can still
|
|
684
|
-
// attribute the reply manually).
|
|
674
|
+
// #224 PR 3 — fetch it via the same hook MessageBody uses.
|
|
685
675
|
const isQuoting =
|
|
686
676
|
mode === "reply" || mode === "reply-all" || mode === "forward";
|
|
687
|
-
const {
|
|
677
|
+
const {
|
|
678
|
+
data: sourceBody,
|
|
679
|
+
isLoading: quoteIsLoading,
|
|
680
|
+
isError: quoteFailed,
|
|
681
|
+
refetch: refetchQuote,
|
|
682
|
+
} = useMessageBodyContent({
|
|
688
683
|
messageId: sourceMessage?.message.messageId,
|
|
689
684
|
bodyParts: sourceMessage?.bodyParts,
|
|
690
685
|
enabled: isQuoting && !!sourceMessage,
|
|
691
686
|
});
|
|
692
687
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
688
|
+
/**
|
|
689
|
+
* The original as it will be sent, and as it is shown while the answer is
|
|
690
|
+
* written — one value for both, because the two disagreeing is the whole of
|
|
691
|
+
* #845.5.
|
|
692
|
+
*
|
|
693
|
+
* Absent for a draft read back from the server: that document was saved with
|
|
694
|
+
* the quote already in it, so the editor holds it and appending a second copy
|
|
695
|
+
* would send the original twice.
|
|
696
|
+
*/
|
|
697
|
+
const quotedBlock = useMemo<QuotedBlock | undefined>(() => {
|
|
698
|
+
if (!isQuoting || !sourceMessage || documentHoldsQuote) return undefined;
|
|
699
|
+
if (!sourceBody) return undefined;
|
|
700
|
+
const body: QuotedSourceBody =
|
|
701
|
+
sourceBody.kind === "html"
|
|
702
|
+
? { kind: "html", content: sanitizeQuotedHtml(sourceBody.body) }
|
|
703
|
+
: { kind: "text", content: sourceBody.body };
|
|
704
|
+
return buildQuotedBlock(
|
|
705
|
+
mode === "forward" ? "forward" : "reply",
|
|
706
|
+
sourceMessage.envelope,
|
|
707
|
+
body,
|
|
708
|
+
);
|
|
709
|
+
}, [isQuoting, sourceMessage, documentHoldsQuote, sourceBody, mode]);
|
|
698
710
|
|
|
699
711
|
const senderName =
|
|
700
712
|
sourceMessage?.envelope.from[0]?.displayName ??
|
|
@@ -806,6 +818,9 @@ export const ComposeForm = ({
|
|
|
806
818
|
if (selectedAccountMissingSmtp) {
|
|
807
819
|
return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
|
|
808
820
|
}
|
|
821
|
+
if (quoteIsLoading) {
|
|
822
|
+
return { status: "blocked", reason: QUOTE_LOADING_MESSAGE };
|
|
823
|
+
}
|
|
809
824
|
if (unparsed) {
|
|
810
825
|
return { status: "blocked", reason: unparsedRefusal(unparsed) };
|
|
811
826
|
}
|
|
@@ -814,7 +829,7 @@ export const ComposeForm = ({
|
|
|
814
829
|
}
|
|
815
830
|
return { status: "ready", accountId: selectedAccountId };
|
|
816
831
|
},
|
|
817
|
-
[isSending, selectedAccountId, selectedAccountMissingSmtp],
|
|
832
|
+
[isSending, selectedAccountId, selectedAccountMissingSmtp, quoteIsLoading],
|
|
818
833
|
);
|
|
819
834
|
const sendReadiness = useMemo<SendReadiness>(
|
|
820
835
|
() =>
|
|
@@ -850,6 +865,7 @@ export const ComposeForm = ({
|
|
|
850
865
|
bodyMode,
|
|
851
866
|
body,
|
|
852
867
|
composeLanguage,
|
|
868
|
+
quotedBlock,
|
|
853
869
|
);
|
|
854
870
|
|
|
855
871
|
const payload = {
|
|
@@ -887,6 +903,7 @@ export const ComposeForm = ({
|
|
|
887
903
|
body,
|
|
888
904
|
bodyMode,
|
|
889
905
|
composeLanguage,
|
|
906
|
+
quotedBlock,
|
|
890
907
|
saveDraft,
|
|
891
908
|
]);
|
|
892
909
|
|
|
@@ -908,6 +925,7 @@ export const ComposeForm = ({
|
|
|
908
925
|
bodyMode,
|
|
909
926
|
body,
|
|
910
927
|
composeLanguage,
|
|
928
|
+
quotedBlock,
|
|
911
929
|
);
|
|
912
930
|
const createdThisAttempt = !outboxMessageId;
|
|
913
931
|
|
|
@@ -976,6 +994,7 @@ export const ComposeForm = ({
|
|
|
976
994
|
body,
|
|
977
995
|
bodyMode,
|
|
978
996
|
composeLanguage,
|
|
997
|
+
quotedBlock,
|
|
979
998
|
mode,
|
|
980
999
|
sourceMessage,
|
|
981
1000
|
outboxMessageId,
|
|
@@ -1066,12 +1085,28 @@ export const ComposeForm = ({
|
|
|
1066
1085
|
<ComposeFormShell
|
|
1067
1086
|
layout={layout}
|
|
1068
1087
|
banner={
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1088
|
+
<>
|
|
1089
|
+
{selectedAccount && selectedAccountMissingSmtp ? (
|
|
1090
|
+
<ComposeSmtpMissingBanner
|
|
1091
|
+
accountId={selectedAccount.accountId}
|
|
1092
|
+
configureRef={smtpConfigureRef}
|
|
1093
|
+
/>
|
|
1094
|
+
) : null}
|
|
1095
|
+
{quoteFailed ? (
|
|
1096
|
+
<Banner tone="warning" data-testid="compose-quote-failed">
|
|
1097
|
+
<span>{QUOTE_FAILED_MESSAGE}</span>{" "}
|
|
1098
|
+
<button
|
|
1099
|
+
type="button"
|
|
1100
|
+
className="underline"
|
|
1101
|
+
onClick={() => {
|
|
1102
|
+
void refetchQuote();
|
|
1103
|
+
}}
|
|
1104
|
+
>
|
|
1105
|
+
Try again
|
|
1106
|
+
</button>
|
|
1107
|
+
</Banner>
|
|
1108
|
+
) : null}
|
|
1109
|
+
</>
|
|
1075
1110
|
}
|
|
1076
1111
|
header={
|
|
1077
1112
|
<WiredComposeHeader
|
|
@@ -1095,10 +1130,10 @@ export const ComposeForm = ({
|
|
|
1095
1130
|
/>
|
|
1096
1131
|
}
|
|
1097
1132
|
quoted={
|
|
1098
|
-
|
|
1133
|
+
quotedBlock ? (
|
|
1099
1134
|
<QuotedText
|
|
1100
|
-
text={
|
|
1101
|
-
html={
|
|
1135
|
+
text={quotedBlock.text}
|
|
1136
|
+
html={quotedBlock.html}
|
|
1102
1137
|
senderName={senderName}
|
|
1103
1138
|
/>
|
|
1104
1139
|
) : undefined
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #845.5: the composer showed the quoted original under the editor the
|
|
3
|
+
* whole time a reply or a forward was being written, and sent neither. A
|
|
4
|
+
* forward left carrying the signature and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* Asserted here on the request, because the request is what the recipient gets:
|
|
7
|
+
* the body compose writes before it dispatches has to hold the message on
|
|
8
|
+
* screen. Send is also refused while that message is still being fetched — a
|
|
9
|
+
* press landing in that window is the same empty forward with the timing
|
|
10
|
+
* changed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import type {
|
|
16
|
+
RemitImapAccountResponse,
|
|
17
|
+
RemitImapDescribeMessageResponse,
|
|
18
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import {
|
|
20
|
+
type AnyRouter,
|
|
21
|
+
createMemoryHistory,
|
|
22
|
+
createRootRoute,
|
|
23
|
+
createRoute,
|
|
24
|
+
createRouter,
|
|
25
|
+
RouterContextProvider,
|
|
26
|
+
} from "@tanstack/react-router";
|
|
27
|
+
import { createElement, useState } from "react";
|
|
28
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
29
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
30
|
+
import { ComposeForm } from "./ComposeForm";
|
|
31
|
+
import type { ComposeMode } from "./ComposeProvider";
|
|
32
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
33
|
+
|
|
34
|
+
const ACCOUNT_ID = "acc-1";
|
|
35
|
+
const BODY_URL = "/content/msg-1/body.txt";
|
|
36
|
+
const ORIGINAL = "Are we still on for Thursday?\n\nI can do 12:30.";
|
|
37
|
+
|
|
38
|
+
const account = {
|
|
39
|
+
accountId: ACCOUNT_ID,
|
|
40
|
+
email: "me@example.com",
|
|
41
|
+
smtpEnabled: true,
|
|
42
|
+
} as unknown as RemitImapAccountResponse;
|
|
43
|
+
|
|
44
|
+
const sourceMessage = {
|
|
45
|
+
message: { messageId: "msg-1" },
|
|
46
|
+
envelope: {
|
|
47
|
+
subject: "Lunch",
|
|
48
|
+
date: Date.UTC(2026, 5, 24, 9, 14),
|
|
49
|
+
messageIdValue: "<m1@example.com>",
|
|
50
|
+
from: [{ normalizedEmail: "dana@example.com", displayName: "Dana" }],
|
|
51
|
+
replyTo: [],
|
|
52
|
+
to: [{ normalizedEmail: "me@example.com" }],
|
|
53
|
+
cc: [],
|
|
54
|
+
},
|
|
55
|
+
references: [],
|
|
56
|
+
bodyParts: [
|
|
57
|
+
{
|
|
58
|
+
mediaType: "TEXT",
|
|
59
|
+
mediaSubtype: "PLAIN",
|
|
60
|
+
contentUrl: BODY_URL,
|
|
61
|
+
isMultipart: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
65
|
+
|
|
66
|
+
const outboxEntry = () => ({
|
|
67
|
+
outboxMessageId: "ob-845",
|
|
68
|
+
accountId: ACCOUNT_ID,
|
|
69
|
+
fromAddress: account.email,
|
|
70
|
+
toAddresses: ["dana@example.com"],
|
|
71
|
+
ccAddresses: [],
|
|
72
|
+
bccAddresses: [],
|
|
73
|
+
references: [],
|
|
74
|
+
subject: "Re: Lunch",
|
|
75
|
+
status: "draft",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let harness: DomHarness | undefined;
|
|
79
|
+
let http: HttpMock | undefined;
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
harness?.close();
|
|
83
|
+
harness = undefined;
|
|
84
|
+
http?.restore();
|
|
85
|
+
http = undefined;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const Opened = ({ mode }: { mode: ComposeMode }) => {
|
|
89
|
+
const [draftId, setDraftId] = useState<string | undefined>(undefined);
|
|
90
|
+
|
|
91
|
+
return createElement(ComposeForm, {
|
|
92
|
+
mode,
|
|
93
|
+
account,
|
|
94
|
+
sourceMessage,
|
|
95
|
+
outboxMessageId: draftId,
|
|
96
|
+
onDraftCreated: setDraftId,
|
|
97
|
+
onClose: () => {},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
102
|
+
|
|
103
|
+
const rootRoute = createRootRoute();
|
|
104
|
+
const mailboxRoute = createRoute({
|
|
105
|
+
getParentRoute: () => rootRoute,
|
|
106
|
+
path: "/mail/$mailboxId",
|
|
107
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const testRouter = (): AnyRouter =>
|
|
111
|
+
createRouter({
|
|
112
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
113
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
114
|
+
}) as unknown as AnyRouter;
|
|
115
|
+
|
|
116
|
+
interface MountOptions {
|
|
117
|
+
mode: ComposeMode;
|
|
118
|
+
/** Hold the quoted body's fetch open, so Send is pressed without it. */
|
|
119
|
+
gateBody?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const mount = async (
|
|
123
|
+
options: MountOptions,
|
|
124
|
+
): Promise<{ releaseBody: () => void }> => {
|
|
125
|
+
let release = (): void => {};
|
|
126
|
+
const bodyGate = options.gateBody
|
|
127
|
+
? new Promise<void>((resolve) => {
|
|
128
|
+
release = resolve;
|
|
129
|
+
})
|
|
130
|
+
: Promise.resolve();
|
|
131
|
+
|
|
132
|
+
http = mockFetch(async (call) => {
|
|
133
|
+
if (call.path === BODY_URL) {
|
|
134
|
+
await bodyGate;
|
|
135
|
+
return new Response(ORIGINAL, {
|
|
136
|
+
status: 200,
|
|
137
|
+
headers: { "content-type": "text/plain" },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
141
|
+
return outboxEntry();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
harness = createDomHarness();
|
|
145
|
+
harness.renderApp(
|
|
146
|
+
createElement(RouterContextProvider, {
|
|
147
|
+
router: testRouter(),
|
|
148
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
149
|
+
children: createElement(
|
|
150
|
+
ComposeProvider,
|
|
151
|
+
null,
|
|
152
|
+
createElement(Opened, { mode: options.mode }),
|
|
153
|
+
),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
await harness.flush();
|
|
157
|
+
await harness.wait(100);
|
|
158
|
+
await harness.flush();
|
|
159
|
+
|
|
160
|
+
return { releaseBody: release };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const sendButton = (): HTMLElement => {
|
|
164
|
+
const button = harness?.byText("button", "Send");
|
|
165
|
+
if (!button) throw new Error("the compose send button is not mounted");
|
|
166
|
+
return button;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const addRecipient = (email: string): void => {
|
|
170
|
+
const field = harness?.query<HTMLInputElement>("#address-field-To");
|
|
171
|
+
if (!field) throw new Error("the To field is not mounted");
|
|
172
|
+
harness?.type(field, email);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const written = (): Record<string, unknown> => {
|
|
176
|
+
const body = (http?.calls ?? []).find(
|
|
177
|
+
(call) =>
|
|
178
|
+
(call.method === "POST" && call.path.endsWith("/outbox")) ||
|
|
179
|
+
call.method === "PATCH",
|
|
180
|
+
)?.body;
|
|
181
|
+
if (!body) throw new Error("nothing was written for the send");
|
|
182
|
+
return body;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
describe("the message a reply and a forward leave with (#845.5)", () => {
|
|
186
|
+
it("sends a reply carrying the message it answers", async () => {
|
|
187
|
+
await mount({ mode: "reply" });
|
|
188
|
+
|
|
189
|
+
harness?.click(sendButton());
|
|
190
|
+
await harness?.flush();
|
|
191
|
+
await harness?.wait(100);
|
|
192
|
+
|
|
193
|
+
const body = written();
|
|
194
|
+
assert.match(String(body.textBody), /^On .+ Dana .+ wrote:$/m);
|
|
195
|
+
assert.match(String(body.textBody), /^> Are we still on for Thursday\?$/m);
|
|
196
|
+
assert.match(String(body.htmlBody), /<blockquote type="cite">/);
|
|
197
|
+
assert.match(String(body.htmlBody), /Are we still on for Thursday\?/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("sends a forward carrying the message it forwards", async () => {
|
|
201
|
+
await mount({ mode: "forward" });
|
|
202
|
+
|
|
203
|
+
addRecipient("bob@example.com");
|
|
204
|
+
harness?.click(sendButton());
|
|
205
|
+
await harness?.flush();
|
|
206
|
+
await harness?.wait(100);
|
|
207
|
+
|
|
208
|
+
const body = written();
|
|
209
|
+
assert.match(String(body.textBody), /-+ Forwarded message -+/);
|
|
210
|
+
assert.match(String(body.textBody), /^Subject: Lunch$/m);
|
|
211
|
+
assert.match(String(body.textBody), /Are we still on for Thursday\?/);
|
|
212
|
+
assert.match(String(body.htmlBody), /Forwarded message/);
|
|
213
|
+
assert.match(String(body.htmlBody), /Are we still on for Thursday\?/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("refuses to send while the message being quoted is still loading", async () => {
|
|
217
|
+
const { releaseBody } = await mount({ mode: "reply", gateBody: true });
|
|
218
|
+
|
|
219
|
+
harness?.click(sendButton());
|
|
220
|
+
await harness?.flush();
|
|
221
|
+
await harness?.wait(50);
|
|
222
|
+
|
|
223
|
+
assert.equal(
|
|
224
|
+
(http?.calls ?? []).filter((call) => call.path.endsWith("/send")).length,
|
|
225
|
+
0,
|
|
226
|
+
"nothing was dispatched without the message it quotes",
|
|
227
|
+
);
|
|
228
|
+
assert.match(harness?.text() ?? "", /Loading the message you're quoting/);
|
|
229
|
+
|
|
230
|
+
releaseBody();
|
|
231
|
+
await harness?.flush();
|
|
232
|
+
await harness?.wait(50);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #845.5: the quoted original was a layout slot and never part of the
|
|
3
|
+
* body, so a forward left carrying the signature alone. These hold the two
|
|
4
|
+
* halves of what a send now carries — the block itself, and its merge into the
|
|
5
|
+
* two body columns in each of the two body modes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import type { RichTextValue } from "@remit/ui";
|
|
12
|
+
import { buildQuotedBlock, outgoingBody } from "./quoted-message";
|
|
13
|
+
|
|
14
|
+
type Envelope = RemitImapDescribeMessageResponse["envelope"];
|
|
15
|
+
|
|
16
|
+
const envelope = {
|
|
17
|
+
subject: "Lunch",
|
|
18
|
+
date: Date.UTC(2026, 5, 24, 9, 14),
|
|
19
|
+
messageIdValue: "<m1@example.com>",
|
|
20
|
+
from: [
|
|
21
|
+
{ normalizedEmail: "dana@example.com", displayName: "Dana Whitfield" },
|
|
22
|
+
],
|
|
23
|
+
replyTo: [],
|
|
24
|
+
to: [{ normalizedEmail: "me@example.com", displayName: "Me" }],
|
|
25
|
+
cc: [],
|
|
26
|
+
} as unknown as Envelope;
|
|
27
|
+
|
|
28
|
+
const written = (text: string, html: string): RichTextValue => ({
|
|
29
|
+
text,
|
|
30
|
+
html,
|
|
31
|
+
formatting: [],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("the quoted original a reply carries", () => {
|
|
35
|
+
const block = buildQuotedBlock("reply", envelope, {
|
|
36
|
+
kind: "text",
|
|
37
|
+
content: "Are we still on for Thursday?\n\nI can do 12:30.",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("opens on the attribution line the rest of the tree recognises", () => {
|
|
41
|
+
assert.match(block.text.split("\n")[0] ?? "", /^On .+ wrote:$/);
|
|
42
|
+
assert.match(block.text, /Dana Whitfield <dana@example\.com>/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("indents every line of the original behind it", () => {
|
|
46
|
+
assert.match(block.text, /^> Are we still on for Thursday\?$/m);
|
|
47
|
+
assert.match(block.text, /^> I can do 12:30\.$/m);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("carries the same original in the HTML column, as a quote", () => {
|
|
51
|
+
assert.match(block.html, /<blockquote type="cite">/);
|
|
52
|
+
assert.match(block.html, /Are we still on for Thursday\?/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("the quoted original a forward carries", () => {
|
|
57
|
+
const block = buildQuotedBlock(
|
|
58
|
+
"forward",
|
|
59
|
+
{
|
|
60
|
+
...envelope,
|
|
61
|
+
cc: [{ normalizedEmail: "sam@example.com" }],
|
|
62
|
+
} as unknown as Envelope,
|
|
63
|
+
{ kind: "text", content: "Are we still on for Thursday?" },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
it("states who wrote it, when, about what and to whom", () => {
|
|
67
|
+
assert.match(block.text, /^-+ Forwarded message -+$/m);
|
|
68
|
+
assert.match(block.text, /^From: Dana Whitfield <dana@example\.com>$/m);
|
|
69
|
+
assert.match(block.text, /^Subject: Lunch$/m);
|
|
70
|
+
assert.match(block.text, /^To: Me <me@example\.com>$/m);
|
|
71
|
+
assert.match(block.text, /^Cc: sam@example\.com$/m);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes the original on whole rather than quoting it", () => {
|
|
75
|
+
assert.match(block.text, /^Are we still on for Thursday\?$/m);
|
|
76
|
+
assert.doesNotMatch(block.text, /^> /m);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries the header and the original in the HTML column", () => {
|
|
80
|
+
assert.match(block.html, /Forwarded message/);
|
|
81
|
+
assert.match(block.html, /Subject: Lunch/);
|
|
82
|
+
assert.match(block.html, /Are we still on for Thursday\?/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("the two representations of one original", () => {
|
|
87
|
+
it("reads an HTML original into lines for the text column", () => {
|
|
88
|
+
const block = buildQuotedBlock("reply", envelope, {
|
|
89
|
+
kind: "html",
|
|
90
|
+
content: "<p>First paragraph.</p><p>Second paragraph.</p>",
|
|
91
|
+
});
|
|
92
|
+
assert.match(block.text, /^> First paragraph\.$/m);
|
|
93
|
+
assert.match(block.text, /^> Second paragraph\.$/m);
|
|
94
|
+
assert.doesNotMatch(block.text, /<p>|<\/p>/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("escapes a plain-text original on its way into the HTML column", () => {
|
|
98
|
+
const block = buildQuotedBlock("reply", envelope, {
|
|
99
|
+
kind: "text",
|
|
100
|
+
content: '5 < 6 & "quoted"',
|
|
101
|
+
});
|
|
102
|
+
assert.match(block.html, /5 < 6 & "quoted"/);
|
|
103
|
+
assert.match(block.text, /5 < 6 & "quoted"/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("the body a send leaves with", () => {
|
|
108
|
+
const block = buildQuotedBlock("reply", envelope, {
|
|
109
|
+
kind: "text",
|
|
110
|
+
content: "Are we still on for Thursday?",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("carries the answer and the original in both columns, in rich mode", () => {
|
|
114
|
+
const body = outgoingBody(
|
|
115
|
+
"rich",
|
|
116
|
+
written("Yes, 12:30 works.", "<p>Yes, 12:30 works.</p>"),
|
|
117
|
+
"en",
|
|
118
|
+
block,
|
|
119
|
+
);
|
|
120
|
+
assert.match(body.textBody ?? "", /Yes, 12:30 works\./);
|
|
121
|
+
assert.match(body.textBody ?? "", /^> Are we still on for Thursday\?$/m);
|
|
122
|
+
assert.match(body.htmlBody ?? "", /Yes, 12:30 works\./);
|
|
123
|
+
assert.match(body.htmlBody ?? "", /<blockquote type="cite">/);
|
|
124
|
+
assert.match(body.htmlBody ?? "", /^<div lang="en">/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("carries the original in the text column alone, in plain mode", () => {
|
|
128
|
+
const body = outgoingBody(
|
|
129
|
+
"plain",
|
|
130
|
+
written("Yes, 12:30 works.", ""),
|
|
131
|
+
"en",
|
|
132
|
+
block,
|
|
133
|
+
);
|
|
134
|
+
assert.match(body.textBody ?? "", /Yes, 12:30 works\./);
|
|
135
|
+
assert.match(body.textBody ?? "", /^> Are we still on for Thursday\?$/m);
|
|
136
|
+
assert.doesNotMatch(body.textBody ?? "", /<blockquote/);
|
|
137
|
+
// An HTML column here would reopen the draft as rich and send the
|
|
138
|
+
// recipient an HTML alternative of a message written as plain text.
|
|
139
|
+
assert.equal(body.htmlBody, "");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("sends the quote when the editor has not reported its document yet", () => {
|
|
143
|
+
const body = outgoingBody("rich", written("", ""), "en", block);
|
|
144
|
+
assert.match(body.textBody ?? "", /^> Are we still on for Thursday\?$/m);
|
|
145
|
+
assert.match(body.htmlBody ?? "", /<blockquote type="cite">/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("writes what it always wrote when there is nothing to quote", () => {
|
|
149
|
+
const body = outgoingBody(
|
|
150
|
+
"rich",
|
|
151
|
+
written("A new message.", "<p>A new message.</p>"),
|
|
152
|
+
"en",
|
|
153
|
+
undefined,
|
|
154
|
+
);
|
|
155
|
+
assert.equal(body.textBody, "A new message.");
|
|
156
|
+
assert.equal(body.htmlBody, '<div lang="en"><p>A new message.</p></div>');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { type RichTextValue, wrapWithLanguage } from "@remit/ui";
|
|
3
|
+
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
4
|
+
import { formatDate } from "../../lib/format";
|
|
5
|
+
|
|
6
|
+
type Envelope = RemitImapDescribeMessageResponse["envelope"];
|
|
7
|
+
type EnvelopeAddress = Envelope["from"][number];
|
|
8
|
+
|
|
9
|
+
/** Whether the original is being answered or passed on. */
|
|
10
|
+
export type QuoteKind = "reply" | "forward";
|
|
11
|
+
|
|
12
|
+
/** The message being quoted, in both the representations a send carries. */
|
|
13
|
+
export interface QuotedBlock {
|
|
14
|
+
text: string;
|
|
15
|
+
html: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The original's body as the content route hands it over: one part, either
|
|
20
|
+
* HTML or plain text, never both. The other representation is derived here,
|
|
21
|
+
* because a message goes out with both and HTML markup dropped into the
|
|
22
|
+
* `text/plain` part arrives as visible tags.
|
|
23
|
+
*/
|
|
24
|
+
export type QuotedSourceBody =
|
|
25
|
+
| { kind: "text"; content: string }
|
|
26
|
+
| { kind: "html"; content: string };
|
|
27
|
+
|
|
28
|
+
export const escapeHtml = (text: string): string =>
|
|
29
|
+
text
|
|
30
|
+
.replace(/&/g, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """);
|
|
34
|
+
|
|
35
|
+
export const textToHtml = (text: string): string =>
|
|
36
|
+
text
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
|
39
|
+
.join("");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Elements that end the line they are on. Everything else is inline and runs
|
|
43
|
+
* into its neighbours, which is what separates a plain-text rendering of a
|
|
44
|
+
* message from its `textContent` — that returns one long line with the words
|
|
45
|
+
* of adjacent paragraphs joined together.
|
|
46
|
+
*/
|
|
47
|
+
const LINE_BREAKING_TAGS = new Set([
|
|
48
|
+
"ADDRESS",
|
|
49
|
+
"ARTICLE",
|
|
50
|
+
"BLOCKQUOTE",
|
|
51
|
+
"BR",
|
|
52
|
+
"DIV",
|
|
53
|
+
"H1",
|
|
54
|
+
"H2",
|
|
55
|
+
"H3",
|
|
56
|
+
"H4",
|
|
57
|
+
"H5",
|
|
58
|
+
"H6",
|
|
59
|
+
"HR",
|
|
60
|
+
"LI",
|
|
61
|
+
"OL",
|
|
62
|
+
"P",
|
|
63
|
+
"PRE",
|
|
64
|
+
"SECTION",
|
|
65
|
+
"TABLE",
|
|
66
|
+
"TR",
|
|
67
|
+
"UL",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const htmlToPlainText = (html: string): string => {
|
|
71
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
let line = "";
|
|
74
|
+
|
|
75
|
+
const endLine = (): void => {
|
|
76
|
+
lines.push(line.trim());
|
|
77
|
+
line = "";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const visit = (node: Node): void => {
|
|
81
|
+
if (node.nodeType === node.TEXT_NODE) {
|
|
82
|
+
line += (node.textContent ?? "").replace(/\s+/g, " ");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (node.nodeType !== node.ELEMENT_NODE) return;
|
|
86
|
+
const breaks = LINE_BREAKING_TAGS.has((node as Element).tagName);
|
|
87
|
+
if (breaks && line !== "") endLine();
|
|
88
|
+
for (const child of node.childNodes) visit(child);
|
|
89
|
+
if (breaks) endLine();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
visit(parsed.body);
|
|
93
|
+
if (line !== "") endLine();
|
|
94
|
+
return lines
|
|
95
|
+
.join("\n")
|
|
96
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
97
|
+
.trim();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const addressLabel = (address: EnvelopeAddress): string =>
|
|
101
|
+
address.displayName
|
|
102
|
+
? `${address.displayName} <${address.normalizedEmail}>`
|
|
103
|
+
: address.normalizedEmail;
|
|
104
|
+
|
|
105
|
+
const addressList = (addresses: readonly EnvelopeAddress[]): string =>
|
|
106
|
+
addresses.map(addressLabel).join(", ");
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An absolute date, because the attribution outlives the moment it was written
|
|
110
|
+
* — the relative form the message list uses would reach the recipient reading
|
|
111
|
+
* "Yesterday" about a day that is no longer yesterday.
|
|
112
|
+
*/
|
|
113
|
+
const quotedDate = (date: Envelope["date"]): string =>
|
|
114
|
+
formatDate(date, {
|
|
115
|
+
year: "numeric",
|
|
116
|
+
month: "short",
|
|
117
|
+
day: "numeric",
|
|
118
|
+
hour: "numeric",
|
|
119
|
+
minute: "numeric",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** The form `snippet.ts` and the search chunker both recognise as a quote. */
|
|
123
|
+
const attributionLine = (envelope: Envelope): string => {
|
|
124
|
+
const who = addressList(envelope.from) || "the sender";
|
|
125
|
+
const when = quotedDate(envelope.date);
|
|
126
|
+
if (!when) return `${who} wrote:`;
|
|
127
|
+
return `On ${when}, ${who} wrote:`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const FORWARD_RULE = "---------- Forwarded message ----------";
|
|
131
|
+
|
|
132
|
+
const forwardHeader = (envelope: Envelope): string[] => {
|
|
133
|
+
const lines = [
|
|
134
|
+
FORWARD_RULE,
|
|
135
|
+
`From: ${addressList(envelope.from)}`,
|
|
136
|
+
`Date: ${quotedDate(envelope.date)}`,
|
|
137
|
+
`Subject: ${envelope.subject ?? ""}`,
|
|
138
|
+
`To: ${addressList(envelope.to)}`,
|
|
139
|
+
];
|
|
140
|
+
if (envelope.cc.length > 0) lines.push(`Cc: ${addressList(envelope.cc)}`);
|
|
141
|
+
return lines;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const quotePrefixed = (text: string): string[] =>
|
|
145
|
+
text.split("\n").map((line) => (line === "" ? ">" : `> ${line}`));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The original, as the message being written will carry it. One value, so what
|
|
149
|
+
* the composer shows under the editor is the same block that leaves for the
|
|
150
|
+
* API rather than a second rendering of the same source (#845.5).
|
|
151
|
+
*
|
|
152
|
+
* A reply indents the original behind an attribution line; a forward passes it
|
|
153
|
+
* on whole under the header block, because a forward is the original message
|
|
154
|
+
* rather than a passage cited from it.
|
|
155
|
+
*/
|
|
156
|
+
export const buildQuotedBlock = (
|
|
157
|
+
kind: QuoteKind,
|
|
158
|
+
envelope: Envelope,
|
|
159
|
+
body: QuotedSourceBody,
|
|
160
|
+
): QuotedBlock => {
|
|
161
|
+
const bodyText =
|
|
162
|
+
body.kind === "text" ? body.content.trim() : htmlToPlainText(body.content);
|
|
163
|
+
const bodyHtml =
|
|
164
|
+
body.kind === "html" ? body.content : textToHtml(body.content.trim());
|
|
165
|
+
|
|
166
|
+
if (kind === "forward") {
|
|
167
|
+
const header = forwardHeader(envelope);
|
|
168
|
+
return {
|
|
169
|
+
text: [...header, "", bodyText].join("\n"),
|
|
170
|
+
html: `${textToHtml(header.join("\n"))}${bodyHtml}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const attribution = attributionLine(envelope);
|
|
175
|
+
return {
|
|
176
|
+
text: [attribution, ...quotePrefixed(bodyText)].join("\n"),
|
|
177
|
+
html: `<p>${escapeHtml(attribution)}</p><blockquote type="cite">${bodyHtml}</blockquote>`,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const joinText = (written: string, quoted: string): string =>
|
|
182
|
+
written === "" ? quoted : `${written}\n\n${quoted}`;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What the two body columns carry for this mode.
|
|
186
|
+
*
|
|
187
|
+
* Plain mode writes the empty string rather than omitting `htmlBody`: absent
|
|
188
|
+
* means "leave alone" at every layer below, so a plain draft that omitted it
|
|
189
|
+
* would send the HTML it was written as before the switch. The empty string is
|
|
190
|
+
* defined, so the repository's update guard clears the column, and nodemailer
|
|
191
|
+
* branches on the value being truthy — an empty one builds no HTML alternative
|
|
192
|
+
* and the message leaves as a single `text/plain` part. It is also why the
|
|
193
|
+
* quote reaches a plain message through `textBody` alone: putting its markup in
|
|
194
|
+
* `htmlBody` would reopen the draft as rich and send the recipient an HTML
|
|
195
|
+
* alternative of a message written as plain text.
|
|
196
|
+
*
|
|
197
|
+
* Rich mode leaves `htmlBody` alone while it has nothing to say, so the moments
|
|
198
|
+
* before the lazily-loaded editor reports its document cannot write a draft
|
|
199
|
+
* back as plain. A quote is something to say, so a send pressed in those
|
|
200
|
+
* moments still carries the message it quotes.
|
|
201
|
+
*/
|
|
202
|
+
export const outgoingBody = (
|
|
203
|
+
bodyMode: ComposeBodyMode,
|
|
204
|
+
body: RichTextValue,
|
|
205
|
+
language: string,
|
|
206
|
+
quoted: QuotedBlock | undefined,
|
|
207
|
+
): { textBody: string | undefined; htmlBody: string | undefined } => {
|
|
208
|
+
const text = quoted ? joinText(body.text, quoted.text) : body.text;
|
|
209
|
+
if (bodyMode === "plain")
|
|
210
|
+
return { textBody: text || undefined, htmlBody: "" };
|
|
211
|
+
const html = quoted ? `${body.html}${quoted.html}` : body.html;
|
|
212
|
+
return {
|
|
213
|
+
textBody: text || undefined,
|
|
214
|
+
htmlBody: html ? wrapWithLanguage(html, language) : undefined,
|
|
215
|
+
};
|
|
216
|
+
};
|