Table of Contents

Reading nodes and tables

PstFile opens a PST file as a read-only session over its node database. This guide covers the end-to-end recipe: open the file, address a node by identifier, walk the directory and a node's private subnodes, and read the two LTP views every object exposes - the property context (a property bag) and the table context (a table). Values keep their on-disk wire types throughout; nothing here knows what a "subject" is.

The samples run against sample1.pst from the runnable PST sample, copied as archive.pst; the quoted output is what they print.

Pattern 1 - open a file

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

Console.WriteLine($"{file.Format} format, block data: {file.CryptMethod}");

// Unicode format, block data: Permute

OpenRead(path) owns the file it opens. OpenRead(stream, leaveOpen) and Open(stream, options, leaveOpen) read a stream that must be readable and seekable (ArgumentException otherwise); the container starts at the stream's current position, so a PST embedded in a larger stream opens without copying. Opening parses the header only - a wrong magic, an undeclared version, or (under Strict) a bad header CRC throws PstFileFormatException here, and the 4 KiB-page OST variant throws PstUnsupportedFormatException.

Format reports the variant (Unicode, 64-bit structures, or Ansi, 32-bit); every public type behaves identically over either. CryptMethod is the header's content encoding - None, Permute, or Cyclic - which the reader undoes transparently on every block read; you never see encoded bytes.

using Bodu.IO.Pst;

using FileStream stream = File.OpenRead("archive.pst");
if (!PstFile.IsPstFile(stream))
    return;

var options = new PstFileOptions { ValidationLevel = PstValidationLevel.Strict };
using PstFile file = PstFile.Open(stream, options, leaveOpen: true);
Console.WriteLine(file.Format);

IsPstFile checks the magic only and restores the position. The options are covered in Streaming and validation.

Node identifiers

Every object in the file is addressed by a PstNodeId: a 32-bit value whose five low bits carry the PstNodeType and whose 27 high bits carry an index. The type bits make identifiers self-describing, and the format leans on it - a folder's hierarchy, contents, and associated-contents tables reuse the folder's index with the corresponding table type bits, so you can compute them without any lookup.

using Bodu.IO.Pst;

PstNodeId root = PstNodeId.RootFolder;                                    // 0x00000122
var hierarchy = new PstNodeId(PstNodeType.HierarchyTable, root.Index);   // same index, table type bits
var contents = new PstNodeId(PstNodeType.ContentsTable, root.Index);
var raw = new PstNodeId(0x00002223u);                                     // from a value read out of a table

Console.WriteLine($"{root} type={root.Type} index={root.Index}");
Console.WriteLine($"{hierarchy} type={hierarchy.Type} index={hierarchy.Index}");
Console.WriteLine($"{contents} type={contents.Type}");
Console.WriteLine($"{raw} type={raw.Type} index={raw.Index}");
Console.WriteLine(PstNodeId.MessageStore == new PstNodeId(0x21));        // True

// 0x00000122 type=NormalFolder index=9
// 0x0000012D type=HierarchyTable index=9
// 0x0000012E type=ContentsTable
// 0x00002223 type=SearchFolder index=273
// True

new PstNodeId(type, index) rejects an index above 0x07FFFFFF with ArgumentOutOfRangeException; new PstNodeId(uint) wraps any raw value. ToString renders 0x plus eight upper-case hexadecimal digits, and the struct is IEquatable with == / !=.

Three fixed identifiers anchor every file and are exposed as well-known values:

Well-known Value Holds
PstNodeId.MessageStore 0x21 The store object's property context (display name, record key, …).
PstNodeId.NameToIdMap 0x61 The named-property mapping the messaging layer resolves 0x8000+ identifiers through.
PstNodeId.RootFolder 0x122 The root folder; user folders hang beneath it.

The PstNodeType values you will meet most: NormalFolder (0x02), SearchFolder (0x03), NormalMessage (0x04), Attachment (0x05), AssociatedMessage (0x08), the table kinds HierarchyTable (0x0D), ContentsTable (0x0E), AssociatedContentsTable (0x0F), AttachmentTable (0x11), RecipientTable (0x12), plus Internal (0x01) and Ltp (0x1F) for bookkeeping nodes. A file may carry type values MS-PST does not name; they surface as the raw number.

Pattern 2 - walk the directory and look nodes up

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

var census = new SortedDictionary<PstNodeType, int>();
long largest = 0;
foreach (PstNodeInfo info in file.EnumerateNodes())
{
    census[info.NodeId.Type] = census.GetValueOrDefault(info.NodeId.Type) + 1;
    largest = Math.Max(largest, info.DataLength);
}

foreach ((PstNodeType type, int count) in census)
    Console.WriteLine($"{type,-24}{count,5}");
Console.WriteLine($"largest payload: {largest} bytes");

PstNode store = file.GetNode(PstNodeId.MessageStore);             // throws PstNodeNotFoundException when absent
Console.WriteLine($"{store} parent={store.ParentId} subnodes={store.HasSubnodes} data={store.DataLength} bytes");

if (!file.TryGetNode(new PstNodeId(PstNodeType.NormalFolder, 0x7FFFFFF), out PstNode? missing))
    Console.WriteLine("no such node");

// Internal                   12
// NormalFolder                5
// SearchFolder                2
// NormalMessage               1
// SearchUpdateQueue           2
// SearchCriteria              2
// ReceiveFolderTable          1
// OutgoingQueueTable          1
// HierarchyTable              6
// ContentsTable               6
// AssociatedContentsTable     6
// SearchContentsTable         3
// AttachmentTable             1
// RecipientTable              1
// 22                          1
// 23                          1
// 24                          1
// largest payload: 4198 bytes
// 0x00000021 (Internal) parent=0x00000000 subnodes=False data=290 bytes
// no such node

EnumerateNodes walks the node B-tree in identifier order and yields a PstNodeInfo snapshot per node - NodeId, ParentNodeId, DataLength, HasSubnodes - resolving the length from the block B-tree without reading any payload. GetNode throws PstNodeNotFoundException (Error == PstFileError.NodeNotFound) for an absent identifier; TryGetNode returns false. Either returns a PstNode that caches its data-tree leaf list and subnode directory for its own lifetime, so keep the instance around when you will read it more than once.

Pattern 3 - dump the store node's property context

ReadPropertyContext parses the node's heap and returns a PstPropertyContext: a read-only collection of PstPropertyValue keyed by 16-bit property identifier, in ascending identifier order. The records are materialized when the context is read, but each value's payload resolves only when accessed, so listing identifiers and wire types costs nothing per value.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

PstNode store = file.GetNode(PstNodeId.MessageStore);
PstPropertyContext properties = store.ReadPropertyContext();

Console.WriteLine($"{properties.Count} properties");
foreach (ushort id in properties.EnumeratePropertyIds())
{
    properties.TryGetWireType(id, out ushort wireType);
    Console.WriteLine($"  0x{id:X4} wire 0x{wireType:X4}");
}

// PidTagDisplayName (0x3001): a UTF-16 string in a Unicode-format file.
if (properties.TryGetValue(0x3001, out PstPropertyValue displayName) && displayName.WireType == 0x001F)
    Console.WriteLine($"display name: {displayName.GetString()}");

// PidTagRecordKey (0x0FF9): opaque bytes.
if (properties.Contains(0x0FF9))
    Console.WriteLine($"record key: {Convert.ToHexString(properties.GetValue(0x0FF9).GetBytes())}");

// Every value at once, with its diagnostic form.
foreach (PstPropertyValue value in properties)
    Console.WriteLine($"  {value}");

// 12 properties
//   0x0E34 wire 0x0102
//   0x0E38 wire 0x0003
//   0x0FF9 wire 0x0102
//   0x3001 wire 0x001F
//   ...
// display name: sample1
// record key: 6A552B813C43F94384F18B7DA2393E95
//   0x0E34 (0x0102, 24 bytes)
//   0x0E38 (0x0003, 4 bytes)
//   0x3001 (0x001F, 14 bytes)
//   ...

The lookup surface: Count, Contains(id), EnumeratePropertyIds(), TryGetWireType(id, out wireType) (no payload read), TryGetValue(id, out value), and GetValue(id), which throws PstFileException with Error == PstFileError.PropertyNotFound for an absent identifier. Enumerating the context resolves every payload in turn. TryGetValueLength and TryOpenValueStream are the streaming pair, covered in Streaming and validation.

Note

ReadPropertyContext and ReadTableContext re-read the heap from the source on every call. Retain the returned instance for the duration of your reads rather than calling them per property or per row.

Typed accessors

A PstPropertyValue carries PropertyId, the raw WireType (the MS-OXCDATA code, unchanged from the file), and RawData - the resolved little-endian payload (empty for a null value). The typed accessors require the matching wire type and at least the type's width; a mismatch throws InvalidOperationException.

Accessor Accepts wire type Returns
GetInt16() 0x0002 short
GetInt32() 0x0003, or the 32-bit error code 0x000A int
GetInt64() 0x0014, the currency 0x0006, or the FILETIME 0x0040 long - for 0x0040, pass to DateTime.FromFileTimeUtc
GetBoolean() 0x000B bool (any nonzero byte)
GetSingle() 0x0004 float
GetDouble() 0x0005, or the floating time 0x0007 double
GetGuid() 0x0048 Guid
GetString() 0x001F only string (UTF-16LE)
GetBytes() any a copy of RawData

GetString deliberately handles only the UTF-16 type: a code-page string (0x001E) stays bytes because choosing its encoding is a format-layer decision, as are multi-valued (0x1000 flag) and object-typed payloads, which surface raw. The mail-store reader is where those decode.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");
PstPropertyContext properties = file.GetNode(PstNodeId.MessageStore).ReadPropertyContext();

try
{
    _ = properties.GetValue(0x3001).GetInt32();   // the display name is a string
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

try
{
    _ = properties.GetValue(0x0001);              // not present
}
catch (PstFileException ex) when (ex.Error == PstFileError.PropertyNotFound)
{
    Console.WriteLine(ex.Message);
}

// The property value has wire type 0x001F and cannot be read as 0003.
// No property 0x0001 exists in the property context of node 0x00000021.

Pattern 4 - list the root hierarchy table

ReadTableContext returns a PstTableContext: Columns (a PstTableColumn per column - property identifier, wire type, cell width), RowCount from the table's row index, and rows whose identifier names the object the row stands for. A folder's hierarchy table lists its child folders, so each RowId is a child folder's node identifier.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

var hierarchyId = new PstNodeId(PstNodeType.HierarchyTable, PstNodeId.RootFolder.Index);
PstNode tableNode = file.GetNode(hierarchyId);
PstTableContext table = tableNode.ReadTableContext();

Console.WriteLine($"{table.RowCount} rows x {table.Columns.Count} columns");
foreach (PstTableColumn column in table.Columns)
    Console.WriteLine($"  {column}");

foreach (PstTableRow row in table.EnumerateRows())
{
    var childId = new PstNodeId(row.RowId);                  // each row stands for a child folder node

    string name = row.TryGetCell(0x3001, out PstPropertyValue display) && display.WireType == 0x001F
        ? display.GetString()
        : "(unnamed)";
    int messages = row.TryGetCell(0x3602, out PstPropertyValue count) ? count.GetInt32() : 0;   // PidTagContentCount
    bool hasChildren = row.TryGetCell(0x360A, out PstPropertyValue sub) && sub.GetBoolean();     // PidTagSubfolders

    Console.WriteLine($"  {childId} ({childId.Type}) {name}: {messages} messages, subfolders={hasChildren}");
}

// 4 rows x 13 columns
//   0x0E30 (0x0102, 4 bytes)
//   0x0E33 (0x0014, 8 bytes)
//   ...
//   0x3001 (0x001F, 4 bytes)
//   0x3602 (0x0003, 4 bytes)
//   0x360A (0x000B, 1 bytes)
//   ...
//   0x00008022 (NormalFolder) Top of Outlook data file: 0 messages, subfolders=True
//   0x00008042 (NormalFolder) Search Root: 0 messages, subfolders=False
//   0x00002223 (SearchFolder) SPAM Search Folder 2: 0 messages, subfolders=False
//   0x00080023 (SearchFolder) ItemProcSearch: 0 messages, subfolders=False

Note the widths: a variable-size column such as the 0x001F display name occupies four bytes in the row - a value reference into the heap or a subnode - and resolves when the cell is read, exactly like a property-context value. Fixed-width cells (Int32, Boolean, the eight-byte Int64) sit inline. The container lists the two search folders the mail-store reader deliberately hides.

EnumerateRows streams the row matrix one block at a time and never materializes the whole table; each yielded PstTableRow copies its own bytes, so rows stay valid after the enumeration advances. The row surface is RowId, TryGetCell(id, out value) - true only when the table declares the column and the row's existence bitmap marks the cell present - EnumerateCells() over the present cells, and the streaming pair TryGetCellLength / TryOpenCellStream.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

var hierarchyId = new PstNodeId(PstNodeType.HierarchyTable, PstNodeId.RootFolder.Index);
PstTableContext table = file.GetNode(hierarchyId).ReadTableContext();

uint[] rowIds = table.EnumerateRowIds().ToArray();          // no row materialized
Console.WriteLine(string.Join(", ", rowIds.Select(id => new PstNodeId(id))));

if (table.TryGetRow(rowIds[0], out PstTableRow? row))
{
    foreach (PstPropertyValue cell in row.EnumerateCells())
        Console.WriteLine($"  {cell}");
}

// 0x00008022, 0x00008042, 0x00002223, 0x00080023
//   0x3001 (0x001F, 48 bytes)
//   0x3602 (0x0003, 4 bytes)
//   0x3603 (0x0003, 4 bytes)
//   0x360A (0x000B, 1 bytes)
//   0x67F2 (0x0003, 4 bytes)
//   0x67F3 (0x0003, 4 bytes)

EnumerateRowIds reads the leading dword of each row slot in place and allocates nothing per row - the right call when, as with hierarchy, contents, and attachment tables, you only want the referenced node identifiers. TryGetRow(rowId, out row) goes through the row index for a keyed lookup; it returns false for an unknown identifier and throws PstFileFormatException when the index names a row the matrix does not hold.

Pattern 5 - read a message node without MAPI semantics

A message is a NormalMessage node whose property context holds the message's properties and whose private subnode tree holds its recipient table, attachment table, and attachment objects - invisible to the node B-tree, reachable only through the owning node.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

PstNodeInfo first = file.EnumerateNodes().First(n => n.NodeId.Type == PstNodeType.NormalMessage);
PstNode message = file.GetNode(first.NodeId);
PstPropertyContext properties = message.ReadPropertyContext();

Console.WriteLine($"{message}: {properties.Count} properties, parent folder {message.ParentId}");

// 0x0037 PidTagSubject, 0x001A PidTagMessageClass - UTF-16 (0x001F) in a Unicode-format file.
if (properties.TryGetValue(0x0037, out PstPropertyValue subject) && subject.WireType == 0x001F)
    Console.WriteLine($"subject: {subject.GetString()}");
if (properties.TryGetValue(0x001A, out PstPropertyValue messageClass) && messageClass.WireType == 0x001F)
    Console.WriteLine($"class: {messageClass.GetString()}");

// 0x0E06 PidTagMessageDeliveryTime - a FILETIME (0x0040), read as Int64 ticks.
if (properties.TryGetValue(0x0E06, out PstPropertyValue delivered) && delivered.WireType == 0x0040)
    Console.WriteLine($"delivered: {DateTime.FromFileTimeUtc(delivered.GetInt64()):u}");

// 0x0E07 PidTagMessageFlags - Int32; bit 0 is MSGFLAG_READ.
if (properties.TryGetValue(0x0E07, out PstPropertyValue flags))
    Console.WriteLine($"flags: 0x{flags.GetInt32():X8}");

// The message's private subnodes: recipient table, attachment table, attachment objects.
foreach (PstNodeInfo subnode in message.EnumerateSubnodes())
    Console.WriteLine($"  subnode {subnode.NodeId} ({subnode.NodeId.Type}), {subnode.DataLength} bytes");

if (message.TryGetSubnodeOfType(PstNodeType.RecipientTable, out PstNode? recipientTable))
{
    foreach (PstTableRow row in recipientTable.ReadTableContext().EnumerateRows())
    {
        string name = row.TryGetCell(0x3001, out PstPropertyValue display) && display.WireType == 0x001F ? display.GetString() : "?";
        int type = row.TryGetCell(0x0C15, out PstPropertyValue recipientType) ? recipientType.GetInt32() : 0;   // 1 = To, 2 = Cc, 3 = Bcc
        Console.WriteLine($"  recipient type {type}: {name}");
    }
}

// 0x00200024 (NormalMessage): 110 properties, parent folder 0x00008082
// subject: Here is a sample message
// class: IPM.Note
// delivered: 2010-03-15 17:12:07Z
// flags: 0x00000031
//   subnode 0x00000671 (AttachmentTable), 514 bytes
//   subnode 0x00000692 (RecipientTable), 1000 bytes
//   subnode 0x00008025 (Attachment), 326 bytes
//   subnode 0x0000807F (Ltp), 1701 bytes
//   subnode 0x0000809F (Ltp), 2196 bytes
//   recipient type 1: Terry Mahaffey

EnumerateSubnodes yields PstNodeInfo snapshots in stored order, with ParentNodeId set to the owning node; TryGetSubnode(id, out node) resolves one by identifier and TryGetSubnodeOfType(type, out node) the first of a type in a single pass - the natural way to reach a message's recipient and attachment tables. A subnode is a full PstNode: it has its own payload, its own LTP views, and (for an attachment object) may carry a subnode tree of its own holding an embedded message. The two Ltp subnodes above hold large property values that overflowed the message's heap-on-node; the property context resolves them transparently when their property is read.

Which of these identifiers means subject, delivery time, or recipient type is MS-OXPROPS knowledge the container does not carry - that is the mail-store reader's job, and the reason this layer exists separately.

API summary

Type Members
PstFile OpenRead(path) · OpenRead(stream, leaveOpen) · Open(stream, options, leaveOpen) · IsPstFile(stream) · Format · CryptMethod · EnumerateNodes() · GetNode(id) · TryGetNode(id, out node) · Dispose
PstNodeId .ctor(uint) · .ctor(type, index) · MessageStore · NameToIdMap · RootFolder · Value · Type · Index
PstNode Id · ParentId · HasSubnodes · DataLength · ReadAllBytes() · OpenDataStream() · EnumerateSubnodes() · TryGetSubnode(id, out node) · TryGetSubnodeOfType(type, out node) · ReadPropertyContext() · ReadTableContext()
PstPropertyContext Count · Contains(id) · EnumeratePropertyIds() · TryGetWireType(id, out wireType) · TryGetValue(id, out value) · GetValue(id) · TryGetValueLength(id, out length) · TryOpenValueStream(id, out stream) · enumeration
PstPropertyValue PropertyId · WireType · RawData · GetInt16() · GetInt32() · GetInt64() · GetBoolean() · GetSingle() · GetDouble() · GetGuid() · GetString() · GetBytes()
PstTableContext Columns · RowCount · EnumerateRows() · EnumerateRowIds() · TryGetRow(rowId, out row)
PstTableRow RowId · TryGetCell(id, out value) · EnumerateCells() · TryGetCellLength(id, out length) · TryOpenCellStream(id, out stream)

Where to go next