DEV Community

Ren
Ren

Posted on Originally published at openedfile.com

Writing a TNEF parser so winmail.dat never leaves the browser

If you have ever received an email from Outlook with an attachment called winmail.dat, you know the feeling: no usable extension, nothing opens it, no hint of what is inside. When I went looking for a converter, every one I found said the same thing first: "upload your file to our server."

A winmail.dat is, by definition, an email somebody sent you. It might be a contract or an invoice. Handing it to a site you know nothing about just to find out what is in it gets the order backwards. So I implemented TNEF in TypeScript and made the whole thing run in the browser.

Some background, if you have been lucky enough never to meet it: when Outlook sends a message in Rich Text Format, it does not send normal MIME with normal attachments. It packs the subject, the formatted body, every attachment and a pile of Outlook metadata into one binary blob, encoded as TNEF (Transport Neutral Encapsulation Format), and attaches that as winmail.dat. Outlook on the other end unpacks it silently, so the sender never sees a problem. Everyone else gets the blob.

This post covers the three places where a straightforward implementation breaks, and two problems I only found after shipping: bodies that looked empty, and sender addresses stored in a form you cannot send mail to.

The container itself is simple

signature   uint32   0x223e9f78
key         uint16

repeat until EOF:
  level     uint8    1 = message, 2 = attachment
  attribute uint32   high 16 bits = data type, low 16 bits = attribute id
  length    uint32
  payload   byte[length]
  checksum  uint16
Enter fullscreen mode Exit fullscreen mode

No tree, no index, no offsets to chase. It is a flat run of attribute records, so reading it is a loop. That part worked in an afternoon. The trouble was inside the MAPI property blocks.

1. Everything is 4-byte aligned

String and binary values are length-prefixed, but the length is the number of meaningful bytes, not the number of bytes written. Each value is padded to the next 4-byte boundary.

const len = d.readUint32LE();
const bytes = d.readBytes(len);
d.skip(pad4(len) - len);   // forget this line and everything after it is garbage
Enter fullscreen mode Exit fullscreen mode

The nasty part is how it fails. You do not get a slightly odd filename. The stream slips by one to three bytes, and every property type you read after that is nonsense. It fails silently and completely, which makes it very easy to conclude "this file is corrupt" rather than "my reader is off by two."

2. Named properties have a variable-length header

This one cost me the most time. A property id of 0x8000 or above is a named property, and it is preceded by an extra header that is not mentioned anywhere near the description of the record layout.

if (propId >= 0x8000) {
  d.skip(16);                       // property set GUID
  const kind = d.readUint32LE();
  if (kind === 0) {
    d.skip(4);                      // named by numeric id
  } else {
    const nameLen = d.readUint32LE();
    d.skip(pad4(nameLen));          // named by string; length depends on the name
  }
}
Enter fullscreen mode Exit fullscreen mode

Skip a fixed number of bytes here and it works on your test files, then breaks on somebody's real mail. Same failure as the alignment bug: total desync, no error.

3. Filenames live in four places, in a priority order

This is the one that matters in practice. The same attachment can carry its name in up to four fields, and they do not agree.

const name =
  a.mapiLongFilename ||   // 0x3707, UTF-16LE, full name
  a.mapiFilename ||       // 0x3704
  a.mapiDisplayName ||    // 0x3001
  a.legacyName ||         // attAttachTitle, ANSI, often 8.3
  'attachment';
Enter fullscreen mode Exit fullscreen mode

attAttachTitle sits right next to the attachment bytes, so it is the easiest one to find. But it is an "ANSI" string in whatever legacy code page the sender's Windows used, and it is often truncated to 8.3. On a Japanese system that code page is Shift_JIS (code page 932), the two-byte encoding Windows still uses for non-Unicode text in Japanese locales. A parser that reads only this field produces filenames like 8E9095948E.PDF instead of what the sender typed, which is exactly the mojibake people complain about in winmail.dat tools.

The encoding is declared, not guessed

The encoding of ANSI strings is declared by attribute 0x9007 (the OEM code page): 932 is Shift_JIS, 936 is GBK, 949 is EUC-KR, 950 is Big5, the 125x range is the Windows Latin family, 65001 is UTF-8.

It governs every ANSI string that comes after it, so the parse is order-dependent. You have to read and apply the code page before decoding the subject. Process attributes in whatever order is convenient and you will quietly decode a Japanese subject line as Windows-1252.

Found later: the body often exists only as compressed RTF

For a while after launch, many winmail.dat files showed up as "no body". The body was there. It was stored as Outlook's compressed RTF (PR_RTF_COMPRESSED, 0x1009), and in a winmail.dat that is often the only copy of the body.

The compression (MS-OXRTFCP) is a small LZ77 variant. Its 4,096-byte circular dictionary starts out holding 207 bytes of common RTF:

// MS-OXRTFCP 2.1.2.1
const RTF_DICTIONARY_PREFIX =
  '{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript ' +
  '\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n' +
  '\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx';
Enter fullscreen mode Exit fullscreen mode

Each bit of a control byte says whether the next token is a literal byte or a reference into the dictionary, eight tokens per control byte. A reference can read into the very bytes it is writing, so the copy has to go one byte at a time. The compressed data carries a CRC, so you know it is damaged before you decompress it.

The RTF that comes out is usually not RTF anyone wrote. When Outlook stores an HTML message this way, it wraps the HTML in RTF (MS-OXRTFEX). The tags go into {\*\htmltag …} groups, and whatever Outlook added for RTF readers sits between \htmlrtf and \htmlrtf0. Drop that part, join the rest, and the original HTML comes back. It then goes through the same treatment as any other HTML body from a stranger: sanitized with DOMPurify against an allowlist, and rendered in a sandboxed iframe whose content security policy blocks network loads, so remote tracking images never fire.

The encoding lesson applies here too. The RTF declares its own code page (\ansicpg932), and a double-byte character arrives as two separate escapes, \'82\'a0. If you decode each escape on its own, you get garbage; collect the bytes first and decode them together.

Also found later: the sender's address is stored in a form you cannot send to

Since winmail.dat is created by the sender's Outlook settings, the recipient cannot stop it from happening. So under the result I show a short "here is what to ask the sender" message and a mailto: link that opens it in the user's mail app. I wanted the sender's address in the To field. That turned out to be harder than it sounds.

The sender is recorded in two places:

  • MAPI sender properties (PidTagSenderName / PidTagSenderEmailAddress, plus the PidTagSentRepresenting… pair for sending on behalf of someone)
  • The TNEF attribute attFrom (MS-OXTNEF 2.1.3.3.3): an 8-byte header (0x0004, the structure's length, the name's length, the address's length), then the name, then the address as type:address, then 8 zero bytes

For mail sent from inside an Exchange organization, both addresses are often X.500 paths like EX:/O=EXCHANGELABS/OU=…/CN=RECIPIENTS/CN=…. No mail app can send to that. The internet address lives in yet another pair of properties: PidTagSenderSmtpAddress (0x5D01) and PidTagSentRepresentingSmtpAddress (0x5D02).

// Use the first candidate that is an internet address.
const isInternetAddress = (a: string) => /^[^\s@/<>]+@[^\s@<>]+$/.test(a);
const address = [smtp, mapiEmail, attFromEmail].find(isInternetAddress);
Enter fullscreen mode Exit fullscreen mode

If only the X.500 path exists, the From line still shows it (that is what the file holds), but the mailto: link leaves the To field empty for the user to fill in.

Delegated sending adds one more trap. When an assistant sends on behalf of their manager, the "sender" and "sent representing" names differ. Name and address have to come from the same person, or you build a recipient with the manager's name and the assistant's address. The name in attFrom is ANSI, so characters can arrive as ?; I only compare names when both are readable.

Wrapping up

The result is a winmail.dat viewer that runs entirely in the browser. You can check that nothing is uploaded: open the Network tab in your browser's developer tools and convert a file.

It does not yet extract emails attached inside the winmail.dat, or OLE objects.

If you have a winmail.dat it opens wrong, I would like to hear about it. This format has more corners than one person's inbox can cover.

Top comments (0)