Merge pull request #205 from Coffey-Labs/feat/tnef

Open winmail.dat
This commit is contained in:
Coffey Labs
2026-09-01 23:35:26 -07:00
committed by GitHub
5 changed files with 575 additions and 1 deletions
+16
View File
@@ -298,6 +298,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 - **Attachments** listed with type and size: download, open in a new tab, and an
inline preview for images and PDFs. inline preview for images and PDFs.
- **Show original**, **Show headers**, **Download (.eml)** and **Print**. - **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 - **Forward as attachment** sends the message itself rather than a quotation of
it — headers, structure and every attachment intact, which is what a bounce 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 or a phishing report needs and what quoting destroys. It costs **no upload at
+42 -1
View File
@@ -100,7 +100,43 @@ const subjects = [
]; ];
const emails: Obj[] = []; const emails: Obj[] = [];
let counter = 1; 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 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 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.`; 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: "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 }); 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" }); 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 = { 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"), 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: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true }); addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true }); addEmail({ from: ["Finance Team", "[email protected]"], 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 // A thread whose unread message is not the last one: someone's server queued
+177
View File
@@ -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([]);
});
});
+262
View File
@@ -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<string, string> = {
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;
}
+78
View File
@@ -11,6 +11,7 @@ import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment"; import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { emlFilename } from "@/lib/emlName"; import { emlFilename } from "@/lib/emlName";
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore"; import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
@@ -708,6 +709,80 @@ export function attachmentIcon(type: string, name?: string | null) {
return <File size={18} />; return <File size={18} />;
} }
/**
* 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<TnefAttachment[]>([]);
const [urls, setUrls] = useState<string[]>([]);
// 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 (
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
<span className="grow">{translate("This message packs its attachments into a winmail.dat, which most clients cannot open.")}</span>
<button onClick={() => void open()}>{translate("Open it")}</button>
</div>
);
}
if (state === "loading") return <div className="list-hint" style={{ margin: "0 16px 8px" }}><span className="grow">{translate("Opening…")}</span></div>;
if (state === "error") {
return (
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
<span className="grow">{translate("Could not read winmail.dat. The original is still attached below.")}</span>
</div>
);
}
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 (
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
<span className="grow">{translate("No files inside — it carries only the formatted copy of the message.")}</span>
</div>
);
}
return (
<div className="attachments">
{files.map((f, i) => (
<a key={`${f.name}-${i}`} className="attachment" href={urls[i]} download={f.name} title={`${f.name} · ${formatSize(f.size)}`}>
<span className="att-icon">{attachmentIcon(f.type, f.name)}</span>
<span className="att-text">
<span className="att-name">{f.name}</span>
<span className="att-size">{formatSize(f.size)}</span>
</span>
</a>
))}
</div>
);
}
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) { function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
const [preview, setPreview] = useState<EmailBodyPart | null>(null); const [preview, setPreview] = useState<EmailBodyPart | null>(null);
/* Whether we can show it, and whether the server will serve it inline, are /* 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; const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null;
return ( return (
<> <>
{attachments.filter((a) => isTnef(a.type, a.name) && a.blobId).map((a) => (
<TnefContents key={`tnef-${a.blobId}`} part={a} accountId={accountId} />
))}
<div className="attachments"> <div className="attachments">
{attachments.map((a, i) => { {attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#"; const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";