How Winmail Viewer is built
These are the engineering notes for Winmail Viewer: the technologies it is built on, what each one is, and how it is used in the tool.
Tech used
The interop quirk: why winmail.dat exists at all
Outlook has a per-recipient/per-contact setting that formats outgoing mail as “Outlook Rich Text Format” — Microsoft’s own rich-text mail encoding, not the RTF-in-MIME (text/rtf) that other clients understand. When Exchange sends a message like that to a recipient whose mail system isn’t Outlook/Exchange, it can’t express Outlook Rich Text as normal MIME parts, so it does the only thing it can: it takes the entire message (rich-text body plus every real attachment) and packs the whole thing into one binary blob, attaches that blob as winmail.dat, and sends a near-empty plain-text message alongside it. The recipient’s real attachment — a PDF, a scan, a spreadsheet — isn’t lost; it’s serialized inside that blob. Almost nobody sending the mail realizes this happens, because it looks completely normal from inside Outlook.
The format: TNEF (MS-OXTNEF)
That blob’s format is TNEF — Transport Neutral Encapsulation Format — and Microsoft publishes its structure openly as MS-OXTNEF. A TNEF file is refreshingly simple as binary formats go: a 4-byte signature (0x223e9f78, little-endian), a 2-byte arbitrary “key,” and then a flat stream of attributes — no nesting, no index, just one attribute after another until the buffer ends. Each attribute is a TLV (type-length-value) record:
LEVEL (1 byte) | TAG (4 bytes) | LENGTH (4 bytes) | DATA (LENGTH bytes) | CHECKSUM (2 bytes)
LEVEL is one of two values: LVL_MESSAGE (the attribute describes the message as a whole — subject, message class, the rich-text body) or LVL_ATTACHMENT (it describes a file attachment). TAG is a 32-bit value where the high 16 bits name a wire type (string, byte array, dword, …) and the low 16 bits name the attribute itself — attAttachTitle, attAttachData, and so on are just well-known values of that low half. There is no “attachment record” type: an attachment is reconstructed by reading a run of LVL_ATTACHMENT attributes.
Reconstructing attachments: the attAttachRenddata marker
Because attachments aren’t discrete records, something has to mark where one ends and the next begins. That’s attAttachRenddata — an attribute that, in practice, only ever shows up to say “a new attachment starts here.” Everything from one attAttachRenddata up to the next one (or the end of the stream) belongs to that attachment: an attAttachTitle (the legacy 8.3-style DOS filename) or attAttachTransportFilename (the real, un-truncated filename, when the writer included it) for the name, and an attAttachData for the raw bytes. Grouping the flat stream this way — watch for the marker, accumulate attributes until the next marker, flush — is the entire reconstruction algorithm.
Hand-rolled parsing with DataView, no library
There is no well-maintained, permissively-licensed TNEF library to depend on — node-tnef is ISC-licensed but has been unmaintained since 2020. Since MS-OXTNEF is fully documented and the format is genuinely simple (a linear TLV walk, no compression, no nested containers), the parser (src/utils/tnefEngine.ts) is original code written directly from the spec: a small bounds-checked cursor over a DataView/ArrayBuffer, reading LEVEL/TAG/LENGTH/DATA/CHECKSUM in a loop. It’s the same “read the raw bytes yourself, no dependency” approach as Recover ZIP’s local-file-header scan — TNEF just needs far less of it, since every attribute already carries its own length.
Shell, and “download all” bundling
The static Astro + Preact island shell and the Service-Worker PWA are the same across these tools (introduced in the HEIC notes). “Download all” bundles every extracted attachment into one .zip with @zip.js/zip.js (ZipWriter + Uint8ArrayReader, from the Create ZIP notes) — the one library dependency in the tool, and it’s only used for the output side, not for understanding the input format.
Implementation & operational notes
The checksum is read but not enforced. Each attribute’s trailing CHECKSUM is an additive sum of its DATA bytes, mod 65536 — not a framing field. LENGTH alone defines where an attribute ends, so the parser reads the checksum bytes (to stay aligned with the stream) and discards them rather than verifying them. Real-world TNEF writers haven’t always computed this checksum correctly, and rejecting a file over a checksum mismatch would mean rejecting files Outlook itself opens without complaint. This is the opposite tradeoff from Recover ZIP’s CRC-32, which is enforced there — the difference is that ZIP’s CRC-32 is the only signal that DEFLATE decompression produced the right bytes, while TNEF’s LENGTH field is independently authoritative regardless of what the checksum says.
Filename resolution has a priority order. attAttachTransportFilename (the full name) is preferred over attAttachTitle (an 8.3-style DOS name) whenever both are present — some Outlook versions still write only a truncated REPORT~1.PDF-style title even when the real file was quarterly-report.pdf, and only the transport filename attribute carries the readable version.
LEVEL matters, not just TAG. The grouping loop only ever inspects LVL_ATTACHMENT attributes; every LVL_MESSAGE attribute (subject, message class, the rich-text body) is skipped outright rather than pattern-matched by tag. That distinction is also what makes the “no attachments” case correct: a body-only TNEF is entirely LVL_MESSAGE attributes, so the grouping loop naturally produces an empty list instead of needing a special case.
Truncation can’t run past the buffer. A small ByteReader wrapping the DataView throws a stable, localizable error the moment a read would exceed what’s left in the buffer — so a LENGTH field that lies about how much data follows (a truncated download, a corrupted file) surfaces as a clear message instead of an out-of-bounds read or a hung loop.
No real winmail.dat sample exists in the repo. Any real one is someone’s actual private email, so it isn’t something to check into a public repo. Instead, both the unit tests and the e2e tests import the same small TNEF-byte-assembler that builds a signature, a key, and a run of correctly-encoded attributes — including a real, correctly-computed checksum, so the “wrong checksum is tolerated” behavior is actually exercised rather than assumed. Building the fixture and the parser from the same understanding of the spec, then asserting exact byte-for-byte output, is what verifies the parser is right rather than merely “worked on one file downloaded from the internet.”
v1 stops at attachments. The message body, when TNEF-encoded, is stored as compressed RTF (MS-OXRTFCP) — a separate compression scheme with its own dictionary, unrelated to the attribute stream above. Decompressing it is a meaningfully different, harder piece of work and is out of scope for this version; a winmail.dat with no attachments shows an honest notice rather than a fabricated body.
Try it / source
- Tool: Winmail Viewer
- Source: github.com/GeppettoAndRomero/winmail-viewer