PHPackages                             ebbbang/laravel-mailroom - 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. [Testing &amp; Quality](/categories/testing)
4. /
5. ebbbang/laravel-mailroom

ActiveLibrary[Testing &amp; Quality](/categories/testing)

ebbbang/laravel-mailroom
========================

A Laravel mail driver that stores outgoing mail in your database, with an auth-gated mailbox UI, attachment support, and .eml/.html export.

v0.1.0(today)00MITPHPPHP ^8.2CI passing

Since Jul 30Pushed todayCompare

[ Source](https://github.com/ebbbang/laravel-mailroom)[ Packagist](https://packagist.org/packages/ebbbang/laravel-mailroom)[ Docs](https://github.com/ebbbang/laravel-mailroom)[ RSS](/packages/ebbbang-laravel-mailroom/feed)WikiDiscussions main Synced today

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

Laravel Test Mail
=================

[](#laravel-test-mail)

[![Latest version](https://camo.githubusercontent.com/62abc440af13c709855a4fd284ec8236fba2c24bb52cf47f73a12d366ac704e0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f65626262616e672f6c61726176656c2d6d61696c726f6f6d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/ebbbang/laravel-mailroom)[![Tests](https://camo.githubusercontent.com/f8b9b5231a15f15a78b96c2e0f7c4f210e323f498712556a3e8117d236745047/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f65626262616e672f6c61726176656c2d6d61696c726f6f6d2f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/ebbbang/laravel-mailroom/actions/workflows/tests.yml)[![Downloads](https://camo.githubusercontent.com/71e6259a22af53aeda884dc909865dddb36c5a5e67cd06b2755ff389204fef97/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f65626262616e672f6c61726176656c2d6d61696c726f6f6d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/ebbbang/laravel-mailroom)[![License](https://camo.githubusercontent.com/c9a006889f7dcd6d1f17dbcdc4f1a4b5eb734f6ed40d7b4e69a2efa6b0213bb7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f65626262616e672f6c61726176656c2d6d61696c726f6f6d2e7376673f7374796c653d666c61742d737175617265)](LICENSE)

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.1` while this is 0.x.** Composer treats `^0.1` as `0.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

[](#how-this-differs-from-the-neighbours)

A few packages already log mail to the database — [`shvetsgroup/laravel-email-database-log`](https://packagist.org/packages/shvetsgroup/laravel-email-database-log) and [`stackkit/laravel-database-emails`](https://packagist.org/packages/stackkit/laravel-database-emails) among them — and [`spatie/laravel-database-mail-templates`](https://packagist.org/packages/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
------------

[](#requirements)

PHP8.2+Laravel12, 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
----------------

[](#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, `htmlString` or `Mail::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
-----------

[](#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

[](#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 inlineDownload onlyPNG, JPEG, GIF, WebP, AVIF, BMP, ICO, SVGOffice: `.docx`, `.xlsx`, `.pptx`PDFArchives: `.zip`, `.7z`MP3, WAV, OGG, M4A, AAC, FLACTIFF, HEICMP4, WebM, OGV, MOVanything unrecognisedText, 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

[](#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_type` is 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 `.html` or `.js` is shown as source and has no content type to be mis-interpreted through.
- **SVG is only ever rendered through ``**, where browsers permit neither scripts nor external fetches, and the response additionally carries `Content-Security-Policy: sandbox` — no `allow-scripts`, no `allow-same-origin` — which covers someone opening the URL directly.
- `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin` on 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](https://bugs.chromium.org/p/chromium/issues/detail?id=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 `` rather than `` 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

[](#access-control)

The package ships **no login page**. Access is decided in three escalating steps:

1. **Nothing configured** — the mailbox is reachable in the `local` environment only.
2. **A gate** — define `viewMailroom` and 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.
3. **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

[](#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 `` 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
----------

[](#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
-------

[](#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
-------------

[](#configuration)

```
php artisan vendor:publish --tag=mailroom-config
```

KeyDefault`enabled`not productionMaster 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`defaultKeep 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 tableMessage 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

[](#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=
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

[](#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 `$this` inside 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

[](#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
------

[](#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
------------------------

[](#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
-----------

[](#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

[](#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  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
-------

[](#license)

MIT

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity35

Early-stage or recently created project

 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

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/9cc0a5862331a297e4f9c9044dc882cd7a0fa7c6e0137a8bca9109f65d6da176?d=identicon)[ebbbang](/maintainers/ebbbang)

---

Top Contributors

[![ebbbang](https://avatars.githubusercontent.com/u/4510786?v=4)](https://github.com/ebbbang "ebbbang (14 commits)")

---

Tags

testinglaravelmaildevelopmentmailertransportmailboxmailtrapemlmailpit

###  Code Quality

TestsPHPUnit

Static AnalysisRector

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/ebbbang-laravel-mailroom/health.svg)

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

###  Alternatives

[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M256](/packages/laravel-ai)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M348](/packages/psalm-plugin-laravel)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.5k55.4M9.4k](/packages/larastan-larastan)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M134](/packages/roots-acorn)[illuminate/queue

The Illuminate Queue package.

20432.6M1.7k](/packages/illuminate-queue)[flarum/core

Delightfully simple forum software.

211.4M2.4k](/packages/flarum-core)

PHPackages © 2026

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