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
+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([]);
});
});