messaged/php-mime-mail-parser
Parse MIME emails in PHP with ease. Extract headers, plain and HTML bodies, inline content, and attachments from raw messages through a simple API. Handles nested multipart messages, common transfer encodings, and file saving. Useful for inbox processing, archiving, and email import tools.
1. Install the extension first — the package is useless without it.
# Debian/Ubuntu
sudo apt-get install php-mailparse # or: pecl install mailparse
php -m | grep mailparse # must print "mailparse"
Then:
composer require messaged/php-mime-mail-parser
If you are on Laravel Sail/Forge/Docker, add the extension to your image now — this is the single most common reason the package "doesn't work" in CI:
RUN pecl install mailparse && docker-php-ext-enable mailparse
2. Where to look first. Read src/Parser.php and src/Attachment.php end-to-end (they are small). Then open the tests/ fixtures — the .eml files there are the fastest way to see how multipart/alternative, inline images, and RFC 2231 filenames are represented. The public surface is basically five methods: setPath/setText/setStream, getHeader/getHeaders/getAddresses, getMessageBody, getAttachments, and Attachment::save.
3. First use case — dump one real email.
use PhpMimeMailParser\Parser;
$parser = new Parser();
$parser->setPath(storage_path('app/mail/sample.eml'));
dump($parser->getHeader('subject'));
dump($parser->getAddresses('from')); // [['display' => 'Jane', 'address' => '[email protected]']]
dump($parser->getMessageBody('text')); // plain part
dump($parser->getMessageBody('html')); // html part
foreach ($parser->getAttachments() as $a) {
dump($a->getFilename(), $a->getContentType(), strlen($a->getContent()));
}
Start by running this against three real messages from your own mailbox (one plain, one HTML+inline image, one with a PDF). That covers ~90% of the parsing bugs you will hit in production.
Wrap it in a service, never call new Parser() in controllers. The parser is stateful (you set the source, then read), so keep it request/job-scoped and hand back an immutable DTO.
final class ParsedEmail
{
public function __construct(
public readonly array $headers,
public readonly array $from,
public readonly array $to,
public readonly string $subject,
public readonly ?string $text,
public readonly ?string $html,
public readonly array $attachments, // Attachment DTOs, not raw
) {}
}
final class EmailParser
{
public function parseFile(string $path): ParsedEmail
{
$parser = new Parser();
$parser->setPath($path);
return new ParsedEmail(
headers: $parser->getHeaders(),
from: $parser->getAddresses('from'),
to: $parser->getAddresses('to'),
subject: $this->decodeHeader($parser->getHeader('subject')),
text: $this->body($parser, 'text'),
html: $this->body($parser, 'html'),
attachments: iterator_to_array($parser->getAttachments()),
);
}
private function body(Parser $p, string $type): ?string
{
try { return $p->getMessageBody($type); }
catch (\Exception) { return null; } // part absent is normal, not an error
}
}
Ingest pipeline for a real mailbox. The package parses raw bytes only — it does not speak IMAP. Pair it with webklex/php-imap (or a mail:fetch cron) and do the work in a job:
// Fetch raw, store raw, parse later. Never parse inline in the IMAP loop.
$raw = $client->getMessage($uid)->getRawBody();
$path = "mail/raw/{$uid}.eml";
Storage::put($path, $raw);
ParseIncomingEmail::dispatch($path)->onQueue('mail');
Store the raw .eml on disk/object storage and keep only metadata in the DB. Re-parsing a stored file is free; re-fetching from IMAP often is not.
Attachments go straight to a disk, not into the DB.
foreach ($parser->getAttachments() as $attachment) {
$name = $attachment->getFilename() ?: 'attachment-'.Str::uuid();
$safe = Str::slug(pathinfo($name, PATHINFO_FILENAME)).'.'.pathinfo($name, PATHINFO_EXTENSION);
Storage::disk('mail-attachments')->put(
"{$messageId}/{$safe}",
$attachment->getContent()
);
}
Attachment::save($dir) exists and is convenient for one-offs, but it writes to a local path — bypass it when you're on S3 or need to enforce size caps.
Prefer htmlEmbedded for rendering, html + getAttachments() for archiving. getMessageBody('htmlEmbedded') rewrites cid: references to data: URIs so the message renders standalone in a browser. That is exactly what you want for a "view original email" screen. For storage, keep the parts separate.
Always compute a text fallback.
$text = $parser->getMessageBody('text') ?: trim(strip_tags($html ?? ''));
Multipart/alternative messages sometimes ship an empty or whitespace-only text part; indexers and previews then look broken.
Multiple parts of the same type. When a message has two text/plain parts (common in forwarded mail), getMessageBody('text') returns the first. Newer API versions accept an index — getMessageBody('text', 1) — but verify it against the version you pinned; on the old PHP 5.3-era codebase it may not exist.
Testing. Keep .eml fixtures in tests/Fixtures/mail/ and parse them with setPath(); never mock the parser. Assert on getHeader('subject'), attachment count, and a hash of one attachment body. A single fixture with an Outlook-style winmail.dat and a message/rfc822 forward will catch most regressions.
The extension is a hard dependency and fails loudly at the wrong time. Class 'mailparse' not found at runtime, not at composer install. Add ext-mailparse to composer.json's require so the failure surfaces during install, and add a php -m check to your deploy health check.
This specific package is old and effectively unmaintained. messaged/php-mime-mail-parser (PHP 5.3+, ~62 stars, zero dependents) is an ancestor of the actively maintained php-mime-mail-parser/php-mime-mail-parser (eXorus). Before committing, diff the public API — it is largely the same Parser/Attachment surface, so a swap is usually a namespace change plus re-testing fixtures. If you ship this, plan to fork it: you will be the one fixing PHP 8.x deprecations (${var} interpolation, each(), non-nullable implicit params).
getHeader() may return the raw MIME-encoded word. A subject line can come back as =?UTF-8?B?...?=. Do not assume decoding:
$subject = iconv_mime_decode($parser->getHeader('subject') ?? '', 0, 'UTF-8');
getAddresses() does decode display names, which is why the two can look inconsistent.
Line endings matter. setText() on a payload with bare \n can produce truncated or empty bodies. If you are parsing from a queue or a copy-pasted string, normalize first: $raw = preg_replace("~\r\n|\r|\n~", "\r\n", $raw);
setStream() needs a seekable stream. php://input and non-rewindable socket streams will break; read into a string and use setText(), or copy to php://temp first.
setText() holds the entire message (including base64 attachments) in memory. A 25 MB email becomes ~35 MB of PHP strings plus the decoded parts. Cap message size at ingest (upload_max_filesize, IMAP fetch size) and consider a hard reject above a threshold rather than letting PHP hit memory_limit mid-job.
Charset conversion is iconv-based and silently lossy on bad bytes. If you see iconv(): Detected an illegal character notices, that is production email, not a bug in your code. Wrap decoding and fall back to mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1') on failure.
getMessageBody('html') throws when the part is absent. Catch it — the absence of an HTML or text part is a normal condition, not an exception-worthy one. Wrap it in a body() helper (as above) once and never think about it again.
Treat getFilename() as nullable and hostile. It returns the raw filename from the header: wrong charset, ../../etc/passwd, 200-character names, control characters. Slug it, cap the length, and never concatenate it into a path unvalidated.
Sanitize the HTML before rendering it anywhere. Inbound email HTML is attacker-controlled: <script>, onerror=, remote <img> tracking pixels, javascript: hrefs. Run it through a sanitizer (e.g. mews/purifier) and consider proxying images. htmlEmbedded makes this harder because it inlines data: URIs — sanitize before you embed, or sanitize the embedded output with data: explicitly allowed.
Nested message/rfc822 attachments appear as attachments with the full inner message as content. Decide explicitly whether you store them as .eml blobs or recurse into them — silently treating them as generic files is how attachments "disappear" from user-facing lists.
Duplicate Content-IDs and inline images with no Content-ID are common. Key inline images by getContentId(), but keep a positional fallback for the ones missing it.
Extension points are thin. There is no plugin/event system — the practical hooks are a decorator around Parser (recommended), or subclassing it if you need to override header decoding. Do not patch vendor/; fork to app/Support/Mime/ and own it.
How can I help you explore Laravel packages today?