Search by

rudisang / laravel-mailbox

A local mail workbench for Laravel: capture the exact MIME your app generates, browse it in a private mailbox, and assert on the final output.

Maintainers

Package info

github.com/rudisang/laravel-mailbox

pkg:composer/rudisang/laravel-mailbox

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-09-02 13:07 UTC

This package is auto-updated.

Last update: 2026-09-02 13:20:12 UTC


README

Latest Version on Packagist Tests Total Downloads License

Laravel Mailbox is a Laravel-native local mail workbench. It captures the exact prepared RFC 822/MIME stream your application hands to the mail transport, preserves the delivery envelope and original recipient data, and gives you a private mailbox plus assertions against the final rendered result—without a daemon, Docker, or a cloud account.

The promise is a trustworthy view of the email Laravel actually generated: canonical MIME, envelope truth, secure previews, final-output assertions, and deterministic diagnostics.

Important

There is no Gmail, Outlook, or Apple Mail rendering emulation. Laravel Mailbox does not promise pixel-identical rendering in any named email client.

Never enable Laravel Mailbox in production. The environment guard refuses production even if it is added to the configured environment list, and the transport throws instead of silently capturing.

Laravel Mailbox showing a captured invoice email with its recipients, envelope facts, tabs and HTML preview

At a glance:

You get How
Every mail your app sends, captured locally MAIL_MAILER=local — no SMTP server, daemon or account
The exact MIME your app generated Byte-for-byte raw.eml, envelope, original Bcc kept separate
A fast, keyboard-complete mailbox UI /_mailbox, light/dark, sandboxed HTML preview
Assertions on the final output mailbox()->latest()->assertHtmlContains(...) — no Mail::fake()
Real queue-worker test isolation Per-test namespaces that survive php artisan queue:work
Machine-readable diagnostics JSON / JUnit artifacts, .eml export, snapshots

Requirements

Laravel Mailbox requires PHP 8.2+ and supports Laravel 12 and 13. Laravel 12 (PHP 8.2–8.5) now receives security fixes only; Laravel 13 (PHP 8.3–8.5) is the actively maintained release. The package tracks the Symfony mailer/mime/html-sanitizer components each Laravel major ships with (Symfony 7.2 on Laravel 12, 7.4/8.x on Laravel 13), so your installed Symfony version always matches what Laravel itself requires. Development uses Orchestra Testbench 10/11 and Pest 4/5 — running the package's own test suite needs PHP 8.3+, even though the package installs and runs on PHP 8.2.

  • PHP 8.2 or newer
  • Laravel 12+
  • The DOM, Fileinfo, Mbstring, PDO, PDO SQLite, and XMLWriter PHP extensions

Installation

Install the package as a development dependency:

composer require --dev rudisang/laravel-mailbox

Select the package's local transport in your application's .env file:

MAIL_MAILER=local

Package discovery registers the transport and its routes — there is nothing else to configure. Open the mailbox in your browser at your app's URL plus /_mailbox:

http://your-app.test/_mailbox

Then send yourself an example message straight from Tinker — paste this as-is (a small HTML order email with a plaintext fallback):

php artisan tinker --execute='
$html = <<<HTML
<style>html,body{height:100%;margin:0;background:#f4f4f5}</style>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f4f4f5;padding:32px 16px;"><tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:40px;text-align:left;"><tr><td>
<p style="margin:0 0 24px;font-size:16px;font-weight:800;letter-spacing:3px;color:#111111;">ACME STORE</p>
<h1 style="margin:0 0 8px;font-size:24px;color:#111111;">Your order is on its way</h1>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#4b5563;">Order #1042 shipped today. Laravel Mailbox captured this message locally. Nothing was actually sent.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-top:1px solid #e5e7eb;">
<tr><td style="padding:14px 0;font-size:15px;color:#111111;">Desk Lamp</td><td align="right" style="padding:14px 0;font-size:15px;color:#111111;">P79.00</td></tr>
<tr><td style="padding:0 0 14px;font-size:15px;color:#111111;border-bottom:1px solid #e5e7eb;">Monitor Stand</td><td align="right" style="padding:0 0 14px;font-size:15px;color:#111111;border-bottom:1px solid #e5e7eb;">P129.00</td></tr>
<tr><td style="padding:14px 0 0;font-size:16px;font-weight:700;color:#111111;">Total</td><td align="right" style="padding:14px 0 0;font-size:16px;font-weight:700;color:#111111;">P208.00</td></tr>
</table>
<p style="margin:28px 0 0;"><a href="https://example.com/orders/1042" style="display:inline-block;background:#111111;color:#ffffff;padding:13px 30px;border-radius:9999px;font-size:14px;font-weight:600;text-decoration:none;">Track order</a></p>
</td></tr></table>
</td></tr></table>
HTML;

$text = "ACME STORE\n\nYour order is on its way\n\nOrder #1042 shipped today.\n\nDesk Lamp      P79.00\nMonitor Stand  P129.00\nTotal          P208.00\n\nTrack order: https://example.com/orders/1042";

Mail::send([], [], function ($message) use ($html, $text) {
    $message->to("dev@example.com")->subject("Your order is on its way")->html($html)->text($text);
});
'

Refresh /_mailbox and the message is there — rendered HTML preview, plaintext fallback, exact raw MIME, headers and diagnostics included. Every mail your app sends from now on lands in the same place.

The package adds mail.mailers.local only when that mailer name is not already configured. Run php artisan mailbox:doctor if the UI is unavailable or messages are not captured.

To customize the defaults, publish the configuration:

php artisan vendor:publish --tag=mailbox-config

What gets captured

Each successful transport handoff stores:

  • the exact prepared raw message stream as raw.eml; this is the authoritative final MIME output;
  • the delivery envelope sender and recipients;
  • the original structured From, To, Cc, Bcc, and Reply-To fields, including Bcc before Symfony removes it from the transmitted header block;
  • the subject, HTML and text alternatives, MIME tree, inline CID resources, and attachments;
  • bounded Laravel execution context, test namespace, and parse status. Versioned diagnostics are derived deterministically from the capture when requested.

Arbitrary Symfony RawMessage instances are still captured as raw bytes with envelope metadata, but structured extraction is marked unsupported. The package does not implement an ad-hoc MIME parser.

The mailbox UI

The Mailbox inbox with search, filters and a list of captured messages

The server-rendered inbox has search, unread and attachment filters, read state, delete and clear actions, and responsive two-pane navigation. A message detail view provides these tabs:

  • HTML
  • Text
  • Headers
  • Envelope
  • MIME
  • Raw
  • Attachments
  • Links
  • Diagnostics

HTML previews can be switched between Phone (375 px), Tablet (768 px), and Desktop (100%) widths. The theme cycles through system, light, and dark and is remembered locally. The Message-ID in the message header is shortened to fit and copies its full value when clicked. Links and forms remain usable without JavaScript; live updates, notifications, fragment navigation, shortcuts, tabs, viewport presets, and theme switching are progressive enhancements.

Notifications

The mailbox can raise a browser notification when new mail is captured, so the tab does not have to stay in front. Notifications are off until you turn them on with the bell in the toolbar. The browser then asks for permission once, and the choice is remembered per browser; the same bell turns them off again. Each notification shows the subject and sender, and clicking it brings the mailbox forward and opens that message. While the mailbox tab is already in front only the in-page toast is shown.

Browsers only allow notifications on secure pages. localhost and 127.0.0.1 count as secure, so the Testbench workbench and most local servers work over plain HTTP. A site such as http://shop.test does not; open it over HTTPS instead (herd secure on Laravel Herd). If notifications were blocked in the browser, or the page is not secure, the bell explains what to change.

Keyboard shortcuts

Key Action
j Select the next message
k Select the previous message
Enter Open the selected message
/ Focus search
e Delete the open message
u Toggle the open message's read state
[ Select the previous detail tab
] Select the next detail tab
? Open shortcut help
Esc Close help or return to the list on a small screen

Security model

The Diagnostics tab reporting everything the sanitizer removed from a hostile email

Laravel Mailbox is a development tool, not a production observability service.

  • Capture and routes are allowed only in the configured local and testing environments by default. MAILBOX_ENABLED=false can force the package off; no setting can force it on in production.
  • Disallowed routes return 404 and the transport throws MailboxDisabledException. If the application defines a viewMailbox gate, the package also requires that gate to allow access.
  • Email HTML is rendered in a separate opaque-origin iframe with an empty-token sandbox. Active markup and author-controlled resource/navigation attributes are removed, and a restrictive CSP blocks scripts, connections, forms, frames, objects, fonts, and remote resources.
  • CSS is contained, not sanitized. Author CSS can affect the sandboxed preview visually, but its network fetches are blocked by the preview CSP and it cannot enter the mailbox page DOM.
  • Inline display is limited to allowlisted raster images whose bytes match the detected type. Other attachments are served as application/octet-stream downloads with hardened filenames and nosniff.
  • Link destinations are shown as text. Only canonical http, https, and mailto links receive an Open action, and opening one always requires a deliberate click.
  • Capture and browsing do not proxy remote images or check links and do not make network requests on behalf of a message.

Captures contain secrets: bodies, addresses, subjects, attachments, links, and tokens may all be sensitive. Keep the storage private and use php artisan mailbox:clear or php artisan mailbox:prune to remove captures you no longer need.

See the security policy for the complete threat-model summary and private reporting channel.

Testing

Laravel Mailbox tests the final output at the transport boundary instead of replacing mail delivery with Mail::fake().

Add InteractsWithMailbox to a Laravel test case. Pest users can register it directly:

<?php

declare(strict_types=1);

use App\Mail\InvoiceMail;
use App\Models\Invoice;
use Illuminate\Support\Facades\Mail;
use Rudisang\Mailbox\Testing\InteractsWithMailbox;

uses(InteractsWithMailbox::class);

it('captures the final invoice message', function (): void {
    $invoice = Invoice::factory()->create();

    Mail::to('buyer@example.com')->send(new InvoiceMail($invoice));

    $this->mailbox()->latest()
        ->assertTo('buyer@example.com')
        ->assertSubject('Your invoice')
        ->assertHtmlContains('Invoice #123')
        ->assertHasAttachment('invoice-123.pdf', 'application/pdf')
        ->assertRawHeaderMissing('Bcc')
        ->assertEnvelopeContains('buyer@example.com')
        ->assertNoParseErrors();
});

$this->mailbox() and the guarded global mailbox() helper return the same MailboxTester. The helper throws a clear exception if the trait has not initialized the tester.

Queries and capture lifecycle

Method Result
namespace() Current test namespace, or null for an unscoped tester
latest() Newest capture in scope; fails when none exists
all() All captures in scope, newest first
count() Capture count in scope
find($id) Capture by ULID when it belongs to the current scope
whereMessageId($id) Captures with the exact message ID
whereTo($address) Captures with an original To recipient, case-insensitively
whereSubject($subject) Captures with the exact subject, case-insensitively
whereTag($tag) Captures containing the tag
anyNamespace() A new tester that queries every namespace
assertCaptured($count) Assert the scoped capture count
assertNothingCaptured() Assert that the scoped capture count is zero
waitForCapture($count = 1, $timeout = 5.0) Poll for new captures after the high-water sequence and return them newest first
clear() Delete every capture in the tester's current scope

waitForCapture is intended for queued or cross-process delivery. It polls every 50 ms and advances its high-water sequence after each successful wait, so an earlier message does not satisfy a later wait.

Assertions and their fact sources

Fact source Assertions
Original structured message assertFrom, assertTo, assertCc, assertBcc, assertReplyTo, assertSubject, assertSubjectContains, assertHtmlContains, assertHtmlNotContains, assertTextContains, assertSeeInHtml, assertHasAttachment, assertAttachmentCount, assertHasInlineImage, assertTag, assertMetadata, assertNoParseErrors
Raw prepared header block or raw stream assertHeader, assertRawHeaderMissing, assertRawContains
Delivery envelope assertEnvelopeContains, assertEnvelopeSender
Versioned diagnostics over the captured output assertNoRemoteImages, assertNoScripts
Field-aware HTML snapshot assertMatchesSnapshot

The distinction matters for Bcc: assertBcc checks the protected original recipient data, while assertRawHeaderMissing('Bcc') checks the final raw header block.

CapturedMessage also exposes the captured facts through methods: id, seq, messageId, subject, from, to, cc, bcc, replyTo, envelopeSender, envelopeRecipients, rawHeaders, header, headers, html, text, raw, parts, attachments, attachmentContent, tags, metadata, context, parseStatus, parseError, diagnostics, and links.

parts() and attachments() return stable Rudisang\Mailbox\Storage\PartRecord value objects. The transport throws MailboxDisabledException when capture is refused by the environment guard, MessageTooLargeException when the raw-message limit is exceeded, and CaptureFailedException when capture persistence fails.

Namespaces and real queue workers

The trait sets mail.default to local, keeps a non-empty configured mailbox.namespace (including MAILBOX_NAMESPACE) or creates a fresh ULID otherwise, purges the local mailer, and restores the previous configuration during teardown. Storage remains shared, so isolation works across processes rather than hiding messages in a process-private directory.

While the guard allows capture, the package adds mailbox_namespace to Laravel queue payloads. A worker adopts that namespace for the job and restores its previous namespace after the job finishes or fails. This supports workers that were already running when the test dispatched the job. For a worker started manually outside that propagation path, start both producer and worker with the same MAILBOX_NAMESPACE value.

Queue delivery is at-least-once. If a job is retried and hands the same message to the transport more than once, every capture is kept; Laravel Mailbox does not deduplicate attempts.

Snapshots and artifacts

The snapshot methods normalize only fields whose instability is understood:

  • htmlSnapshot() rewrites every cid: occurrence to stable cid:part-{n} tokens;
  • textSnapshot() returns the text body verbatim;
  • headersSnapshot() replaces only Message-ID and Date values;
  • mimeTreeSnapshot() renders a stable tree of types, dispositions, content IDs, filenames, and decoded sizes.

Raw MIME is never normalized. Multipart boundaries remain significant in raw and header output.

Artifacts can be saved explicitly:

$message = mailbox()->latest();

$message->saveDiagnostics(storage_path('mailbox/diagnostics.json'), 'json');
$message->saveDiagnostics(storage_path('mailbox/diagnostics.xml'), 'junit');
$message->saveFixture(storage_path('mailbox/message.json'));
$message->saveEml(storage_path('mailbox/message.eml'));

diagnostics() and saveDiagnostics include a rules version, capture ID, namespace, and findings. JUnit output turns error-severity findings into failures for CI. saveFixture emits portable redacted JSON without Bcc or execution context. saveEml copies the unchanged non-empty raw message.

Queues

No special Mailable API is required: queued Mailables and Notifications use Laravel's normal mail path. The queue payload namespace propagation described above associates the eventual capture with the originating test, including a real long-running worker.

Use MAILBOX_NAMESPACE when coordinating independently launched producers and workers. The value is capture metadata and an isolation key, not an authentication boundary. At-least-once queue duplicates are intentionally kept so retry behaviour remains visible.

Configuration reference

All package settings live under mailbox.* in config/mailbox.php.

Key Default Environment variable Meaning
enabled null MAILBOX_ENABLED null defers to the environment guard; false forces off. It can never force production on.
environments ['local', 'testing'] Application environments allowed to capture and browse. production is always refused.
path _mailbox MAILBOX_PATH Route prefix, without a required leading slash.
middleware ['web'] Middleware on mailbox routes; keep web for CSRF protection on mutations.
storage_path null MAILBOX_STORAGE_PATH Private storage root; null uses storage/framework/mailbox.
namespace null MAILBOX_NAMESPACE Optional capture namespace used for process and test isolation; namespaces are truncated to 128 characters.
retention.days 7 Remove captures older than this many days; 0 disables the age rule.
retention.max_messages 1000 Keep at most this many messages.
retention.max_bytes 250 * 1024 * 1024 Keep at most 250 MiB across capture records.
limits.raw_bytes 50 * 1024 * 1024 Maximum raw message size.
limits.parts 100 Maximum extracted MIME parts.
limits.depth 30 Maximum structured MIME depth.
limits.header_bytes 256 * 1024 Maximum raw header block bytes.
limits.search_text_bytes 512 * 1024 Maximum text indexed for search.
limits.preview_bytes 2 * 1024 * 1024 Maximum HTML accepted by preview sanitization.

Changing .env does not change a cached configuration. Rebuild or clear Laravel's configuration cache after changing any MAILBOX_* value.

Commands

Command Purpose
php artisan mailbox:doctor Inspect the guard, mailer composition, middleware, storage, SQLite/schema, caches, retention, and orphan state. Exits non-zero for critical findings.
php artisan mailbox:doctor --json Emit doctor findings as JSON.
php artisan mailbox:doctor --repair Remove orphan directories, dangling rows, and stale temporary entries before reporting health.
php artisan mailbox:clear Delete every captured message, with confirmation in an interactive shell.
php artisan mailbox:clear --force Delete every captured message without confirmation.
php artisan mailbox:prune Apply the configured age, message-count, and byte retention limits.

Storage and retention

The default private store is storage/framework/mailbox:

storage/framework/mailbox/
├── index.sqlite
├── .lock
├── messages/
│   └── {capture-ulid}/
│       ├── raw.eml
│       └── parts/
│           └── {part-ulid}.bin
└── tmp/

Opaque ULIDs—not attachment filenames—select files on disk. Directories are created with owner-only permissions where the operating system supports POSIX modes.

Retention is enforced opportunistically after captures and explicitly by mailbox:prune. The default policy removes messages older than seven days and keeps at most 1,000 messages or 250 MiB. mailbox:clear removes the entire capture set.

Troubleshooting

Start with:

php artisan mailbox:doctor
  • No messages appear: confirm the application environment is local or testing, MAILBOX_ENABLED is not false, and MAIL_MAILER=local. A named mailer can also be selected explicitly with Laravel's normal Mail::mailer('local') API.
  • Environment changes have no effect: run php artisan config:clear, or rebuild with php artisan config:cache after changing .env.
  • The doctor reports a mailer collision: an application-defined mail.mailers.local takes precedence. Rename it or configure its transport as local deliberately.
  • The doctor reports failover or round-robin composition: remove the local transport from that mailer's mailers list. A development capture transport inside failover can accept mail instead of the intended delivery transport.
  • The UI returns 403: the application defines a viewMailbox gate and the current user is not allowed.
  • The UI returns 404 or sends throw: the fail-closed environment guard is refusing access. Do not bypass it for production.
  • Storage looks inconsistent after a crash: run php artisan mailbox:doctor --repair, then inspect the reported findings.

Alternatives

Laravel Mailbox is complementary to SMTP-focused tools such as Mailpit and Mailtrap Local. Use those tools when you need an SMTP endpoint or their broader client workflow. Use Laravel Mailbox when you want an embedded Laravel transport, final-output assertions, envelope and original-Bcc facts, test namespaces, and queue-aware diagnostics without a separate service.

You can export any capture as .eml from the UI or with saveEml() and inspect it in another tool.

Changelog

See CHANGELOG.md for what changed in each release, and UPGRADE.md when moving between 0.x minors.

Contributing

See the contribution guide. Before opening a pull request, run:

composer validate --strict
composer audit
vendor/bin/pint --test
vendor/bin/phpstan analyse
vendor/bin/pest
composer test:types

Please report security issues through the private channel in the security policy, not in a public issue.

License

Laravel Mailbox is open-source software licensed under the MIT License.