diff --git a/FEATURES.md b/FEATURES.md index 9902edc..13ee1f1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -259,6 +259,22 @@ same query string — so what it builds can be read, edited and learned from. - **Attachments** listed with type and size: download, open in a new tab, and an inline preview for images and PDFs. - **Show original**, **Show headers**, **Download (.eml)** and **Print**. +- **`winmail.dat` opens.** Outlook sending in Rich Text packs every attachment + into one TNEF blob that most clients cannot read, so the files inside are + gone as far as the reader is concerned. A banner offers to open it and the + contents appear as ordinary attachments, named and typed. The long filename + is read out of the MAPI stream where there is one, so a file that arrived as + `Quarterly Report Final.docx` is not called `QUARTE~1.DOC`. + + It is decoded **in the browser, on request**: the server never sees the + contents and has nowhere to keep a decoded copy, and doing the work on sight + would spend the bandwidth whether or not anyone wanted what is inside. A blob + that goes wrong part-way through keeps the files read before that point — + half of them beats none — and the original stays attached either way. + + The message body is deliberately not decoded. TNEF can also carry it as + compressed RTF, which is a second format again for a body the reader already + has in plain text or HTML nine times in ten. - **Forward as attachment** sends the message itself rather than a quotation of it — headers, structure and every attachment intact, which is what a bounce or a phishing report needs and what quoting destroys. It costs **no upload at diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 3ccd56c..c0a296c 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -100,7 +100,43 @@ const subjects = [ ]; const emails: Obj[] = []; let counter = 1; -function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) { +/** + * A real TNEF blob, built to the format description, so the winmail.dat + * decoder has something to open that is not a hand-made fixture in its own + * test file. Two files inside, one of them carrying a long name in the MAPI + * stream behind an 8.3 title -- which is the case the decoder exists for. + */ +function winmailDat(): Buffer { + const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]); + const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]); + const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; }; + const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]); + const asciiProp = (id: number, value: string) => { + const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]); + const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4); + return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]); + }; + const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]); + + const renddata = Buffer.alloc(14); + const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]); + const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1"); + const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1"); + + return Buffer.concat([ + u32(0x223e9f78), u16(0x1234), + attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion + attr(2, 0x00069002, renddata), + attr(2, 0x00018010, title("QUARTE~1.CSV")), + attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])), + attr(2, 0x0006800f, csv), + attr(2, 0x00069002, renddata), + attr(2, 0x00018010, title("notes.txt")), + attr(2, 0x0006800f, notes), + ]); +} + +function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { const id = `e${counter++}`; const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; @@ -112,6 +148,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null }); attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null }); } + if (o.winmail) { + const dat = winmailDat(); + attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null }); + } if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" }); const e: Obj = { id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"), @@ -153,6 +193,7 @@ for (let i = 0; i < 45; i++) { } addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); +addEmail({ from: ["Outlook User", "sales@partner.example"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true }); addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true }); addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true }); // A thread whose unread message is not the last one: someone's server queued diff --git a/web/src/lib/__tests__/tnef.test.ts b/web/src/lib/__tests__/tnef.test.ts new file mode 100644 index 0000000..932a2a1 --- /dev/null +++ b/web/src/lib/__tests__/tnef.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { isTnef, parseTnef } from "@/lib/tnef"; + +/** + * The blobs below are **built from the format description, not captured from + * Outlook**. That is worth saying plainly: they prove the parser reads what the + * spec says, and they cannot prove it reads what Outlook actually emits. The + * cases most likely to differ in the wild are the MAPI property stream, where + * real messages carry many more properties than these do, and named properties + * (id >= 0x8000), which the parser stops at rather than guessing past. + */ + +const SIGNATURE = 0x223e9f78; + +const ATT = { + attachRenddata: 0x00069002, + attachTitle: 0x00018010, + attachData: 0x0006800f, + attachment: 0x00069005, + tnefVersion: 0x00089006, +} as const; + +const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0); +const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff]; +const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]; +const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0)); +const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0))); + +interface Attr { + level?: number; + id: number; + data: number[]; + /** Deliberately wrong, for the desync case. */ + badChecksum?: boolean; +} + +function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array { + const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)]; + for (const a of attrs) { + out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data))); + } + return new Uint8Array(out); +} + +/** A MAPI property stream carrying the given string properties. */ +function mapi(props: Array<{ id: number; type: number; value: string }>): number[] { + const out: number[] = [...u32(props.length)]; + for (const p of props) { + out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff))); + const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0]; + out.push(...u32(bytes.length), ...bytes); + const pad = (4 - (bytes.length % 4)) % 4; + for (let i = 0; i < pad; i++) out.push(0); + } + return out; +} + +const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [ + { id: ATT.attachRenddata, data: new Array(14).fill(0) }, + { id: ATT.attachTitle, data: [...ascii(name), 0] }, + ...extra, + { id: ATT.attachData, data: ascii(body) }, +]; + +const text = (a: Uint8Array) => new TextDecoder().decode(a); + +describe("isTnef", () => { + it("recognises the types and the filename", () => { + expect(isTnef("application/ms-tnef", null)).toBe(true); + expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true); + expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true); + expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true); + }); + + it("leaves everything else alone", () => { + expect(isTnef("application/pdf", "report.pdf")).toBe(false); + expect(isTnef(null, null)).toBe(false); + }); +}); + +describe("parseTnef", () => { + it("pulls one attachment out, with its name and bytes", () => { + const out = parseTnef(tnef(file("report.pdf", "hello"))); + expect(out).toHaveLength(1); + expect(out[0]!.name).toBe("report.pdf"); + expect(text(out[0]!.data)).toBe("hello"); + expect(out[0]!.size).toBe(5); + // Guessed from the extension, since this blob names no type of its own. + expect(out[0]!.type).toBe("application/pdf"); + }); + + it("pulls several out, in order", () => { + const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")])); + expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]); + expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]); + expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]); + }); + + it("prefers the long filename over the 8.3 one", () => { + // The whole reason for reading the MAPI stream at all. + const attrs = file("QUARTE~1.DOC", "body", [ + { id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) }, + ]); + expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx"); + }); + + it("reads a unicode long filename", () => { + const attrs = file("SHORT~1.DOC", "body", [ + { id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) }, + ]); + expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx"); + }); + + it("takes the MIME type the blob states over one guessed from the name", () => { + const attrs = file("data.bin", "body", [ + { id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) }, + ]); + expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp"); + }); + + it("falls back to octet-stream for a name that says nothing", () => { + expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream"); + }); + + it("names an attachment that carries no title at all", () => { + const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }])); + expect(out[0]!.name).toBe("attachment"); + }); + + it("ignores attributes it has no use for", () => { + const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")])); + expect(out.map((a) => a.name)).toEqual(["a.txt"]); + }); + + it("is not TNEF, and says so quietly", () => { + // The caller gets here by guessing from a filename, so this is an ordinary + // answer rather than an error worth showing anybody. + expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]); + expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]); + expect(parseTnef(new Uint8Array([]))).toEqual([]); + }); + + it("keeps what it read when the stream goes out of step", () => { + // Half the attachments beats none: the alternative is a reader who can see + // the file is there and cannot have it. + const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]); + const out = parseTnef(bad); + expect(out.map((a) => a.name)).toEqual(["good.txt"]); + }); + + it("keeps what it read when the blob is truncated mid-attribute", () => { + const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]); + const out = parseTnef(full.slice(0, full.length - 12)); + expect(out.map((a) => a.name)).toEqual(["good.txt"]); + }); + + it("stops at a named property rather than guessing past it", () => { + // A named property carries a GUID before its value; the stream cannot be + // trusted to stay aligned past one, so the long name is simply not found. + const attrs = file("SHORT~1.DOC", "body", [ + { id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) }, + ]); + expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC"); + }); + + it("survives a MAPI stream that is nonsense, keeping the attachment", () => { + const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]); + const out = parseTnef(tnef(attrs)); + expect(out).toHaveLength(1); + expect(out[0]!.name).toBe("keep.txt"); + }); + + it("drops an attachment that has a name but no data", () => { + const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }])); + expect(out).toEqual([]); + }); +}); diff --git a/web/src/lib/tnef.ts b/web/src/lib/tnef.ts new file mode 100644 index 0000000..30c23bd --- /dev/null +++ b/web/src/lib/tnef.ts @@ -0,0 +1,262 @@ +/** + * `winmail.dat`, opened. + * + * Outlook sending in "Rich Text" wraps every attachment into a single + * TNEF blob. Every other client shows one unopenable `winmail.dat` and the + * files inside it are simply gone as far as the reader is concerned — which is + * the whole problem, and it is a decoding problem rather than a mail one. + * + * Written from the published format (MS-OXTNEF): a signature, a key, then a + * flat run of attributes, each one a level byte, a 32-bit id carrying its own + * type, a length, the data, and a 16-bit checksum. Attachments are delimited + * by `attAttachRenddata`, which is why the parse is a small state machine + * rather than a lookup. + * + * **What this does not do: the message body.** A TNEF blob can also carry the + * message as compressed RTF, and decoding that is a second format again + * (MS-OXRTFCP) for a body the reader already has in plain text or HTML nine + * times in ten. The attachments are the part that is otherwise unreachable, so + * they are the part that is decoded. + */ + +/** Little-endian, and every offset is checked before it is read. */ +const SIGNATURE = 0x223e9f78; + +// Attribute ids, as the 32-bit values they appear as on the wire. +const ATT_ATTACH_RENDDATA = 0x00069002; +const ATT_ATTACH_TITLE = 0x00018010; +const ATT_ATTACH_DATA = 0x0006800f; +const ATT_ATTACHMENT = 0x00069005; + +// MAPI property tags worth reading out of attAttachment. +const PID_ATTACH_LONG_FILENAME = 0x3707; +const PID_ATTACH_MIME_TAG = 0x370e; + +// MAPI property types. +const PT_STRING8 = 0x001e; +const PT_UNICODE = 0x001f; +const PT_BINARY = 0x0102; +const MV_FLAG = 0x1000; + +export interface TnefAttachment { + name: string; + /** From the blob's own MIME tag where it carries one, else guessed from the name. */ + type: string; + size: number; + data: Uint8Array; +} + +/** Whether an attachment is worth trying to open as TNEF. */ +export function isTnef(type: string | null | undefined, name: string | null | undefined): boolean { + const t = (type ?? "").split(";")[0]!.trim().toLowerCase(); + if (t === "application/ms-tnef" || t === "application/vnd.ms-tnef") return true; + return (name ?? "").trim().toLowerCase() === "winmail.dat"; +} + +class Reader { + constructor( + private readonly view: DataView, + public offset = 0, + ) {} + get remaining(): number { + return this.view.byteLength - this.offset; + } + u8(): number { + this.need(1); + return this.view.getUint8(this.offset++); + } + u16(): number { + this.need(2); + const v = this.view.getUint16(this.offset, true); + this.offset += 2; + return v; + } + u32(): number { + this.need(4); + const v = this.view.getUint32(this.offset, true); + this.offset += 4; + return v; + } + bytes(length: number): Uint8Array { + this.need(length); + const out = new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length); + this.offset += length; + // Copied, because the slice would otherwise keep the whole blob alive and + // move underneath anyone who held it. + return new Uint8Array(out); + } + private need(n: number) { + if (n < 0 || this.offset + n > this.view.byteLength) throw new RangeError("truncated"); + } +} + +/** Sum of the bytes, low 16 bits. The format's own, and it is only a checksum. */ +function checksum(data: Uint8Array): number { + let sum = 0; + for (const b of data) sum = (sum + b) & 0xffff; + return sum; +} + +const decodeAscii = (b: Uint8Array) => new TextDecoder("windows-1252").decode(b).replace(/\0+$/, ""); +const decodeUtf16 = (b: Uint8Array) => new TextDecoder("utf-16le").decode(b).replace(/\0+$/, ""); + +/** + * The MAPI property stream inside `attAttachment`, read only for the two + * properties worth having: the long filename, and the MIME type. + * + * `attAttachTitle` carries an 8.3 name, so a file that arrived as + * `Quarterly Report Final.docx` is `QUARTE~1.DOC` there and correct here. + */ +function readMapiProps(data: Uint8Array): { name?: string; type?: string } { + const out: { name?: string; type?: string } = {}; + try { + const r = new Reader(new DataView(data.buffer, data.byteOffset, data.byteLength)); + const count = r.u32(); + // A count larger than the bytes could describe means this is not the + // stream we think it is; give up rather than walking off into it. + if (count > data.byteLength) return out; + for (let i = 0; i < count; i++) { + const tag = r.u32(); + const type = tag & 0xffff; + const id = (tag >>> 16) & 0xffff; + // A named property carries a GUID and either an id or a name before its + // value. Nothing wanted here is one, so the stream cannot be trusted to + // stay aligned past it. + if (id >= 0x8000) return out; + const multi = (type & MV_FLAG) !== 0; + const base = type & ~MV_FLAG; + const values = multi ? r.u32() : 1; + if (base === PT_STRING8 || base === PT_UNICODE || base === PT_BINARY) { + let first: Uint8Array | null = null; + for (let v = 0; v < values; v++) { + const length = r.u32(); + const raw = r.bytes(length); + if (v === 0) first = raw; + // Values are padded to a four-byte boundary. + const pad = (4 - (length % 4)) % 4; + r.offset += pad; + } + if (first) { + if (id === PID_ATTACH_LONG_FILENAME) out.name = base === PT_UNICODE ? decodeUtf16(first) : decodeAscii(first); + if (id === PID_ATTACH_MIME_TAG) out.type = (base === PT_UNICODE ? decodeUtf16(first) : decodeAscii(first)).trim(); + } + } else if (base === 0x0002) { + r.offset += 2 * values + 2; // PT_SHORT is padded to four bytes + } else if (base === 0x0003 || base === 0x000a || base === 0x000b || base === 0x0004) { + r.offset += 4 * values; + } else if (base === 0x0005 || base === 0x0006 || base === 0x0007 || base === 0x0014 || base === 0x0040) { + r.offset += 8 * values; + } else if (base === 0x0048) { + r.offset += 16 * values; + } else { + // An unknown type has an unknown width, so the rest cannot be walked. + return out; + } + } + } catch { + // Truncated or misaligned: keep whatever was read before it went wrong. + } + return out; +} + +/** A last resort when the blob names no type of its own. */ +function guessType(name: string): string { + const ext = name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? ""; + const map: Record = { + pdf: "application/pdf", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + txt: "text/plain", + csv: "text/csv", + html: "text/html", + htm: "text/html", + rtf: "application/rtf", + zip: "application/zip", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }; + return map[ext] ?? "application/octet-stream"; +} + +/** + * Every file inside a TNEF blob. + * + * Returns an empty list rather than throwing for anything that is simply not + * TNEF — the caller reaches this by guessing from a filename, so "not that + * after all" is an ordinary answer and not an error worth showing anybody. + * + * A blob that *is* TNEF but goes wrong part of the way through keeps what was + * read before that point. Half the attachments is better than none, and the + * alternative is a reader who can see the file is there and cannot have it. + */ +export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + if (bytes.byteLength < 6) return []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint32(0, true) !== SIGNATURE) return []; + + const r = new Reader(view, 6); // signature (4) + key (2) + const out: TnefAttachment[] = []; + let current: { title?: string; mapiName?: string; mapiType?: string; data?: Uint8Array } | null = null; + + const flush = () => { + if (!current?.data) { + current = null; + return; + } + const name = (current.mapiName || current.title || "attachment").trim() || "attachment"; + out.push({ + name, + type: current.mapiType || guessType(name), + size: current.data.byteLength, + data: current.data, + }); + current = null; + }; + + try { + while (r.remaining > 0) { + r.u8(); // level: message or attachment. The attribute id already says which. + const id = r.u32(); + const length = r.u32(); + const data = r.bytes(length); + const stated = r.u16(); + // A mismatch means the stream has come out of step, and every offset + // after it is a guess. Stop, and keep what is already read. + if (stated !== checksum(data)) break; + + switch (id) { + case ATT_ATTACH_RENDDATA: + // Each one opens a new attachment, so it also closes the last. + flush(); + current = {}; + break; + case ATT_ATTACH_TITLE: + if (current) current.title = decodeAscii(data); + break; + case ATT_ATTACHMENT: { + if (!current) break; + const props = readMapiProps(data); + if (props.name) current.mapiName = props.name; + if (props.type) current.mapiType = props.type; + break; + } + case ATT_ATTACH_DATA: + if (current) current.data = data; + break; + default: + break; + } + } + } catch { + // Truncated. Keep what was read. + } + flush(); + return out; +} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 8c7dde1..5975479 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -11,6 +11,7 @@ import { useCalendar } from "@/store/calendar"; import { startAppointment } from "@/lib/appointment"; import { client } from "@/jmap/client"; import { emlFilename } from "@/lib/emlName"; +import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { spamReport, type SpamReport } from "@/lib/spamScore"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; @@ -708,6 +709,80 @@ export function attachmentIcon(type: string, name?: string | null) { return ; } +/** + * The files inside a `winmail.dat`, once the reader asks for them. + * + * Opened on request rather than on sight: the blob has to be fetched and + * decoded, and doing that to every message carrying one would spend the + * bandwidth whether or not anybody wanted what is inside. + * + * The decode happens here, in the browser. The server never sees the contents + * and stores nothing, which is the same bargain as the rest of the app -- + * there is nowhere for it to put a decoded copy even if it wanted one. + */ +function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id }) { + const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle"); + const [files, setFiles] = useState([]); + const [urls, setUrls] = useState([]); + + // Object URLs hold their blob alive until they are revoked, so they are + // released when the message closes rather than left to the page's lifetime. + useEffect(() => () => urls.forEach((u) => URL.revokeObjectURL(u)), [urls]); + + const open = async () => { + if (!part.blobId) return; + setState("loading"); + try { + const blob = await client.fetchBlob(accountId, part.blobId, part.type); + const found = parseTnef(await blob.arrayBuffer()); + setFiles(found); + setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: f.type })))); + setState("done"); + } catch { + setState("error"); + } + }; + + if (state === "idle") { + return ( +
+ {translate("This message packs its attachments into a winmail.dat, which most clients cannot open.")} + +
+ ); + } + if (state === "loading") return
{translate("Opening…")}
; + if (state === "error") { + return ( +
+ {translate("Could not read winmail.dat. The original is still attached below.")} +
+ ); + } + if (!files.length) { + // It decoded, and there was nothing in it. Saying so is better than + // leaving the button looking like it did nothing. + return ( +
+ {translate("No files inside — it carries only the formatted copy of the message.")} +
+ ); + } + return ( +
+ {files.map((f, i) => ( + + {attachmentIcon(f.type, f.name)} + + {f.name} + {formatSize(f.size)} + + + ))} +
+ ); +} + function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) { const [preview, setPreview] = useState(null); /* Whether we can show it, and whether the server will serve it inline, are @@ -715,6 +790,9 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null; return ( <> + {attachments.filter((a) => isTnef(a.type, a.name) && a.blobId).map((a) => ( + + ))}
{attachments.map((a, i) => { const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";