cosmira/outlook-msg
A PHP library for reading Microsoft Outlook .msg files without Outlook installed. Extract subject, sender, recipients, body, headers, dates, and attachments into structured data for email migration, search indexing, and archival workflows.
Install and confirm the basics
composer require cosmira/outlook-msg:^1.3
^1.3) rather than floating. There is now a real release line (1.2.1 → 1.3.0) with a changelog on GitHub, which is mildly reassuring, but it is still a small package: commit composer.lock, mirror the source internally.composer.json for the PHP floor (8.1+) and extensions. MSG is an OLE2 Compound File; parsing is pure PHP — no ext-com, no Outlook installed on the server. That remains the whole reason to pick this over a Windows-only COM bridge.README.md → tests/ → src/. The test fixtures are still the most valuable artifact: real .msg files (plain, HTML, RTF-only, with attachments, with embedded messages) plus, as of 1.3.0, fixtures around RTF-encapsulated HTML. They show the exact class names and signatures the author actually uses.composer show cosmira/outlook-msg should report 1.3.0, and composer validate should be clean. A stale or oddly-dated dist archive is a two-minute supply-chain check worth doing.First use case: pull a message out of a .msg uploaded by a user
The canonical day-one task in a Laravel app is "someone forwarded us a .msg, give me the subject, sender, body and attachments."
use Cosmira\OutlookMsg\...; // confirm exact FQCN from src/ or README
$msg = MsgReader::fromFile(storage_path('app/inbox/forwarded.msg'));
$subject = $msg->subject();
$from = $msg->sender(); // name + address
$htmlBody = $msg->bodyHtml(); // may still be null; see fallback chain below
$textBody = $msg->bodyText();
$sentAt = $msg->sentAt(); // expect UTC, verify
$headers = $msg->transportHeaders();
foreach ($msg->attachments() as $attachment) {
$attachment->filename(); // sanitize before writing
$attachment->contents(); // string; watch memory on large files
}
Second use case: produce a .msg Outlook users can double-click
$writer = MsgWriter::create()
->subject('Invoice 1042')
->from('[email protected]', 'Billing')
->to('[email protected]')
->htmlBody(view('mail.invoice', $data)->render())
->attach('/path/to/invoice.pdf', 'invoice-1042.pdf');
$writer->save(storage_path('app/outbox/invoice-1042.msg'));
// or ->toString() and stream it as a download response
Treat the first ->save() round-trip as a smoke test: open the result in Outlook, not just in Thunderbird or a re-parse by the same library.
Validate before you parse. Sniff the OLE magic bytes rather than trusting the extension, and fail fast in a form request or a custom validation rule:
Rule::file()->max(25 * 1024)->extensions(['msg'])
plus a d0cf11e0a1b11ae1 header check. Malformed MSG files throw; they should land in a 422, not a 500.
Ingestion pipeline: raw file is the source of truth.
.msg in object storage (Storage::disk('s3')->put(...)) with a content hash as the key — dedupe for free.ParseMsgJob) that parses and writes normalized rows: messages (subject, from/to, sent_at, body_html, body_text, internet_message_id), attachments (message_id, filename, mime, size, storage path, content_id).mews/purifier or HTMLPurifier) before it ever reaches a Blade view — you are rendering untrusted rich text, and 1.3.0 gives you more HTML, not less.failed() should mark the message row as unparsed so you can re-run after a library upgrade.Let 1.3.0 do the RTF work — don't hand-roll LZFu. Before this release you either accepted plain text for RTF-only messages or wrote your own PR_RTF_COMPRESSED decompression and \htmltag/\fromhtml de-encapsulation. That work is now upstream: bodyHtml() can return HTML recovered from the RTF body when PR_BODY_HTML is absent. Prefer the library's path, and keep your own fallback chain as the safety net rather than the primary mechanism:
bodyHtml() → recovered-from-RTF HTML → bodyText()
Log which rung you landed on. "Body is empty" bugs are almost always a missing branch, not a parser failure.
Backfill is a decision, not an afterthought. If you ingested messages before upgrading, they were stored with body_html = null and a plain-text body. After 1.3.0 the same input file yields HTML. Either reparse from the stored raw .msg (the reason you kept it) or explicitly accept the mixed state — but know which one you chose.
Extract attachments out, not into memory. For anything user-uploaded, stream or chunk to disk and enforce a hard cap. A .msg can legally carry hundreds of MB, and OLE decompression is a classic zip-bomb vector.
Rewrite inline images. HTML bodies — including RTF-recovered ones — reference cid:... URLs. Index attachments by content_id, copy them to your storage, and rewrite src="cid:x" to your signed/public URL before rendering, otherwise the body looks broken.
Bridge to Laravel Mail for the write path. If you want to hand a user a .msg of something your app already renders as an email, build the MSG from the same Mailable view so there's one template source. For the receive path, convert MSG → MIME/EML (or straight to your DB schema) at the boundary and keep MIME parsers (zbateson/mail-mime-parser) for the rest of the app — don't let the MSG type leak into your domain.
Wrap it behind your own interface. Small package, small maintainer pool; you are the QA department. Define:
interface MsgParser {
public function parse(string $path): ParsedMessage;
}
and bind one implementation. When the package stalls, you swap in a COM/shell-out path on Windows, a different OLE reader, or an external converter — with zero call-site churn.
Fixture-driven tests. Drop real .msg samples in tests/Fixtures/ (HTML body, RTF-only body, nested message attachment, non-ASCII subject) and assert on your ParsedMessage DTO, not on the library's internals. Use Storage::fake() for extraction tests. This suite is your upgrade canary — it is exactly what would have caught the 1.2 → 1.3 body-output change.
Config and container wiring. If the package ships a service provider and publishable config, publish it (php artisan vendor:publish) and diff it on every upgrade — small packages change config keys without semver ceremony. If it doesn't, register a singleton in your own provider so the parser isn't re-instantiated per message in a batch loop.
Three bodies, not one — but the gap just narrowed. PR_BODY (plain), PR_BODY_HTML, and PR_RTF_COMPRESSED are independent. As of 1.3.0 the library recovers HTML encapsulated in the RTF body, so RTF-only messages that used to come back plain-text now come back rich. Keep the fallback chain (native HTML → RTF-recovered HTML → plain) anyway: recovery is heuristic, not guaranteed, and bodyHtml() can still be null.
RTF recovery is not lossless. Outlook's RTF encapsulation carries HTML in \htmltag groups and markers like \fromhtml; extraction can drop CSS, mangle tables, or leak the odd {\*\htmltag...} fragment. Sanitize with a real HTML purifier and eyeball the output on your RTF fixture before trusting it in a mail-client-style preview pane.
Encoding. MAPI stores most string properties as UTF-16LE and some as ANSI codepages. Subject lines and filenames are the usual mojibake suspects. Assert on a fixture with Japanese or emoji in the subject; if you get raw bytes, convert with mb_convert_encoding($s, 'UTF-8', 'UTF-16LE') after trimming the terminator. (This release touched RTF hex byte handling in the spell-check CI config, not the runtime encoder — don't read it as an encoding fix.)
Attachment filenames. Short (8.3) and long filename properties coexist. Prefer long, fall back to short, then sanitize hard — strip path separators, control characters, and leading dots, cap the length. Never use the sender-supplied name as a filesystem path.
Dates. MAPI times are FILETIME (100ns ticks since 1601, UTC). Check whether the package returns a DateTimeImmutable in UTC or a raw int; convert explicitly at your boundary and store UTC. Off-by-timezone on sent_at is a silent data-quality bug.
Nested messages. An attachment can itself be a message (message/rfc822 equivalent). Decide up front whether you flatten (recurse one level, tag as embedded) or store as an opaque blob. Recursing without a depth cap is an easy DoS.
Writing is stricter than reading. Outlook validates the property stream. If generated files won't open, the usual culprits are a missing __properties_version1.0 stream, a missing __nameid_version1.0 storage, or inconsistent property counts in the header. Verify against real Outlook, and diff your output's stream list against a known-good file (7z l file.msg lists the OLE streams — genuinely the fastest debugging tool here).
Debugging toolkit. 7z l / olefile (Python) to dump streams; __substg1.0_0037001F is subject, _0C1A001F sender name, _1000001F body — property IDs in hex tell you instantly whether the data is absent or the parse is wrong. When an RTF-recovered body looks off, dump __substg1.0_10090102 (PR_RTF_COMPRESSED) and compare against what you got back.
Hardening. Assume hostile input: cap file size before parse, cap attachment count and total extracted bytes, disable network fetch of remote images/URLs during rendering, and run parsing in a queued worker (memory limit and timeout) rather than the request cycle.
Upstream now has signal — use it. 1.3.0 lands two PRs from outside contributors (@erth5 on RTF HTML recovery, @tabuna on the CI spell-check), and the repo runs a typos CI job on PRs. That's a modest but real improvement over "one author, no gates". Still: pin the exact version, mirror the source or fork under your org, and subscribe to releases. If you need a fix, expect to submit the PR yourself — budget for that.
Upgrade checklist for this bump. No breaking changes are advertised in 1.3.0, but the body-extraction path changed. (1) Run your fixture suite and diff bodyHtml()/bodyText() output against 1.2.x. (2) Decide whether to backfill already-ingested messages. (3) Re-check your sanitizer on newly non-null HTML. (4) Confirm your attachment and cid: rewriting still works — richer bodies mean more cid: references to resolve.
Licensing. MIT is fine for commercial use, but check whether the bundled test fixtures (real .msg files) carry separate provenance — keep them out of shipped artifacts and out of your own test suite unless you're comfortable with that.
Don't reach for it for EML. If your inputs can be .eml instead of .msg, use zbateson/mail-mime-parser or php-mime-mail-parser — mature, widely depended on, and MIME is an open standard. Reserve this package for the genuinely proprietary .msg case, and convert at the edge so the dependency stays small.
How can I help you explore Laravel packages today?