@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.
- package/package.json +1 -1
- package/src/components/button.render.test.ts +29 -0
- package/src/components/button.tsx +44 -2
- package/src/components/primitives.stories.tsx +6 -0
- package/src/components/progress-bar.render.test.ts +46 -0
- package/src/components/progress-bar.stories.tsx +35 -0
- package/src/components/progress-bar.tsx +62 -0
- 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/components/selection-top-bar.render.test.ts +167 -17
- package/src/components/selection-top-bar.stories.tsx +106 -16
- package/src/components/selection-top-bar.tsx +117 -64
- package/src/components/swipeable-row.render.test.ts +29 -0
- package/src/components/swipeable-row.tsx +42 -2
- package/src/components/touch-list-body.render.test.ts +25 -0
- package/src/components/touch-list-body.stories.tsx +15 -0
- package/src/components/touch-list.tsx +26 -15
- package/src/index.ts +37 -1
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
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 { Button } from "./button.js";
|
|
6
|
+
|
|
7
|
+
describe("Button", () => {
|
|
8
|
+
it("defaults to the md size", () => {
|
|
9
|
+
const html = renderToString(createElement(Button, {}, "Save"));
|
|
10
|
+
assert.match(html, /h-9/);
|
|
11
|
+
assert.doesNotMatch(html, /h-11/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders the sm size at 28px", () => {
|
|
15
|
+
const html = renderToString(createElement(Button, { size: "sm" }, "Save"));
|
|
16
|
+
assert.match(html, /h-7/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the touch size at 44px square", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(Button, {
|
|
22
|
+
size: "touch",
|
|
23
|
+
"aria-label": "Delete",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
assert.match(html, /h-11/);
|
|
27
|
+
assert.match(html, /w-11/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,8 +1,12 @@
|
|
|
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";
|
|
5
|
-
type Size = "sm" | "md";
|
|
9
|
+
type Size = "sm" | "md" | "touch";
|
|
6
10
|
|
|
7
11
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
12
|
variant?: Variant;
|
|
@@ -25,6 +29,10 @@ const variants: Record<Variant, string> = {
|
|
|
25
29
|
const sizes: Record<Size, string> = {
|
|
26
30
|
sm: "h-7 px-2.5 text-xs",
|
|
27
31
|
md: "h-9 px-3.5 text-sm",
|
|
32
|
+
/** 44px square — the touch-target floor (HIG 44 / Material 48-ish). For an
|
|
33
|
+
* icon-only control; a control carrying a text label should size itself
|
|
34
|
+
* with `md` plus an explicit `min-h-11` instead of stretching to square. */
|
|
35
|
+
touch: "h-11 w-11 text-sm",
|
|
28
36
|
};
|
|
29
37
|
|
|
30
38
|
export function Button({
|
|
@@ -46,3 +54,37 @@ export function Button({
|
|
|
46
54
|
</button>
|
|
47
55
|
);
|
|
48
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,46 @@
|
|
|
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 { ProgressBar } from "./progress-bar.js";
|
|
6
|
+
|
|
7
|
+
describe("ProgressBar", () => {
|
|
8
|
+
it("renders progressbar role with the aria value triad", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(ProgressBar, { value: 1200, max: 3412 }),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /role="progressbar"/);
|
|
13
|
+
assert.match(html, /aria-valuenow="1200"/);
|
|
14
|
+
assert.match(html, /aria-valuemin="0"/);
|
|
15
|
+
assert.match(html, /aria-valuemax="3412"/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("computes the fill width as a percentage of value/max", () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
createElement(ProgressBar, { value: 25, max: 100 }),
|
|
21
|
+
);
|
|
22
|
+
assert.match(html, /width:25%/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("clamps the fill at 100% when value exceeds max", () => {
|
|
26
|
+
const html = renderToString(
|
|
27
|
+
createElement(ProgressBar, { value: 500, max: 100 }),
|
|
28
|
+
);
|
|
29
|
+
assert.match(html, /width:100%/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("omits the aria value triad when indeterminate", () => {
|
|
33
|
+
const html = renderToString(
|
|
34
|
+
createElement(ProgressBar, { value: 0, max: 0, indeterminate: true }),
|
|
35
|
+
);
|
|
36
|
+
assert.doesNotMatch(html, /aria-valuenow/);
|
|
37
|
+
assert.doesNotMatch(html, /aria-valuemax/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("applies the tone's fill color", () => {
|
|
41
|
+
const html = renderToString(
|
|
42
|
+
createElement(ProgressBar, { value: 1, max: 2, tone: "danger" }),
|
|
43
|
+
);
|
|
44
|
+
assert.match(html, /bg-danger/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ProgressBar } from "./progress-bar.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ProgressBar> = {
|
|
5
|
+
title: "Components/ProgressBar",
|
|
6
|
+
component: ProgressBar,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
render: (args) => (
|
|
9
|
+
<div className="w-80">
|
|
10
|
+
<ProgressBar {...args} />
|
|
11
|
+
</div>
|
|
12
|
+
),
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof ProgressBar>;
|
|
17
|
+
|
|
18
|
+
export const Started: Story = { args: { value: 340, max: 3412 } };
|
|
19
|
+
|
|
20
|
+
export const Halfway: Story = { args: { value: 1706, max: 3412 } };
|
|
21
|
+
|
|
22
|
+
export const NearlyDone: Story = { args: { value: 3072, max: 3412 } };
|
|
23
|
+
|
|
24
|
+
export const Success: Story = {
|
|
25
|
+
args: { value: 3412, max: 3412, tone: "success" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Danger: Story = {
|
|
29
|
+
args: { value: 1200, max: 3412, tone: "danger" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Total is unknown — a paging search count, before the first page resolves. */
|
|
33
|
+
export const Indeterminate: Story = {
|
|
34
|
+
args: { value: 0, max: 0, indeterminate: true },
|
|
35
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import type { BannerTone } from "./banner.js";
|
|
3
|
+
|
|
4
|
+
export interface ProgressBarProps {
|
|
5
|
+
/** Units completed so far. Ignored when `indeterminate`. */
|
|
6
|
+
value: number;
|
|
7
|
+
/** Total units. Ignored when `indeterminate`. */
|
|
8
|
+
max: number;
|
|
9
|
+
tone?: BannerTone;
|
|
10
|
+
/** Unknown total (or unknown rate) — an animated fill with no fixed end. */
|
|
11
|
+
indeterminate?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tones: Record<BannerTone, string> = {
|
|
16
|
+
info: "bg-accent-2",
|
|
17
|
+
success: "bg-positive",
|
|
18
|
+
warning: "bg-warning",
|
|
19
|
+
danger: "bg-danger",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determinate (or indeterminate) progress meter for a bulk operation, e.g. a
|
|
24
|
+
* multi-thousand-message delete running in batches. A bare running count
|
|
25
|
+
* gives no sense of rate over a long operation; a filling bar answers "is
|
|
26
|
+
* this stuck?" pre-attentively.
|
|
27
|
+
*/
|
|
28
|
+
export function ProgressBar({
|
|
29
|
+
value,
|
|
30
|
+
max,
|
|
31
|
+
tone = "info",
|
|
32
|
+
indeterminate = false,
|
|
33
|
+
className,
|
|
34
|
+
}: ProgressBarProps) {
|
|
35
|
+
const pct = indeterminate
|
|
36
|
+
? 100
|
|
37
|
+
: max <= 0
|
|
38
|
+
? 0
|
|
39
|
+
: Math.min(100, Math.max(0, (value / max) * 100));
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
role="progressbar"
|
|
44
|
+
aria-valuenow={indeterminate ? undefined : value}
|
|
45
|
+
aria-valuemin={indeterminate ? undefined : 0}
|
|
46
|
+
aria-valuemax={indeterminate ? undefined : max}
|
|
47
|
+
className={cn(
|
|
48
|
+
"h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
"h-full rounded-full transition-[width] duration-300 ease-out",
|
|
55
|
+
tones[tone],
|
|
56
|
+
indeterminate && "w-1/3 animate-pulse",
|
|
57
|
+
)}
|
|
58
|
+
style={indeterminate ? undefined : { width: `${pct}%` }}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -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
|
+
});
|