Skip to content

Search Outlook mail stored in .pst files (Windows first) #69

Open

Description

@njeanselme

Use case

On Windows, many people keep years of mail in Outlook data files (.pst): tens of thousands of messages, with attachments. Finding "the mail where the supplier announced a price increase" in them is exactly what DigUp is good at, and Outlook's own search is not. I'd like DigUp to search those mails the way it searches documents.

What it would look like

  • A .pst inside a chosen folder is read like any other file. Each mail in it becomes a searchable item, like the pages of a PDF: its subject, sender, recipients, date, body (without the quoted history) and its attachments' text.
  • A result shows the mail (subject, sender, date, folder in the PST, snippet). Enter opens it in Outlook. A PST message's EntryID can be computed from the file itself (4 zero bytes, the store's record key, the message's node id in little-endian), so no Outlook is needed to index, only to open.
  • No Outlook, MAPI or COM to index: the file is read directly, read-only.

Reading PST files: MIT only

microsoft/outlook-pst-rs is Microsoft's own read-only, clean-room implementation of the MS-PST spec. It is MIT licensed and cross-platform, and it includes compressed RTF. libpff, the usual alternative, is LGPL, so I left it out.

It's Rust, like the Windows shell. Two ways it could fit, which is your call:

  1. a small helper (digup pst-read or similar) that streams mails as JSON lines to the index service, the way the helpers already talk;
  2. a static library with a C ABI that the engine links, like llama.cpp.

Things I found that shape it

  • Outlook is reported to lock an open PST for exclusive access, so nothing else can read or copy it while Outlook runs (I'll confirm on Windows). DigUp would read it when Outlook is closed, and otherwise treat it as "can't read now, try again at the next sync".
  • The file changes while Outlook has it open, even without new mail. Re-reading it is cheap (see below); what costs is embedding. Mails compared by node id and modification time, plus DigUp's usual checks, keep that to new or changed mails.
  • Size and speed: a large PST is several GB and around 100,000 mails. I prototyped on public Enron PSTs (CC BY 3.0). A 189 MB PST with 7,289 items reads in 0.39 s: about 18,700 mails/s with bodies and attachment lists, and 11 MB peak memory. Indexing time is all embeddings, not reading.
  • outlook-pst-rs 1.2.0 needs a small fix: it rejects messages that have no subnode tree, which MS-PST allows (a zero bidSub). That was 47% of that PST. With that fix, already proposed upstream in feat: tolerate real-world archive quirks (recall fork) microsoft/outlook-pst-rs#62, it reads the same 7,229 mails as libpff, and the 60 failures are damaged blocks in the files themselves.

Questions

  1. Is mail search something you'd want in DigUp, or outside its direction?
  2. Where should PST reading live: a helper, or in the engine?
  3. How should a file holding thousands of items show up in results and in the index (one row per mail, grouped under the PST)?
  4. Windows only, or the Mac too (a PST copied over can be read there; opening a mail would then export it to .eml)?

I'm happy to do the work and to shape it the way you prefer. I can also share the prototype and the test PSTs.

Activity

  1. njeanselme commented on Oct 14, 2026

    @njeanselme
    ContributorAuthor

    Follow-up, now that the Windows beta is out. I prototyped and checked things so the choice is easier; it's yours.

    What I verified (Windows 11, classic Outlook 16.0)

    • A PST can be read without Outlook, MAPI or COM, with microsoft/outlook-pst-rs (MIT). My prototype streams one JSON line per mail: EntryID, folder, subject, sender, recipients, dates, body as plain text (from text, HTML or compressed RTF) and attachments. A 189 MB PST with 7,229 mails reads in 0.75 s using 44 MB of memory, bodies and attachments included.
    • It needs one fix in the crate: messages without a subnode tree, which MS-PST allows, fail to open. That was 47% of one archive. The fix is pending upstream as feat: tolerate real-world archive quirks (recall fork) microsoft/outlook-pst-rs#62. With it, the results match libpff mail for mail.
    • EntryIDs computed from the file match Outlook's exactly, and GetItemFromID opens the mail with them (checked on a PST that Outlook created).
    • Outlook locks an attached PST until Outlook exits: the reader can only read it while Outlook is closed. The reader reports that case with its own exit code.
    • Outlook stores bodies as HTML, even plain-text ones, so the reader has to convert HTML, not only read PR_BODY.
    • In 0.7.0-beta.1, digup estimate skips .pst, .eml, .msg, .xlsx, .xls, .pptx, .ods and .odp.

    Two ways to get there

    1. Native PST in DigUp.

      • Reading: a pst-read.exe beside digup.exe in helper\, found and run the way ffmpeg is.
      • Index: each mail becomes an item, for example a virtual path archive.pst::<EntryID>, so that results stay one per item.
      • Preview: the side pane already shows chunk passages; it would need mail facts (subject, sender, date) and a full-mail view on Space.
      • Opening: through Outlook COM.
      • Opens the original mail; the biggest change to DigUp.
    2. Export outside DigUp.

      • A converter writes one .eml per mail into a local folder that DigUp indexes. It updates only new or changed mails, and deletes the ones that are gone.
      • DigUp would only need an .eml reader, which is small, and also useful for Thunderbird or Apple Mail exports.
      • Costs: a second copy of the mail on disk, which must stay out of OneDrive-synced folders, and results open a copy, not the original in Outlook.
      • (Exporting .html instead would work with today's beta without any change, but results would then open in a browser.)

    Which direction fits DigUp, if either? I'm happy to do the work either way, and I can start with a standalone .eml reader PR if that's useful on its own.

  2. leivorn commented on Oct 16, 2026

    @leivorn

    Disclosure: I build Mailward, a browser-based PST/OST viewer, so I've been on the reading side of this. One thing worth knowing before "Windows first" settles on .pst only:

    On Windows, a Microsoft 365 / Exchange account's mail usually lives in an .ost, not a .pst, and the current outlook-pst-rs rejects every .ost at the header. UnicodeHeader::read and AnsiHeader::read (crates/pst/src/ndb/header.rs) require wMagicClient to be SM before they read wVer; an OST carries SO there, so open_store() fails with Invalid HEADER wMagicClient: 0x4F53 for any .ost, whatever its page size. OSTs can also use the 4 KB-page variant of the format: header wVer 36 (0x24), which libpff documents as "64-bit Unicode format with 4k pages", first found in Outlook 2013 OSTs. NdbVersion::try_from accepts only 14–15 (ANSI) and 23 (Unicode), so a file marked as a PST (SM) with that version would stop one step later, with InvalidNdbVersion(36). For many people the .ost is the bigger mailbox, so it may be worth deciding early whether .ost is in scope and how the reader reports it.

    If 4K support gets added (here or upstream), one trap I hit in a different reader (pst-extractor 1.12.0): 4K files have larger, deflate-compressed data blocks, so a Table Context's row matrix can't be walked with the Unicode PST's 8,176-byte block arithmetic. In pst-extractor's own test OST (MIT), the contents table of the 160-item "United States holidays" folder is a single block that inflates to 54,240 bytes (160 rows × 339). Walked as 8 KB blocks, row 24 is read from offset 8,176 instead of 8,136, its row id is garbage, and the folder ends at 24 of 160 items without any error. Checking each row's dwRowID against the table's row index (the BTH keyed by row id) catches it. That OST is a ready-made 4K test file.

    What I can't tell you: whether EntryIDs computed from the file open OST items in Outlook the way they do for a PST (I haven't tested OSTs against Outlook), and I haven't had a real 4K file whose row matrix spans several blocks.

    Edit (24 Oct): corrected the second paragraph. I first wrote that a 4 KB OST stops with InvalidNdbVersion(36). The crate checks wMagicClient before wVer, so an .ost of any page size stops one step earlier, with Invalid HEADER wMagicClient: 0x4F53; I checked by replaying the header checks on pst-extractor's test OST (magic and partial CRC pass, wMagicClient is SO). InvalidNdbVersion(36) is what a file marked as a PST with 4 KB pages gets.

  3. Malcomle commented on Oct 16, 2026

    @Malcomle

    I tried the Apple Mail side of this on my Mac (0.6.1): a small .emlx reader, plus letting the crawl go into Mail's .mbox folders, which it skips today because they're packages.

    On my ~/Library/Mail (6 accounts, about 30,800 messages) the estimate first said 19 h. Most of that was the estimate itself: at size / 2000, a 40 KB .emlx that's mostly HTML and base64 counts as ~20 chunks. The rest came from mail that isn't worth searching. Gmail's Spam alone had 2,437 messages, the trash mailboxes another ~2,200, and Mail's Attachments folders held 1,600 PNGs, mostly logos and signatures.

    What I ended up doing: read only the message part of the .emlx, prefer text/plain over HTML, cut the quoted thread ("On … wrote:", "Le … a écrit :", > lines) since every message is indexed on its own anyway, cap the body at about two chunks (one for mail with List-Unsubscribe), drop links, skip spam/trash/outbox mailboxes, and keep only PDFs among the attachments. That's 26,000 messages and 1.6 h, with ~2.35 chunks per message on a sample. "payment problem" finds the bank and billing mails without the exact words.

    Two things I didn't solve: results show 12345.emlx instead of the subject, and ~/Library/Mail needs Full Disk Access. If Apple Mail fits where DigUp is going, I can open a PR on main with tests and the evals. Or take whatever helps.

  4. njeanselme commented on Oct 16, 2026

    @njeanselme
    ContributorAuthor

    Thanks both, this is useful.

    @leivorn: agreed, and #88 reads .pst only, on purpose. Besides the 4K format (I checked: outlook-pst-rs stops at wVer 36), an .ost is Outlook's cache of a mailbox that lives on the server. Outlook keeps it open whenever it runs, so DigUp could rarely read it, and its items have Exchange EntryIDs rather than ones built from the node id, so opening them would need another way. The case #69 is about is archives kept as .pst. A 4K .pst would fail cleanly today (the PST is marked failed); a clearer message for it is an easy follow-up. Thanks for the dwRowID check, worth passing to outlook-pst-rs if it adds 4K pages.

    @Malcomle: the subject-as-title part is solved in #88: a mail is a row at a path inside its file, named by its subject. That could carry over to .emlx if Apple Mail fits. Your other findings apply to PSTs too, and #88 doesn't do them yet: skipping Deleted Items and Junk, cutting quoted replies, and a lower cap on chunks per mail. On Enron (7,229 mails), #88 averages 2.06 chunks per mail, and its estimate is close (about 12,600 chunks estimated, 14,904 real), but each reply in a thread indexes the text it quotes again. Happy to add those to #88 if @ARahim3 wants them there.

  5. ARahim3 commented on Oct 21, 2026

    @ARahim3
    Owner

    Thanks @njeanselme, and @leivorn and @Malcomle for the details. I've read #88. Here's where I land: a mailbox is a different kind of source from a file (one file, thousands of items, locked while Outlook runs, another program to ship and keep up), and I don't want to take that on now. If mail comes to DigUp, the way that fits is one file per mail: an .eml/.emlx reader with the subject as the title, which also covers Thunderbird and Apple Mail exports, and a PST exported that way would be searchable with it. That's a later item, not a promise of when. Leaving this open for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions