Open winmail.dat

Outlook sending in Rich Text packs every attachment into one TNEF blob.
Every other client shows a single unopenable winmail.dat, and the files
inside it are gone as far as the reader is concerned -- which is a
decoding problem rather than a mail one.

Written from the published format: 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 checksum. Attachments are delimited by
attAttachRenddata rather than named, which is why the parse is a small
state machine.

The MAPI property stream inside attAttachment is read for two properties:
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. The stream stops at a named property (id >=
0x8000) rather than guessing past it, since those carry a GUID before
their value and nothing after one can be trusted to stay aligned.

Decoded in the browser, on request. The server never sees the contents and
has nowhere to keep a decoded copy; doing the work on sight would spend
the bandwidth whether or not anybody wanted what is inside.

A blob that goes wrong part-way through keeps what was read before that
point, whether it ran out or the checksum stopped matching. Half the
attachments beats none: the alternative is a reader who can see the file
is there and cannot have it. 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.

The mock now sends one, built by its own encoder rather than by the
parser's fixtures, so the two are independent implementations of the same
description.
This commit is contained in:
2026-09-01 23:16:22 -07:00
parent d300107be0
commit 9a474ef2c8
5 changed files with 575 additions and 1 deletions
+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([]);
});
});