@remit/ui 0.0.15 → 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.
- package/package.json +1 -1
- package/src/components/button.tsx +39 -1
- package/src/components/quarantine-bug-dialog.tsx +72 -0
- package/src/components/quarantine-entry-row.tsx +71 -0
- package/src/components/quarantine-fixtures.ts +81 -0
- package/src/components/quarantine-report.test.ts +151 -0
- package/src/components/quarantine-report.ts +221 -0
- package/src/components/quarantine-section.render.test.ts +101 -0
- package/src/components/quarantine-section.stories.tsx +74 -0
- package/src/components/quarantine-section.tsx +71 -0
- package/src/index.ts +31 -1
package/package.json
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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'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
|
+
}
|
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 {
|
|
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,
|