@remit/ui 0.0.15 → 0.0.17

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/ui",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,4 +1,8 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from "react";
1
+ import type {
2
+ AnchorHTMLAttributes,
3
+ ButtonHTMLAttributes,
4
+ ReactNode,
5
+ } from "react";
2
6
  import { cn } from "../lib/cn.js";
3
7
 
4
8
  type Variant = "primary" | "secondary" | "ghost" | "danger";
@@ -50,3 +54,37 @@ export function Button({
50
54
  </button>
51
55
  );
52
56
  }
57
+
58
+ export interface ButtonLinkProps
59
+ extends AnchorHTMLAttributes<HTMLAnchorElement> {
60
+ variant?: Variant;
61
+ size?: Size;
62
+ icon?: ReactNode;
63
+ /** Opens in a new tab with the repo's standard `rel` hardening. */
64
+ external?: boolean;
65
+ }
66
+
67
+ /**
68
+ * An anchor that carries the button styling — same base, so it keeps the
69
+ * focus-visible ring a hand-rolled `<a className="bg-accent …">` drops.
70
+ */
71
+ export function ButtonLink({
72
+ variant = "primary",
73
+ size = "md",
74
+ icon,
75
+ external,
76
+ className,
77
+ children,
78
+ ...props
79
+ }: ButtonLinkProps) {
80
+ return (
81
+ <a
82
+ className={cn(base, variants[variant], sizes[size], className)}
83
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
84
+ {...props}
85
+ >
86
+ {icon}
87
+ {children}
88
+ </a>
89
+ );
90
+ }
@@ -0,0 +1,72 @@
1
+ import { Copy, ExternalLink } from "lucide-react";
2
+ import { Button, ButtonLink } from "./button.js";
3
+ import { Dialog } from "./dialog.js";
4
+ import {
5
+ formatQuarantineReport,
6
+ type QuarantineEntry,
7
+ } from "./quarantine-report.js";
8
+
9
+ export interface QuarantineBugDialogProps {
10
+ entry: QuarantineEntry | null;
11
+ onClose: () => void;
12
+ /**
13
+ * Prefilled new-issue URL, built by the app's shared bug-report helper so
14
+ * the URL budget and the repository constant stay in one place.
15
+ */
16
+ issueUrl: string;
17
+ onCopy: (report: string) => void;
18
+ }
19
+
20
+ /**
21
+ * The report, in full, before it goes anywhere. Filing opens the user's own
22
+ * GitHub session with the issue prefilled — Remit never posts on their behalf,
23
+ * and nothing is sent that is not on this screen.
24
+ */
25
+ export function QuarantineBugDialog({
26
+ entry,
27
+ onClose,
28
+ issueUrl,
29
+ onCopy,
30
+ }: QuarantineBugDialogProps) {
31
+ if (!entry) return null;
32
+ const report = formatQuarantineReport(entry);
33
+
34
+ return (
35
+ <Dialog open onClose={onClose} title="Report this message">
36
+ <div className="flex max-h-[80vh] flex-col">
37
+ <header className="space-y-1 border-b border-line px-4 py-3">
38
+ <h3 className="text-sm font-semibold text-fg">Report this message</h3>
39
+ <p className="text-xs text-fg-muted">
40
+ This is everything the report contains. It describes the shape of
41
+ the message — never its contents, addresses, subject, attachment
42
+ names, or the parser's own error text.
43
+ </p>
44
+ </header>
45
+ <pre className="flex-1 overflow-auto bg-surface-sunken px-4 py-3 text-2xs leading-relaxed whitespace-pre-wrap text-fg-muted">
46
+ {report}
47
+ </pre>
48
+ <footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line px-4 py-3">
49
+ <Button variant="ghost" size="sm" onClick={onClose}>
50
+ Cancel
51
+ </Button>
52
+ <Button
53
+ variant="secondary"
54
+ size="sm"
55
+ icon={<Copy className="size-3.5" />}
56
+ onClick={() => onCopy(report)}
57
+ >
58
+ Copy report
59
+ </Button>
60
+ <ButtonLink
61
+ external
62
+ size="sm"
63
+ href={issueUrl}
64
+ icon={<ExternalLink className="size-3.5" />}
65
+ >
66
+ Open on GitHub
67
+ </ButtonLink>
68
+ </footer>
69
+ </div>
70
+ </Dialog>
71
+ );
72
+ }
@@ -0,0 +1,71 @@
1
+ import { Bug } from "lucide-react";
2
+ import { Badge } from "./badge.js";
3
+ import { Button } from "./button.js";
4
+ import { canonicalRoleLabel, providerLeaf } from "./folder-role.js";
5
+ import {
6
+ type QuarantineEntry,
7
+ quarantineSummary,
8
+ } from "./quarantine-report.js";
9
+
10
+ export interface QuarantineEntryRowProps {
11
+ entry: QuarantineEntry;
12
+ onCutBug: (entry: QuarantineEntry) => void;
13
+ }
14
+
15
+ function formatQuarantinedAt(epochMillis: number): string {
16
+ return new Date(epochMillis).toLocaleString(undefined, {
17
+ dateStyle: "medium",
18
+ timeStyle: "short",
19
+ });
20
+ }
21
+
22
+ /**
23
+ * One quarantined message. Leads with what went wrong in plain language, then
24
+ * the parser's own words — which stay on this screen and never enter a report
25
+ * — and the folder, uid and time as identifying detail.
26
+ */
27
+ export function QuarantineEntryRow({
28
+ entry,
29
+ onCutBug,
30
+ }: QuarantineEntryRowProps) {
31
+ return (
32
+ <li className="flex flex-col gap-2 border-b border-line px-row-inset py-3 last:border-b-0 sm:flex-row sm:items-start sm:justify-between">
33
+ <div className="min-w-0 space-y-1">
34
+ <p className="text-sm text-fg">
35
+ {quarantineSummary(entry.failureStage)}
36
+ </p>
37
+ <p
38
+ className="truncate text-xs text-fg-muted"
39
+ title={entry.failureMessage}
40
+ >
41
+ {entry.failureMessage}
42
+ </p>
43
+ <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-fg-subtle">
44
+ <Badge tone="warning">{canonicalRoleLabel(entry.mailboxRole)}</Badge>
45
+ <span className="truncate" title={entry.mailboxPath}>
46
+ {providerLeaf(entry.mailboxPath)}
47
+ </span>
48
+ <span aria-hidden>·</span>
49
+ <span>{`uid ${entry.uid}`}</span>
50
+ <span aria-hidden>·</span>
51
+ <span>{formatQuarantinedAt(entry.quarantinedAt)}</span>
52
+ {entry.attempts > 1 && (
53
+ <>
54
+ <span aria-hidden>·</span>
55
+ <span>{`${entry.attempts} attempts`}</span>
56
+ </>
57
+ )}
58
+ </p>
59
+ </div>
60
+ <Button
61
+ variant="secondary"
62
+ size="sm"
63
+ className="shrink-0"
64
+ icon={<Bug className="size-3.5" />}
65
+ onClick={() => onCutBug(entry)}
66
+ >
67
+ Cut a bug
68
+ </Button>
69
+ </li>
70
+ );
71
+ }
@@ -0,0 +1,81 @@
1
+ import type { QuarantineEntry } from "./quarantine-report.js";
2
+
3
+ /**
4
+ * Demo entries backing the quarantine stories. Exported so the kit's own
5
+ * stories and the workbench settings screen render the same data — they had
6
+ * drifted on `appVersion` when each file carried its own copy.
7
+ */
8
+ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
9
+ {
10
+ quarantineId: "q-1",
11
+ accountId: "acct-1",
12
+ mailboxId: "mbx-inbox",
13
+ uid: 40217,
14
+ mailboxRole: "inbox",
15
+ mailboxPath: "INBOX",
16
+ failureStage: "BodyParse",
17
+ failureCode: "UnterminatedMultipartBoundary",
18
+ failureMessage: "multipart boundary was never closed",
19
+ failurePartPath: null,
20
+ quarantinedAt: Date.parse("2026-07-18T09:12:00Z"),
21
+ attempts: 3,
22
+ sizeBytes: 184_233,
23
+ contentType: "multipart/mixed",
24
+ transferEncoding: "7bit",
25
+ charset: "utf-8",
26
+ structure: {
27
+ contentType: "multipart/mixed",
28
+ parts: [
29
+ {
30
+ contentType: "multipart/alternative",
31
+ parts: [{ contentType: "text/plain" }, { contentType: "text/html" }],
32
+ },
33
+ { contentType: "application/pdf" },
34
+ ],
35
+ },
36
+ messageIdHash: "sha256:6f1c4a9d20",
37
+ appVersion: "worker 1.0.0",
38
+ },
39
+ {
40
+ quarantineId: "q-2",
41
+ accountId: "acct-1",
42
+ mailboxId: "mbx-archive",
43
+ uid: 40219,
44
+ mailboxRole: "archive",
45
+ mailboxPath: "Archive/2026",
46
+ failureStage: "BodyParse",
47
+ failureCode: "UnknownCharset",
48
+ failureMessage: "declared charset is not a known encoding",
49
+ failurePartPath: "1.2",
50
+ quarantinedAt: Date.parse("2026-07-18T14:40:00Z"),
51
+ attempts: 3,
52
+ sizeBytes: 9_812,
53
+ contentType: "text/plain",
54
+ transferEncoding: "quoted-printable",
55
+ charset: "x-user-defined",
56
+ structure: { contentType: "text/plain" },
57
+ messageIdHash: "sha256:b31e0744af",
58
+ appVersion: "worker 1.0.0",
59
+ },
60
+ {
61
+ quarantineId: "q-3",
62
+ accountId: "acct-1",
63
+ mailboxId: "mbx-junk",
64
+ uid: 40251,
65
+ mailboxRole: "junk",
66
+ mailboxPath: "Junk",
67
+ failureStage: "BodyParse",
68
+ failureCode: "TruncatedBody",
69
+ failureMessage: "stream ended before the declared body length",
70
+ failurePartPath: null,
71
+ quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
72
+ attempts: 1,
73
+ sizeBytes: 2_140,
74
+ contentType: "text/html",
75
+ transferEncoding: "base64",
76
+ charset: null,
77
+ structure: { contentType: "text/html" },
78
+ messageIdHash: "sha256:0a77de1c05",
79
+ appVersion: "worker 1.0.0",
80
+ },
81
+ ];
@@ -0,0 +1,151 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { quarantineDemoEntries } from "./quarantine-fixtures.js";
4
+ import {
5
+ formatQuarantineReport,
6
+ QUARANTINE_REPORT_DISCLAIMER,
7
+ type QuarantineEntry,
8
+ quarantineIssueTitle,
9
+ quarantineReportSections,
10
+ quarantineSummary,
11
+ } from "./quarantine-report.js";
12
+
13
+ const entry: QuarantineEntry = {
14
+ ...quarantineDemoEntries[0],
15
+ mailboxPath: "Clients/Acme Holdings",
16
+ failureMessage: "invalid address: joan@acme-holdings.example",
17
+ };
18
+
19
+ describe("formatQuarantineReport", () => {
20
+ it("names the stage, the code and the build that failed", () => {
21
+ const report = formatQuarantineReport(entry);
22
+ assert.match(report, /BodyParse/);
23
+ assert.match(report, /UnterminatedMultipartBoundary/);
24
+ assert.match(report, /worker 1\.0\.0/);
25
+ });
26
+
27
+ it("renders the MIME tree as structure with no content", () => {
28
+ const report = formatQuarantineReport(entry);
29
+ assert.match(report, /\n- multipart\/mixed/);
30
+ assert.match(report, /\n {2}- multipart\/alternative/);
31
+ assert.match(report, /\n {4}- text\/html/);
32
+ assert.match(report, /\n {2}- application\/pdf/);
33
+ });
34
+
35
+ it("withholds the user's own folder name", () => {
36
+ const report = formatQuarantineReport(entry);
37
+ assert.doesNotMatch(report, /Acme Holdings/);
38
+ assert.match(report, /Folder role.*inbox/);
39
+ });
40
+
41
+ it("withholds the parser's own error text, which can quote the input", () => {
42
+ const report = formatQuarantineReport(entry);
43
+ assert.doesNotMatch(report, /joan@acme-holdings\.example/);
44
+ assert.doesNotMatch(report, /invalid address/);
45
+ });
46
+
47
+ it("strips content-type parameters, which carry attachment filenames", () => {
48
+ const report = formatQuarantineReport({
49
+ ...entry,
50
+ contentType: 'multipart/mixed; boundary="=_a1b2"',
51
+ structure: {
52
+ contentType: "multipart/mixed",
53
+ parts: [
54
+ {
55
+ contentType:
56
+ 'application/octet-stream; name="Q3 payroll — Acme.pdf"',
57
+ },
58
+ ],
59
+ },
60
+ });
61
+ assert.doesNotMatch(report, /payroll/);
62
+ assert.doesNotMatch(report, /boundary/);
63
+ assert.match(report, /- application\/octet-stream/);
64
+ });
65
+
66
+ it("says so when the failure is not attributable to one part", () => {
67
+ assert.match(formatQuarantineReport(entry), /Failing part.*whole message/);
68
+ assert.match(
69
+ formatQuarantineReport({ ...entry, failurePartPath: "1.2" }),
70
+ /Failing part.*`1\.2`/,
71
+ );
72
+ });
73
+
74
+ it("marks an undeclared charset rather than omitting it", () => {
75
+ const report = formatQuarantineReport({ ...entry, charset: null });
76
+ assert.match(report, /Charset.*not declared/);
77
+ });
78
+ });
79
+
80
+ describe("sender-controlled BODYSTRUCTURE strings", () => {
81
+ // charset, transferEncoding and contentType are arbitrary quoted strings
82
+ // chosen by whoever sent the message, echoed into an issue filed under the
83
+ // user's own account. They are hostile input.
84
+ const injection = "`\n\n**Click here:** https://evil.example\n\n`";
85
+
86
+ it("cannot break out of the code span and inject markdown", () => {
87
+ const report = formatQuarantineReport({ ...entry, charset: injection });
88
+ const line = report.split("\n").find((l) => l.startsWith("- **Charset**:"));
89
+ assert.ok(line, "Expected a charset line");
90
+ // Neutralised, not deleted: the payload survives as literal text inside
91
+ // a code span, which needs the value to carry no raw newline and no
92
+ // backtick beyond the two delimiters.
93
+ assert.equal((line.match(/`/g) ?? []).length, 2);
94
+ assert.doesNotMatch(report, /^\s*\*\*Click here:\*\*/m);
95
+ });
96
+
97
+ it("keeps the malformed value visible, since it is usually the bug", () => {
98
+ const report = formatQuarantineReport({ ...entry, charset: injection });
99
+ assert.match(report, /malformed/);
100
+ assert.match(report, /evil\.example/);
101
+ });
102
+
103
+ it("renders a conforming value as itself", () => {
104
+ const report = formatQuarantineReport({ ...entry, charset: "utf-8" });
105
+ assert.match(report, /\*\*Charset\*\*: `utf-8`/);
106
+ assert.doesNotMatch(report, /malformed/);
107
+ });
108
+
109
+ it("keeps a hostile node type from closing the MIME fence", () => {
110
+ const report = formatQuarantineReport({
111
+ ...entry,
112
+ structure: {
113
+ contentType: "multipart/mixed",
114
+ parts: [{ contentType: "```\n## Injected heading" }],
115
+ },
116
+ });
117
+ assert.equal((report.match(/^```/gm) ?? []).length, 2);
118
+ assert.doesNotMatch(report, /^## Injected heading/m);
119
+ });
120
+ });
121
+
122
+ describe("quarantineReportSections", () => {
123
+ it("hands the MIME tree over unfenced, so it can be truncated safely", () => {
124
+ const { head, structure, disclaimer } = quarantineReportSections(entry);
125
+ assert.doesNotMatch(structure, /```/);
126
+ assert.doesNotMatch(head, /```/);
127
+ assert.equal(disclaimer, QUARANTINE_REPORT_DISCLAIMER);
128
+ });
129
+
130
+ it("assembles back into the fenced report the dialog shows", () => {
131
+ const report = formatQuarantineReport(entry);
132
+ assert.equal((report.match(/^```/gm) ?? []).length, 2);
133
+ assert.ok(report.endsWith(QUARANTINE_REPORT_DISCLAIMER));
134
+ });
135
+ });
136
+
137
+ describe("quarantineIssueTitle", () => {
138
+ it("carries only the closed vocabulary", () => {
139
+ const title = quarantineIssueTitle(entry);
140
+ assert.match(title, /UnterminatedMultipartBoundary/);
141
+ assert.match(title, /BodyParse/);
142
+ assert.doesNotMatch(title, /joan@/);
143
+ });
144
+ });
145
+
146
+ describe("quarantineSummary", () => {
147
+ it("explains the stage without parser jargon", () => {
148
+ const summary = quarantineSummary("BodyParse");
149
+ assert.doesNotMatch(summary, /MIME|RFC|mailparser|charset=/i);
150
+ });
151
+ });
@@ -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
+ }
@@ -74,9 +74,15 @@ export const AllSelected: Story = {
74
74
 
75
75
  /**
76
76
  * The search has more matches than are loaded: an escalation notice offers a
77
- * real button (not prose) naming the total. Tapping it is what flips the
78
- * selection's identity from an id set to the search query (out of scope for
79
- * this kit the caller supplies the count once paging resolves it).
77
+ * real button (not prose) naming the scope. Tapping it is what flips the
78
+ * selection's identity from an id set to the search query (`useEscalatedDelete`
79
+ * in web-client). No count in the label yet the real client's own read path
80
+ * (`ThreadOperations.searchThreads`) only counts within a capped recency
81
+ * window short of paging the whole result set, and paging it just to seed a
82
+ * button label the user hasn't asked for yet would burn a request on every
83
+ * render of "all loaded selected" for a number that goes stale the moment new
84
+ * mail arrives. Tapping the button is what pays for the real count, via the
85
+ * counting state below.
80
86
  */
81
87
  export const EscalationAvailable: Story = {
82
88
  args: {
@@ -90,7 +96,7 @@ export const EscalationAvailable: Story = {
90
96
  tone: "info",
91
97
  text: "",
92
98
  action: {
93
- label: 'Select all 3,412 matching "npm"',
99
+ label: 'Select all matching "npm"',
94
100
  onClick: () => undefined,
95
101
  },
96
102
  },
package/src/index.ts CHANGED
@@ -75,7 +75,12 @@ export {
75
75
  BriefSections,
76
76
  type BriefSectionsProps,
77
77
  } from "./components/brief-sections.js";
78
- export { Button, type ButtonProps } from "./components/button.js";
78
+ export {
79
+ Button,
80
+ ButtonLink,
81
+ type ButtonLinkProps,
82
+ type ButtonProps,
83
+ } from "./components/button.js";
79
84
  export {
80
85
  Card,
81
86
  CardBody,
@@ -243,6 +248,31 @@ export {
243
248
  PullToRefresh,
244
249
  type PullToRefreshProps,
245
250
  } from "./components/pull-to-refresh.js";
251
+ export {
252
+ QuarantineBugDialog,
253
+ type QuarantineBugDialogProps,
254
+ } from "./components/quarantine-bug-dialog.js";
255
+ export {
256
+ QuarantineEntryRow,
257
+ type QuarantineEntryRowProps,
258
+ } from "./components/quarantine-entry-row.js";
259
+ export { quarantineDemoEntries } from "./components/quarantine-fixtures.js";
260
+ export {
261
+ formatQuarantineReport,
262
+ QUARANTINE_REPORT_DISCLAIMER,
263
+ type QuarantineEntry,
264
+ type QuarantineFailureCode,
265
+ type QuarantineFailureStage,
266
+ type QuarantineMimeNode,
267
+ type QuarantineReportSections,
268
+ quarantineIssueTitle,
269
+ quarantineReportSections,
270
+ quarantineSummary,
271
+ } from "./components/quarantine-report.js";
272
+ export {
273
+ QuarantineSection,
274
+ type QuarantineSectionProps,
275
+ } from "./components/quarantine-section.js";
246
276
  export {
247
277
  QuotedText,
248
278
  type QuotedTextProps,