A standalone Swift Package for parsing forensic email evidence — PST, Apple Mail .emlx / .eml, Unix .mbox, Outlook for Mac (olk15), Outlook (New) HxStore, and Spark — into a single EmailData model.
EmailParsingKit parses those formats into a common EmailData / EmailAttachment model, independent of any particular app or persistence layer. It has no GRDB, no app-specific logging, and no assumptions about how a consuming app stores or displays the result.
PST support wraps libpff-spm (itself a wrapper around libyal's libpff). Every other format is custom Swift. Spark and Outlook Classic open their SQLite stores read-only (immutable=1).
All seven format parsers throw. An unreadable or corrupt store is not the same as an empty mailbox: a successful parse of a store with zero messages returns []; a file that cannot be opened or whose blocks cannot be decoded throws.
- macOS 13.0+
- Xcode 15+
This repo uses a git submodule for libpff-spm — clone with --recurse-submodules, or the submodule directory will be empty and the package won't resolve:
git clone --recurse-submodules https://github.com/saadtahir-dev/EmailParsingKit.gitIf you already cloned without it:
git submodule update --init --recursiveThen add as a dependency:
dependencies: [
.package(url: "https://github.com/saadtahir-dev/EmailParsingKit.git", from: "1.0.0")
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "EmailParsingKit", package: "EmailParsingKit")
]
)
]For local development:
dependencies: [
.package(path: "../EmailParsingKit")
],Or add via Xcode: File → Add Package Dependencies.
Pass a file URL, a source UUID for provenance, and the format. Every format returns [EmailData] and every branch throws.
import EmailParsingKit
let parser = EmailParser()
let sourceUUID = "550e8400-e29b-41d4-a716-446655440000"
let appleMail = try parser.parse(url: emlxFileURL, sourceUUID: sourceUUID, format: .emlx)
let gmail = try parser.parse(url: mboxFileURL, sourceUUID: sourceUUID, format: .mbox)
let outlook = try parser.parse(url: pstFileURL, sourceUUID: sourceUUID, format: .pst)
let eml = try parser.parse(url: emlFileURL, sourceUUID: sourceUUID, format: .eml)
let classic = try parser.parse(url: outlookSqliteURL, sourceUUID: sourceUUID, format: .olk15)
let hx = try parser.parse(url: hxStoreURL, sourceUUID: sourceUUID, format: .outlookHx)
let spark = try parser.parse(url: sparkStoreURL, sourceUUID: sourceUUID, format: .spark)let one = try EMLXParser.parse(url: emlxURL, sourceUUID: sourceUUID)
let many = try MBOXParser.parse(url: mboxURL, sourceUUID: sourceUUID)
let pst = try PSTParser.parse(path: pstURL.path, sourceUUID: sourceUUID)
let eml = try EMLParser.parse(url: emlURL, sourceUUID: sourceUUID)
let olk15 = try OutlookClassicParser.parse(url: sqliteURL, sourceUUID: sourceUUID)
let hx = try OutlookHxParser.parse(url: hxStoreURL, sourceUUID: sourceUUID)
let spark = try SparkParser.parse(url: messagesSQLiteURL, sourceUUID: sourceUUID)EMLX / MBOX / PST / EML / olk15 populate EmailAttachment.data at parse time. Spark attachments that were never cached leave data nil and both path fields empty; a cache hit sets sourcePath relative to the directory that contains Library, never an absolute path.
EmailAttachment.data is excluded from Codable.
Sources/EmailParsingKit/
├── EmailParser.swift
├── Model/
├── Parsers/
│ ├── RFC822/ EMLX, MBOX, EML, shared RFC822/MIME
│ ├── PST/
│ ├── OutlookClassic/ olk15 store + body scan
│ ├── OutlookHx/ HxStore LZ4 + IPM.Note carving
│ └── Spark/
└── Helpers/
| Source | Role |
|---|---|
EmailParser |
Format-routed entry point. All seven branches try. |
Parsers/RFC822/ |
Apple Mail .emlx, Unix .mbox, standalone .eml, shared MIME parser. |
Parsers/PST/ |
Outlook PST via libpff. |
Parsers/OutlookClassic/ |
Outlook for Mac Data/Outlook.sqlite + .olk15Message body recovery. |
Parsers/OutlookHx/ |
New Outlook HxStore.hxd (LZ4 + unpublished record carve). |
Parsers/Spark/ |
Spark core-data/messages.sqlite. |
Model/EmailFormat |
Stable email_type strings: "Emlx", "Mbox", "Pst", "Eml", "Olk15", "OutlookHx", "Spark". |
Canonical parse result for a single email, shared across all seven formats.
| Field | Type | Notes |
|---|---|---|
emailType |
String |
Use EmailFormat raw values. |
sourceUUID |
String |
Caller-supplied provenance identifier. |
sourceFile |
String |
Absolute path to the origin container (or olk15 message blob). |
accountID |
String |
Derived account, or EmailParsing.orphanAccountID ("Orphan"). |
rawBytes |
Data? |
MBOX only (full message bytes). Every other format leaves nil. |
attachments |
[EmailAttachment] |
|
attachmentCount |
Int |
Denormalized from attachments.count. |
| Field | Type | Notes |
|---|---|---|
relativePath |
String |
Empty from parsers; consumer fills after writing data. |
sourcePath |
String |
Source-relative evidence path (Spark cache). Empty when the consumer should use data. |
data |
Data? |
In-memory bytes when the parser extracted them. Not Codable. |
RFC822/MIME via RFC822MessageParser. Multipart delimiters are matched only at line start (RFC 2046). Base64 that will not decode becomes empty attachment bytes plus parseWarning — never the raw alphabet presented as the file.
- EMLX: length prefix + RFC822 body.
rawBytesnil. - EML: standalone RFC822. Account stays
"Orphan". Mailbox is the enclosing.mboxor parent folder. - MBOX:
Fromsplit, Gmail account heuristics, sibling Orphan backfill.rawBytesis the full message.
Account resolution: message store, then first message, then "Orphan". rawBytes always nil. Cc falls back to MAPI recipient type CC.
…/Data/Outlook.sqlite. Account UIDs mask to the low 32 bits. Bodies are scanned inside .olk15Message files, not sought at a fixed header offset.
HxStore.hxd is LZ4 block format, then pattern-carved IPM.Note records. Subjects are prefixed [PREVIEW]. Folder attribution is not recoverable from the carve. A file whose blocks exist but none decode throws; a tiny file with no blocks returns [].
Thin client: most bodies were never downloaded. Missing bodies are labelled, never left blank. Attachment bytes exist only under Library/Caches when Spark cached them.
swift test --package-path .Fixtures:
| File | Covers |
|---|---|
outlook.pst |
PST folder walk, account resolution, attachments (libyal testdata) |
sample.emlx |
EMLX length prefix, RFC822 multipart, attachment data |
sample.mbox |
Two-message mbox, Gmail Delivered-To, Orphan sibling backfill |
sample.eml |
Standalone RFC822 .eml |
OutlookClassic/ |
Synthetic Data/Outlook.sqlite + .olk15Message body |
Spark/ |
Synthetic core-data/messages.sqlite with a cache-miss attachment |
Outlook (New) end-to-end tests build a synthetic HxStore.hxd in memory (Nostromo header + one LZ4 block + UTF-16LE IPM.Note runs) rather than shipping a captured store.
MIT — see LICENSE
libpff, accessed via the libpff-spm submodule, is licensed LGPL-2.1 by its upstream author. This repository's MIT license covers EmailParsingKit's own code only.
- libpff-spm — PST support, wraps libyal's libpff
- libewf-spm — sibling package for EWF/EnCase disk images
- swift-forensic-playbook — build guides for forensic Swift Packages