Bodu.Formats.Outlook - Core concepts
This page is the vocabulary the rest of the documentation assumes. Read it once before the getting-started samples, and refer back whenever a term feels imprecise.
Bodu.Formats.Outlook is part of the Binary Formats & I/O topic - the value model shared by the .msg reader (over Bodu.IO.Compound) and the .pst reader (over Bodu.IO.Pst). For the high-level shape of the family, start with the introduction.
Session and view
Both readers expose a session - OutlookMessage for a .msg file, OutlookMailStore for a .pst file - that owns the container and, unless leaveOpen: true was passed, the source stream. Everything obtained from a session is a view bound to its lifetime: a .pst folder, message, recipient, or attachment, and a .msg recipient, attachment, or nested message. Disposing the session invalidates them all; members called afterwards throw ObjectDisposedException.
Reads are lazy. A .msg session decodes the root property stream eagerly at open (so Properties needs no further I/O) and materializes recipients, attachments, and the named-property mapping on first access; a .pst session parses only the container header at open, streams folder and message tables row block by row block, and decodes each object's properties once on first access. The .pst session is documented single-threaded - its members, and those of every view, must not be called concurrently. Treat a .msg session the same way.
Property
A MAPI property is a 32-bit tag paired with a value. MapiProperty is the decoded pair; MapiPropertyCollection is the bag of them on every object - message, folder, store, recipient, attachment. The collection keeps properties in first-occurrence order, replaces a later property that carries an exact-duplicate tag, and answers lookups by full tag (TryGetValue(tag, out property), Contains(tag)), by identifier alone (TryGetValue(id, out property) returns the first property carrying that identifier; GetAll(id) returns every one), and through typed accessors that probe the plausible wire types and return null when nothing matches: GetString (Unicode, then code-page), GetInt32 (Int32, then a widened Int16), GetInt64, GetBoolean, GetDouble (Double, then a widened Float), GetDateTime, GetGuid, GetBinary, and GetStringArray. A typed accessor never throws for a missing property.
Property tag anatomy
MapiPropertyTag is the 32-bit tag laid out per MS-OXCDATA §2.11.4:
| Bits | Meaning | Exposed as |
|---|---|---|
| 31-16 | The 16-bit property identifier | Id |
12 (0x1000 of the low word) |
The multi-valued flag | IsMultiValued |
| 15-0 minus the flag | The base wire type | Type (a MapiPropertyType) |
Value preserves the raw combination; Type always reports the base code with the flag stripped. IsNamed is true for identifiers at or above 0x8000 - the named-property range (below). Construct a tag from an identifier and type (new MapiPropertyTag(MapiPropertyIds.Subject, MapiPropertyType.Unicode)), from a raw value (new MapiPropertyTag(0x0037001F)), or as a multi-valued tag with MapiPropertyTag.ForMultiValue(id, elementType). ToString renders the canonical eight-digit hexadecimal form.
The wire-type codes:
| Code | MapiPropertyType |
Decodes to |
|---|---|---|
0x0002 |
Int16 |
short |
0x0003 |
Int32 |
int |
0x0004 |
Float |
float |
0x0005 |
Double |
double |
0x0006 |
Currency |
fixed-point integer |
0x0007 |
AppTime |
application time |
0x000A |
ErrorCode |
error code |
0x000B |
Boolean |
bool |
0x000D |
Object |
an embedded object (attachment storages) |
0x0014 |
Int64 |
long |
0x001E |
String8 |
string, decoded through the object's code page |
0x001F |
Unicode |
string (UTF-16) |
0x0040 |
SystemTime |
DateTimeOffset (FILETIME) |
0x0048 |
Guid |
Guid |
0x0102 |
Binary |
byte[] |
0x0000 / 0x0001 |
Unspecified / Null |
no value |
A multi-valued property decodes to an array of the element type (string[], int[], byte[][], …).
Well-known identifiers
MapiPropertyIds is a deliberately small catalogue of PidTag* identifiers - the ones the convenience surfaces and common consumer scenarios need (Subject, SenderName, ClientSubmitTime, Body, Html, RtfCompressed, AttachMethod, AttachLongFilename, MessageCodepage, ContainerClass, …). Every property remains reachable by raw identifier whether or not it is listed there.
Named property
Identifiers at or above 0x8000 are named properties: the file assigns them, so 0x8004 means one thing in this message and something else in the next. The durable identity is a MapiNamedProperty - a property-set GUID scoping either a numeric Id or a string Name (exactly one is set, per MS-OXCDATA §2.6.1). Well-known property sets include PS_MAPI, PS_PUBLIC_STRINGS (00020329-0000-0000-C000-000000000046, the home of Keywords), and the application-defined GUIDs of Outlook's own item classes.
Each file carries a mapping from identifier to identity, and each session resolves it in both directions with the same two members:
TryGetNamedPropertyId(MapiNamedProperty name, out ushort id)- the file-specific identifier a name maps to; combine it with the expectedMapiPropertyTypeto address a property collection.TryGetPropertyName(MapiPropertyTag tag, out MapiNamedProperty name)- the identity behind a named tag.
Where the mapping lives differs by format. A .msg file keeps it in the root's __nameid_version1.0 storage, parsed lazily and shared by every nested attached message (per MS-OXMSG). A .pst file keeps one name-to-id map node per store (MS-PST §2.4.7), so OutlookMailStore resolves store-wide and one resolution applies to every message and attachment of the session. In both, entry i of the entry stream defines identifier 0x8000 + i; a malformed entry throws the format's exception under strict validation and is skipped otherwise.
Recipient
OutlookRecipient is a container-free view over one recipient's property collection: RecipientType (an OutlookRecipientType - Originator, To, Cc, or Bcc; null when the property is absent), DisplayName, EmailAddress, and AddressType. A .msg message lists recipients in storage-index order (its __recip_version1.0_# child storages); a .pst message lists them in recipient-table order, each row decoding directly into the recipient's collection. Both readers hand out the same type, so recipient-handling code is shared.
Attachment and attachment method
OutlookAttachment (.msg) and OutlookMailAttachment (.pst) expose the same surface: Method, FileName (the long form, falling back to the short), ContentId, MimeTag, Size, and the two content accessors. Method is an OutlookAttachmentMethod:
| Method | Meaning | Content access |
|---|---|---|
ByValue |
The bytes are stored in the file (PidTagAttachDataBinary). |
OpenContentStream() |
EmbeddedMessage |
The attachment is another message. | OpenMessage() |
ByReference / ByReferenceResolve / ByReferenceOnly |
The file holds a path to the content, not the content. | Neither - no payload in the file |
Ole |
An OLE object. | Neither |
None |
No content declared. | Neither |
Real-world writers frequently omit PidTagAttachMethod: an attachment with no declared method that carries a by-value payload reports ByValue, otherwise None. Calling the wrong accessor for the method throws NotSupportedException. Size reports the recorded PidTagAttachSize, falling back to the payload length taken from the container's index - a deferred payload is never read to measure it.
A nested message opened through OpenMessage() reports EmbeddedDepth one greater than its parent (a file-level message is depth zero). In the .msg reader it shares the root session's container and named-property mapping, and disposing it is a no-op; in the .pst reader it is a view like any other, inheriting the attachment's code page.
Subject normalization
Outlook stores a subject that carries a prefix (RE:, FW:, …) as U+0001, then a character whose code is the prefix length plus one, then the full subject text (MS-PST §2.4.5.1.2; the same encoding appears in .msg files). The Subject convenience on both message types strips the marker pair and returns the full text, prefix included; Properties.GetString(MapiPropertyIds.Subject) always surfaces the value as stored.
Code page
A String8 property is bytes in some character set; the readers resolve which one from the object's own properties - PidTagMessageCodepage first, then PidTagInternetCodepage, then Windows-1252, the historical default for objects that declare nothing. Child objects inherit: in a .msg file, recipients and attachments inherit the message's encoding; in a .pst file, a folder inherits from its parent (the root from the store), a message from its folder, an attachment from its message, and an embedded message from its attachment - each level overriding only when it declares a code page of its own. The BodyHtml convenience decodes PidTagHtml with the internet code page first, falling back to the message code page, or returns the value verbatim when the writer stored it as a string. The Windows code pages that dominate real-world mail (Windows-1252, Shift-JIS, …) are available on every platform because the readers register CodePagesEncodingProvider; the System.Text.Encoding.CodePages package dependency exists for this.
Bodies and compressed RTF
Three conveniences cover the body forms a message stores, on both message types:
BodyText- the plain-textPidTagBody.BodyHtml- thePidTagHtmlpayload, decoded as above; decoded once and cached.BodyRtf- thePidTagRtfCompressedpayload decompressed per MS-OXRTFCP, decompressed once and cached.
Compressed RTF (LZFu) is an LZ77 variant over a 4096-byte dictionary preseeded with a standard RTF prologue, behind a 16-byte header that declares the compressed and uncompressed sizes, a magic (LZFu compressed, MELA raw), and a CRC. The declared uncompressed size sits outside the checksum, so it is bounded rather than trusted: a payload whose declared or produced size exceeds MaxDecompressedRtfBytes is rejected before allocation. Set DecompressRtf to false on either options type to skip decompression entirely - BodyRtf then returns null and the raw payload stays reachable through Properties.GetBinary(MapiPropertyIds.RtfCompressed).
Validation level
Each reader reuses its container's level rather than defining its own:
| Reader | Option | Values |
|---|---|---|
.msg |
ValidationLevel | CompoundValidationLevel - Strict / Compatible (default) / Minimal |
.pst |
ValidationLevel | PstValidationLevel - Compatible (default) / Strict / Minimal |
Under the tolerant levels a malformed property is omitted and decoding continues, a table row that references no valid node is skipped, and an undefined attachment method is inferred - the tolerance real-world files need. Under Strict, structural problems throw the format's exception: a missing .msg value stream or recipient/attachment count mismatch raises OutlookMsgFormatException; a .pst hierarchy, contents, recipient, or attachment row that does not reference a valid node - or a malformed name-to-id map - raises OutlookPstFormatException. The level also flows down to the container, which enforces its own checksums and signatures under Strict.
Resource limits
Both option types carry hardening limits that a crafted file cannot talk its way past. They are init-only, validated at construction (zero or negative values throw ArgumentOutOfRangeException), and - except where noted - enforced at every validation level.
| Option | .msg default |
.pst default |
When it trips |
|---|---|---|---|
MaxEmbeddedMessageDepth |
16 | 16 | OpenMessage() on an attachment that would sit deeper than the limit throws OutlookMsgFormatException / OutlookPstFormatException. A few kilobytes can nest attachments thousands deep; the limit keeps a recursive walk on its stack. |
MaxDecompressedRtfBytes |
64 MiB | 64 MiB | BodyRtf on a payload whose declared or produced size exceeds the ceiling throws the same format exception, before any allocation. |
MaxInlineAttachmentBytes |
1 MiB | 1 MiB | Not an error. A by-value payload above the threshold is not decoded into Properties: the property is present with a null value (Contains and Method are unaffected, GetBinary returns null), and OpenContentStream() serves it from the container. Combined with a streaming read the payload is never held in memory in full. This threshold does not vary with the validation level. |
MaxNodeDataLength |
- | 256 MiB | A .pst node payload that any single property value, attachment, or table would materialize at once above the ceiling is refused by the container with PstFileFormatException and LimitExceeded. Streaming reads (OpenContentStream on a deferred payload) are unbounded by design. |
BlockCacheSize |
- | 256 entries | Not a limit - the decoded-block LRU budget of the container; 0 disables the cache. |
ReadStrategy |
Buffered |
- | Not a limit - CompoundReadStrategy.Streaming reads the .msg container sector by sector on demand instead of buffering it whole. |
The .pst options forward ValidationLevel, BlockCacheSize, and MaxNodeDataLength to the underlying PstFileOptions; its remaining limit, MaxDataTreeLeaves, is not surfaced and keeps the container default of 65,536 leaf blocks per node.
Exceptions
The readers raise a small family rooted at OutlookFormatException, so a single handler catches both formats:
- OutlookMsgFormatException - the
.msgfile is not a compound file, the container is malformed, or the message violates MS-OXMSG (including the depth and RTF limits above). Container corruption is wrapped: a failed open carries the CompoundFileFormatException asInnerException, and a container fault while streaming an attachment surfaces as this exception too. - OutlookPstFormatException - a messaging-level violation of MS-PST: a folder or message object that breaks the format's conventions, a table row that references no valid node under strict validation, a malformed name-to-id map, or a tripped limit. Container corruption is not wrapped - it propagates as the PstFileException family, and opening a
.pstthrows PstFileFormatException (not structurally a PST) or PstUnsupportedFormatException (the OST variant) directly.
Beyond the family: NotSupportedException for a content accessor that does not match the attachment method, ObjectDisposedException after the session is disposed, and ArgumentException when a .pst stream is not readable and seekable.
Where to go next
- Getting started - install + minimal samples for both readers.
- Introduction - the high-level shape, the package table, and headline types.
- Properties and named properties - the raw property surface in practice.
- API reference - Bodu.Formats.Outlook · Bodu.IO.Compound · Bodu.IO.Pst.