Public: anyone can read this; the crew can change it.

An .eml file in a repository opens as a message: From, To, Subject, Date and the plain-text body, drawn on the server #11038

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11022-an-eml-file-in-a-repository-opens-as-a-m → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11022-an-eml-file-in-a-repository-opens-as-a-m was merged into main as d280635.

Review

Sign in to review this change.
10 files changed +470 −0
docs/features/file-eml.md added +13
@@ -0,0 +1,13 @@
1 # An .eml file opens as a message
2
3 `src/web/render/eml.ts` draws a mail file on the blob page; `eml-parse.ts` is the pure parser, shared with the terminal.
4
5 - **Rows:** From, To, Cc, Subject and Date (those present), as the same `.status-rows` used by contacts and GPX. Headers are unfolded and RFC 2047 encoded words decoded.
6 - **Body:** the first `text/plain` part, quoted-printable and base64 decoded, UTF-8 or latin-1 (any charset the runtime knows). Multipart messages are walked (depth and part count capped).
7 - **HTML-only:** the text with scripts, styles and tags stripped, and a line saying so. HTML is never rendered.
8 - **Attachments:** listed by name, type and size (decoded). Their bytes are never served from this page.
9 - **Malformed:** a file with no mail headers falls back to Source with the reason.
10 - **Cap:** 5 MB (`RENDER_CAP.mail`); every value is escaped by the template.
11 - **Terminal:** the workspace shows the same rows as Field/Value; `s` flips to the source.
12
13 Left out: inline images, charset conversion beyond what `TextDecoder` knows, saving attachments.
src/tui/screens/workspace.ts +25
@@ -60,6 +60,8 @@ import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
6060 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6262 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
63 import { fileSize } from "../../web/ui.ts";
64 import { parseEml } from "../../web/render/eml-parse.ts";
6365 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
6466 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
6567 import { layoutTable, type Grid } from "../ui/grid.ts";
@@ -363,6 +365,26 @@ function vcfTable(source: string): Table | null {
363365 };
364366 }
365367
368 /** `.eml`: a message, drawn as rows of its headers, body and attachments. */
369 function isEmlPath(path: string): boolean {
370 return /\.eml$/i.test(path);
371 }
372
373 const EML_BODY_LINES = 200;
374
375 /** The message as Field/Value rows; null when the file is not one. Shares the web's parser. */
376 function emlTable(source: Uint8Array): Table | null {
377 const m = parseEml(source);
378 if ("error" in m) return null;
outdated

The body is cut at 200 lines with no sign of it. Say so in a row. Also format attachment sizes as the page does.

379 const rows: string[][] = m.headers.map(([k, v]) => [k, v]);
380 if (m.fromHtml) rows.push(["Note", "only an HTML part; tags stripped"]);
381 const lines = m.body.split("\n");
382 for (const line of lines.slice(0, EML_BODY_LINES)) rows.push(["", line]);
383 if (lines.length > EML_BODY_LINES) rows.push(["Note", `body cut: ${lines.length - EML_BODY_LINES} more lines`]);
384 for (const a of m.attachments) rows.push(["Attachment", `${a.name} · ${a.type} · ${fileSize(a.size)}`]);
385 return { header: ["Field", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
386 }
387
366388 /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */
367389 function isGpxPath(path: string): boolean {
368390 return /\.gpx$/i.test(path);
@@ -562,6 +584,9 @@ class Tab {
562584 } else if (isVcfPath(this.path)) {
563585 this.table = vcfTable(new TextDecoder().decode(source));
564586 if (this.table !== null) this.grid = layoutTable(this.table);
587 } else if (isEmlPath(this.path)) {
588 this.table = emlTable(source);
589 if (this.table !== null) this.grid = layoutTable(this.table);
565590 } else if (isGpxPath(this.path)) {
566591 this.table = gpxTable(new TextDecoder().decode(source), this.path);
567592 if (this.table !== null) this.grid = layoutTable(this.table);
src/web/render/eml-parse.ts added +252
@@ -0,0 +1,252 @@
1 /**
2 * A mail message (`.eml`, RFC 5322 with MIME) as plain data: the headers a
3 * reader wants, the first text/plain body (or the text of an HTML-only one,
4 * tags stripped), and the attachments by name, type and size. Headers are
5 * unfolded and RFC 2047 encoded words decoded; quoted-printable and base64
6 * bodies decoded; UTF-8 and latin-1 (and any charset TextDecoder knows)
7 * read. Pure; never throws; nothing here is markup: the caller escapes and
8 * HTML is never kept, only its text.
9 */
10
11 export const HEADER_ROWS = ["From", "To", "Cc", "Subject", "Date"] as const;
12 /** Parts walked and nesting followed, whatever the file holds. */
13 const PART_CAP = 200;
14 const DEPTH_CAP = 6;
15
16 export interface Attachment {
17 readonly name: string;
18 readonly type: string;
19 readonly size: number;
20 }
21
22 export interface Eml {
23 /** Only the headers the message has, in `HEADER_ROWS` order. */
24 readonly headers: ReadonlyArray<readonly [string, string]>;
25 readonly body: string;
26 /** The body came from text/html with its tags stripped. */
27 readonly fromHtml: boolean;
28 readonly attachments: readonly Attachment[];
29 }
30
31 const latin1 = (b: Uint8Array): string => {
32 let s = "";
33 for (let i = 0; i < b.length; i += 8192) s += String.fromCharCode(...b.subarray(i, i + 8192));
34 return s;
35 };
36 const toBytes = (s: string): Uint8Array => {
37 const out = new Uint8Array(s.length);
38 for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff;
39 return out;
40 };
41
42 /** Bytes as text in a charset: UTF-8 when it is valid, else the named one, else latin-1. */
43 function decodeText(bytes: Uint8Array, charset: string): string {
44 const cs = charset.trim().toLowerCase();
45 try {
46 return new TextDecoder((cs === "" || cs === "us-ascii" ? "utf-8" : cs) as "utf-8", { fatal: true }).decode(bytes);
47 } catch {
48 try {
49 return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
50 } catch {
51 return new TextDecoder("windows-1252" as "utf-8").decode(bytes);
52 }
53 }
54 }
55
56 function qpBytes(s: string, header: boolean): Uint8Array {
57 const text = header ? s.replace(/_/g, " ") : s.replace(/=\r?\n/g, "");
58 const out: number[] = [];
59 for (let i = 0; i < text.length; i++) {
60 const c = text[i]!;
61 const hex = c === "=" ? text.slice(i + 1, i + 3) : "";
62 if (/^[0-9a-fA-F]{2}$/.test(hex)) {
63 out.push(parseInt(hex, 16));
64 i += 2;
65 } else out.push(text.charCodeAt(i) & 0xff);
66 }
67 return Uint8Array.from(out);
68 }
69
70 function b64Bytes(s: string): Uint8Array {
71 try {
72 const clean = s.replace(/[^A-Za-z0-9+/]/g, "");
73 return Uint8Array.from(atob(clean.slice(0, clean.length - (clean.length % 4 === 1 ? 1 : 0))), (c) => c.charCodeAt(0));
74 } catch {
75 return new Uint8Array(0);
76 }
77 }
78
79 /** RFC 2047: `=?utf-8?Q?caf=C3=A9?=` and `=?iso-8859-1?B?...?=` anywhere in a value. */
80 export function decodeWords(value: string): string {
81 const joined = value.replace(/(\?=)\s+(=\?)/g, "$1$2");
82 return joined.replace(/=\?([^?\s]+)\?([QqBb])\?([^?\s]*)\?=/g, (_m, cs: string, enc: string, data: string) => {
83 const bytes = enc.toLowerCase() === "b" ? b64Bytes(data) : qpBytes(data, true);
84 return decodeText(bytes, cs.split("*")[0]!);
85 });
86 }
87
88 interface Header {
89 readonly name: string;
90 readonly value: string;
91 }
92
93 function parseHeaders(block: string): Header[] {
94 const out: Header[] = [];
95 for (const line of block.split(/\r?\n/)) {
96 if (/^[ \t]/.test(line) && out.length > 0) {
97 const last = out[out.length - 1]!;
98 out[out.length - 1] = { name: last.name, value: `${last.value} ${line.trim()}` };
99 continue;
100 }
101 const m = /^([!-9;-~]+):[ \t]*(.*)$/.exec(line);
102 if (m !== null) out.push({ name: m[1]!.toLowerCase(), value: m[2]!.trim() });
103 }
104 return out;
105 }
106
107 const get = (hs: readonly Header[], name: string): string => hs.find((h) => h.name === name)?.value ?? "";
108
109 /** `type/sub; a=b; c="d"` as the lowercased type and its parameters. */
110 function structured(value: string): { type: string; params: Map<string, string> } {
111 const params = new Map<string, string>();
112 const parts: string[] = [];
113 let cur = "";
114 let quoted = false;
115 for (const ch of value) {
116 if (ch === '"') quoted = !quoted;
117 if (ch === ";" && !quoted) {
118 parts.push(cur);
119 cur = "";
120 } else cur += ch;
121 }
122 parts.push(cur);
123 for (const p of parts.slice(1)) {
124 const eq = p.indexOf("=");
125 if (eq < 0) continue;
126 const key = p.slice(0, eq).trim().toLowerCase();
127 let v = p.slice(eq + 1).trim();
128 if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1).replace(/\\(.)/g, "$1");
129 params.set(key, v);
130 }
131 return { type: parts[0]!.trim().toLowerCase(), params };
132 }
133
134 /** The filename of a part: RFC 2231 `filename*=utf-8''…`, else `filename`, else the type's `name`. */
135 function fileName(disposition: ReturnType<typeof structured>, type: ReturnType<typeof structured>): string {
136 const star = disposition.params.get("filename*") ?? type.params.get("name*");
137 if (star !== undefined) {
138 const m = /^([^']*)'[^']*'(.*)$/.exec(star);
139 if (m !== null) {
140 try {
141 return decodeText(qpBytes(m[2]!.replace(/%([0-9a-fA-F]{2})/g, "=$1"), false), m[1]!);
142 } catch {
143 /* fall through */
144 }
145 }
146 }
147 const plain = disposition.params.get("filename") ?? type.params.get("name") ?? "";
148 return decodeWords(plain);
149 }
150
151 function decodeBody(raw: string, encoding: string): Uint8Array {
152 const e = encoding.trim().toLowerCase();
153 if (e === "base64") return b64Bytes(raw);
154 if (e === "quoted-printable") return qpBytes(raw, false);
155 return toBytes(raw);
156 }
157
158 const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
159
160 /** The text of an HTML body: scripts and styles dropped, tags stripped, entities undone. */
161 export function htmlText(source: string): string {
162 return source
163 .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, "")
164 .replace(/<!--[\s\S]*?-->/g, "")
165 .replace(/<(br|\/p|\/div|\/tr|\/li|\/h[1-6])\b[^>]*>/gi, "\n")
166 .replace(/<[^>]*>?/g, "")
167 .replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
168 if (e[0] === "#") {
169 const n = e[1]!.toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
170 return n > 0 && n < 0x110000 && !(n >= 0xd800 && n < 0xe000) ? String.fromCodePoint(n) : "";
171 }
172 return ENTITIES[e.toLowerCase()] ?? m;
173 })
174 .replace(/[ \t]+\n/g, "\n")
175 .replace(/\n{3,}/g, "\n\n")
176 .trim();
177 }
178
179 interface Walk {
180 plain: string | undefined;
181 html: string | undefined;
182 attachments: Attachment[];
183 parts: number;
184 }
185
186 function splitHead(text: string): [string, string] {
187 const m = /\r?\n\r?\n/.exec(text);
188 return m === null ? [text, ""] : [text.slice(0, m.index), text.slice(m.index + m[0].length)];
189 }
190
191 function walk(hs: readonly Header[], raw: string, w: Walk, depth: number): void {
192 if (++w.parts > PART_CAP) return;
193 const type = structured(get(hs, "content-type") || "text/plain");
194 const disp = structured(get(hs, "content-disposition"));
195 if (type.type.startsWith("multipart/") && depth < DEPTH_CAP) {
196 const boundary = type.params.get("boundary");
197 if (boundary === undefined || boundary === "") return;
198 const marker = `--${boundary}`;
199 const lines = raw.split(/\r?\n/);
200 let cur: string[] | null = null;
201 const chunks: string[] = [];
202 for (const raw of lines) {
203 const line = raw.trimEnd();
204 if (line === `${marker}--`) {
205 if (cur !== null) chunks.push(cur.join("\n"));
206 cur = null;
207 break;
208 }
209 if (line === marker) {
210 if (cur !== null) chunks.push(cur.join("\n"));
211 cur = [];
212 } else if (cur !== null) cur.push(line);
213 }
214 if (cur !== null) chunks.push(cur.join("\n"));
215 for (const chunk of chunks) {
216 // A part with no headers starts with the blank line that ends them: its whole chunk is body.
217 const [head, body] = chunk.startsWith("\n") ? ["", chunk.slice(1)] : splitHead(chunk);
218 walk(parseHeaders(head), body, w, depth + 1);
219 }
220 return;
221 }
222 const bytes = decodeBody(raw, get(hs, "content-transfer-encoding"));
223 const name = fileName(disp, type);
224 const isText = type.type === "text/plain" || type.type === "text/html";
225 if (isText && disp.type !== "attachment" && name === "") {
226 const text = decodeText(bytes, type.params.get("charset") ?? "");
227 if (type.type === "text/plain") w.plain ??= text;
228 else w.html ??= text;
229 return;
230 }
231 if (type.type.startsWith("multipart/")) return;
232 w.attachments.push({ name: name === "" ? "(unnamed)" : name, type: type.type, size: bytes.length });
233 }
234
235 /** The message, or the reason it is not one. */
236 export function parseEml(bytes: Uint8Array): Eml | { error: string } {
237 const text = latin1(bytes);
238 const [head, body] = splitHead(text.replace(/^|^\xEF\xBB\xBF/, ""));
239 const hs = parseHeaders(head);
240 if (hs.length === 0 || !/^[!-9;-~]+:/.test(head)) return { error: "it does not begin with mail headers" };
241 if (!hs.some((h) => ["from", "to", "subject", "date", "message-id", "mime-version", "received"].includes(h.name)))
242 return { error: "it has none of From, To, Subject or Date" };
243 const headers = HEADER_ROWS.flatMap((n): [string, string][] => {
244 const v = get(hs, n.toLowerCase());
245 return v === "" ? [] : [[n, decodeWords(v)]];
246 });
247 const w: Walk = { plain: undefined, html: undefined, attachments: [], parts: 0 };
248 walk(hs, body, w, 0);
249 const fromHtml = w.plain === undefined && w.html !== undefined;
250 const out = w.plain ?? (w.html === undefined ? "" : htmlText(w.html));
251 return { headers, body: out.replace(/\r\n?/g, "\n").trim(), fromHtml, attachments: w.attachments };
252 }
src/web/render/eml.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * A mail file (`.eml`) as a message: From, To, Cc, Subject and Date as rows,
3 * the plain-text body, then the attachments by name, type and size. HTML is
4 * never rendered, only its text; every value is escaped by the template.
5 */
6
7 import { html } from "../html.ts";
8 import { fileSize } from "../ui.ts";
9 import type { Rendered, RenderInput } from "./renderers.ts";
10 import { parseEml } from "./eml-parse.ts";
11
12 export function eml(input: RenderInput): Rendered {
13 const m = parseEml(input.body);
14 if ("error" in m) return { content: html``, lines: 0, fallback: `This file is not drawn as a message: ${m.error}.` };
15 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
16 const rows = m.headers.map(([k, v]) => html`<div class="status-row"><dt>${k}</dt><dd>${v}</dd></div>`);
17 const note = m.fromHtml
18 ? html`<p class="muted small">This message has only an HTML part: its text is shown with the tags stripped.</p>`
19 : html``;
20 const body = m.body === "" ? html`<p class="muted">This message has no text body.</p>` : html`<div class="prose" data-text="message"><pre>${m.body}</pre></div>`;
21 const files =
22 m.attachments.length === 0
23 ? html``
24 : html`<h3>Attachments</h3><dl class="status-rows" aria-label="Attachments">${m.attachments.map(
25 (a) =>
26 html`<div class="status-row"><dt>${a.name}</dt><dd>${a.type} · ${fileSize(a.size)}</dd></div>`,
27 )}</dl>`;
28 const content = html`<dl class="status-rows" aria-label="${name} headers">${rows}</dl>${note}${body}${files}`;
29 const n = m.attachments.length;
30 return { content, lines: 0, summary: `message${n > 0 ? ` · ${n} ${n === 1 ? "attachment" : "attachments"}` : ""} · ${fileSize(input.size)}` };
31 }
src/web/render/kinds.ts +3
@@ -35,6 +35,7 @@ export type FileKind =
3535 | "geojson"
3636 | "calendar"
3737 | "contacts"
38 | "mail"
3839 | "gcode"
3940 | "text"
4041 | "binary";
@@ -69,6 +70,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
6970 geojson: 5 * MB,
7071 calendar: 5 * MB,
7172 contacts: 5 * MB,
73 mail: 5 * MB,
7274 gcode: 50 * MB,
7375 text: 1 * MB,
7476 binary: 0,
@@ -188,6 +190,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
188190 ics: "calendar",
189191 vcf: "contacts",
190192 vcard: "contacts",
193 eml: "mail",
191194 gcode: "gcode",
192195 gco: "gcode",
193196 };
src/web/render/renderers.ts +2
@@ -10,6 +10,7 @@ import { archive } from "./archive-view.ts";
1010 import { audio } from "./audio.ts";
1111 import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
13 import { eml } from "./eml.ts";
1314 import { csv } from "./csv.ts";
1415 import { geojson } from "./geojson.ts";
1516 import { gcode } from "./gcode.ts";
@@ -88,6 +89,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
8889 geojson,
8990 calendar: ics,
9091 contacts: vcf,
92 mail: eml,
9193 archive,
9294 font,
9395 };
test/fuzz-renderers.test.ts +8
@@ -12,6 +12,7 @@ import type { Renderer } from "../src/web/render/renderers.ts";
1212 import { csv } from "../src/web/render/csv.ts";
1313 import { log } from "../src/web/render/log.ts";
1414 import { ics } from "../src/web/render/ics.ts";
15 import { eml } from "../src/web/render/eml.ts";
1516 import { vcf } from "../src/web/render/vcf.ts";
1617 import { gpx } from "../src/web/render/gpx.ts";
1718 import { geojson } from "../src/web/render/geojson.ts";
@@ -87,6 +88,13 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly
8788 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEMAIL:[email protected]\r\nTEL:+1 555\r\nURL:https://a.test/\r\nORG:Engines\r\nNOTE:hi\r\nEND:VCARD\r\n",
8889 ],
8990 },
91 eml: {
92 path: "mail.eml",
93 render: eml,
94 files: [
95 "From: Ada <[email protected]>\r\nSubject: =?utf-8?Q?<script>?=\r\nMIME-Version: 1.0\r\nContent-Type: text/html\r\n\r\n<p onclick=x>hi</p>\r\n",
96 ],
97 },
9098 gpx: {
9199 path: "track.gpx",
92100 render: gpx,
test/render-eml.test.ts added +107
@@ -0,0 +1,107 @@
1 /** Mail files as a message: headers, bodies, encodings, attachments, html-only, hostile text, a bad file. */
2 import { describe, expect, test } from "bun:test";
3 import { eml } from "../src/web/render/eml.ts";
4 import { parseEml } from "../src/web/render/eml-parse.ts";
5 import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts";
6 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
7
8 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
9 const draw = (text: string | Uint8Array) => {
10 const body = typeof text === "string" ? enc(text) : text;
11 return eml({ path: "a/mail.eml", size: body.length, body, media: "/m", download: "/d" });
12 };
13 const out = (r: ReturnType<typeof eml>): string => String(r.content);
14 const crlf = (s: string): string => s.replace(/\n/g, "\r\n");
15
16 describe("mail files", () => {
17 test("the registry names .eml, capped at 5 MB, and a renderer draws it", () => {
18 expect(fileKind("x/a.EML", enc("From: [email protected]\n\nhi"))).toBe("mail");
19 expect(RENDER_CAP.mail).toBe(5_000_000);
20 expect(RENDERERS.mail).toBe(eml);
21 });
22
23 test("a plain message shows its rows and body", () => {
24 const r = draw(crlf("From: Ada <[email protected]>\nTo: Bob <[email protected]>\nCc: Cy <[email protected]>\nSubject: Hi\nDate: Mon, 1 Jan 2026 10:00:00 +0000\n\nHello,\nworld\n"));
25 const html = out(r);
26 for (const label of ["From", "To", "Cc", "Subject", "Date"]) expect(html).toContain(`<dt>${label}</dt>`);
27 expect(html).toContain("Ada &lt;[email protected]&gt;");
28 expect(html).toContain("<pre>Hello,\nworld</pre>");
29 expect(r.fallback).toBeUndefined();
30 });
31
32 test("folded and encoded headers", () => {
33 const m = parseEml(enc("From: [email protected]\nSubject: one\n two\n\t=?UTF-8?Q?caf=C3=A9_?= =?iso-8859-1?B?6Q==?=\nTo: =?utf-8?B?w6k=?= <[email protected]>\n\nx"));
34 if ("error" in m) throw new Error(m.error);
35 expect(m.headers.find((h) => h[0] === "Subject")![1]).toBe("one two café é");
36 expect(m.headers.find((h) => h[0] === "To")![1]).toBe("é <[email protected]>");
37 });
38
39 test("quoted-printable and base64 bodies, utf-8 and latin-1", () => {
40 const qp = parseEml(enc("Subject: a\nContent-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: quoted-printable\n\ncaf=C3=A9 soft=\nbreak\n"));
41 if ("error" in qp) throw new Error(qp.error);
42 expect(qp.body).toBe("café softbreak");
43 const lat = parseEml(enc("Subject: a\nContent-Type: text/plain; charset=iso-8859-1\nContent-Transfer-Encoding: quoted-printable\n\ncaf=E9\n"));
outdated

Dead setup: b64 is never asserted and the split/map/join is a no-op. Remove or assert.

44 if ("error" in lat) throw new Error(lat.error);
45 expect(lat.body).toBe("café");
46 const u = parseEml(enc(`Subject: a\nContent-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: base64\n\n${Buffer.from("héllo ✓").toString("base64")}\n`));
47 if ("error" in u) throw new Error(u.error);
48 expect(u.body).toBe("héllo ✓");
49 });
50
51 test("multipart: first text/plain, attachments by name, type and size", () => {
52 const file = Buffer.from("x".repeat(2048)).toString("base64");
53 const r = draw(
54 crlf(
55 `From: [email protected]\nSubject: s\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary="BB"\n\n--BB\nContent-Type: multipart/alternative; boundary=AA\n\n--AA\nContent-Type: text/plain\n\nPlain part\n--AA\nContent-Type: text/html\n\n<p>Html part</p>\n--AA--\n--BB\nContent-Type: application/pdf; name="report.pdf"\nContent-Disposition: attachment; filename="report.pdf"\nContent-Transfer-Encoding: base64\n\n${file}\n--BB--\n`,
56 ),
57 );
58 const html = out(r);
59 expect(html).toContain("Plain part");
60 expect(html).not.toContain("Html part");
61 expect(html).toContain("<dt>report.pdf</dt>");
62 expect(html).toContain("application/pdf · 2.0 KB");
63 expect(r.summary).toContain("1 attachment");
64 });
65
66 test("a boundary line with trailing spaces still splits; a headerless part is plain text", () => {
67 const m = parseEml(enc('From: [email protected]\nContent-Type: multipart/mixed; boundary="BB"\n\n--BB \n\nno headers here\n--BB \t\nContent-Type: text/html\n\n<p>x</p>\n--BB-- \n'));
68 if ("error" in m) throw new Error(m.error);
69 expect(m.body).toBe("no headers here");
70 expect(m.attachments).toEqual([]);
71 });
72
73 test("the message body sits in a wrapping box", () => {
74 expect(out(draw("From: [email protected]\n\nhello"))).toContain('<div class="prose" data-text="message"><pre>hello</pre></div>');
75 });
76
77 test("html-only shows its text with tags stripped and says so; html is never rendered", () => {
78 const r = draw("From: [email protected]\nSubject: s\nContent-Type: text/html\n\n<style>p{}</style><p onclick=x>Hi &amp; bye</p><script>alert(1)</script><br>there");
79 const html = out(r);
80 expect(html).toContain("Hi &amp; bye");
81 expect(html).toContain("tags stripped");
82 expect(html).not.toContain("<script");
83 expect(html).not.toContain("onclick");
84 expect(html).not.toContain("alert(1)");
85 });
86
87 test("a <script> in Subject is escaped", () => {
88 const html = out(draw("From: [email protected]\nSubject: <script>alert(1)</script>\n\nbody <img src=x onerror=y>\n"));
89 expect(html).not.toContain("<script");
90 expect(html).not.toContain("<img");
91 expect(html).toContain("&lt;script&gt;");
92 });
93
94 test("a malformed file falls back to the source with the reason", () => {
95 for (const bad of ["", "just some prose\nwith no headers", "\u0001\u0002binary"]) {
96 const r = draw(bad);
97 expect(r.fallback).toContain("not drawn as a message");
98 }
99 expect(draw("X-Thing: 1\n\nbody").fallback).toContain("none of From");
100 });
101
102 test("past the size cap the renderer is not used", () => {
103 const blob = (size: number) => ({ kind: "text", body: enc("From: [email protected]\n\nhi"), size, oid: "0".repeat(40) }) as never;
104 expect(rendererFor("a.eml", blob(RENDER_CAP.mail))?.render).toBe(eml);
105 expect(rendererFor("a.eml", blob(RENDER_CAP.mail + 1))).toBeUndefined();
106 });
107 });
test/render-kinds.test.ts +1
@@ -76,6 +76,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
7676 gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"],
7777 vcf: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"],
7878 vcard: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"],
79 eml: [enc("From: [email protected]\n\nhi\n"), "mail", "application/octet-stream"],
7980 geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"],
8081 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
8182 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
test/tui-workspace.test.ts +28
@@ -467,6 +467,34 @@ describe("the terminal workspace", () => {
467467 }
468468 });
469469
470 test("an .eml opens as its header rows and body, and s flips to the source", async () => {
471 const made = await createRepo(forge, alice, { name: "mail", visibility: "public", description: "mail" });
472 if (!made.ok) throw new Error("repo failed");
473 await commitToBranch(forge, alice, made.value, {
474 message: "seed",
475 files: [
476 {
477 path: "note.eml",
478 content: `From: Ada <[email protected]>\r\nSubject: Hello there\r\nDate: Mon, 1 Jan 2026 10:00:00 +0000\r\nContent-Type: multipart/mixed; boundary=BB\r\n\r\n--BB\r\nContent-Type: text/plain\r\n\r\nBody line one\r\n--BB\r\nContent-Type: application/pdf; name="r.pdf"\r\nContent-Transfer-Encoding: base64\r\n\r\n${Buffer.from("x".repeat(2048)).toString("base64")}\r\n--BB--\r\n`,
479 },
480 ],
481 });
482 const h = boot();
483 try {
484 await h.settle();
485 await open(h, "mail");
486 await openFile(h, "note.eml");
487 expect(h.text()).toContain("Subject");
488 expect(h.text()).toContain("Hello there");
489 expect(h.text()).toContain("Body line one");
490 expect(h.text()).toContain("2.0 KB");
491 await h.press("s");
492 expect(h.text()).toContain("From: Ada");
493 } finally {
494 h.stop();
495 }
496 });
497
470498 test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => {
471499 const made = await createRepo(forge, alice, {
472500 name: "tracks",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs