Skip to content

Repository files navigation

EmailParsingKit

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.


What This Is

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.


Requirements

  • macOS 13.0+
  • Xcode 15+

Installation

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.git

If you already cloned without it:

git submodule update --init --recursive

Then 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.


Usage

Recommended: EmailParser

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)

Format-specific parsers

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)

Attachments

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.


Package Layout

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".

Model

EmailData

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.

EmailAttachment

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.

Per-Format Notes

EMLX / EML / MBOX

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. rawBytes nil.
  • EML: standalone RFC822. Account stays "Orphan". Mailbox is the enclosing .mbox or parent folder.
  • MBOX: From split, Gmail account heuristics, sibling Orphan backfill. rawBytes is the full message.

PST (Outlook)

Account resolution: message store, then first message, then "Orphan". rawBytes always nil. Cc falls back to MAPI recipient type CC.

Outlook Classic (olk15)

…/Data/Outlook.sqlite. Account UIDs mask to the low 32 bits. Bodies are scanned inside .olk15Message files, not sought at a fixed header offset.

Outlook (New) Hx

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 [].

Spark

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.


Testing

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.


License

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.


Related

About

Swift Package for parsing forensic email evidence — PST (via libpff), EMLX, MBOX, EML, Outlook Classic, Outlook (New)/Hx, and Spark.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages