@remit/web-client 0.0.228 → 0.0.230

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.
@@ -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, "&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
+ };
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Walking the config import wizard against a mocked instance.
3
+ *
4
+ * Every state here is one the components could already render in Storybook; the
5
+ * question this file answers is whether the app can ever reach them. Each of
6
+ * these was unreachable at one point: the 409 came back with its code on the
7
+ * thrown wrapper's body rather than on the error, an account whose password had
8
+ * just been verified stayed red because `connectionState` is written later by a
9
+ * worker, and "Check the file" was live before there was a document to send.
10
+ *
11
+ * Held with the real caches wired to `lib/query-error-handler.ts`, because
12
+ * "does the fatal overlay swallow this" is a question about what those two
13
+ * handlers pass, and nothing below them can see it. The overlay itself is not
14
+ * mounted — `getCurrentFatalError` is the same signal without a second app's
15
+ * worth of chrome in the tree.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, describe, it } from "node:test";
20
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
21
+ import { createElement } from "react";
22
+ import { ApiError } from "../../lib/api";
23
+ import "../../lib/client";
24
+ import { shouldEscalate, softErrorStatuses } from "../../lib/error-classifier";
25
+ import { __resetFatalError, getCurrentFatalError } from "../../lib/fatal-error";
26
+ import {
27
+ handleMutationCacheError,
28
+ handleQueryCacheError,
29
+ } from "../../lib/query-error-handler";
30
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
31
+ import {
32
+ type HttpCall,
33
+ type HttpMock,
34
+ mockFetch,
35
+ } from "../../test-support/http";
36
+ import { StepWelcome } from "../onboarding/OnboardingWizard";
37
+ import { ConfigImportWizard } from "./ConfigImportWizard";
38
+
39
+ let harness: DomHarness | undefined;
40
+ let http: HttpMock | undefined;
41
+
42
+ afterEach(() => {
43
+ harness?.close();
44
+ harness = undefined;
45
+ http?.restore();
46
+ http = undefined;
47
+ __resetFatalError();
48
+ });
49
+
50
+ const ACCOUNT_ID = "acct-imported";
51
+
52
+ const escalatingClient = (): QueryClient =>
53
+ new QueryClient({
54
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
55
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
56
+ defaultOptions: {
57
+ queries: { retry: false },
58
+ mutations: { retry: false },
59
+ },
60
+ });
61
+
62
+ /** The flat body the API emits, at the status it emits it with. */
63
+ const refusal = (status: number, body: unknown): Response =>
64
+ new Response(JSON.stringify(body), {
65
+ status,
66
+ headers: { "content-type": "application/json" },
67
+ });
68
+
69
+ const report = (overrides: Record<string, unknown> = {}) => ({
70
+ valid: true,
71
+ schemaVersion: 1,
72
+ applied: false,
73
+ items: [{ section: "accounts", key: "a@example.test", verdict: "created" }],
74
+ errors: [],
75
+ warnings: [],
76
+ accountsNeedingCredentials: [],
77
+ ...overrides,
78
+ });
79
+
80
+ const importedAccount = (): Record<string, unknown> => ({
81
+ accountId: ACCOUNT_ID,
82
+ accountConfigId: "cfg-1",
83
+ username: "a@example.test",
84
+ email: "a@example.test",
85
+ authType: "password",
86
+ imapHost: "imap.example.test",
87
+ imapPort: 993,
88
+ imapTls: true,
89
+ imapStartTls: false,
90
+ smtpEnabled: false,
91
+ smtpHost: "",
92
+ smtpPort: 587,
93
+ smtpTls: false,
94
+ smtpStartTls: false,
95
+ smtpUsername: "",
96
+ isActive: false,
97
+ // The server keeps saying this: only the imap worker clears it, on a
98
+ // connection that has not happened yet.
99
+ connectionState: "credentials_missing",
100
+ createdAt: 0,
101
+ updatedAt: 0,
102
+ folderAppointments: [],
103
+ });
104
+
105
+ interface Backend {
106
+ onImport?: (call: HttpCall) => unknown;
107
+ accounts?: Array<Record<string, unknown>>;
108
+ }
109
+
110
+ const done: string[] = [];
111
+
112
+ const start = (backend: Backend = {}): DomHarness => {
113
+ done.length = 0;
114
+ http = mockFetch((call) => {
115
+ if (call.path === "/config/import") {
116
+ return backend.onImport?.(call) ?? report();
117
+ }
118
+ if (call.path === "/config") {
119
+ return { accounts: backend.accounts ?? [], mailboxes: [] };
120
+ }
121
+ if (call.path === "/accounts/test-connection") {
122
+ return { imapSuccess: true, smtpSuccess: true };
123
+ }
124
+ return {};
125
+ });
126
+ harness = createDomHarness({ queryClient: escalatingClient() });
127
+ harness.renderApp(
128
+ createElement(ConfigImportWizard, {
129
+ onDone: (outcome: string) => done.push(outcome),
130
+ }),
131
+ );
132
+ return harness;
133
+ };
134
+
135
+ /**
136
+ * Hand the picker a file. jsdom has no `DataTransfer`, so the drop path cannot
137
+ * be driven from here; the input behind "Choose file" is the same entry point
138
+ * and takes a `files` list that only needs `item`.
139
+ */
140
+ const pickFile = async (dom: DomHarness, name: string, text: string) => {
141
+ const input = dom.query<HTMLInputElement>('input[type="file"]');
142
+ assert.ok(input, "expected the file input");
143
+ const file = new File([text], name, { type: "application/json" });
144
+ Object.defineProperty(input, "files", {
145
+ configurable: true,
146
+ value: { 0: file, length: 1, item: (i: number) => (i === 0 ? file : null) },
147
+ });
148
+ dom.dispatch(input, new dom.window.Event("change", { bubbles: true }));
149
+ await dom.flush();
150
+ await dom.wait(10);
151
+ await dom.flush();
152
+ };
153
+
154
+ const CONFIG = JSON.stringify({ kind: "reader.config", schemaVersion: 1 });
155
+
156
+ const checkButton = (dom: DomHarness): HTMLButtonElement =>
157
+ dom.byText("button", "Check the file") as HTMLButtonElement;
158
+
159
+ describe("ConfigImportWizard — a file that cannot be sent", () => {
160
+ it("keeps the check disabled and says why, for a file that is not JSON", async () => {
161
+ const dom = start();
162
+ await pickFile(dom, "photos.zip", "PKnot json");
163
+
164
+ assert.match(dom.text(), /That file is not JSON/);
165
+ assert.equal(checkButton(dom).disabled, true);
166
+ // Nothing left the browser: the refusal happened before the request.
167
+ assert.equal(http?.to("/config/import").length, 0);
168
+ });
169
+
170
+ it("arms the check only once a document has parsed", async () => {
171
+ const dom = start();
172
+ assert.equal(checkButton(dom).disabled, true);
173
+
174
+ await pickFile(dom, "config.json", CONFIG);
175
+ assert.equal(checkButton(dom).disabled, false);
176
+ });
177
+ });
178
+
179
+ describe("ConfigImportWizard — an instance that is not empty", () => {
180
+ const conflicted = () =>
181
+ refusal(409, {
182
+ code: "config_not_empty",
183
+ message: "already holds configuration",
184
+ details: { accounts: "2", filters: "14" },
185
+ });
186
+
187
+ it("offers abort or merge on the 409 rather than a connection error", async () => {
188
+ const dom = start({ onImport: conflicted });
189
+ await pickFile(dom, "config.json", CONFIG);
190
+ dom.click(checkButton(dom));
191
+ await dom.flush();
192
+
193
+ assert.match(dom.text(), /This instance already has configuration/);
194
+ assert.match(dom.text(), /2 accounts, 14 rules are set up here already/);
195
+ });
196
+
197
+ /**
198
+ * The other half of reaching that screen: the refusal must not also take the
199
+ * app to the fatal page over it. That decision is `shouldEscalate` reading
200
+ * the mutation's meta, and it is pinned here rather than through the wizard
201
+ * because under this loader `lib/client.ts` and the generated SDK resolve
202
+ * `client.gen` through two specifiers (`…/client.gen.ts` and `./client.gen.js`)
203
+ * and so hold two client instances — the error interceptor registers on one
204
+ * and the request goes through the other, which never happens under Vite,
205
+ * where both specifiers resolve to the same module.
206
+ */
207
+ it("keeps the 409 off the fatal page, and still escalates a 401", () => {
208
+ const meta = softErrorStatuses(409);
209
+ assert.equal(
210
+ shouldEscalate(new ApiError("not empty", 409, {}), meta, "user"),
211
+ false,
212
+ );
213
+ assert.equal(
214
+ shouldEscalate(new ApiError("signed out", 401, {}), meta, "user"),
215
+ true,
216
+ );
217
+ assert.equal(
218
+ shouldEscalate(new ApiError("broken", 500, {}), meta, "user"),
219
+ true,
220
+ );
221
+ });
222
+
223
+ it("does not record an import when the reader aborts the 409", async () => {
224
+ const dom = start({ onImport: conflicted });
225
+ await pickFile(dom, "config.json", CONFIG);
226
+ dom.click(checkButton(dom));
227
+ await dom.flush();
228
+
229
+ dom.click(dom.byText("button", "Stop, keep this instance"));
230
+ await dom.flush();
231
+ dom.click(dom.byText("button", "Continue"));
232
+ await dom.flush();
233
+
234
+ assert.deepEqual(done, ["abandoned"]);
235
+ });
236
+ });
237
+
238
+ describe("ConfigImportWizard — a session that has lapsed", () => {
239
+ it("escalates a 401 rather than keeping it in the wizard", async () => {
240
+ const dom = start({
241
+ onImport: () => refusal(401, { message: "signed out" }),
242
+ });
243
+ await pickFile(dom, "config.json", CONFIG);
244
+ dom.click(checkButton(dom));
245
+ await dom.flush();
246
+
247
+ assert.ok(
248
+ getCurrentFatalError(),
249
+ "expected the 401 to escalate rather than stay in the wizard",
250
+ );
251
+ });
252
+ });
253
+
254
+ describe("ConfigImportWizard — the credentials the file does not carry", () => {
255
+ const applied = () => {
256
+ let call = 0;
257
+ return () => {
258
+ call += 1;
259
+ return call === 1
260
+ ? report({ accountsNeedingCredentials: [ACCOUNT_ID] })
261
+ : report({
262
+ applied: true,
263
+ accountsNeedingCredentials: [ACCOUNT_ID],
264
+ importId: "imp-1",
265
+ });
266
+ };
267
+ };
268
+
269
+ it("marks the account ready once its password is verified and stored", async () => {
270
+ const dom = start({
271
+ onImport: applied(),
272
+ accounts: [importedAccount()],
273
+ });
274
+ await pickFile(dom, "config.json", CONFIG);
275
+ dom.click(checkButton(dom));
276
+ await dom.flush();
277
+ dom.click(dom.byText("button", "Import 1 changes"));
278
+ await dom.flush();
279
+ await dom.wait(20);
280
+ await dom.flush();
281
+
282
+ assert.match(dom.text(), /These accounts need credentials/);
283
+ assert.match(dom.text(), /credentials needed/);
284
+
285
+ dom.click(dom.byText("button", "Enter password"));
286
+ await dom.flush();
287
+ const password = dom.query<HTMLInputElement>("#credentials-password");
288
+ assert.ok(password, "expected the password field");
289
+ dom.type(password, "mailbox-password");
290
+ dom.click(dom.byText("button", "Test and continue"));
291
+ await dom.flush();
292
+ // The test step stages IMAP then SMTP before it settles, then hands over.
293
+ await dom.wait(500);
294
+ await dom.flush();
295
+ await dom.wait(900);
296
+ await dom.flush();
297
+
298
+ const [stored] = http?.to(`/accounts/${ACCOUNT_ID}`) ?? [];
299
+ assert.ok(stored, "expected the password to be stored on the account");
300
+ assert.equal(stored.body?.password, "mailbox-password");
301
+
302
+ // GET /config still says `credentials_missing`, and the row is green
303
+ // anyway: this wizard verified the credential itself.
304
+ assert.match(dom.text(), /These accounts need credentials/);
305
+ assert.match(dom.text(), /ready/);
306
+ assert.doesNotMatch(dom.text(), /credentials needed/);
307
+ });
308
+ });
309
+
310
+ describe("The onboarding welcome step", () => {
311
+ it("offers the import beside adding an account, where /onboarding mounts it", () => {
312
+ harness = createDomHarness();
313
+ const imported: string[] = [];
314
+ harness.renderApp(
315
+ createElement(StepWelcome, {
316
+ onStart: () => undefined,
317
+ onImportConfig: () => imported.push("import"),
318
+ }),
319
+ );
320
+
321
+ assert.match(harness.text(), /Add your first account/);
322
+ harness.click(harness.byText("button", "Import a config file"));
323
+ assert.deepEqual(imported, ["import"]);
324
+ });
325
+
326
+ it("offers no import where the wizard is embedded to add an account", () => {
327
+ harness = createDomHarness();
328
+ harness.renderApp(createElement(StepWelcome, { onStart: () => undefined }));
329
+
330
+ assert.match(harness.text(), /Add your first account/);
331
+ assert.equal(
332
+ harness
333
+ .queryAll("button")
334
+ .some((button) => button.textContent === "Import a config file"),
335
+ false,
336
+ );
337
+ });
338
+ });