Notes collected while implementing a parser for each of these six formats (for Mailward, a browser-based archive viewer — disclosure: I build it). Most of what is written about these formats online is converter marketing; this file is the technical part, including the parts that are inconvenient.
Corrections welcome — open an issue.
| Format | What it actually is | Written by | Readable without the original app? |
|---|---|---|---|
| PST | Single-file B-tree database ([MS-PST]) | Outlook for Windows | Yes, with a real parser |
| OST | Same container as PST, used as an offline cache | Outlook, automatically | Yes — the account lock lives in Outlook, not the file |
| OLM | ZIP of per-message XML | Outlook for Mac (File → Export) | Yes — unzip and parse the XML |
| MBOX | Plain text, one folder of mail, messages split on From lines |
Thunderbird, Apple Mail, Google Takeout | Yes, trivially |
| EML | One RFC 5322 message with MIME body | Nearly every client | Yes, it is the standard |
| MSG | CFBF/OLE2 compound file of typed MAPI properties | Outlook for Windows (drag out / Save As) | Yes, with a CFBF parser |
.pst uses. A reader that handles only one fails confusingly on the other; the variant is distinguishable from the header version bytes.scanpst cannot open it, reading tools cannot either — recovery is a different problem from parsing..ost to .pst does not help — Outlook inspects the file, not the extension.The least-documented format of the five, so in the most detail:
.olm is a ZIP container. Each message is a small XML document whose elements carry an OPF prefix: OPFMessageCopySubject, OPFMessageCopySenderAddress, OPFMessageCopyHTMLBody, and so on.OPFAttachmentURL.2022-05-26T17:55:40, no trailing Z. Parse that as local time and every date in the archive silently shifts by your UTC offset. This is the most common way an OLM conversion goes quietly wrong.OPFMessageCopyHTMLBody, so HTML body extraction is dependable.From (sender + timestamp, the “From_ line”).From , writers escape it as >From — and the variants (mboxo, mboxrd, mboxcl) disagree on exactly how, which is why round-tripping an MBOX through the wrong tool can corrupt quoting.X-Gmail-Labels header on each message. Any tool that presents a Takeout export as a folder tree is synthesizing it from those headers.Received headers are prepended by each hop, so read them bottom-up to follow a message’s actual path.multipart/mixed over a bare text body) parses in nothing..msg is one Outlook item saved as a file — drag a message out of Outlook and this is what you get. It is not text: it is a CFBF/OLE2 compound file (the old Office container), a miniature filesystem of storages and streams with one typed MAPI property per stream (__substg1.0_0037001F is the subject, and so on). Recipients and attachments are sub-storages.PR_SENDER_EMAIL_ADDRESS) is frequently an Exchange X.500 distinguished name — /O=ORG/OU=EXCHANGE ADMINISTRATIVE GROUP(...)/CN=... — not an SMTP address. The usable address lives in a separate property (PR_SENDER_SMTP_ADDRESS, tag 0x5D01). Tools that display the raw sender field show gibberish for exactly the messages people most need to identify.PR_TRANSPORT_MESSAGE_HEADERS exists only on messages that arrived from outside. A .msg saved from a sent folder never had an internet header block — absence is a fact about the message, not a parsing failure.PidTagMessageCodepage, PidTagMessageLocaleId) and applied in a second decode pass. Skip that and a Japanese subject renders as ú{ê ^Cg.smime.p7m wrapper; without the cryptographic layer you can see the wrapper, not the message.Notes from making the PST parser work on multi-GB files inside a Web Worker, on top of pst-extractor:
PSTFile.readSync. Subclass it, override that method on the prototype, and you can feed the parser from any random-access source — no filesystem required.File.slice() + FileReaderSync. A 4 KB slice read measures ~200 µs in Chrome — fast enough that B-tree walks feel instant, and the file is never loaded whole.PSTFile with the filename, not a Buffer from fs.readFileSync — the descriptor-backed path has no 2 GB Buffer ceiling.And from doing the same for .msg (CFBF) in a Web Worker, on top of @kenjiuno/msgreader:
ArrayBuffer or DataView, not a Uint8Array — slice the view’s exact byte range first or the offsets are silently wrong.iconv-lite dependency drags in Node’s buffer and string_decoder; in a browser bundle both must be polyfilled (webpack resolve.fallback + ProvidePlugin) or the worker dies at runtime while the build stays green.bodyHtml (string) → html (bytes, new-Outlook) → plain body. Classic Outlook often writes only compressedRtf (RTF-encapsulated HTML), which needs a separate decompression step if you want it.Numbers from walking the public PST/OST test files with pst-extractor 1.12.0 — the two Outlook.com OSTs in pst-extractor’s repository, java-libpst’s example-2013.ost, and enron.pst. Details, and the maintainer’s independent reproduction, are in Anonify2.0 #90.
PR_TRANSPORT_MESSAGE_HEADERS; their Sent Items: 0/4. But example-2013.ost’s one sent message and both of enron’s carry a block. Decide on the property.PR_SENDER_ADDRTYPE is EX and PidTagSenderSmtpAddress (0x5D01) / PidTagSentRepresentingSmtpAddress (0x5D02) are absent; every Inbox item has both. A rebuilt From: there is a reconstruction — and an X.500 DN must never be emitted as an address.Received: lines, 1–2 DKIM-Signature, an Authentication-Results, ~40 X-* headers, 7–10 KB per message.From: maps to the sent-representing group, Sender: to the sender group — but not uniformly. On three meeting requests ingested from Google Calendar, Exchange rewrote sent-representing to the iCalendar organizer while the wire From: stayed Google Calendar; only the response carried From: <organizer> + Sender: Google Calendar. Preferring sent-representing alone agrees with the wire on 15/18 messages, sender alone on 17/18; the property-faithful rendering is From: = sent-representing plus Sender: = sender when they differ, and the wire From: of a meeting request is recoverable only from a captured block. Hence the per-message provenance stamp Mailward now writes: X-Mailward-Header-Source: captured | synthesized | captured-with-fallback.Date: equals PR_CLIENT_SUBMIT_TIME to the second on 18/18 messages with a block — the submit time, not the delivery time, is what a synthesized Date: should carry.PidTagMessageCodepage (0x3FFD) is absent on every item in two of four stores (enron 0/71, example-2013 0/3) and present as 1252 on 62/70 and 45/47 items in the Outlook.com stores; no folder or store carries a fallback. PidTagInternetCodepage (0x3FDE) is on every mail item but is documented for PR_BODY/PR_BODY_HTML only. There is no public ANSI PST fixture anywhere (java-libpst’s two .pst files are wVer 23), so nobody can measure how often a 2002-era store lacks one. pst-extractor’s CPID table maps 932 to iso-2022-jp, which iconv-lite cannot decode, so 932/50220/50221/52936/38598 all fell through to a UTF-8 decode — byte-identical mojibake for “no codepage”, “unknown codepage” and “undecodable codepage”. A refusal has to be per string and gated on a byte ≥ 0x80; pure ASCII decodes identically everywhere.ibFileEof and the two B-tree root offsets, a standard-library Python check, and what it prints for enron.pst and for a copy cut in half.Longer write-ups of everything above, kept current:
Licensed CC BY 4.0 — reuse freely with attribution.