@remit/ui 0.0.14 → 0.0.16

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,221 @@
1
+ import type { FolderRole } from "./folder-role.js";
2
+
3
+ /**
4
+ * The pipeline step that refused the message.
5
+ *
6
+ * One member, because **no catch site on the sync path can currently tell a
7
+ * parse failure from an infrastructure failure**. The per-message frame in
8
+ * `body-sync.ts` wraps the S3 body write, the parsed-body cache write, the
9
+ * body-part `pMap` and DynamoDB upsert, the placement move (SQS + DynamoDB)
10
+ * and the label and counter writes, alongside the `simpleParser` call; only
11
+ * connection drops are filtered out of it.
12
+ *
13
+ * **Precondition on Phase 3**: narrow the try block to the parse call before
14
+ * quarantining anything. Quarantining at the frame as it stands would set a
15
+ * message aside for a DynamoDB throttle, tell the user it was unreadable, and
16
+ * invite a public GitHub issue for an outage — the same defect as naming an
17
+ * S3 write `AttachmentExtract`, one layer up.
18
+ *
19
+ * There are three distinct parse sites, and a quarantine would have to
20
+ * attribute them differently: the fresh-fetch path, `backfillClassification`
21
+ * (its own parse over a different result list), and the header parse in
22
+ * `imapflow-connection.ts`, which is swallowed and treated as "no thread
23
+ * parent". Stages are added as those sites are separated — never ahead of it.
24
+ *
25
+ * The stages this replaced could not fire at all: an unparseable Date falls
26
+ * back to INTERNALDATE, and an unrecognized MIME type maps to a safe default
27
+ * rather than throwing. Unparseable addresses are dropped with a `continue`
28
+ * and the message is written anyway — so that defect is reached but discarded,
29
+ * never raised, which is a reason to have no `AddressParse` stage but not the
30
+ * reason that it cannot happen.
31
+ */
32
+ export type QuarantineFailureStage = "BodyParse";
33
+
34
+ /**
35
+ * The specific defect within a stage.
36
+ *
37
+ * Closed, because `quarantineIssueTitle` interpolates it into a public issue
38
+ * title: a `string` here would make the one field whose publishability is
39
+ * asserted rather than derived into free text. Grows with the narrowing
40
+ * described on `QuarantineFailureStage`, and no faster.
41
+ */
42
+ export type QuarantineFailureCode =
43
+ | "UnterminatedMultipartBoundary"
44
+ | "UnknownCharset"
45
+ | "TruncatedBody";
46
+
47
+ /**
48
+ * A node in the message's MIME tree.
49
+ *
50
+ * `contentType` is `type/subtype` only. BODYSTRUCTURE hands the type and its
51
+ * parameters over separately, so a node is built from `type`/`subtype` and
52
+ * never from a raw content-type line — parameters carry `name=` and
53
+ * `filename=`, which name the user's attachments.
54
+ */
55
+ export interface QuarantineMimeNode {
56
+ contentType: string;
57
+ parts?: readonly QuarantineMimeNode[];
58
+ }
59
+
60
+ /**
61
+ * A quarantined message as the settings surface sees it.
62
+ *
63
+ * Two fields are stored and shown on screen but never travel in the report:
64
+ * `mailboxPath` (the user's own folder names can be personal) and
65
+ * `failureMessage` (parser errors quote the input that broke them, and
66
+ * redacting arbitrary parser text is not solvable). Everything else is safe to
67
+ * paste into a public issue without reading it first.
68
+ */
69
+ export interface QuarantineEntry {
70
+ quarantineId: string;
71
+ accountId: string;
72
+ mailboxId: string;
73
+ /** IMAP uid of the message that was not written. */
74
+ uid: number;
75
+ /** Canonical role of the folder it arrived in — travels in the report. */
76
+ mailboxRole: FolderRole;
77
+ /** The user's own folder name. Shown on screen, withheld from the report. */
78
+ mailboxPath: string;
79
+ failureStage: QuarantineFailureStage;
80
+ failureCode: QuarantineFailureCode;
81
+ /** Parser error text. Shown on screen, never in the report. */
82
+ failureMessage: string;
83
+ /**
84
+ * Dot-numbered part path the failure is attributable to, or null when it is
85
+ * not attributable to one node — the case for a whole-body parse failure.
86
+ */
87
+ failurePartPath: string | null;
88
+ /** Epoch millis the message was quarantined. */
89
+ quarantinedAt: number;
90
+ /** Rounds attempted before the message was set aside. */
91
+ attempts: number;
92
+ sizeBytes: number;
93
+ /** Top-level Content-Type, `type/subtype` only. */
94
+ contentType: string;
95
+ transferEncoding: string;
96
+ /** Declared charset, or null when the message declared none. */
97
+ charset: string | null;
98
+ /** The MIME tree, structure only. */
99
+ structure: QuarantineMimeNode;
100
+ /** SHA-256 of the Message-ID, `sha256:` prefixed. */
101
+ messageIdHash: string;
102
+ /** Build of the worker that failed to parse — a parse bug belongs to it. */
103
+ appVersion: string;
104
+ }
105
+
106
+ const stageSummaries: Record<QuarantineFailureStage, string> = {
107
+ BodyParse: "The message is built in a way Remit could not read.",
108
+ };
109
+
110
+ /** Plain-language one-liner for a row. The detail lives in the report. */
111
+ export function quarantineSummary(stage: QuarantineFailureStage): string {
112
+ return stageSummaries[stage];
113
+ }
114
+
115
+ /**
116
+ * Guards the node contract where the report is rendered, so a node built from
117
+ * a raw content-type line cannot put an attachment filename in the report even
118
+ * if Phase 3 gets the construction wrong.
119
+ */
120
+ function stripParameters(contentType: string): string {
121
+ return contentType.split(";")[0].trim();
122
+ }
123
+
124
+ /** RFC 2045 token characters — what a charset or encoding is allowed to be. */
125
+ const TOKEN = /^[A-Za-z0-9!#$%&'*+._-]+$/;
126
+ const MEDIA_TYPE = /^[A-Za-z0-9!#$%&'*+._-]+\/[A-Za-z0-9!#$%&'*+._-]+$/;
127
+
128
+ /**
129
+ * `charset`, `transferEncoding` and `contentType` are BODYSTRUCTURE strings —
130
+ * arbitrary quoted text chosen by whoever sent the message, echoed into an
131
+ * issue filed under the user's own account. A conforming value renders as
132
+ * itself; anything else is JSON-escaped, so newlines and backticks cannot
133
+ * close the code span and inject markdown. The malformed value is kept rather
134
+ * than dropped, because a malformed value is usually the bug.
135
+ */
136
+ function renderToken(value: string, pattern: RegExp): string {
137
+ if (pattern.test(value)) return `\`${value}\``;
138
+ const escaped = JSON.stringify(value).replace(/`/g, "\\u0060");
139
+ return `\`${escaped}\` (malformed)`;
140
+ }
141
+
142
+ /** Inside a fence the only escape is a lone fence line, so tokens are enough. */
143
+ function renderNodeType(contentType: string): string {
144
+ const stripped = stripParameters(contentType);
145
+ if (MEDIA_TYPE.test(stripped)) return stripped;
146
+ return JSON.stringify(stripped);
147
+ }
148
+
149
+ function renderStructure(node: QuarantineMimeNode, depth = 0): string[] {
150
+ const line = `${" ".repeat(depth)}- ${renderNodeType(node.contentType)}`;
151
+ const children = node.parts ?? [];
152
+ return [
153
+ line,
154
+ ...children.flatMap((part) => renderStructure(part, depth + 1)),
155
+ ];
156
+ }
157
+
158
+ export const QUARANTINE_REPORT_DISCLAIMER =
159
+ "_No message content, addresses, subject, attachment names or parser output are included._";
160
+
161
+ /**
162
+ * The report split into the parts a bug report assembles.
163
+ *
164
+ * `structure` is the only unbounded section, so it is handed over unfenced:
165
+ * the URL budget truncates it and fences it afterwards, the same shape a
166
+ * stacktrace already uses. Fencing here instead would let the binary search
167
+ * cut inside the fence, leaving every following section rendered inside one
168
+ * code block — and the first line lost would be the disclaimer, so the one
169
+ * report that is truncated would be the one with no statement of what was
170
+ * withheld.
171
+ */
172
+ export interface QuarantineReportSections {
173
+ head: string;
174
+ structure: string;
175
+ disclaimer: string;
176
+ }
177
+
178
+ export function quarantineReportSections(
179
+ entry: QuarantineEntry,
180
+ ): QuarantineReportSections {
181
+ const head = [
182
+ `### Message quarantined at \`${entry.failureStage}\``,
183
+ "",
184
+ `- **Failure**: \`${entry.failureCode}\``,
185
+ `- **Folder role**: ${entry.mailboxRole}`,
186
+ `- **Failing part**: ${entry.failurePartPath === null ? "_whole message_" : `\`${entry.failurePartPath}\``}`,
187
+ `- **Attempts before quarantine**: ${entry.attempts}`,
188
+ `- **Worker build**: ${entry.appVersion}`,
189
+ `- **Message-ID hash**: \`${entry.messageIdHash}\``,
190
+ "",
191
+ "#### Message shape",
192
+ "",
193
+ `- **Content-Type**: ${renderToken(stripParameters(entry.contentType), MEDIA_TYPE)}`,
194
+ `- **Content-Transfer-Encoding**: ${renderToken(entry.transferEncoding, TOKEN)}`,
195
+ `- **Charset**: ${entry.charset === null ? "_not declared_" : renderToken(entry.charset, TOKEN)}`,
196
+ `- **Size**: ${entry.sizeBytes} bytes`,
197
+ "",
198
+ "MIME structure:",
199
+ ].join("\n");
200
+
201
+ return {
202
+ head,
203
+ structure: renderStructure(entry.structure).join("\n"),
204
+ disclaimer: QUARANTINE_REPORT_DISCLAIMER,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * The whole report as one string — what the dialog shows and the copy action
210
+ * copies. The published issue is assembled from the sections instead, so that
211
+ * a long MIME tree can be truncated without breaking the fence.
212
+ */
213
+ export function formatQuarantineReport(entry: QuarantineEntry): string {
214
+ const { head, structure, disclaimer } = quarantineReportSections(entry);
215
+ return [head, "", "```", structure, "```", "", disclaimer].join("\n");
216
+ }
217
+
218
+ /** Issue title. Closed vocabulary only, so it is safe to publish. */
219
+ export function quarantineIssueTitle(entry: QuarantineEntry): string {
220
+ return `Message quarantined: ${entry.failureCode} at ${entry.failureStage}`;
221
+ }
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { QuarantineBugDialog } from "./quarantine-bug-dialog.js";
6
+ import { quarantineDemoEntries } from "./quarantine-fixtures.js";
7
+ import type { QuarantineEntry } from "./quarantine-report.js";
8
+ import { QuarantineSection } from "./quarantine-section.js";
9
+
10
+ const noop = () => {};
11
+ const [base, second] = quarantineDemoEntries;
12
+ const ISSUE_URL = "https://github.com/remit-mail/reader/issues/new?title=x";
13
+
14
+ const render = (entries: readonly QuarantineEntry[]) =>
15
+ renderToString(createElement(QuarantineSection, { entries, onCutBug: noop }));
16
+
17
+ describe("QuarantineSection", () => {
18
+ it("reassures when nothing is set aside", () => {
19
+ const html = render([]);
20
+ assert.match(html, /Nothing is set aside/);
21
+ assert.doesNotMatch(html, /role="alert"/);
22
+ });
23
+
24
+ it("shows a single entry as a fact, without an alert", () => {
25
+ const html = render([base]);
26
+ assert.match(html, /uid 40217/);
27
+ assert.doesNotMatch(html, /role="alert"/);
28
+ });
29
+
30
+ it("raises an alert once more than one message is set aside", () => {
31
+ const html = render([base, second]);
32
+ assert.match(html, /role="alert"/);
33
+ assert.match(html, /2 messages could not be read/);
34
+ });
35
+
36
+ it("shows the parser's own words on screen, where the report will not", () => {
37
+ const html = render([base]);
38
+ assert.match(html, /multipart boundary was never closed/);
39
+ });
40
+
41
+ it("offers reporting as the only per-row action", () => {
42
+ const html = render([base]);
43
+ assert.match(html, /Cut a bug/);
44
+ assert.doesNotMatch(html, /Try again|Retry/);
45
+ });
46
+ });
47
+
48
+ describe("QuarantineBugDialog", () => {
49
+ it("shows the whole report before anything is filed", () => {
50
+ const html = renderToString(
51
+ createElement(QuarantineBugDialog, {
52
+ entry: base,
53
+ onClose: noop,
54
+ onCopy: noop,
55
+ issueUrl: ISSUE_URL,
56
+ }),
57
+ );
58
+ assert.match(html, /UnterminatedMultipartBoundary/);
59
+ assert.match(html, /attachment names, or the parser&#x27;s own error text/);
60
+ assert.match(html, /Copy report/);
61
+ });
62
+
63
+ it("never renders the parser's own error text into the report", () => {
64
+ const html = renderToString(
65
+ createElement(QuarantineBugDialog, {
66
+ entry: base,
67
+ onClose: noop,
68
+ onCopy: noop,
69
+ issueUrl: ISSUE_URL,
70
+ }),
71
+ );
72
+ // The row shows it; the report must not. Locked at the render boundary,
73
+ // which is where the regression would actually happen.
74
+ assert.doesNotMatch(html, /multipart boundary was never closed/);
75
+ });
76
+
77
+ it("files through the supplied url with hardened external rel", () => {
78
+ const html = renderToString(
79
+ createElement(QuarantineBugDialog, {
80
+ entry: base,
81
+ onClose: noop,
82
+ onCopy: noop,
83
+ issueUrl: ISSUE_URL,
84
+ }),
85
+ );
86
+ assert.match(html, /rel="noopener noreferrer"/);
87
+ assert.match(html, /focus-visible:ring-2/);
88
+ });
89
+
90
+ it("renders nothing without an entry", () => {
91
+ const html = renderToString(
92
+ createElement(QuarantineBugDialog, {
93
+ entry: null,
94
+ onClose: noop,
95
+ onCopy: noop,
96
+ issueUrl: ISSUE_URL,
97
+ }),
98
+ );
99
+ assert.equal(html, "");
100
+ });
101
+ });
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { QuarantineBugDialog } from "./quarantine-bug-dialog.js";
4
+ import { quarantineDemoEntries } from "./quarantine-fixtures.js";
5
+ import type { QuarantineEntry } from "./quarantine-report.js";
6
+ import { QuarantineSection } from "./quarantine-section.js";
7
+
8
+ const [unterminatedBoundary, unknownCharset, truncatedBody] =
9
+ quarantineDemoEntries;
10
+
11
+ /**
12
+ * Stands in for the app's shared bug-report helper, which owns the URL budget
13
+ * and the repository constant.
14
+ */
15
+ const demoIssueUrl = "https://github.com/remit-mail/reader/issues/new";
16
+
17
+ const meta: Meta<typeof QuarantineSection> = {
18
+ title: "Settings/Quarantine",
19
+ component: QuarantineSection,
20
+ parameters: { layout: "padded" },
21
+ args: { onCutBug: () => {} },
22
+ decorators: [
23
+ (Story) => (
24
+ <div className="mx-auto max-w-2xl">
25
+ <Story />
26
+ </div>
27
+ ),
28
+ ],
29
+ };
30
+ export default meta;
31
+
32
+ type Story = StoryObj<typeof QuarantineSection>;
33
+
34
+ export const Empty: Story = {
35
+ args: { entries: [] },
36
+ };
37
+
38
+ export const OneEntry: Story = {
39
+ args: { entries: [unterminatedBoundary] },
40
+ };
41
+
42
+ export const AlertState: Story = {
43
+ args: { entries: [unterminatedBoundary, unknownCharset, truncatedBody] },
44
+ };
45
+
46
+ export const CutABugFlow: Story = {
47
+ render: () => {
48
+ const [open, setOpen] = useState<QuarantineEntry | null>(null);
49
+ const [copied, setCopied] = useState(false);
50
+ return (
51
+ <>
52
+ <QuarantineSection entries={quarantineDemoEntries} onCutBug={setOpen} />
53
+ {copied && <p className="mt-3 text-xs text-positive">Report copied.</p>}
54
+ <QuarantineBugDialog
55
+ entry={open}
56
+ issueUrl={demoIssueUrl}
57
+ onClose={() => setOpen(null)}
58
+ onCopy={() => setCopied(true)}
59
+ />
60
+ </>
61
+ );
62
+ },
63
+ };
64
+
65
+ export const BugReport: Story = {
66
+ render: () => (
67
+ <QuarantineBugDialog
68
+ entry={unterminatedBoundary}
69
+ issueUrl={demoIssueUrl}
70
+ onClose={() => {}}
71
+ onCopy={() => {}}
72
+ />
73
+ ),
74
+ };
@@ -0,0 +1,71 @@
1
+ import { CheckCircle2, TriangleAlert } from "lucide-react";
2
+ import { Banner } from "./banner.js";
3
+ import { QuarantineEntryRow } from "./quarantine-entry-row.js";
4
+ import type { QuarantineEntry } from "./quarantine-report.js";
5
+
6
+ export interface QuarantineSectionProps {
7
+ entries: readonly QuarantineEntry[];
8
+ onCutBug: (entry: QuarantineEntry) => void;
9
+ }
10
+
11
+ /**
12
+ * The quarantine list in settings.
13
+ *
14
+ * A message that could not be read was never written, so it cannot be found
15
+ * anywhere else — this list is the only record that it existed. One entry is a
16
+ * fact and reads as one. More than one is a pattern, and a pattern is a bug, so
17
+ * it raises an alert.
18
+ */
19
+ export function QuarantineSection({
20
+ entries,
21
+ onCutBug,
22
+ }: QuarantineSectionProps) {
23
+ return (
24
+ <section className="space-y-3">
25
+ <header className="space-y-1">
26
+ <h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
27
+ <p className="text-xs text-fg-muted">
28
+ Mail Remit could not read is set aside here instead of being skipped,
29
+ so nothing goes missing quietly. The rest of the folder keeps syncing.
30
+ Recovering a set-aside message is a re-sync, not a per-row action.
31
+ </p>
32
+ </header>
33
+
34
+ {entries.length === 0 && (
35
+ <div className="flex items-center gap-2 rounded-sm border border-line bg-surface px-row-inset py-3">
36
+ <CheckCircle2 className="size-4 shrink-0 text-positive" aria-hidden />
37
+ <p className="text-sm text-fg-muted">
38
+ Every message has been read successfully. Nothing is set aside.
39
+ </p>
40
+ </div>
41
+ )}
42
+
43
+ {entries.length > 1 && (
44
+ <Banner tone="warning">
45
+ <p className="flex items-start gap-2">
46
+ <TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
47
+ <span>
48
+ <span className="font-semibold">
49
+ {`${entries.length} messages could not be read.`}
50
+ </span>{" "}
51
+ More than one means something is wrong with how Remit reads mail,
52
+ not with the mail. Reporting one of these gets it fixed.
53
+ </span>
54
+ </p>
55
+ </Banner>
56
+ )}
57
+
58
+ {entries.length > 0 && (
59
+ <ul className="rounded-sm border border-line bg-surface">
60
+ {entries.map((entry) => (
61
+ <QuarantineEntryRow
62
+ key={entry.quarantineId}
63
+ entry={entry}
64
+ onCutBug={onCutBug}
65
+ />
66
+ ))}
67
+ </ul>
68
+ )}
69
+ </section>
70
+ );
71
+ }