PHPackages                             melloww/laravel-mailfiles - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Mail &amp; Notifications](/categories/mail)
4. /
5. melloww/laravel-mailfiles

ActiveLibrary[Mail &amp; Notifications](/categories/mail)

melloww/laravel-mailfiles
=========================

Read .eml and .msg email files through one unified, framework-agnostic API. Pure PHP, no external mail-parsing libraries.

v1.0.0(1mo ago)00MITPHPPHP ^8.2CI passing

Since Jul 2Pushed 1mo agoCompare

[ Source](https://github.com/melloww/laravel-mailfiles)[ Packagist](https://packagist.org/packages/melloww/laravel-mailfiles)[ Docs](https://github.com/melloww/laravel-mailfiles)[ GitHub Sponsors](https://github.com/Melloww)[ RSS](/packages/melloww-laravel-mailfiles/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (12)Versions (2)Used By (0)

Laravel MailFiles — one API for `.eml` and `.msg`
=================================================

[](#laravel-mailfiles--one-api-for-eml-and-msg)

[![Latest Version on Packagist](https://camo.githubusercontent.com/37679b4e164af4318147455af02eeccc532ab26e1a79edc8c633b4b959c70b0c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d656c6c6f77772f6c61726176656c2d6d61696c66696c65732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/melloww/laravel-mailfiles)[![Tests](https://camo.githubusercontent.com/3fe1428360a7407b378f1377c75129566e0ffb7fed520af5e142bee25924c645/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6d656c6c6f77772f6c61726176656c2d6d61696c66696c65732f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/melloww/laravel-mailfiles/actions/workflows/run-tests.yml)[![Total Downloads](https://camo.githubusercontent.com/b99835ad210ef6668d360ba23fc0eae84432df4516d458264e90844135644a64/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6d656c6c6f77772f6c61726176656c2d6d61696c66696c65732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/melloww/laravel-mailfiles)

Read **RFC 822 `.eml`** files and **Outlook `.msg`** (MAPI) files through a single, consistent, read-only API. Subject, sender, recipients, dates, text/HTML bodies and attachments all come back the same way no matter which format you started with.

```
use Melloww\MailFiles\MailFile;

$email = MailFile::read('/path/to/message.msg'); // or message.eml — auto-detected

$email->subject();        // "Quarterly report"
$email->from();           // Address { name: "Jane Doe", email: "jane@acme.test" }
$email->to();             // list
$email->date();           // DateTimeImmutable
$email->htmlBody();       // "…"
$email->attachments();    // list
```

Why this package
----------------

[](#why-this-package)

To handle support for both the .eml and .msg format, I often found myself duck taping together several parsers for either format to read the email properly depending on whether it was a .eml from a Mac client or a .msg from an Outlook inbox on Windows. To streamline this process, this package can hopefully fill that need for others as well.

- **One API, two formats.** Both parsers emit the same immutable `Email` value object.
- **No external mail-parsing libraries.** The `.msg` reader is a from-scratch pure-PHP OLE2 / MAPI reader; the `.eml` reader is a from-scratch MIME parser. Neither `php-mime-mail-parser` nor `hfig/mapi` is pulled in.
- **No required PHP extension beyond `mbstring`.** The `mailparse` extension is *optional* — see [EML drivers](#eml-drivers).
- **Framework-agnostic core.** Works fine outside Laravel; the Laravel layer just adds a facade, container binding and an Artisan command.

Installation
------------

[](#installation)

```
composer require melloww/laravel-mailfiles
```

The service provider and `MailFiles` facade are auto-discovered. Optionally publish the config file:

```
php artisan vendor:publish --tag="mailfiles-config"
```

Requires PHP 8.2+ and the `mbstring` extension (`iconv` is used as a fallback for exotic charsets when present).

Reading a message
-----------------

[](#reading-a-message)

```
use Melloww\MailFiles\MailFile;          // static helper (works anywhere)
use Melloww\MailFiles\Facades\MailFiles; // Laravel facade (container-managed)

$email = MailFile::read($path);                 // detect format from the bytes
$email = MailFile::fromString($contents);       // from an in-memory string
$email = MailFile::eml($path);                   // force the EML parser
$email = MailFile::msg($path);                   // force the MSG parser

$email = MailFiles::read($path);                 // same thing, via the facade
$email = MailFiles::readUploadedFile($request->file('mail')); // Laravel upload
```

Format detection is based on file **content** (the OLE2 signature), not the extension, so a mislabelled file still reads correctly.

The unified API
---------------

[](#the-unified-api)

Every accessor below works identically for `.eml` and `.msg`.

### Envelope

[](#envelope)

MethodReturnsNotes`format()``MailFormat` enum`MailFormat::Eml` or `MailFormat::Msg``subject()``?string`RFC 2047 encoded-words decoded to UTF-8`from()``?Address`the author of the message`sender()``?Address`the actual submitter; falls back to `from()``to()``list``cc()``list``bcc()``list``replyTo()``list``recipients()``list`To + Cc + Bcc combined`date()``?DateTimeImmutable`when the message was sent`messageId()``?string`without the surrounding `< >`### Metadata

[](#metadata)

Beyond the envelope, richer metadata is first-class (and works for `.msg` too — MAPI priority/sensitivity/threading properties are surfaced through the same API):

```
$email->inReplyTo();      // parent Message-ID
$email->references();     // list of the thread's Message-IDs
$email->priority();       // Priority::High | Normal | Low
$email->sensitivity();    // Sensitivity::Normal | Personal | Private | Confidential
$email->isAutoSubmitted();// auto-reply / bulk / list (for out-of-office notices)
$email->returnPath();     // ?Address
$email->deliveredTo();    // ?string
$email->listId();         // ?string
$email->listUnsubscribe();// list (mailto:/https: endpoints)
$email->wantsReadReceipt();
$email->readReceiptTo();  // list
$email->authenticationResults();
$email->spfResult();      // "pass" | "fail" | "softfail" | ...
$email->dkimResult();
$email->dmarcResult();
```

### Bodies

[](#bodies)

MethodReturnsNotes`textBody()``?string`the `text/plain` part, UTF-8`htmlBody()``?string`the `text/html` part, UTF-8`body()``?string`text body, or the HTML stripped to text`markdown()``?string`the HTML body converted to Markdown (or the text body)`cleanHtml()``?string`HTML with scripts/styles/tags removed`bodyAs(BodyFormat $f)``?string`the body as `Html`, `Text` or `Markdown``rtfBody()``?string`the decompressed RTF body (`.msg` only)`hasTextBody()` / `hasHtmlBody()` / `hasRtfBody()``bool`For Outlook `.msg` files that carry only a compressed-RTF body, the reader decompresses it (MS-OXRTFCP) and, when the RTF encapsulates HTML, de-encapsulates that HTML into `htmlBody()`; otherwise it falls back to text. `rtfBody()` always exposes the raw decompressed RTF.

```
use Melloww\MailFiles\Enums\BodyFormat;

$email->htmlBody();                    // raw HTML
$email->body();                        // plain text (text part, or HTML stripped)
$email->markdown();                    // "# Heading\n\nHello **world** …"
$email->bodyAs(BodyFormat::Markdown);  // same, choosing the format dynamically
```

The HTML→Markdown conversion is built in (no dependency); it uses the bundled `dom` extension when available and degrades to plain text otherwise.

### Attachments

[](#attachments)

```
foreach ($email->attachments() as $attachment) {
    $attachment->filename();        // "invoice.pdf"
    $attachment->contentType();     // "application/pdf" — as declared (may be null)
    $attachment->mimeType();        // declared type, else detected from the bytes.
    $attachment->extension();       // "pdf"
    $attachment->size();            // 48213 (bytes)
    $attachment->humanSize();       // "47.1 KB"
    $attachment->disposition();     // AttachmentDisposition::Attachment
    $attachment->isInline();        // false
    $attachment->isEmbeddedImage(); // false
    $attachment->contentId();       // null, or the cid for inline images
    $attachment->content();         // the raw decoded bytes
    $attachment->saveTo('/tmp/'.$attachment->filename());
}
```

MethodReturnsNotes`attachments()``list`**genuine** attachments only (no inline images)`inlineAttachments()``list`embedded body images (logos, icons, pixels)`allAttachments()``list`everything`hasAttachments()``bool`true only when there are genuine attachments`inlineAttachmentByContentId($cid)``?Attachment`resolve a `cid:` reference`contentType()` returns the type exactly as the message declared it (fast, and `null` when omitted). `mimeType()` falls back to detecting the type from the payload's **magic bytes** (via `ext-fileinfo`) — never from the file-name extension, which is attacker-controlled and can lie. Use `mimeType()` when you need a dependable type; note it resolves the content to sniff it.

#### Real attachments vs. inline/embedded images

[](#real-attachments-vs-inlineembedded-images)

A common headache with other libraries is that the four tiny social-media icons in a signature, an embedded logo or a tracking pixel all show up as "attachments", drowning out the one PDF the sender actually attached.

`attachments()` returns only the **genuine** ones. Each part is tagged with an `AttachmentDisposition` (`Attachment` or `Inline`), decided from what the message *actually does* rather than a single unreliable header:

1. **Is the part referenced by the body?** If the HTML contains `cid:` (or its Content-Location), it is body content → **Inline**. This is the decisive signal, and it catches the notorious case where Outlook marks a body-referenced image as `Content-Disposition: attachment`.
2. **`Content-Disposition: inline`**, or the MAPI `ATT_MHTML_REF` / hidden flags for `.msg` → **Inline**.
3. **Sits in a `multipart/related` container** as an image → **Inline**.
4. Otherwise → a real **Attachment** (an unreferenced image with a stray Content-ID is still a file the sender attached).

```
$email->attachments();       // the PDF the customer actually sent
$email->inlineAttachments(); // the LinkedIn/Twitter icons + logo + tracking pixel

$attachment->disposition();     // AttachmentDisposition::Attachment | ::Inline
$attachment->isEmbeddedImage(); // true for an inline image
```

### Nested / embedded messages

[](#nested--embedded-messages)

When an attachment is itself an email — a `.eml` attached to a `.eml`, a `.msg`embedded in a `.msg`, or one format inside the other — it is parsed recursively into its own `Email`:

```
foreach ($email->attachments() as $attachment) {
    if ($attachment->isEmbeddedMessage()) {
        $inner = $attachment->embeddedMessage();   // a full Email instance
        $inner->subject();
        $inner->attachments();                      // …with its own attachments
    }
}

$email->embeddedMessages();         // list — just the nested messages
$email->allAttachmentsRecursive();  // every file across every nesting level
```

Recursion (and its depth limit) is configurable — see [Nested message options](#nested-message-options). It is bounded by `max_depth`to stay safe against maliciously deep files.

### Headers

[](#headers)

```
$email->header('Message-ID');     // first value, case-insensitive
$email->headers()->all('Received'); // every value of a repeated header
$email->headers()->toArray();       // name => first value
```

For `.msg` files the headers bag is populated from the original transport headers (`PR_TRANSPORT_MESSAGE_HEADERS`) when present, and otherwise synthesised from the MAPI properties so `Subject`, `From`, `To`, `Date` and `Message-ID` are always there.

### `Address`

[](#address)

```
$from = $email->from();
$from->name;          // "Jane Doe"
$from->email;         // "jane@acme.test"
$from->displayName(); // name, or email if there is no name
(string) $from;       // '"Jane Doe" '
```

### Serialising

[](#serialising)

`$email->toArray()` returns a plain array of everything (minus attachment bytes), handy for logging, JSON responses or persisting metadata.

Email threads (forwards &amp; replies)
--------------------------------------

[](#email-threads-forwards--replies)

When a message forwards or replies to earlier ones, the chain can be reconstructed — including the different contacts at each hop.

```
$email = MailFile::read('fwd.eml');

$email->isForwarded();        // true
$email->isReply();            // false

// The original author + who it was originally addressed to:
$email->originalSender();     // Address { "Alice Original", alice@origin.example }
$email->originalRecipients(); // [Address bob@example.com]

// Who it was forwarded TO is simply the current recipient:
$email->to();                 // [Address carol@example.com]

// The full chain, newest-first, with per-message contacts:
foreach ($email->thread()->messages() as $msg) {
    $msg->type();     // ThreadEntryType::Message | Forwarded | Reply | Attached
    $msg->from();     // Address|null
    $msg->to();       // list
    $msg->cc();
    $msg->subject();
    $msg->date();
}
```

`Thread` helpers: `current()` (the message itself), `original()` (the oldest recovered message), `hasHistory()`, `count()`, `isForwarded()`, `isReply()`.

**How the chain is recovered, in order of reliability:**

1. **Attached originals** — a message forwarded *as an attachment*(`message/rfc822` or an embedded `.msg`) is parsed structurally, so its contacts are exact (`ThreadEntryType::Attached`).
2. **Quoted "forwarded message" header blocks** in the body — the `From:`/`To:`/ `Cc:`/`Subject:` lines are parsed, giving full per-item contacts (`ThreadEntryType::Forwarded`).
3. **Reply attribution lines** ("On … wrote:") — yield the quoted author (`ThreadEntryType::Reply`).
4. **Forwarding headers** — `X-Original-Sender`, `Resent-From`, `X-MS-Exchange-Organization-OriginalSender`, etc., as a fallback.

> Body-based recovery (2 &amp; 3) is **heuristic**: it reads text a human wrote, so it copes with quirks like non-breaking spaces, Outlook HTML-table layouts and ~15 languages of labels/verbs — but it cannot be perfect. Prefer forwarded-as-attachment when accuracy is critical. `ThreadMessage::isReliable()`tells you whether an entry came from a structural source (1) or a heuristic one.

Signed, encrypted &amp; special parts
-------------------------------------

[](#signed-encrypted--special-parts)

```
$email->isSigned();       // multipart/signed (S/MIME or PGP)
$email->isEncrypted();    // multipart/encrypted or S/MIME enveloped
$email->securityProtocol();// "smime" | "pgp" | null
```

For signed messages the *signed content* is what you read through `body()` and `attachments()` — the detached signature part is not surfaced as an attachment.

Calendar invitations (text/calendar) are parsed into events:

```
if ($email->hasCalendar()) {
    $event = $email->calendarEvents()[0];
    $event->method();     // REQUEST | CANCEL | REPLY | ...
    $event->summary();
    $event->organizer();  // ?Address
    $event->attendees();  // list
    $event->start(); $event->end();
    $event->isCancellation();
}
```

Automated messages are flagged too:

```
$email->isDeliveryStatusNotification(); // a bounce/DSN
$email->isReadReceipt();                // an MDN
$email->hasTnef();                      // carries winmail.dat
$email->tnefAttachment();               // the raw TNEF bytes, if any
```

Mailboxes and batch reading
---------------------------

[](#mailboxes-and-batch-reading)

Read a whole Unix **mbox** spool (streaming — one message in memory at a time):

```
foreach (MailFile::mbox('/var/mail/archive.mbox') as $email) {
    echo $email->subject().PHP_EOL;
}
```

Or every `.eml`/`.msg` in a **directory** (keyed by path):

```
foreach (MailFile::directory('/inbox', recursive: true) as $path => $email) {
    // ...
}
```

Memory &amp; performance
------------------------

[](#memory--performance)

- Attachment payloads and nested messages are **resolved lazily**: listing attachments (names, types, content-ids, disposition) does not decode or hold the bytes. Only the attachments you call `content()`, `size()` or `saveTo()`on are materialised — note that `size()` resolves the payload, because a MIME body's *decoded* length is not known from its headers. `.msg` streams are likewise read on demand.
- **Header-only reads** for indexing skip the body, attachments, calendar and nested messages entirely. For EML they read only the header block from disk (not the whole file):

    ```
    $email = MailFile::headers('/inbox/msg-42.eml'); // or ::headers() on a .msg
    $email->isHeadersOnly();  // true
    $email->subject(); $email->from(); $email->date(); $email->inReplyTo();
    $email->body();           // null — reparse with MailFile::read() for the body
    ```
- `mbox` reading streams message-by-message.
- The remaining whole-file load is the source file for a *full* `read()`(`file_get_contents`), because MIME needs to scan boundaries and OLE2 needs random access. For a mailbox, prefer `MailFile::mbox()`; for indexing, prefer `MailFile::headers()`. A fully streaming single-message parser is not provided.

Scope &amp; boundaries
----------------------

[](#scope--boundaries)

In scope: reading `.eml` (RFC 822/MIME) and `.msg` (Outlook MAPI), mbox spools, and directories. Deliberately **out of scope**:

- **Writing/mutating** messages — this package is read-only.
- **PST/OST** mailbox databases — use a dedicated extractor to split them into `.msg`/`.eml` first, then read those here.
- **Decrypting** S/MIME/PGP or **verifying** signatures — the package *detects*them and reads the signed content, but does no cryptography.
- **Decoding the TNEF (winmail.dat) container** — it is detected and its bytes exposed, but not unpacked.

Laravel usage
-------------

[](#laravel-usage)

```
use Melloww\MailFiles\MailFileReader;

class InboxController
{
    public function show(MailFileReader $reader, Request $request)
    {
        $email = $reader->readUploadedFile($request->file('mail'));

        return response()->json([
            'subject' => $email->subject(),
            'from'    => $email->from()?->toArray(),
            'to'      => array_map(fn ($a) => $a->toArray(), $email->to()),
            'body'    => $email->cleanHtml() ?? $email->body(),
        ]);
    }
}
```

### Artisan command

[](#artisan-command)

A small reference command prints a parsed summary of any file:

```
php artisan mailfiles:inspect storage/app/message.msg
```

EML drivers
-----------

[](#eml-drivers)

`.eml` parsing has two interchangeable drivers, configured in `config/mailfiles.php`:

```
'eml' => [
    'driver' => env('MAILFILES_EML_DRIVER', 'native'), // native | mailparse | auto
],
```

- **`native`** (default) — the bundled pure-PHP MIME parser. No extension required.
- **`mailparse`** — delegates *structural* parsing to the PHP [`mailparse`](https://www.php.net/manual/en/book.mailparse.php) extension. Decoding, charset handling and the public API are identical to the native driver.
- **`auto`** — use `mailparse` when the extension is loaded, otherwise `native`.

`.msg` files are always read by the bundled native MAPI reader.

Nested message options
----------------------

[](#nested-message-options)

Recursive extraction of messages-within-messages is on by default and tunable in `config/mailfiles.php`:

```
'attachments' => [
    'extract_nested' => env('MAILFILES_EXTRACT_NESTED', true), // parse embedded .eml/.msg
    'max_depth'      => env('MAILFILES_MAX_DEPTH', 5),          // how deep to recurse
],
```

Outside Laravel, pass a `ParseOptions` to the reader:

```
use Melloww\MailFiles\MailFileReader;
use Melloww\MailFiles\ParseOptions;

$reader = new MailFileReader('native', new ParseOptions(
    extractNestedMessages: true,
    maxDepth: 3,
));
```

How it works
------------

[](#how-it-works)

- **`.eml`** — a recursive RFC 822 / MIME parser: header unfolding, RFC 2047 encoded-words, RFC 2231 parameter continuations, nested `multipart/*` trees, quoted-printable / base64 / uuencode transfer decoding and charset conversion to UTF-8.
- **`.msg`** — a read-only OLE2 / Compound File reader (MS-CFB: FAT, mini-FAT and the directory tree) feeding a MAPI property decoder (MS-OXMSG / MS-OXPROPS) that understands the `__substg1.0_*` streams, the `__properties_version1.0` table, and the `__recip_*` / `__attach_*` storages.

Replacing `php-mime-mail-parser` + `hfig/mapi`
----------------------------------------------

[](#replacing-php-mime-mail-parser--hfigmapi)

PreviouslyNow`(new Parser)->setText($eml)->getSubject()``MailFile::read($path)->subject()``$parser->getHeader('from')``$email->from()` / `$email->header('from')``$parser->getTo()``$email->to()``$parser->getMessageBody('text')``$email->textBody()``$parser->getMessageBody('html')``$email->htmlBody()``$parser->getAttachments()``$email->attachments()``$messageFactory->parseMessage($doc)->getProperties()['subject']``$email->subject()``$msg->getAttachments()[0]->getContent()``$email->attachments()[0]->content()`Testing
-------

[](#testing)

```
composer test       # Pest
composer analyse    # PHPStan
composer format     # Pint
```

The test suite runs against real `.eml` and `.msg` fixtures in `tests/Fixtures`.

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance90

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity46

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

47d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/31374439?v=4)[melloww](/maintainers/melloww)[@melloww](https://github.com/melloww)

---

Top Contributors

[![melloww](https://avatars.githubusercontent.com/u/31374439?v=4)](https://github.com/melloww "melloww (2 commits)")

---

Tags

laravelmimeemail parserRFC822outlookemlmapimsgmellowwlaravel-mailfiles

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/melloww-laravel-mailfiles/health.svg)

```
[![Health](https://phpackages.com/badges/melloww-laravel-mailfiles/health.svg)](https://phpackages.com/packages/melloww-laravel-mailfiles)
```

###  Alternatives

[spatie/laravel-permission

Permission handling for Laravel 12 and up

13.0k107.5M1.6k](/packages/spatie-laravel-permission)[dedoc/scramble

Automatic generation of API documentation for Laravel applications.

2.2k12.6M141](/packages/dedoc-scramble)[vormkracht10/laravel-mails

Laravel Mails can collect everything you might want to track about the mails that has been sent by your Laravel app.

25060.1k](/packages/vormkracht10-laravel-mails)[rawilk/profile-filament-plugin

Profile &amp; MFA starter kit for filament.

3915.5k](/packages/rawilk-profile-filament-plugin)[backstage/laravel-mails

Laravel Mails can collect everything you might want to track about the mails that has been sent by your Laravel app.

25094.0k18](/packages/backstage-laravel-mails)[harris21/laravel-fuse

Circuit breaker for Laravel queue jobs. Protect your workers from cascading failures.

46273.9k](/packages/harris21-laravel-fuse)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
