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([]);
});
});
+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 { 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 <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 }) {
const [preview, setPreview] = useState<EmailBodyPart | null>(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) => (
<TnefContents key={`tnef-${a.blobId}`} part={a} accountId={accountId} />
))}
<div className="attachments">
{attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";