laravel-mailroom maintained by ebbbang
Laravel Test Mail
A mail driver that stores outgoing mail in your database, plus a mailbox at /mailroom to read it.
MAIL_MAILER=log flattens a message into a log line and throws away the attachments. MAIL_MAILER=array forgets everything at the end of the request. This keeps the whole thing — the full MIME, attachments, embedded images, tags, metadata and custom headers — and gives you somewhere to look at it.
composer require --dev ebbbang/laravel-mailroom
php artisan mailroom:install
php artisan migrate
Then set MAIL_MAILER=mailroom and send something.
Pin with
^0.1while this is 0.x. Composer treats^0.1as0.1.*only, so moving to a 0.2 release will need a deliberate bump. Breaking changes may land in minor versions until 1.0.
How this differs from the neighbours
A few packages already log mail to the database — shvetsgroup/laravel-email-database-log and stackkit/laravel-database-emails among them — and spatie/laravel-database-mail-templates stores templates rather than sent mail.
This one is a development tool rather than a logging or queueing layer: it exists to be read. That means the mailbox UI with attachment previews and .eml export, and a package that refuses to run in production unless you explicitly opt in.
Requirements
| PHP | 8.2+ |
| Laravel | 12, 13 (11 best-effort) |
The test matrix covers PHP 8.2–8.5 against Laravel 11, 12 and 13.
On Laravel 11: the code works, but the branch reached end of life on 12 March 2026 and every stable 11.x release is now flagged by unpatched security advisories. Composer blocks those by default, so an install there resolves to the 11.x-dev branch rather than a tagged release. It is tested and supported on a best-effort basis; Laravel 12 or 13 is the real recommendation.
What it captures
Everything Laravel's mail layer can produce, because it hooks in as a Symfony transport rather than an event listener — it sees the finished message:
- HTML and text bodies, from views, markdown,
htmlStringorMail::raw() - Attachments via
attach(),attachData(),fromStorage()and the rest, byte for byte - Inline images from
$message->embed(), kept separate from real attachments Cc,Bcc,Reply-To, and the envelope recipients actually delivered to- Tags and metadata (
TagHeader/MetadataHeader) - Custom headers set through
withSymfonyMessage() - Queued mailables, multiple mailers,
Mail::alwaysTo()
The mailbox
/mailroom gives you a two-pane reader: search, filter by mailer, and per-message HTML / text / attachments / headers / raw tabs. Light, dark and system themes. No build step, no npm, no published assets — the styles are inlined, so it works offline and behind a strict CSP.
Every message can be exported as .eml (opens in Mail.app, Thunderbird, Outlook) or as a standalone .html file with embedded images rewritten to data: URIs so it renders anywhere.
Attachment previews
Click an attachment to open it in a lightbox — arrow keys to move between them, Esc to close, and the open preview stays in the URL (#preview-2) so it survives a refresh and can be pasted to someone else.
| Previewed inline | Download only |
|---|---|
| PNG, JPEG, GIF, WebP, AVIF, BMP, ICO, SVG | Office: .docx, .xlsx, .pptx |
Archives: .zip, .7z |
|
| MP3, WAV, OGG, M4A, AAC, FLAC | TIFF, HEIC |
| MP4, WebM, OGV, MOV | anything unrecognised |
| Text, Markdown, CSV/TSV (as a table), JSON (pretty-printed) | |
| HTML, XML, YAML, JS, CSS, SQL (as source) | |
.ics invites (parsed), .eml messages (headers + body) |
Office documents genuinely cannot be previewed here. Gmail and Outlook render them with server-side viewers — Google Docs Viewer, Office Online — and those services have to fetch the file over the public internet. A dev mailbox on 127.0.0.1 behind an auth gate is unreachable to them, and the self-hosted alternative is a multi-megabyte JS bundle this package has no build step for. Rather than half-render them, the mailbox says "no preview for .docx" and offers the download.
TIFF and HEIC are left out for a smaller reason: Safari draws them and Chrome does not, so a preview would work on one machine and look broken on another.
Media previews support HTTP Range, so seeking in audio and video works.
How previewing stays safe
Serving an attachment with its own MIME type is how stored XSS happens, so the preview route is separate from the download route (which still forces application/octet-stream) and is hardened on its own terms:
- The content type comes from an allowlist, never from the attachment.
mime_typeis filled in by whoever composed the mail, so it is treated as a lookup key, not a value. It can only ever select a known-safe entry or be refused. - Anything text-shaped is never served at all. It is read and escaped into the page server-side, so an emailed
.htmlor.jsis shown as source and has no content type to be mis-interpreted through. - SVG is only ever rendered through
<img>, where browsers permit neither scripts nor external fetches, and the response additionally carriesContent-Security-Policy: sandbox— noallow-scripts, noallow-same-origin— which covers someone opening the URL directly. X-Content-Type-Options: nosniffandCross-Origin-Resource-Policy: same-originon every preview response.
PDF is a deliberate exception to the sandbox. Chrome renders PDFs by loading its viewer extension in a cross-process iframe, and a sandbox directive on the response blocks that extension's own scripts, so the PDF does not render at all (crbug.com/413851). Little is given up: a page-level CSP never governed PDF JavaScript in the first place — that runs inside PDFium's sandbox, where it cannot reach the page's DOM, cookies or storage. The allowlisted type, nosniff and CORP still apply. PDFs use <object> rather than <iframe> because Safari has never rendered a PDF in an iframe.
Set preview.enabled to false to switch all of this off and go back to plain downloads.
Access control
The package ships no login page. Access is decided in three escalating steps:
-
Nothing configured — the mailbox is reachable in the
localenvironment only. -
A gate — define
viewMailroomand it takes over completely:Gate::define('viewMailroom', fn (?User $user) => $user?->isAdmin());Type the parameter as nullable and guests are evaluated too, which is what lets this work without auth middleware.
-
A callback — for anything the gate can't express:
Mailroom::auth(fn (Request $request) => $request->ip() === '10.0.0.1');
To put the mailbox behind your app's existing login, add auth to the middleware stack and your own login flow handles the redirect:
'middleware' => ['web', 'auth', Authorize::class],
Rendering untrusted mail safely
Email HTML is attacker-controlled as far as your app is concerned. The preview is served from its own route into an <iframe sandbox> with neither allow-scripts nor allow-same-origin, putting it in an opaque origin that cannot run scripts, reach the parent page, or touch cookies, under a default-src 'none' CSP.
Attachments are always sent as application/octet-stream with Content-Disposition: attachment — never their own MIME type, or an emailed .svg or .html would execute on your origin.
Production
Disabled in production by default. The database transport refuses to be constructed and the routes are never registered, so /mailroom 404s rather than 403s.
If you select the mailer anyway, it throws rather than silently accepting the message — quietly swallowing production mail while looking like successful delivery is the worst possible failure here.
To use it in production regardless, opt in explicitly:
MAILROOM_ENABLED=true
Pruning
php artisan mailroom:prune # uses mailroom.prune.retention_days (default 7)
php artisan mailroom:prune --days=30
php artisan mailroom:prune --hours=6
php artisan mailroom:prune --pretend
php artisan mailroom:clear # everything, including stored files
Deletes go through model events so the raw .eml and attachment blobs are removed with the row — a mass delete would orphan every file on disk. The model is Prunable, so php artisan model:prune --model="Ebbbang\Mailroom\Models\MailroomMessage" works too.
Schedule it yourself, or set mailroom.prune.schedule to a cron expression or frequency name (daily, hourly) and the package registers it for you.
Configuration
php artisan vendor:publish --tag=mailroom-config
| Key | Default | |
|---|---|---|
enabled |
not production | Master switch, MAILROOM_ENABLED |
path |
mailroom |
Mailbox URL |
domain |
null |
Serve the mailbox on its own domain |
middleware |
['web', Authorize::class] |
Add auth to require login |
storage.disk |
local |
Where .eml and attachments go |
storage.max_attachment_size |
null |
Skip storing bytes above this size |
database.connection |
default | Keep captured mail off your main database |
forward |
null |
Also deliver via another mailer after capturing |
prune.retention_days |
7 |
|
ui.per_page / ui.poll_interval |
25 / 5 |
|
preview.enabled |
true |
Attachment previews, MAILROOM_PREVIEW |
preview.max_inline_bytes |
512 KB |
Cap on text-shaped previews |
preview.max_csv_rows |
200 |
Rows shown before truncating a table |
Message metadata lives in the database so the list stays fast; raw MIME and attachment bytes go to a disk, so the table stays small even with large attachments.
Laravel Cloud and other ephemeral platforms
The package works on Laravel Cloud, but you must set MAILROOM_DISK — the default local disk is the wrong choice there. Laravel Cloud's docs are explicit that environment filesystems are
ephemeral […] each replica of your compute cluster has its own filesystem. Thus, you should treat the filesystem as temporary, unshared disk space that is only consistent during a single request or job.
Message metadata and bodies live in the database, so those survive fine. The raw .eml and attachment bytes do not: they vanish on redeploy, and a message captured on one replica is unreadable from another. The mailbox stays usable, but .eml export, attachment downloads and embedded images break — intermittently, which is worse than breaking outright.
Point the disk at persistent object storage and set the master switch, since Cloud environments carry APP_ENV=production even when they are really staging:
MAIL_MAILER=mailroom
MAILROOM_DISK=<your object storage disk>
MAILROOM_ENABLED=true
If blobs do go missing, the mailbox says so explicitly rather than quietly hiding the download button — and it distinguishes a file that vanished from one deliberately skipped by storage.max_attachment_size.
The same applies to any ephemeral or multi-replica setup: containers without a shared volume, autoscaling groups, /tmp-backed disks.
Laravel Octane
Supported, and tested against a sandbox modelled on Octane's own
CurrentApplication::set().
The transport is built from whichever container resolved the mail manager, not
from the one the service provider booted with. Under Octane those differ:
providers boot against the base application while each request runs in a
sandbox clone, and mail.manager is not one of Octane's warmed services, so it
is rebuilt per request. Following it into the sandbox is what keeps
MessageStored firing on the same dispatcher as Laravel's own MessageSent.
Nothing in the package holds per-request state between requests. The one piece
of state that deliberately outlives a request is the Mailroom::auth()
callback, which is set once from a service provider — the same lifetime a
provider has under Octane. Two things follow:
- Do not capture a request, a user, or
$thisinside that closure. It receives the current request as its argument; use that. Mailroom::flushState()clears it, if a worker ever needs resetting.
Attachment bytes are read into memory to be written to the disk, which is a
transient spike but a spike nonetheless on long-lived workers. If your
application mails large files, set storage.max_attachment_size so oversized
parts are recorded without their payloads.
Capture and still deliver
Set forward to another configured mailer and messages are stored and sent on — useful on staging:
MAIL_MAILER=mailroom
MAILROOM_FORWARD=smtp
Events
use Ebbbang\Mailroom\Events\MessageStored;
Event::listen(function (MessageStored $event) {
$event->message; // the stored MailroomMessage
$event->sentMessage; // Symfony's SentMessage
});
Laravel's own MessageSent carries no reference to the stored row, so this is how you get hold of it.
Two things worth knowing
BCC is not in the .eml. Symfony strips the Bcc header when rendering a message, because it must never travel with it. The recipients are recorded in the database and shown in the mailbox, but the exported file will not contain them. That is correct MIME behaviour, not a bug.
Mail::fake() bypasses this entirely. It replaces the mail manager, so no transport runs. Use Mail::assertSent() for those tests; use this package when you want to look at what was sent.
Development
composer test # parallel, via paratest
composer test:serial
composer lint # rector, then pint
composer lint:check
composer serve # migrate, seed and serve the workbench demo
composer seed # reseed the demo mailbox from scratch
The demo mailbox
composer serve creates the database, migrates it, seeds it and starts the
server, so a clean checkout gets you a populated mailbox at
http://127.0.0.1:8000/mailroom in one step. Re-running it just starts the
server, because each step is a no-op once done.
The seeder aims at one message per branch of the UI, so everything can be inspected without composing anything by hand. Scenario subjects are prefixed with what they demonstrate:
[all kinds] every previewable attachment type in one message
[html only] no text part, so no Text tab
[text only] no HTML part
[no body] opens on Headers instead
[markdown] a markdown mailable
[addressing] Cc, Bcc, Reply-To, tags, metadata, custom header
[long] a subject and recipient list that need truncating
[unicode] CJK, RTL, emoji, zero-width space
[long body] sixty paragraphs, for scrolling
[inline only] embedded images and nothing attached
[preview states] too large, empty, malformed JSON, truncated CSV
[hostile names] a path-traversal filename and unpreviewable types
[skipped] an attachment over storage.max_attachment_size
[envelope] delivered somewhere other than the To header
[missing files] row intact, blobs deleted
[secondary mailer] a second mailer, so the filter appears
[queued] dispatched through the queue
Plus ~55 ordinary messages spread over 45 days, which gives three pages of
pagination, something for search to match, and a range of ages so
mailroom:prune --days=7 and --days=30 both have work to do.
php artisan demo:seed # skips if the mailbox already has mail
php artisan demo:seed --fresh # wipe and reseed
php artisan demo:seed --filler=0 # scenarios only, no padding
Every attachment is generated at runtime, so no binaries are committed. The one exception is a 1.6 KB MP4 held as base64 — a valid video file cannot be assembled in code the way the PDF and WAV fixtures are, and shelling out to ffmpeg would mean the video scenario vanished on machines without it.
License
MIT