phattarachai / mail-log-laravel
Self-hosted outbound mail logger for Laravel — captures Mail + Notification sends, groups by Mailable class + model, ships its own Tailwind UI.
Requires
- php: ^8.4
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/mail: ^12.0 || ^13.0
- illuminate/queue: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- illuminate/view: ^12.0 || ^13.0
- spatie/laravel-medialibrary: ^11.0
Requires (Dev)
- driftingly/rector-laravel: ^2.5
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- rector/rector: ^2.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Self-hosted outbound mail logger for Laravel. Captures every Mail::send(...) + Notification mail-channel call, groups identical sends together by Mailable class + Eloquent model, and exposes the result at a /mail-log dashboard. Ships its own Tailwind UI — no Filament, no Livewire-from-the-package, no npm install in the host.
What it gives you
- One row per template, not per send. Sending
OrderShippedMail($order)to ten recipients lands as one group withsent_count=10+ ten event rows. DifferentOrder→ different group. - A
/mail-logdashboard styled like Laravel Horizon / Pulse: list of groups (last-sent first), per-group detail page with body preview (sandboxed<iframe srcdoc>, sized to the mail), Sends table with per-recipient outcomes, attachments, "Test send" modal. No third-party requests: system fonts, inlined CSS + JS. - Per-mailable opt-in via a single trait — drop
HasMailLoginto a Mailable, return the originating model frommailLogModel(), return$this->withMailLog(new Headers())fromheaders(). That's the whole integration surface. - Every send settles. A send whose transport throws becomes FAILED with the exception message, whether it was a queued Mailable, a queued Notification, or a synchronous
Mail::send(). A scheduledmail-log:settle-stalecatches the rest (see Failed and stale sends). - Secrets stay out of the preview. Reset tokens, signed-URL signatures and verify hashes in the stored body are masked as
[redacted]. - Several models per send. Besides the primary model that groups the mail, a send can relate to any number of models (a customer, a shipment). Filter the dashboard by any of them.
- Resend from the dashboard. Mailables that opt in can be rendered again with current data and sent to the original recipients or another address.
- Nothing internal reaches the recipient. The trait's
X-Mail-*headers are removed before the message is sent; each send is matched by itsMessage-ID, which is stored for later bounce matching. - First-class retention.
MailLogGroupimplementsPrunable;php artisan model:prune(daily via scheduler) cascades old groups + their events.
Install
composer require phattarachai/mail-log-laravel php artisan mail-log:install
mail-log:install is idempotent. It publishes the package config, the Spatie laravel-medialibrary migration (auto-detected when the media table is missing), and the package's own migrations, then offers to run php artisan migrate. It writes three env keys (MAIL_LOG_ENABLED, MAIL_LOG_RETENTION_DAYS, MAIL_LOG_UI_PATH) only when absent, and prints the snippets you need to paste into AppServiceProvider::boot(), a Mailable, and the scheduler.
Re-run safely. Pass --dry-run to print intended changes without writing.
After install, paste the auth gate snippet:
use Phattarachai\MailLogLaravel\MailLog; use Phattarachai\MailLogLaravel\Models\MailLogGroup; MailLogGroup::registerMorphMap(); MailLog::auth(fn ($request) => $request->user()?->isAdmin() ?? false);
The default policy blocks /mail-log unless APP_DEBUG=true — loud, safe failure until you opt in.
Per-Mailable opt-in
Drop the trait into a Mailable and tell it what model originated the send:
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Headers; use Phattarachai\MailLogLaravel\Concerns\HasMailLog; class OrderShippedMail extends Mailable { use HasMailLog; public function __construct(public Order $order) {} protected function mailLogModel(): ?\Illuminate\Database\Eloquent\Model { return $this->order; } public function headers(): Headers { return $this->withMailLog(new Headers()); } }
Now every Mail::to($recipient)->send(new OrderShippedMail($order)) to ANY recipient lands in the same group; sending the same Mailable for a different Order creates a separate group.
Trait hooks
All return safe defaults — override only what you need.
| Hook | Returns | Behavior |
|---|---|---|
mailLogModel(): ?Model |
null |
The originating record. Folded into the fingerprint when mode includes 'model' (default). |
mailLogNotificationClass(): ?string |
null |
The source Notification when the Mailable is constructed inside toMail(). Lets you fingerprint on the notification instead. |
mailLogFingerprintHints(): array |
[] |
Extra strings (tenant id, A/B variant) folded in when mode includes 'hints'. |
mailLogFingerprintMode(): ?array |
null |
Per-Mailable override of ['class', 'model']. Valid: class, notification_class, model, hints, subject, body, mailer. |
mailLogSkip(): bool |
false |
Opt this Mailable out of capture entirely (health-check pings, transactional one-offs). |
mailLogModels(): iterable |
[] |
Further models this send relates to. Stored per send, searchable, never part of the fingerprint. See Several models per send. |
mailLogResendable(): bool |
false |
Store this Mailable per send so it can be resent. See Resending. |
Group / event model
Two tables in lock-step:
mail_log_groups (one row per fingerprint)
├── fingerprint (CHAR 64, UNIQUE — the dedup key)
├── subject / from / mailable_class / notification_class / mailer (latest seen)
├── model_type / model_id (morphTo)
├── html_body / text_body (first-send wins; representative preview only)
├── sent_count / failed_count / latest_status (denormalized counters)
└── created_at / updated_at
mail_logs (one row per Mail::send(), append-only)
├── group_id (FK → mail_log_groups.id, cascadeOnDelete)
├── to / cc / bcc (JSON)
├── status (Pending → Sent | Failed)
├── error_message / seconds / sent_at
├── message_id (the Message-ID that was sent, indexed)
├── resend_payload (encrypted Mailable, only for mailLogResendable())
├── resent_from_id (FK → mail_logs.id when this send is a resend)
└── created_at
mail_log_associations (one row per model per send)
├── event_id (FK → mail_logs.id, cascadeOnDelete)
├── model_type / model_id (indexed)
└── is_primary (true for mailLogModel())
The body shown on the show page is the first send in the group — per-recipient body variation (signed URLs, magic-login tokens, personalized greetings) is NOT captured per row. The package favors fingerprint stability over body fidelity, and per-event body storage is not planned.
Recipients on the dashboard are the distinct to, cc and bcc addresses across every send in the group, in the order they were first mailed (oldest send first; within a send, to before cc before bcc). The index shows the first one plus a +N more count, so the primary recipient of the first send leads.
Sends counts every event in the group, Pending included. sent_count / failed_count only count settled outcomes.
Redacted bodies
Mail sent without the trait goes through the raw fallback path, and its body carries one recipient's secret: Laravel's ResetPassword and VerifyEmail notifications, magic-login links, any signed URL. The group stores the first send's body, so by default the listener masks these before saving:
- query values of
token,signature,expires,_token,code,hash,otp(raw&or HTML-escaped&) - the token in
/password/reset/{token}and/reset-password/{token} - the hash in
/email/verify/{id}/{hash}and/verify-email/{id}/{hash}
Each becomes [redacted]. Add your own patterns under redact.patterns in config/mail-log.php (pattern => replacement), or set MAIL_LOG_REDACT_BODIES=false to store bodies verbatim. Rows stored before v0.3.0 keep their original body until they are deleted or pruned.
Model deep links
Implement MailLogLinkable on a model a Mailable returns from mailLogModel(), and the dashboard's Model column (index) and Model row (group page) link back into your app:
use Phattarachai\MailLogLaravel\Contracts\MailLogLinkable; class Order extends Model implements MailLogLinkable { public function mailLogTitle(): string { return "Order #{$this->number}"; } public function mailLogUrl(): ?string { return route('orders.show', $this); } }
Models without the contract render as Order#42.
Several models per send
mailLogModel() is the primary model: it decides the group. Return further models from mailLogModels() and each send records them too:
protected function mailLogModel(): ?Model { return $this->order; } protected function mailLogModels(): iterable { return [$this->order->customer, $this->shipment]; }
- They are stored per send (
mail_log_associations), so a group whose sends relate to different customers keeps them apart. - They never change the fingerprint: the mail above still groups by Mailable + order.
- The group page lists every related model (linked through
MailLogLinkable), and each send shows its own under the recipient. - All mail → next to a model opens the index filtered to
?model=Type:id: every group whose primary model is that model, or that has a send related to it.
Resending
A Mailable opts in with mailLogResendable():
class OrderShippedMail extends Mailable { use HasMailLog, SerializesModels; protected function mailLogResendable(): bool { return true; } }
Each send of it then stores the Mailable itself: serialized the way a queued job is (models as identifiers, via SerializesModels) and encrypted with your APP_KEY. Resending restores it and renders it again, with the models' current data and fresh links, so it never replays the stored preview (which is the first send's, and redacted).
- Dashboard: ↻ Resend on a send in the Sends table. Keep the original recipients or enter up to 10 other addresses.
- CLI:
php artisan mail-log:resend {send id} [--to=a@example.com --to=…]. Without--toit goes to the original to, cc and bcc.
The resend is logged as a new send in the same group, marked ↻ resent, with resent_from_id pointing at the original. It goes out immediately, even for a ShouldQueue Mailable.
Things to know:
- Opt in only where resending is safe. Leave it off for mail that carries one-time secrets (login links, reset tokens) as constructor values; mail without the trait (Laravel's
ResetPassword,VerifyEmail) can't be resent at all. - A Mailable that sets its own recipients (in
envelope()orbuild()) can only be resent to them. A resend that would reach any address you didn't choose is cancelled before it is sent, with an error. - It can't be restored after the
APP_KEYchanges, the class is renamed or removed, or a model it holds is deleted; the dashboard says which. - A Mailable holding a closure can't be serialized, and one larger than
mail-log.resend.max_payload_bytes(64 KB) isn't stored; those sends show no Resend button. - The route sits behind the same
MailLog::auth()gate as the dashboard. Turn the feature off withMAIL_LOG_RESEND_ENABLED=false.
Outgoing headers and Message-ID
HasMailLog passes its metadata to the listener in X-Mail-* headers (class, model keys, fingerprint hints, the encrypted resend payload). The listener reads them and removes them before the message is handed to the transport, so recipients never see class names or record ids.
Each send is matched to its row by Message-ID: your own if the Mailable sets one (new Headers(messageId: …)), otherwise one the listener assigns. It's stored in mail_logs.message_id, which is what bounce notifications (DSNs) refer to.
Failed and stale sends
Every event starts Pending when Laravel fires MessageSending and becomes Sent on MessageSent. When the transport throws, the listener settles it as Failed with the exception message:
| Where the send ran | Settled by |
|---|---|
Queued Mailable or queued Notification (SendQueuedMailable, SendQueuedNotifications) or your own job |
JobExceptionOccurred / JobFailed in the same worker. A job with tries > 1 records one Failed row per failed attempt (Attempt 1 failed, the job will retry: …) plus the final outcome. |
| A job that caught the send exception itself | JobProcessed |
Synchronous Mail::send() in a request or command (Laravel fires no event for a transport error) |
the app's exception reporter, with the transport's message, when the exception reaches it. If your code catches it without report(), the app's terminating hook marks the send Failed with a generic note pointing at the log. |
| A worker killed mid-send, a timeout retried on another worker, a crashed process | php artisan mail-log:settle-stale |
mail-log:settle-stale marks events still Pending after MAIL_LOG_PENDING_TIMEOUT_MINUTES (default 15) as Failed. Schedule it (see Scheduling); --minutes=N overrides the timeout for one run.
A send that another MessageSending listener cancels after Mail Log logged it also ends up Failed, since it was never delivered.
Config
MAIL_LOG_ENABLED=true # master switch (1/true/on) — false skips listener registration MAIL_LOG_RETENTION_DAYS=365 # prunable; null = never prune; events cascade with the group MAIL_LOG_UI_PATH=mail-log # URL prefix; null skips route registration MAIL_LOG_TEST_SEND_ENABLED=true # show the Test-send button + POST /test-send route MAIL_LOG_MAX_RECIPIENTS_PER_EVENT=200 # cap recipients stored in a single event row's JSON columns MAIL_LOG_PENDING_TIMEOUT_MINUTES=15 # mail-log:settle-stale fails sends still Pending after this MAIL_LOG_REDACT_BODIES=true # mask tokens / signatures / verify hashes in stored bodies MAIL_LOG_RESEND_ENABLED=true # resend button + POST /{group}/events/{event}/resend
See config/mail-log.php for the full schema; the Boost skill reference.md walks every key.
Scheduling
Prune old groups daily and settle stale Pending sends every few minutes. In Laravel 12 / 13, in routes/console.php:
use Illuminate\Support\Facades\Schedule; use Phattarachai\MailLogLaravel\Models\MailLogGroup; Schedule::command('model:prune', ['--model' => [MailLogGroup::class]])->daily(); Schedule::command('mail-log:settle-stale')->everyTenMinutes();
Or in bootstrap/app.php:
->withSchedule(function (Schedule $schedule): void { $schedule->command('model:prune', [ '--model' => [\Phattarachai\MailLogLaravel\Models\MailLogGroup::class], ])->daily(); $schedule->command('mail-log:settle-stale')->everyTenMinutes(); })
Events cascade-delete via FK when their parent group is pruned.
Dashboard language
The dashboard's labels, buttons and flash messages are in English. Dates are always in Thai format: day, Thai month abbreviation, two-digit Buddhist-era year and 24-hour time, e.g. 19 พ.ค. 69 · 15:07 for 19 May 2026, 15:07. The format is not configurable yet.
Requirements
- PHP
^8.4 - Laravel
^12or^13 spatie/laravel-medialibrary ^11- SQLite, PostgreSQL or MySQL / MariaDB (CI runs the suite on all three)
Credits
Built by Phattarachai Chaimongkol. Same shipping pattern as Laravel Horizon and Pulse — pre-built CSS + JS committed to dist/, inlined into the layout via MailLog::css() / MailLog::js() helpers, no host-side npm install required.
License
MIT. See LICENSE.
ผู้พัฒนา
พัฒนาและดูแลโดย บริษัท ภัทรชัย อาร์ทิซาน จำกัด (Phattarachai Artisan) บริษัทที่ปรึกษาและพัฒนาเว็บ ที่เรียนรู้และแบ่งปันกับชุมชน Laravel แพ็กเกจนี้แบ่งปันให้ชุมชนนำไปใช้และต่อยอดได้อย่างอิสระ
ดูแพ็กเกจอื่นของเราได้ที่ phattarachai.dev/open-source และติดต่อเราได้ที่ phattarachai.dev