@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.228",
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, "&lt;")
97
- .replace(/>/g, "&gt;")
98
- .replace(/"/g, "&quot;");
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, and degrade
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 { data: sourceBody } = useMessageBodyContent({
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
- const quotedText = sourceBody?.kind === "text" ? sourceBody.body : "";
694
- const quotedHtml =
695
- sourceBody?.kind === "html"
696
- ? sanitizeQuotedHtml(sourceBody.body)
697
- : undefined;
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
- selectedAccount && selectedAccountMissingSmtp ? (
1070
- <ComposeSmtpMissingBanner
1071
- accountId={selectedAccount.accountId}
1072
- configureRef={smtpConfigureRef}
1073
- />
1074
- ) : undefined
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
- quotedText || quotedHtml ? (
1133
+ quotedBlock ? (
1099
1134
  <QuotedText
1100
- text={quotedText}
1101
- html={quotedHtml}
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 &lt; 6 &amp; &quot;quoted&quot;/);
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, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;");
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
+ };