Walk the store’s folders with PstStore, convert each message with MapiToMimeConverter, and write one .eml per message. The program below exports Apache Tika’s public test store.
dotnet new console -o PstToEml && cd PstToEml dotnet add package RecursiveAI.OneMail --version 0.3.0-preview.2 curl -LO https://raw.githubusercontent.com/apache/tika/4.2.0/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testPST.pst
Put this in Program.cs and run dotnet run:
using RecursiveAI.OneMail.Conversion.OxcMail; using RecursiveAI.OneMail.Pst.Messaging; if (!PstStore.TryOpen("testPST.pst", out PstStore? store, out var defects)) { Console.WriteLine($"Not a PST: {string.Join(", ", defects)}"); return; } var converter = new MapiToMimeConverter(); int written = 0, unresolved = 0; using (store) { foreach (PstFolder folder in store!.EnumerateFolders()) { foreach (PstMessage message in folder.EnumerateMessages()) { // One folder per PST folder, one .eml per message string dir = Path.Combine(["eml", .. folder.Path.Split('/').Select(Safe)]); Directory.CreateDirectory(dir); var result = converter.Convert(message); string file = Path.Combine(dir, $"{++written:D5}.eml"); using (FileStream eml = File.Create(file)) result.Value.WriteTo(eml); // Exchange-only senders with no SMTP address are left out and reported unresolved += result.Report.Count(e => e.Code == "AddressUnresolved"); Console.WriteLine($"{file} {new FileInfo(file).Length,7:N0} bytes"); } } } Console.WriteLine($"{written} messages, {unresolved} unresolved addresses"); static string Safe(string name) => string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
eml/Début du fichier de données Outlook/00001.eml 5,899 bytes eml/Début du fichier de données Outlook/00002.eml 9,006 bytes eml/Début du fichier de données Outlook/00003.eml 4,493 bytes eml/Début du fichier de données Outlook/00004.eml 4,745 bytes eml/Début du fichier de données Outlook/00005.eml 5,277 bytes eml/Début du fichier de données Outlook/00006.eml 18,368 bytes eml/Début du fichier de données Outlook/00007.eml 21,015 bytes 7 messages, 2 unresolved addresses
Every file opens in Python’s email package and with the onemail tool. Messages that arrived over the Internet keep the headers they arrived with. The forwarded message in 00007.eml is still attached as a message. Messages are read one at a time, so a large store doesn’t have to fit in memory.
The two unresolved addresses
Message 7 was sent inside an Exchange organization. Its sender has only an Exchange (X.500) address, with no SMTP form in the store, so the .eml has no From header. The report lists it as AddressUnresolved. The project reports the same gap on its own test set: 30 such entries over 70 items.
Limits
- A preview. 0.3.0-preview.2: the API can change before 1.0.
- Exchange addresses. There is no directory lookup: “EX addresses with no SMTP form in the item are left out and reported”.
- Bcc isn’t written by default (
OxcMailExportOptions.IncludeBcc). - Mail only. This loop writes messages. To write appointments and tasks as
.icsfiles too, convert the whole store withMail.ConvertAsync, as in Convert a PST file to mbox. - Search folders and hidden items are skipped, so no message is written twice.
To write one mbox instead of many files, see Convert a PST file to mbox. One message at a time: Convert MSG to EML. Every rule: MAPI and MIME conversion.