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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11022-an-eml-file-in-a-repository-opens-as-a-m → main 3 commits fork 2 threads · 2 unresolved
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"; | ||
| 60 | 60 | import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; |
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | 62 | 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"; | |
| 63 | 65 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; |
| 64 | 66 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 65 | 67 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| @@ -363,6 +365,26 @@ function vcfTable(source: string): Table | null { | ||
| 363 | 365 | }; |
| 364 | 366 | } |
| 365 | 367 | |
| 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; | |
| 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 | ||
| 366 | 388 | /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */ |
| 367 | 389 | function isGpxPath(path: string): boolean { |
| 368 | 390 | return /\.gpx$/i.test(path); |
| @@ -562,6 +584,9 @@ class Tab { | ||
| 562 | 584 | } else if (isVcfPath(this.path)) { |
| 563 | 585 | this.table = vcfTable(new TextDecoder().decode(source)); |
| 564 | 586 | 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); | |
| 565 | 590 | } else if (isGpxPath(this.path)) { |
| 566 | 591 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 567 | 592 | 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 = | ||
| 35 | 35 | | "geojson" |
| 36 | 36 | | "calendar" |
| 37 | 37 | | "contacts" |
| 38 | | "mail" | |
| 38 | 39 | | "gcode" |
| 39 | 40 | | "text" |
| 40 | 41 | | "binary"; |
| @@ -69,6 +70,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 69 | 70 | geojson: 5 * MB, |
| 70 | 71 | calendar: 5 * MB, |
| 71 | 72 | contacts: 5 * MB, |
| 73 | mail: 5 * MB, | |
| 72 | 74 | gcode: 50 * MB, |
| 73 | 75 | text: 1 * MB, |
| 74 | 76 | binary: 0, |
| @@ -188,6 +190,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 188 | 190 | ics: "calendar", |
| 189 | 191 | vcf: "contacts", |
| 190 | 192 | vcard: "contacts", |
| 193 | eml: "mail", | |
| 191 | 194 | gcode: "gcode", |
| 192 | 195 | gco: "gcode", |
| 193 | 196 | }; |
src/web/render/renderers.ts +2
| @@ -10,6 +10,7 @@ import { archive } from "./archive-view.ts"; | ||
| 10 | 10 | import { audio } from "./audio.ts"; |
| 11 | 11 | import { config } from "./config.ts"; |
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | import { eml } from "./eml.ts"; | |
| 13 | 14 | import { csv } from "./csv.ts"; |
| 14 | 15 | import { geojson } from "./geojson.ts"; |
| 15 | 16 | import { gcode } from "./gcode.ts"; |
| @@ -88,6 +89,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 88 | 89 | geojson, |
| 89 | 90 | calendar: ics, |
| 90 | 91 | contacts: vcf, |
| 92 | mail: eml, | |
| 91 | 93 | archive, |
| 92 | 94 | font, |
| 93 | 95 | }; |
test/fuzz-renderers.test.ts +8
| @@ -12,6 +12,7 @@ import type { Renderer } from "../src/web/render/renderers.ts"; | ||
| 12 | 12 | import { csv } from "../src/web/render/csv.ts"; |
| 13 | 13 | import { log } from "../src/web/render/log.ts"; |
| 14 | 14 | import { ics } from "../src/web/render/ics.ts"; |
| 15 | import { eml } from "../src/web/render/eml.ts"; | |
| 15 | 16 | import { vcf } from "../src/web/render/vcf.ts"; |
| 16 | 17 | import { gpx } from "../src/web/render/gpx.ts"; |
| 17 | 18 | import { geojson } from "../src/web/render/geojson.ts"; |
| @@ -87,6 +88,13 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly | ||
| 87 | 88 | "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", |
| 88 | 89 | ], |
| 89 | 90 | }, |
| 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 | }, | |
| 90 | 98 | gpx: { |
| 91 | 99 | path: "track.gpx", |
| 92 | 100 | 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 <[email protected]>"); | |
| 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 & bye</p><script>alert(1)</script><br>there"); | |
| 79 | const html = out(r); | |
| 80 | expect(html).toContain("Hi & 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("<script>"); | |
| 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]> = { | ||
| 76 | 76 | gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"], |
| 77 | 77 | vcf: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"], |
| 78 | 78 | vcard: [enc("BEGIN:VCARD\nEND:VCARD\n"), "contacts", "application/octet-stream"], |
| 79 | eml: [enc("From: [email protected]\n\nhi\n"), "mail", "application/octet-stream"], | |
| 79 | 80 | geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"], |
| 80 | 81 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 81 | 82 | 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", () => { | ||
| 467 | 467 | } |
| 468 | 468 | }); |
| 469 | 469 | |
| 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 | ||
| 470 | 498 | test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => { |
| 471 | 499 | const made = await createRepo(forge, alice, { |
| 472 | 500 | name: "tracks", |
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.