Skip to main content

pypstreader — a pure-Python reader for Outlook PST stores

A port of the read path of microsoft/outlook-pst-rs (MIT) into Python, with no dependencies outside the standard library — and a command that turns a PST into mail your tools already read.

Status: the read path is fully ported and verified against the Rust oracle: pypstreader.open() opens a Unicode PST and walks folders, messages, recipients and attachments — and exports them as .eml files or as mbox, which is what the reader is for. Unicode (Outlook 2003 and later) stores only; an ANSI store is refused, never guessed at.

Install

pip install pypstreader          # the library and the command
uv tool install pypstreader      # just the command, in its own environment

Nothing comes with it: the package imports the standard library and nothing else, on any platform, with no compiler and no network.

pip install pstreader installs the same thing under an alias name, for people who reach for the shorter one.

The command

pypstreader store.pst                      # -> store.mbox, every folder, every message
pypstreader store.pst -o mail.mbox         # somewhere else
pypstreader --list store.pst               # the folder tree, with message counts
pypstreader --per-folder store.pst -o out/ # one <nid>.mbox per folder, plus folders.txt
pypstreader --format eml store.pst -o out/ # one <nid>.eml per message
pypstreader --folder 'Top of Personal Folders/Inbox' store.pst

The default writes one mbox — the format mutt, Thunderbird, formail, readpst, Python's own mailbox and every e-discovery loader read as-is (RFC 4155) — with every message stamped X-Pypstreader-Folder: <display path>, so flattening the store does not lose the tree. --folder takes exactly the path --list prints, and covers that folder and everything under it.

A message that will not open is skipped and counted, not fatal: the summary line on stderr says how many were written and how many were skipped, and --strict turns the first refusal into the end of the run instead. Limits are on the command line too — --max-depth, --max-attachment-bytes, --max-embedded-depth — because the file being read is not trusted.

Exit status is 0 when the run finished, 1 when the store or the filesystem refused (one line on stderr, never a traceback), 2 when the command line was wrong.

$ pypstreader --list mail.pst
      0  Top of Personal Folders
      4  Top of Personal Folders/Inbox
      1  Top of Personal Folders/Sent
$ pypstreader mail.pst
pypstreader: 3 folders, 5 messages written, 0 skipped -> mail.mbox

The library

import pypstreader

with pypstreader.open("store.pst") as store:
    for folder in store.root_folder.walk():
        print(folder.display_name, folder.content_count)
        for message in folder.messages():
            print("  ", message.subject, "from", message.sender_name)

    # One mbox per folder (`<nid>.mbox`, plus `folders.txt` naming them):
    pypstreader.export_mbox(store.root_folder, "out/")

    # ...or one RFC 5322 `.eml` per message, named by node id:
    pypstreader.export_folder(store.root_folder, "out-eml/")

    # ...or one message at a time, as an `email.message.EmailMessage`:
    message = next(iter(store.root_folder.messages()))
    eml = pypstreader.to_eml(message)           # headers, bodies, attachments
    pypstreader.write_eml(message, "one.eml")   # or pypstreader.eml_bytes(message)

Everything that can fail raises a PstErrorPstFormatError, PstUnsupportedError, PstLimitError, PstNotFoundError — and nothing else: a struct.error or an IndexError out of this package is a bug, and the test suite sweeps every public entry point over thousands of deliberately corrupted stores to keep that true.

There is a second command for reading the format rather than the mail, which prints one layer of a store in the upstream Rust example's own wording:

python -m pypstreader.debug messages store.pst   # what the oracle prints
python -m pypstreader.debug eml store.pst 10001  # one message, on stdout
python -m pypstreader.debug --list               # every layer it can dump

Where the headers come from. A message that arrived over SMTP keeps its internet headers in a MAPI property, and those are passed through verbatim — Message-ID, Date, the Received chain, In-Reply-To, References. A message composed locally has none, so From, To/Cc/Bcc, Subject, Date and Message-ID are rebuilt from MAPI properties, and every header that was rebuilt is listed in X-Pypstreader-Synthesized:. An invented Message-ID lives under @pypstreader.invalid and is deterministic, so a reconstructed thread is never mistaken for a delivered one. (In the test corpus the split is 6 messages of 12 either way, and it follows the message class: delivered IPM.Notes have headers, appointments and locally-composed items do not.)

Why this exists

Every existing way to read a PST from Python goes through libpff — a C library under LGPL-3, reached via a compiled extension. That is three problems in one dependency: a copyleft obligation on anything that ships it, a native build in every install, and a large C parser being fed bytes an attacker chose.

There is no pure-Python PST reader. This is an attempt at one:

  • MIT, like the Rust it is ported from — no copyleft obligation to convey.
  • Pure stdlib, so it installs as a plain wheel, air-gapped, on any platform.
  • Memory-safe by construction, which matters when the input is a mail store from a hostile source.
  • Auditable — a few thousand lines of Python you can actually read, each module naming the upstream file it came from.

Develop

git clone <this repo> && cd pypstreader
scripts/setup.sh          # venv, git hooks, Rust oracle, test run

scripts/setup.sh --no-rust skips the oracle if you only want the Python side.

The differential oracle

The porting method is not "read the Rust and write Python". It is: port a layer, then run both implementations over the same bytes and diff the output. The upstream repository ships ten example binaries that dump each layer — headers, B-trees, the named-property map, the root folder — and those are the ground truth.

scripts/get_rust_source.sh                          # pinned clone into reference/
scripts/build_oracle.sh                             # ~2 min, ~270 MB
scripts/oracle.sh read_header tests/fixtures/Empty.pst
scripts/oracle.sh --list                            # what else it can dump

The Rust is fetched, never vendored — it is the oracle, not the product. The pin lives in docs/UPSTREAM.txt.

The oracle's output over every public fixture is committed as goldens under tests/golden/, which is why every-push CI stays Rust-free: the differential tests diff Python against the file. A nightly job, .github/workflows/nightly-oracle.yml, is what proves the file still agrees with the Rust. It fetches upstream at the pin, builds the example binaries, and checks that every upstream test has a Python twin, that the goldens are what the oracle emits today, that the synthetic fixture regenerates byte-for-byte, and that the oracle-marked tests pass live. Every one of those checks is a call into scripts/nightly_oracle_local.sh, so the same run works on a laptop (SKIP_RUST=1 for the Python-only half). Upstream's only message-level example is an interactive TUI, so oracle/ holds a small Rust crate of our own (MIT; it links the pinned upstream crate as a path dependency and copies nothing in) with the examples upstream does not ship. dump_messages walks every folder, message, recipient and attachment non-interactively and prints them in upstream's Debug style — body lengths and CRC-32s, never body text — and is captured into tests/golden/ like the other eight. scripts/oracle.sh finds it by name:

scripts/oracle.sh dump_messages tests/fixtures/public/pstsdk-test_unicode.pst
cd oracle && cargo build --example dump_messages     # or let oracle.sh build it

It builds into the reference checkout's target/ (debug profile, the one oracle.sh already uses), so nothing is compiled twice.

Test

uv run pytest                    # everything
uv run pytest -m "not oracle"    # skip differential tests (no Rust needed)

Mail stores in the repository

Two kinds, both pinned by hash, nothing else:

  • tests/fixtures/Empty.pst — Microsoft's MIT-licensed empty store.
  • tests/fixtures/public/ — a small corpus of licensed test stores (Microsoft's PST SDK samples, Apache Tika's, java-libpst's, and a synthetic MIT one), 29 KB–265 KB each, every one listed with its SHA-256, source commit and licence in that directory's README. The Rust oracle's output over each is captured in tests/golden/, so the differential tests run without a Rust toolchain.

Real stores live in tests/fixtures/private/, which is gitignored, and a pre-commit hook (and CI) refuses any commit containing a store that is not in the manifest. A PST is somebody's correspondence; a public repository that has ever held one has published it.

Tests may assert that a private store parses. They may not assert, print, or log what it says — a CI log is a publication channel.

Unicode stores only. Stores written by Outlook 97–2002 (ANSI format) are refused with PstUnsupportedError; see ADR-0003.

Licence and attribution

MIT. Portions are derived from microsoft/outlook-pst-rs, Copyright (c) Microsoft Corporation, also MIT. See NOTICE — it carries the attribution the MIT licence requires, the trademark position (this project is not affiliated with or endorsed by Microsoft), and the patent posture of every open PST implementation, which this one inherits and does not change.

The format itself is documented by Microsoft as the open specification [MS-PST], whose IP notice expressly permits copying it in order to build implementations.

Release files for pypstreader 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pypstreader 0.1.0
File Size Uploaded
pypstreader-0.1.0.tar.gz 150.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pypstreader 0.1.0
File Interpreter ABI Platform
pypstreader-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 322.7 kB

Release files / pypstreader-0.1.0.tar.gz

Download URL pypstreader-0.1.0.tar.gz
Size 150.3 kB
Tags Source
SHA-256 checksum
How to use checksums
57ce042fcd6f48b915659fc45de873f16987abee9cab093141d672da273a4014
BLAKE2b-256 checksum
How to use checksums
624103b391094c7b09e3a6b688fc290792bedb4b86cfb66a796fe3df1a608cd5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / pypstreader-0.1.0-py3-none-any.whl

Download URL pypstreader-0.1.0-py3-none-any.whl
Size 172.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8136ed1aec0a90c1c03eb4ff7ceedaf8eb8d394b3792daf850f18694b9728d7e
BLAKE2b-256 checksum
How to use checksums
c3993d8a5b4e187b101f545c49a16d27fc3cf8dff7b5e797433725301c2a9c7b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

1.0.0

2 release files

This release

0.1.0 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page