@remit/ui 0.0.98 → 0.0.100

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.98",
3
+ "version": "0.0.100",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,120 @@
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 { AttachmentList, type AttachmentListProps } from "./attachment-list.js";
6
+
7
+ const render = (props: AttachmentListProps): string =>
8
+ renderToString(createElement(AttachmentList, props));
9
+
10
+ const item = (
11
+ overrides: Partial<AttachmentListProps["attachments"][number]>,
12
+ ) => ({
13
+ attachmentId: "part-1",
14
+ filename: "Quarterly report.pdf",
15
+ typeLabel: "PDF",
16
+ sizeOctets: 1024 * 512,
17
+ download: { status: "idle" } as const,
18
+ ...overrides,
19
+ });
20
+
21
+ describe("AttachmentList", () => {
22
+ it("renders nothing when the message has no attachments", () => {
23
+ assert.equal(render({ attachments: [], onDownload: () => undefined }), "");
24
+ });
25
+
26
+ it("names the file, its type and its size", () => {
27
+ const html = render({
28
+ attachments: [item({})],
29
+ onDownload: () => undefined,
30
+ });
31
+ assert.match(html, /Quarterly report\.pdf/);
32
+ assert.match(html, /PDF/);
33
+ assert.match(html, /512 KB/);
34
+ });
35
+
36
+ it("gives every attachment a labelled download control", () => {
37
+ const html = render({
38
+ attachments: [
39
+ item({}),
40
+ item({ attachmentId: "part-2", filename: "photo.png" }),
41
+ ],
42
+ onDownload: () => undefined,
43
+ });
44
+ assert.match(html, /aria-label="Download Quarterly report\.pdf"/);
45
+ assert.match(html, /aria-label="Download photo\.png"/);
46
+ assert.match(html, /2 attachments/);
47
+ });
48
+
49
+ it("counts a single attachment in the singular", () => {
50
+ const html = render({
51
+ attachments: [item({})],
52
+ onDownload: () => undefined,
53
+ });
54
+ assert.match(html, /1 attachment</);
55
+ });
56
+
57
+ it("isolates the filename's text direction", () => {
58
+ const html = render({
59
+ attachments: [item({})],
60
+ onDownload: () => undefined,
61
+ });
62
+ assert.match(html, /<bdi/);
63
+ });
64
+
65
+ it("disables the control while a download is in flight", () => {
66
+ const html = render({
67
+ attachments: [item({ download: { status: "downloading" } })],
68
+ onDownload: () => undefined,
69
+ });
70
+ assert.match(html, /disabled=""/);
71
+ });
72
+
73
+ it("states what failed, the likely fix, and offers a retry", () => {
74
+ const html = render({
75
+ attachments: [
76
+ item({
77
+ download: {
78
+ status: "failed",
79
+ title: "Your session expired",
80
+ detail: "Sign in again, then download it once more.",
81
+ },
82
+ }),
83
+ ],
84
+ onDownload: () => undefined,
85
+ });
86
+ assert.match(html, /role="alert"/);
87
+ assert.match(html, /Your session expired/);
88
+ assert.match(html, /Sign in again, then download it once more\./);
89
+ assert.match(html, /Try again/);
90
+ });
91
+
92
+ it("offers a report link when the failure came with one", () => {
93
+ const html = render({
94
+ attachments: [
95
+ item({
96
+ download: {
97
+ status: "failed",
98
+ title: "Couldn't download this attachment",
99
+ detail: "The server refused the request.",
100
+ reportUrl:
101
+ "https://github.com/remit-mail/reader/issues/new?title=x",
102
+ },
103
+ }),
104
+ ],
105
+ onDownload: () => undefined,
106
+ });
107
+ assert.match(html, /Report this/);
108
+ assert.match(html, /issues\/new\?title=x/);
109
+ });
110
+
111
+ it("says so when the server promised an attachment no part describes", () => {
112
+ const html = render({
113
+ attachments: [],
114
+ onDownload: () => undefined,
115
+ hasUnlistedAttachment: true,
116
+ });
117
+ assert.match(html, /carries an attachment, but none of/);
118
+ assert.match(html, /Attachment</);
119
+ });
120
+ });
@@ -0,0 +1,159 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { type AttachmentItem, AttachmentList } from "./attachment-list.js";
3
+
4
+ /**
5
+ * The attachment list on an open message (#683). Every row saves a file, so
6
+ * every row is a button; the paperclip in the heading is the only glyph and it
7
+ * is decoration. Nothing here fetches — the app owns the download and hands
8
+ * back per-row state, which is what makes the failure story below the same
9
+ * component the app renders.
10
+ */
11
+ const meta: Meta<typeof AttachmentList> = {
12
+ title: "Mail/AttachmentList",
13
+ component: AttachmentList,
14
+ parameters: { layout: "padded" },
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof AttachmentList>;
19
+
20
+ const report: AttachmentItem = {
21
+ attachmentId: "part-2",
22
+ filename: "Q3 board pack.pdf",
23
+ typeLabel: "PDF",
24
+ sizeOctets: 2_411_724,
25
+ download: { status: "idle" },
26
+ };
27
+
28
+ export const OneAttachment: Story = {
29
+ args: {
30
+ attachments: [report],
31
+ onDownload: (id) => alert(`Download ${id}`),
32
+ },
33
+ };
34
+
35
+ export const SeveralAttachments: Story = {
36
+ args: {
37
+ attachments: [
38
+ report,
39
+ {
40
+ attachmentId: "part-3",
41
+ filename: "site-plan.png",
42
+ typeLabel: "PNG",
43
+ sizeOctets: 486_120,
44
+ download: { status: "idle" },
45
+ },
46
+ {
47
+ attachmentId: "part-4",
48
+ filename: "notes.txt",
49
+ typeLabel: "PLAIN",
50
+ sizeOctets: 812,
51
+ download: { status: "idle" },
52
+ },
53
+ {
54
+ attachmentId: "part-5",
55
+ filename: "archive",
56
+ typeLabel: "FILE",
57
+ sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
58
+ download: { status: "idle" },
59
+ },
60
+ ],
61
+ onDownload: (id) => alert(`Download ${id}`),
62
+ },
63
+ };
64
+
65
+ export const Downloading: Story = {
66
+ args: {
67
+ attachments: [
68
+ { ...report, download: { status: "downloading" } },
69
+ {
70
+ attachmentId: "part-3",
71
+ filename: "site-plan.png",
72
+ typeLabel: "PNG",
73
+ sizeOctets: 486_120,
74
+ download: { status: "idle" },
75
+ },
76
+ ],
77
+ onDownload: (id) => alert(`Download ${id}`),
78
+ },
79
+ };
80
+
81
+ /**
82
+ * A fetch that failed. The row keeps its control, and the alert underneath
83
+ * names what broke and what to do about it — a dead click that leaves the user
84
+ * guessing whether the app, the server or they themselves are at fault is the
85
+ * outcome this list exists to make impossible.
86
+ */
87
+ export const DownloadFailed: Story = {
88
+ args: {
89
+ attachments: [
90
+ {
91
+ ...report,
92
+ download: {
93
+ status: "failed",
94
+ title: "This attachment is missing from storage",
95
+ detail:
96
+ "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
97
+ reportUrl: "https://github.com/remit-mail/reader/issues/new",
98
+ },
99
+ },
100
+ {
101
+ attachmentId: "part-3",
102
+ filename: "site-plan.png",
103
+ typeLabel: "PNG",
104
+ sizeOctets: 486_120,
105
+ download: { status: "idle" },
106
+ },
107
+ ],
108
+ onDownload: (id) => alert(`Download ${id}`),
109
+ },
110
+ };
111
+
112
+ /**
113
+ * Names written to deceive, as `sanitizeAttachmentFilename` leaves them. The
114
+ * senders wrote `../../../etc/passwd`, `invoice<RLO>gnp.exe` — which renders as
115
+ * `invoiceexe.png` with the override intact — and 400 characters of padding.
116
+ * The list shows exactly the name the file is saved under, so what is read is
117
+ * what lands.
118
+ */
119
+ export const HostileFilename: Story = {
120
+ args: {
121
+ attachments: [
122
+ {
123
+ attachmentId: "part-6",
124
+ filename: "passwd",
125
+ typeLabel: "FILE",
126
+ sizeOctets: 3_120,
127
+ download: { status: "idle" },
128
+ },
129
+ {
130
+ attachmentId: "part-7",
131
+ filename: "invoicegnp.exe",
132
+ typeLabel: "FILE",
133
+ sizeOctets: 118_400,
134
+ download: { status: "idle" },
135
+ },
136
+ {
137
+ attachmentId: "part-8",
138
+ filename: `${"long-name-".repeat(11)}report.pdf`,
139
+ typeLabel: "PDF",
140
+ sizeOctets: 44_000,
141
+ download: { status: "idle" },
142
+ },
143
+ ],
144
+ onDownload: (id) => alert(`Download ${id}`),
145
+ },
146
+ };
147
+
148
+ /**
149
+ * The mail server flagged the message as carrying an attachment, but no body
150
+ * part describes one. Saying nothing here is what made the original paperclip
151
+ * read as broken.
152
+ */
153
+ export const UnlistedAttachment: Story = {
154
+ args: {
155
+ attachments: [],
156
+ onDownload: () => undefined,
157
+ hasUnlistedAttachment: true,
158
+ },
159
+ };
@@ -0,0 +1,182 @@
1
+ import { AlertCircle, Download, Loader2, Paperclip } from "lucide-react";
2
+ import { formatByteSize } from "../lib/attachment-file.js";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export type AttachmentDownloadState =
6
+ | { status: "idle" }
7
+ | { status: "downloading" }
8
+ | { status: "failed"; title: string; detail: string; reportUrl?: string };
9
+
10
+ export interface AttachmentItem {
11
+ attachmentId: string;
12
+ /**
13
+ * The name shown and the name saved — one string for both, already reduced
14
+ * by `sanitizeAttachmentFilename`. Anything that displays a different name
15
+ * than it writes to disk is the bug this field exists to prevent.
16
+ */
17
+ filename: string;
18
+ /** Short type label derived from the MIME subtype, e.g. `PDF`. */
19
+ typeLabel: string;
20
+ sizeOctets: number;
21
+ download: AttachmentDownloadState;
22
+ }
23
+
24
+ export interface AttachmentListProps {
25
+ attachments: readonly AttachmentItem[];
26
+ onDownload: (attachmentId: string) => void;
27
+ /**
28
+ * The message is flagged on the server as carrying an attachment that none
29
+ * of its body parts describe. Says so rather than rendering nothing, which
30
+ * is indistinguishable from a broken app to someone who saw the paperclip
31
+ * in the list.
32
+ */
33
+ hasUnlistedAttachment?: boolean;
34
+ className?: string;
35
+ }
36
+
37
+ function DownloadFailure({
38
+ state,
39
+ onRetry,
40
+ }: {
41
+ state: Extract<AttachmentDownloadState, { status: "failed" }>;
42
+ onRetry: () => void;
43
+ }) {
44
+ return (
45
+ <div
46
+ role="alert"
47
+ data-testid="attachment-error"
48
+ className="flex items-start gap-2 border-t border-danger/30 bg-danger-soft px-2 py-2 text-xs"
49
+ >
50
+ <AlertCircle
51
+ className="mt-0.5 size-3.5 shrink-0 text-danger"
52
+ aria-hidden
53
+ />
54
+ <div className="min-w-0 flex-1">
55
+ <p className="font-medium text-danger">{state.title}</p>
56
+ <p className="mt-0.5 text-fg-muted">{state.detail}</p>
57
+ </div>
58
+ <div className="flex shrink-0 items-center gap-3">
59
+ <button
60
+ type="button"
61
+ onClick={onRetry}
62
+ className="font-medium text-accent hover:underline"
63
+ >
64
+ Try again
65
+ </button>
66
+ {state.reportUrl && (
67
+ <a
68
+ href={state.reportUrl}
69
+ target="_blank"
70
+ rel="noreferrer"
71
+ className="font-medium text-accent hover:underline"
72
+ >
73
+ Report this
74
+ </a>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The attachments carried by an open message: what each one is, how big it is,
83
+ * and a control that saves it.
84
+ *
85
+ * Every row is a real button. The paperclip in the section heading is the only
86
+ * glyph here and it is labelled as decoration — a paperclip that looks
87
+ * pressable and is not reads as a broken download, which is the failure this
88
+ * list replaces (#683).
89
+ */
90
+ export function AttachmentList({
91
+ attachments,
92
+ onDownload,
93
+ hasUnlistedAttachment = false,
94
+ className,
95
+ }: AttachmentListProps) {
96
+ if (attachments.length === 0 && !hasUnlistedAttachment) return null;
97
+
98
+ return (
99
+ <section
100
+ aria-label="Attachments"
101
+ data-testid="attachment-list"
102
+ className={cn("space-y-1.5", className)}
103
+ >
104
+ <h3 className="flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
105
+ <Paperclip className="size-3" aria-hidden />
106
+ {attachments.length === 0
107
+ ? "Attachment"
108
+ : attachments.length === 1
109
+ ? "1 attachment"
110
+ : `${attachments.length} attachments`}
111
+ </h3>
112
+
113
+ {attachments.length > 0 && (
114
+ <ul className="overflow-hidden rounded-sm border border-line bg-surface">
115
+ {attachments.map((attachment) => {
116
+ const isDownloading = attachment.download.status === "downloading";
117
+ return (
118
+ <li
119
+ key={attachment.attachmentId}
120
+ className="border-t border-line first:border-t-0"
121
+ >
122
+ <button
123
+ type="button"
124
+ onClick={() => onDownload(attachment.attachmentId)}
125
+ disabled={isDownloading}
126
+ data-testid="attachment-download"
127
+ aria-label={`Download ${attachment.filename}`}
128
+ className="flex w-full items-center gap-2 px-2 py-2 text-left transition-colors hover:bg-surface-sunken disabled:cursor-progress disabled:opacity-60"
129
+ >
130
+ <span className="min-w-0 flex-1">
131
+ {/* <bdi> isolates the name's own direction so a
132
+ right-to-left filename cannot reorder the label
133
+ around it. */}
134
+ <bdi
135
+ className="block truncate text-sm text-fg"
136
+ title={attachment.filename}
137
+ >
138
+ {attachment.filename}
139
+ </bdi>
140
+ <span className="block truncate text-2xs text-fg-subtle">
141
+ {attachment.typeLabel} ·{" "}
142
+ {formatByteSize(attachment.sizeOctets)}
143
+ </span>
144
+ </span>
145
+ {isDownloading ? (
146
+ <Loader2
147
+ className="size-4 shrink-0 animate-spin text-fg-subtle"
148
+ aria-hidden
149
+ />
150
+ ) : (
151
+ <Download
152
+ className="size-4 shrink-0 text-fg-subtle"
153
+ aria-hidden
154
+ />
155
+ )}
156
+ </button>
157
+ {attachment.download.status === "failed" && (
158
+ <DownloadFailure
159
+ state={attachment.download}
160
+ onRetry={() => onDownload(attachment.attachmentId)}
161
+ />
162
+ )}
163
+ </li>
164
+ );
165
+ })}
166
+ </ul>
167
+ )}
168
+
169
+ {hasUnlistedAttachment && (
170
+ <p
171
+ role="alert"
172
+ data-testid="attachment-unlisted"
173
+ className="rounded-sm border border-line bg-surface px-2 py-2 text-xs text-fg-muted"
174
+ >
175
+ The mail server says this message carries an attachment, but none of
176
+ its parts describe one. Re-sync the account from Settings; if the
177
+ attachment still does not appear, the message is worth reporting.
178
+ </p>
179
+ )}
180
+ </section>
181
+ );
182
+ }
@@ -23,7 +23,12 @@ export const Saving: Story = { args: { saveStatus: "saving" } };
23
23
 
24
24
  export const Saved: Story = { args: { saveStatus: "saved" } };
25
25
 
26
- export const Sending: Story = { args: { sending: true } };
26
+ export const SaveFailed: Story = { args: { saveStatus: "error" } };
27
+
28
+ export const Sending: Story = {
29
+ name: "Sending — also while the pending draft is written",
30
+ args: { sending: true },
31
+ };
27
32
 
28
33
  export const CannotSend: Story = {
29
34
  name: "Cannot send — stays pressable",
@@ -138,3 +138,34 @@ describe("CompactRow", () => {
138
138
  assert.match(html, /Q3 planning notes/);
139
139
  });
140
140
  });
141
+
142
+ describe("attachment indicator", () => {
143
+ // A list row cannot download anything — the file lives on the message, which
144
+ // is one pane over. So the paperclip here is metadata and says so, rather
145
+ // than sitting inertly where a control belongs (#683).
146
+ it("marks a comfortable row as carrying an attachment, without a control", () => {
147
+ const html = renderToString(
148
+ createElement(ComfortableRow, {
149
+ thread: { ...base, isRead: true, hasAttachment: true },
150
+ }),
151
+ );
152
+ assert.match(html, /role="img"/);
153
+ assert.match(html, /aria-label="Has an attachment"/);
154
+ });
155
+
156
+ it("marks a compact row as carrying an attachment", () => {
157
+ const html = renderToString(
158
+ createElement(CompactRow, {
159
+ thread: { ...base, isRead: true, hasAttachment: true },
160
+ }),
161
+ );
162
+ assert.match(html, /aria-label="Has an attachment"/);
163
+ });
164
+
165
+ it("renders no indicator when the message carries nothing", () => {
166
+ const html = renderToString(
167
+ createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
168
+ );
169
+ assert.doesNotMatch(html, /Has an attachment/);
170
+ });
171
+ });
@@ -98,7 +98,11 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
98
98
  <span className="text-fg-subtle"> — {thread.snippet}</span>
99
99
  </span>
100
100
  {thread.hasAttachment && (
101
- <Paperclip className="size-3 shrink-0 text-fg-subtle" />
101
+ <Paperclip
102
+ className="size-3 shrink-0 text-fg-subtle"
103
+ role="img"
104
+ aria-label="Has an attachment"
105
+ />
102
106
  )}
103
107
  <span className="w-11 shrink-0 text-right text-2xs text-fg-subtle tabular-nums">
104
108
  {thread.timeLabel}
@@ -192,7 +196,11 @@ export function ComfortableRowTextContent({
192
196
  <Star className="size-3 shrink-0 fill-warning text-warning" />
193
197
  )}
194
198
  {thread.hasAttachment && (
195
- <Paperclip className="size-3 shrink-0 text-fg-subtle" />
199
+ <Paperclip
200
+ className="size-3 shrink-0 text-fg-subtle"
201
+ role="img"
202
+ aria-label="Has an attachment"
203
+ />
196
204
  )}
197
205
  </span>
198
206
  <span className="flex items-center gap-1.5">
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Paperclip, Star } from "lucide-react";
3
3
  import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
4
+ import { AttachmentList } from "./attachment-list.js";
5
+ import { MessageBodyView } from "./message-body-view.js";
4
6
  import {
5
7
  CollapsedMessage,
6
8
  ExpandedMessage,
@@ -156,7 +158,11 @@ export const CollapsedRowComposed: StoryObj<typeof CollapsedMessage> = {
156
158
  >
157
159
  <Star className="size-3 fill-current" />
158
160
  </button>
159
- <Paperclip className="size-3 text-fg-subtle" />
161
+ <Paperclip
162
+ className="size-3 text-fg-subtle"
163
+ role="img"
164
+ aria-label="Has an attachment"
165
+ />
160
166
  <span className="text-2xs text-fg-subtle tabular-nums">
161
167
  Today, 08:42
162
168
  </span>
@@ -180,7 +186,6 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
180
186
  indicators={
181
187
  <div className="mt-0.5 flex items-center gap-1">
182
188
  <Star className="size-3.5 fill-current text-warning" />
183
- <Paperclip className="size-3.5 text-fg-subtle" />
184
189
  </div>
185
190
  }
186
191
  actionMenu={
@@ -192,3 +197,54 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
192
197
  </div>
193
198
  ),
194
199
  };
200
+
201
+ /**
202
+ * The expanded row as `MessageCard` composes it when the message carries files
203
+ * (#683): body first, attachment list under it. The indicators row holds no
204
+ * paperclip — the list below is the affordance, and a second paperclip beside
205
+ * the live star button only reads as a control that does nothing.
206
+ */
207
+ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
208
+ render: () => (
209
+ <div className="max-w-3xl border border-line">
210
+ <ExpandedMessage
211
+ message={row}
212
+ to={<>to Alex Rivera and 2 others</>}
213
+ indicators={
214
+ <div className="mt-0.5 flex items-center gap-1">
215
+ <Star className="size-3.5 fill-current text-warning" />
216
+ </div>
217
+ }
218
+ body={
219
+ <div className="mt-3">
220
+ <MessageBodyView
221
+ html={row.bodyHtml}
222
+ category="personal"
223
+ allowImages
224
+ />
225
+ <AttachmentList
226
+ className="mt-4 px-2 lg:px-0"
227
+ attachments={[
228
+ {
229
+ attachmentId: "part-2",
230
+ filename: "Q3 board pack.pdf",
231
+ typeLabel: "PDF",
232
+ sizeOctets: 2_411_724,
233
+ download: { status: "idle" },
234
+ },
235
+ {
236
+ attachmentId: "part-3",
237
+ filename: "headcount.csv",
238
+ typeLabel: "CSV",
239
+ sizeOctets: 4_180,
240
+ download: { status: "idle" },
241
+ },
242
+ ]}
243
+ onDownload={(id) => alert(`Download ${id}`)}
244
+ />
245
+ </div>
246
+ }
247
+ />
248
+ </div>
249
+ ),
250
+ };
package/src/index.ts CHANGED
@@ -51,6 +51,12 @@ export {
51
51
  useContainerWidth,
52
52
  } from "./components/app-shell-types.js";
53
53
  export { AppTopBar, type AppTopBarProps } from "./components/app-top-bar.js";
54
+ export {
55
+ type AttachmentDownloadState,
56
+ type AttachmentItem,
57
+ AttachmentList,
58
+ type AttachmentListProps,
59
+ } from "./components/attachment-list.js";
54
60
  export { AuthCard, type AuthCardProps } from "./components/auth-card.js";
55
61
  export {
56
62
  AuthFooter,
@@ -641,6 +647,11 @@ export {
641
647
  sanitizeAdoptedHtml,
642
648
  sanitizeQuotedHtml,
643
649
  } from "./lib/adopted-html.js";
650
+ export {
651
+ DEFAULT_ATTACHMENT_FILENAME,
652
+ formatByteSize,
653
+ sanitizeAttachmentFilename,
654
+ } from "./lib/attachment-file.js";
644
655
  export {
645
656
  buildCidResolver,
646
657
  type CidResolvableBodyPart,
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatByteSize,
5
+ sanitizeAttachmentFilename,
6
+ } from "./attachment-file.js";
7
+
8
+ const RLO = "\u202e";
9
+ const LRI = "\u2066";
10
+ const PDI = "\u2069";
11
+ const ZWSP = "\u200b";
12
+ const BOM = "\ufeff";
13
+
14
+ describe("sanitizeAttachmentFilename", () => {
15
+ it("leaves an ordinary filename alone", () => {
16
+ assert.equal(
17
+ sanitizeAttachmentFilename("Quarterly report.pdf"),
18
+ "Quarterly report.pdf",
19
+ );
20
+ });
21
+
22
+ it("keeps only the last segment of a POSIX path", () => {
23
+ assert.equal(sanitizeAttachmentFilename("../../../etc/passwd"), "passwd");
24
+ });
25
+
26
+ it("keeps only the last segment of a Windows path", () => {
27
+ assert.equal(
28
+ sanitizeAttachmentFilename("..\\..\\Windows\\System32\\evil.dll"),
29
+ "evil.dll",
30
+ );
31
+ });
32
+
33
+ it("falls back when the name is nothing but traversal", () => {
34
+ assert.equal(sanitizeAttachmentFilename("../../"), "attachment");
35
+ });
36
+
37
+ it("uses the caller's fallback when nothing usable survives", () => {
38
+ assert.equal(
39
+ sanitizeAttachmentFilename(" ", "attachment.pdf"),
40
+ "attachment.pdf",
41
+ );
42
+ });
43
+
44
+ it("strips the right-to-left override that disguises an extension", () => {
45
+ assert.equal(
46
+ sanitizeAttachmentFilename(`invoice${RLO}gnp.exe`),
47
+ "invoicegnp.exe",
48
+ );
49
+ });
50
+
51
+ it("strips bidi isolates, zero-width and BOM characters", () => {
52
+ assert.equal(
53
+ sanitizeAttachmentFilename(`${LRI}re${ZWSP}port${PDI}${BOM}.pdf`),
54
+ "report.pdf",
55
+ );
56
+ });
57
+
58
+ it("strips control characters that would break a header line", () => {
59
+ assert.equal(
60
+ sanitizeAttachmentFilename("note\r\n\tX-Evil 1.txt"),
61
+ "noteX-Evil 1.txt",
62
+ );
63
+ });
64
+
65
+ it("replaces characters that are illegal in a path", () => {
66
+ assert.equal(
67
+ sanitizeAttachmentFilename('re<po>rt|"?*.txt'),
68
+ "re_po_rt____.txt",
69
+ );
70
+ });
71
+
72
+ it("drops a leading dot so the file cannot land hidden", () => {
73
+ assert.equal(sanitizeAttachmentFilename(".bashrc"), "bashrc");
74
+ });
75
+
76
+ it("drops trailing dots and spaces", () => {
77
+ assert.equal(sanitizeAttachmentFilename("report.pdf. . "), "report.pdf");
78
+ });
79
+
80
+ it("guards a reserved Windows device name", () => {
81
+ assert.equal(sanitizeAttachmentFilename("NUL.txt"), "_NUL.txt");
82
+ assert.equal(sanitizeAttachmentFilename("com1"), "_com1");
83
+ });
84
+
85
+ it("does not guard a name that merely starts with a device name", () => {
86
+ assert.equal(sanitizeAttachmentFilename("console.log"), "console.log");
87
+ });
88
+
89
+ it("clamps an overlong name and keeps its extension", () => {
90
+ const result = sanitizeAttachmentFilename(`${"a".repeat(400)}.pdf`);
91
+ assert.equal(result.length, 120);
92
+ assert.ok(result.endsWith(".pdf"));
93
+ });
94
+
95
+ it("clamps an overlong name that has no extension", () => {
96
+ assert.equal(sanitizeAttachmentFilename("b".repeat(400)), "b".repeat(120));
97
+ });
98
+
99
+ it("clamps an overlong trailing segment rather than treating it as an extension", () => {
100
+ assert.equal(
101
+ sanitizeAttachmentFilename(`${"c".repeat(200)}.${"d".repeat(40)}`),
102
+ "c".repeat(120),
103
+ );
104
+ });
105
+
106
+ it("clamps an overlong fallback too", () => {
107
+ const result = sanitizeAttachmentFilename(
108
+ "",
109
+ `attachment.${"x".repeat(400)}`,
110
+ );
111
+ assert.equal(result.length, 120);
112
+ });
113
+
114
+ it("clamps on characters, never splitting a surrogate pair", () => {
115
+ const result = sanitizeAttachmentFilename(`${"😀".repeat(200)}.pdf`);
116
+ assert.equal([...result].length, 120);
117
+ assert.ok(result.endsWith(".pdf"));
118
+ assert.equal(/[\ud800-\udfff]/.test(result.replaceAll("😀", "")), false);
119
+ });
120
+ });
121
+
122
+ describe("formatByteSize", () => {
123
+ it("counts small payloads in bytes", () => {
124
+ assert.equal(formatByteSize(0), "0 bytes");
125
+ assert.equal(formatByteSize(1), "1 byte");
126
+ assert.equal(formatByteSize(1023), "1023 bytes");
127
+ });
128
+
129
+ it("switches to kilobytes at 1024", () => {
130
+ assert.equal(formatByteSize(1024), "1 KB");
131
+ assert.equal(formatByteSize(1536), "1.5 KB");
132
+ assert.equal(formatByteSize(1024 * 999), "999 KB");
133
+ });
134
+
135
+ it("switches to megabytes, gigabytes and terabytes", () => {
136
+ assert.equal(formatByteSize(1024 * 1024), "1 MB");
137
+ assert.equal(formatByteSize(1024 * 1024 * 2.5), "2.5 MB");
138
+ assert.equal(formatByteSize(1024 ** 3), "1 GB");
139
+ assert.equal(formatByteSize(1024 ** 4), "1 TB");
140
+ });
141
+
142
+ it("promotes at the point one decimal would round up to 1024", () => {
143
+ assert.equal(formatByteSize(1024 * 1023), "1023 KB");
144
+ assert.equal(formatByteSize(1024 * 1024 - 6), "1 MB");
145
+ assert.equal(formatByteSize(1024 ** 3 - 1), "1 GB");
146
+ assert.equal(formatByteSize(1024 ** 4 - 1), "1 TB");
147
+ });
148
+
149
+ it("stays in terabytes rather than inventing a larger unit", () => {
150
+ assert.equal(formatByteSize(1024 ** 5), "1024 TB");
151
+ });
152
+
153
+ it("drops the decimal once the value no longer needs it", () => {
154
+ assert.equal(formatByteSize(1024 * 1024 * 14.04), "14 MB");
155
+ });
156
+
157
+ it("reads as unknown for a size that was never declared", () => {
158
+ assert.equal(formatByteSize(Number.NaN), "unknown size");
159
+ assert.equal(formatByteSize(-1), "unknown size");
160
+ assert.equal(formatByteSize(Number.POSITIVE_INFINITY), "unknown size");
161
+ });
162
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Filename and size presentation for a mail attachment.
3
+ *
4
+ * An attachment filename is attacker-controlled: it arrives verbatim in a
5
+ * `Content-Disposition` header written by whoever sent the mail. Two things
6
+ * downstream trust it — the browser's save dialog (`<a download>`) and the
7
+ * rendered list — so one sanitizer serves both. What the list shows is exactly
8
+ * what the file is saved as; a name that reads one way and saves another is the
9
+ * whole point of the attack.
10
+ */
11
+
12
+ /**
13
+ * Control characters, zero-width joiners, line/paragraph separators and the
14
+ * bidirectional overrides. A RIGHT-TO-LEFT OVERRIDE placed inside
15
+ * `report<RLO>gnp.exe` makes it render as `reportexe.png`: the extension a
16
+ * user reads is not the extension that executes. Stripping rather than
17
+ * escaping keeps the displayed name and the saved name identical.
18
+ */
19
+ const INVISIBLE_CHARACTERS =
20
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C0/C1 controls out of an attacker-supplied filename is the point
21
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
22
+
23
+ /** Illegal in a Windows path, and `:` is a separator on classic macOS. */
24
+ const UNSAFE_CHARACTERS = /[<>:"|?*]/g;
25
+
26
+ /** Reserved device names on Windows — saving to one has no defined outcome. */
27
+ const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
28
+
29
+ const MAX_FILENAME_LENGTH = 120;
30
+ const MAX_EXTENSION_LENGTH = 12;
31
+
32
+ export const DEFAULT_ATTACHMENT_FILENAME = "attachment";
33
+
34
+ const clampLength = (name: string): string => {
35
+ // Code points, not UTF-16 units: slicing units splits a surrogate pair and
36
+ // leaves a lone half in the saved name.
37
+ const characters = [...name];
38
+ if (characters.length <= MAX_FILENAME_LENGTH) return name;
39
+ const dot = name.lastIndexOf(".");
40
+ const extension =
41
+ dot > 0 && [...name.slice(dot)].length <= MAX_EXTENSION_LENGTH
42
+ ? name.slice(dot)
43
+ : "";
44
+ const budget = MAX_FILENAME_LENGTH - [...extension].length;
45
+ return characters.slice(0, budget).join("") + extension;
46
+ };
47
+
48
+ /**
49
+ * Reduce an attacker-supplied attachment filename to a name that is safe to
50
+ * both display and save: the final path segment only, no invisible characters,
51
+ * no leading dot (a hidden file the user never sees land), and bounded length.
52
+ * Falls back to `fallback` when nothing usable survives.
53
+ */
54
+ export const sanitizeAttachmentFilename = (
55
+ raw: string,
56
+ fallback: string = DEFAULT_ATTACHMENT_FILENAME,
57
+ ): string => {
58
+ const visible = raw.replace(INVISIBLE_CHARACTERS, "");
59
+ const segments = visible.split(/[/\\]/);
60
+ const basename = segments[segments.length - 1] ?? "";
61
+ const trimmed = basename
62
+ .replace(UNSAFE_CHARACTERS, "_")
63
+ .replace(/^[.\s]+/, "")
64
+ .replace(/[.\s]+$/, "");
65
+ if (trimmed.length === 0) return clampLength(fallback);
66
+ const guarded = RESERVED_DEVICE_NAME.test(trimmed) ? `_${trimmed}` : trimmed;
67
+ return clampLength(guarded);
68
+ };
69
+
70
+ const SIZE_UNITS = ["KB", "MB", "GB", "TB"] as const;
71
+
72
+ /**
73
+ * Human-readable byte size, 1024-based, at most one decimal. A negative or
74
+ * non-finite size reads as unknown rather than as a number, so a broken
75
+ * BODYSTRUCTURE never presents itself as a measurement.
76
+ *
77
+ * The promotion threshold is the point where one decimal would round up to
78
+ * `1024`, not `1024` itself — `1048570 B` is `1 MB`, never `1024 KB`.
79
+ */
80
+ export const formatByteSize = (bytes: number): string => {
81
+ if (!Number.isFinite(bytes) || bytes < 0) return "unknown size";
82
+ const octets = Math.round(bytes);
83
+ if (octets < 1024) return octets === 1 ? "1 byte" : `${octets} bytes`;
84
+
85
+ const promoteAt = 1023.95;
86
+ let value = octets / 1024;
87
+ let unit = 0;
88
+ while (value >= promoteAt && unit < SIZE_UNITS.length - 1) {
89
+ value /= 1024;
90
+ unit += 1;
91
+ }
92
+ const rendered = value.toFixed(1).replace(/\.0$/, "");
93
+ return `${rendered} ${SIZE_UNITS[unit]}`;
94
+ };