Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mail Mime Parser Laravel Package

zbateson/mail-mime-parser

A testable, PSR-compliant PHP mail MIME parser for RFC 822/2822/5322 messages. An alternative to imap* functions and Pear libraries, it parses strings, resources, or PSR-7 streams into messages with forgiving, standards-based handling. Requires PHP 8.1+.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require zbateson/mail-mime-parser

Requires PHP 8.1+ (v4). There's no Laravel wrapper package — you install it and wire it yourself. Two entry points:

  • new ZBateson\MailMimeParser\MailMimeParser() — the injectable service (use this).
  • ZBateson\MailMimeParser\Message::from($raw) — static one-liner for tinker, artisan commands, and one-off scripts.

The first thing to look at is the Usage Guide (not just the README), then the IMessage / IMessagePart interfaces in vendor/zbateson/mail-mime-parser/src/Message/. Everything you'll do day-to-day hangs off those two interfaces.

First use case: inbound mail webhooks. Postmark, Mailgun, SendGrid, and SES all hand you raw RFC 822 MIME (or let you fetch it). Parse it, pull subject/from/text/html, and persist attachments:

use ZBateson\MailMimeParser\Message;
use ZBateson\MailMimeParser\Header\HeaderConsts;

$message = Message::from($request->input('RawEmail'));

$subject = $message->getSubject();
$from    = $message->getHeader(HeaderConsts::FROM)->getEmail();
$text    = $message->getTextContent();
$html    = $message->getHtmlContent();

foreach (range(0, $message->getAttachmentCount() - 1) as $i) {
    $att = $message->getAttachmentPart($i);
    Storage::disk('local')->writeStream(
        "mail/{$uuid}/{$att->getFilename()}",
        $att->getContentStream()
    );
}

Bind it as a singleton so limits/logger config apply everywhere:

// app/Providers/AppServiceProvider.php
$this->app->singleton(MailMimeParser::class, fn ($app) =>
    new MailMimeParser($app->make(LoggerInterface::class))
);

Why this package in a Laravel app: ext-imap is gone from PHP core as of 8.4 (PECL-only), and it was never a good parser anyway. This is pure PHP, PSR-7 friendly, and forgiving of real-world malformed mail.

Implementation Patterns

Store raw MIME before parsing. Webhook payloads are often retrievable only once. Save the raw bytes (DB longText, or Storage::put("mail/raw/{$id}.eml")) then queue a job that parses. You get replayability when your parser logic changes, plus you keep the source of truth for audit.

// Controller
$raw = $request->input('RawEmail');
$path = Storage::disk('s3')->put("inbound/{$uuid}.eml", $raw);
ParseInboundMail::dispatch($path);

// Job
$stream = Storage::disk('s3')->readStream($path);
$message = app(MailMimeParser::class)->parse($stream, true); // true = close handle on destruct

Don't getContent() large attachments. getContent() materializes the whole decoded body in memory. For anything that touches storage, use getContentStream() + Laravel's writeStream() (which accepts a resource or PSR-7 stream). Or use saveContent($path) / saveContent($stream) directly.

Text vs. HTML. getTextContent() / getHtmlContent() handle multipart/alternative for you. When you need the part objects themselves (e.g. to preserve exact HTML, or grab the part's Content-Type charset), use getTextPart() / getHtmlPart() — both return null when that alternative doesn't exist, so guard.

Inline images and cid: rewriting. Inline images show up as inline parts, not attachments — use getInlinePartCount() / getInlinePart($i) and read getContentId(). A common pattern for rendering HTML email in a Blade view:

$images = [];
foreach (range(0, $message->getInlinePartCount() - 1) as $i) {
    $part = $message->getInlinePart($i);
    $cid  = trim($part->getContentId(), '<>');
    $url  = Storage::disk('public')->putFileAs("mail/{$uuid}", $part->getContentStream(), ...);
    $images[$cid] = Storage::disk('public')->url($url);
}
$html = strtr($message->getHtmlContent(), collect($images)->mapWithKeys(
    fn ($url, $cid) => ["cid:{$cid}" => $url]
)->all());

Header access pattern. Use HeaderConsts::* constants — they avoid typos on X-*-style strings and document intent. Three levels of detail:

$message->getHeaderValue(HeaderConsts::FROM);         // "[email protected]" (email only)
$message->getHeader(HeaderConsts::FROM)->getPersonName();
$message->getHeader(HeaderConsts::TO)->getAddresses(); // AddressPart[] — handles groups, multiple recipients

getHeaderValue() on a GenericHeader gives you the combined non-comment value; addresses and date headers are special-cased into typed objects.

Composing/rewriting mail. The package builds messages too, which is handy for forwarding with modified headers or generating test fixtures. Boundary generation uses random_bytes() since 4.0.2, and 4.0.6 strips CR/LF from MIME type, charset, encoding, micalg, protocol and filename args — but treat all user-supplied values as untrusted regardless.

S/MIME / PGP. If you need encrypted mail, install zbateson/mmp-crypt-smime (OpenSSL) and decryption happens transparently during parse(). It's an optional package, so it's a no-op unless installed — worth guarding with class_exists() if you support both deployment modes.

Testing. This is where the package shines versus imap_*. Drop .eml fixtures in tests/Fixtures/mail/ and parse them directly in Pest/PHPUnit:

it('extracts the invoice attachment', function () {
    $message = Message::from(file_get_contents(base_path('tests/Fixtures/mail/invoice.eml')));
    expect($message->getAttachmentCount())->toBe(1)
        ->and($message->getSubject())->toBe('Your invoice');
});

No mail server, no IMAP mocking. Keep a handful of ugly real-world fixtures (Outlook HTML, base64-encoded subjects, broken boundaries, nested multipart) — they catch more regressions than any unit test of your own code.

Gotchas and Tips

Limits are your DoS protection — override them, and check the error bag. Parsing untrusted MIME is a resource-exhaustion vector (several CVEs in 3.x/4.0.x were exactly this). v4.0.6 added per-message header caps and lowered maxMessagePartCount to 1000. Defaults: maxMimePartDepth 256, maxHeaderCount 1000, maxHeaderSizeBytes 1 MiB, maxMessageHeaderCount 50000, maxMessageHeaderSizeBytes 8 MiB, maxMessageHeaderTokenCount 250000. For a Laravel webhook handler, tighten these via the DI config passed to the constructor, and enforce a size cap before parsing:

abort_if($request->header('Content-Length') > 25 * 1024 * 1024, 413);

Parsing never throws on malformed mail — it records errors. That's the "forgiving" design goal, and it's also why silently mangled messages slip through. After parsing, check:

if ($errors = $message->getAllErrors()) {
    Log::warning('mime.parse_errors', ['errors' => array_map('strval', $errors)]);
}

When a limit is hit, parsing continues but content is truncated — so a message with 1500 parts will quietly lose parts. If attachment counts or HTML content look wrong in production, this is the first thing to check.

new MailMimeParser() for every message bypasses your config. If you override DI settings in a service provider binding but then call Message::from() in a job, you get a fresh default-configured parser. Pick one path — injected instance everywhere — if limits matter.

Mind the second parse() argument. parse($handle, true) attaches and closes the handle when the IMessage is destroyed; false leaves it to you. The README example mixes both (false + manual fclose), which is fine, but don't double-close. Also, IMessage lazily reads from the stream for some operations — closing the handle too early gives you empty content. Let the object go out of scope, or keep the raw string in the message.

Attachment filenames are attacker-controlled. 4.0.2+ strips CR/LF from filenames and decoded header parameters, but that doesn't stop ../../.env. Always basename() and sanitise before building a storage path:

$name = Str::slug(pathinfo($att->getFilename() ?: 'attachment', PATHINFO_FILENAME))
      . '.' . pathinfo($att->getFilename() ?: '', PATHINFO_EXTENSION);

Don't trust Content-Type for behaviour. Decide on getAttachmentPartCount() / getInlinePartCount() and the disposition, not on whether the MIME type looks binary. Since 4.0.6, a multipart/* part with an attachment disposition is itself treated as an attachment and its children are not returned as message content — an important behavioural change if you upgrade from 4.0.5 and your text extraction suddenly goes empty for some senders.

Charset handling is good but not magic. Decoded text is converted to UTF-8 when the charset is declared; broken senders declare the wrong charset or none. If you see mojibake, inspect getHeaderParameter(HeaderConsts::CONTENT_TYPE, 'charset') on the text part before assuming a bug in your code.

Memory in queued jobs. Parsing a 20 MB message with a 20 MB attachment can easily exceed a default memory_limit of 128M once you have streams, tokens and copies in play. Set memory_limit per-job (ini_set in the job's handle) or reject large payloads at the controller. Prefer stream-based attachment handling over getContent().

Debugging tip: dd($message->getAllParts()) (or $message->getPartCount()) is the fastest way to see what the parser actually understood about a broken message. For a header, getAllParts() on IHeader returns comment parts too — useful when a sender puts the real address in a (comment).

Upgrade cadence matters for security. 4.0.2, 4.0.3 and 4.0.6 all shipped parser hardening and CVE fixes. Pin ^4.0.6 and treat minor bumps as security-relevant; the changelogs are unusually readable, so skim them.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
muka/shape-reader
tdt/json
refinery29/php-cs-fixer-config
hotmeteor/eco-env
mvaliolahi/hydrate
wearede/tbcpay-php
parsnick/steak
thibaud-dauce/mattermost-php
multilinguals/apollo-client
antaresbr/collection
kr0lik/phpstan-rules
loco/loco
starfederation/datastar-php
yupmin/modern-phystrix
stephenjude/extended-artisan-commands
lumphp/laravel-macroable
lumphp/laravel-collections
lumphp/laravel-contracts
sermepa/sermepa
arkecosystem/fortify