blax-software / laravel-mail
Tracked outbound + IMAP-synced inbound mail for Laravel. Per-mailbox SMTP/IMAP credentials, threaded messages, open/click tracking, CQRS read side, scheduler-friendly poller.
Requires
- php: ^8.2
- directorytree/imapengine: ^1.0
- illuminate/bus: ^10.0|^11.0|^12.0|^13.0
- illuminate/container: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/events: ^10.0|^11.0|^12.0|^13.0
- illuminate/mail: ^10.0|^11.0|^12.0|^13.0
- illuminate/queue: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/framework: *
- laravel/pint: ^1.22
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-05-13 10:19:22 UTC
README
Laravel Mail
Per-mailbox SMTP + IMAP, threaded message storage, open / click tracking, CQRS read queries, and a scheduler that polls itself — for Laravel apps that need more than fire-and-forget Mail::send().
Note
Public API may still shift between minor releases. Pin to a tag when you depend on it in production.
Table of contents
- Features
- Requirements
- Installation
- Configuration
- Quick start
- Sending mail
- Receiving mail
- Threading
- Tracking (open / click)
- Events
- CQRS queries
- Models
- Enums
- Console commands
- Scheduler
- Extending
- Security
- Credits
- License
Features
Multi-mailbox identity
- Per-mailbox SMTP + IMAP credentials stored as Eloquent rows. Each
Mailboxcarries its own host / port / encryption / username / password for both directions. The dispatcher builds a one-shot Laravel mailer per send, so you can ship fromsupport@,billing@, andnoreply@from the same Laravel app without touchingconfig/mail.php. - Encrypted password columns via Laravel's
encryptedcast — rotates withAPP_KEY. - Per-row enable flag +
last_error+last_polled_atso admin UIs can show health without re-reading logs.
Outbound
MailDispatcher::dispatch(OutboundMail)— single entry point. Persists aMailMessagerow (status =Queued, withMessage-ID+ tracking token), then queuesSendMailJobfor the real SMTP handshake.- 3 tries, 60 / 300 second backoff on transport errors. Terminal failures flip the row to
Failedand fireOutboundMailFailed. - Idempotent re-runs — a queued job whose row is already
Sent/Deliveredreturns early instead of double-sending. - Custom headers, attachments,
Reply-To,In-Reply-Toall first-class on theOutboundMailDTO.
Inbound
blax-mail:pollcommand — fetches new messages from each enabled mailbox's IMAP folder, dedupes byMessage-ID, persists them as inboundMailMessagerows with full headers / body / attachments.- UID watermarking (
mailbox.meta.last_imap_uid) so a mid-batch crash doesn't re-process what already landed. - Per-message failure isolation — a single malformed message logs a warning and the batch moves on. The watermark only advances on successful persists for that UID.
- Attachment download to any Laravel
Storagedisk, with size cap. - Pure PHP — uses
directorytree/imapengine, noext-imaprequired.
Threading
- Automatic
In-Reply-To/Referencesmatching against existing outboundmessage_ids — inbound replies attach to their parent without listener wiring. thread_root_idcolumn on every message so a single indexed query returns the whole thread.
Tracking
- Open pixel + click rewrite added to outbound HTML during dispatch.
- Both endpoints validate a per-message token (TTL-capped via
tracking.token_ttl_days), record aMailEvent, then redirect / serve the pixel. - Disable globally via
BLAX_MAIL_TRACKING=false; per-send opt-out is on the roadmap.
Read side (CQRS)
- Three query objects —
ListMessagesQuery,GetThreadQuery,FindMessageByMessageIdQuery— resolved from the container. Composable, mockable, no leaky Eloquent scope chains in your controllers.
Operational
- Auto-scheduled poller — the package's service provider registers
blax-mail:pollon the host scheduler (default: every minute,withoutOverlapping). Noroutes/console.phpboilerplate needed. blax-mail:cleanuppurges soft-deleted messages older thanretention.purge_days.- Six events for routing / observability (see Events).
- Model overrides via config — swap any of the package's five models for a subclass without forking.
Requirements
| PHP | 8.2+ |
| Laravel | 10, 11, 12, or 13 |
| Queue driver | any (database, redis, sqs, …) — SendMailJob implements ShouldQueue |
| Inbound | An IMAP-accessible mailbox |
| Outbound | An SMTP-accessible mailbox |
Installation
composer require blax-software/laravel-mail php artisan vendor:publish --tag=blax-mail-config php artisan migrate
The service provider is auto-discovered. The poller registers itself on the scheduler automatically — you don't need to add anything to routes/console.php.
Configuration
All keys are environment-overridable. Defaults in config/blax-mail.php:
return [ 'tracking' => [ 'enabled' => env('BLAX_MAIL_TRACKING', true), 'route_prefix' => env('BLAX_MAIL_ROUTE_PREFIX', 'blax-mail/track'), 'token_ttl_days' => env('BLAX_MAIL_TOKEN_TTL_DAYS', 90), 'middleware' => ['web'], ], 'imap' => [ 'default_folder' => env('BLAX_MAIL_IMAP_FOLDER', 'INBOX'), 'fetch_limit' => (int) env('BLAX_MAIL_IMAP_FETCH_LIMIT', 200), 'default_interval_minutes' => (int) env('BLAX_MAIL_IMAP_INTERVAL', 1), 'schedule_enabled' => env('BLAX_MAIL_SCHEDULE_ENABLED', true), 'poll_cron' => env('BLAX_MAIL_POLL_CRON', '* * * * *'), 'schedule_without_overlapping' => env('BLAX_MAIL_SCHEDULE_NO_OVERLAP', true), 'auto_thread' => env('BLAX_MAIL_AUTO_THREAD', true), 'attachments' => [ 'download' => env('BLAX_MAIL_DOWNLOAD_ATTACHMENTS', true), 'disk' => env('BLAX_MAIL_ATTACHMENT_DISK', 'local'), 'path_prefix' => env('BLAX_MAIL_ATTACHMENT_PATH', 'blax-mail/attachments'), 'max_bytes' => (int) env('BLAX_MAIL_ATTACHMENT_MAX_BYTES', 25 * 1024 * 1024), ], ], 'outbound' => [ 'default_from_name' => env('BLAX_MAIL_DEFAULT_FROM_NAME', config('app.name')), 'list_unsubscribe' => env('BLAX_MAIL_LIST_UNSUBSCRIBE', true), 'click_tracking' => env('BLAX_MAIL_CLICK_TRACKING', true), ], 'retention' => [ 'purge_days' => (int) env('BLAX_MAIL_PURGE_DAYS', 365), ], 'models' => [ 'mailbox' => \Blax\Mail\Models\Mailbox::class, 'mail_message' => \Blax\Mail\Models\MailMessage::class, 'mail_recipient' => \Blax\Mail\Models\MailRecipient::class, 'mail_attachment' => \Blax\Mail\Models\MailAttachment::class, 'mail_event' => \Blax\Mail\Models\MailEvent::class, ], ];
Quick start
1. Configure a mailbox
use Blax\Mail\Models\Mailbox; $box = Mailbox::create([ 'name' => 'Support', 'email' => 'support@example.com', 'from_name' => 'ACME Support', 'smtp_host' => 'smtp.example.com', 'smtp_port' => 587, 'smtp_encryption' => 'tls', 'smtp_username' => 'support@example.com', 'smtp_password' => 'secret', // auto-encrypted on save 'imap_host' => 'imap.example.com', 'imap_port' => 993, 'imap_encryption' => 'ssl', 'imap_username' => 'support@example.com', 'imap_password' => 'secret', // auto-encrypted on save 'imap_folder' => 'INBOX', 'enabled' => true, ]);
2. Send
use Blax\Mail\Services\MailDispatcher; use Blax\Mail\DTOs\OutboundMail; app(MailDispatcher::class)->dispatch(new OutboundMail( mailbox: $box, to: ['tim@example.com'], subject: 'Re: Delivery', bodyHtml: $html, bodyText: $text, ));
3. Receive
The poller is already scheduled (every minute by default — see Scheduler). To process the queue and the scheduler in development:
php artisan queue:work # processes SendMailJob php artisan schedule:work # runs blax-mail:poll on its cron
Listen for new inbound mail:
use Blax\Mail\Events\InboundMailReceived; Event::listen(InboundMailReceived::class, function (InboundMailReceived $event) { // $event->message — the persisted MailMessage row // $event->threadParent — the matched outbound parent (null if first contact) });
Sending mail
OutboundMail DTO
The full constructor signature:
new OutboundMail( mailbox: $box, // Blax\Mail\Models\Mailbox — must canSend() to: ['a@example.com'], // string[] subject: 'Hello', // string bodyHtml: '<p>Hi</p>', // string|null bodyText: 'Hi', // string|null cc: [], // string[] bcc: [], // string[] replyTo: 'support@example.com', // string|null — overrides mailbox.reply_to for this send inReplyTo: '<msg-id@example.com>', // string|null — stamps In-Reply-To + References headers attachments: [$outboundAttachment], // OutboundAttachment[] headers: ['X-Campaign-Id' => 'spring-2026'], // extra mail headers subjectType: 'order', // string|null — polymorphic hint persisted on the row subjectId: (string) $order->id, // string|null meta: ['app_mail_id' => 'abc'],// array — free-form, persisted on the row + on every event );
One of bodyHtml or bodyText is required. The DTO is final + readonly — pass it to MailDispatcher::dispatch() and that's it.
What dispatch() does
- Builds an inbound
MailMessagerow with statusQueued, a generatedMessage-ID, the canonical body, recipients, attachments, and a tracking token. - Logs a
MailEventof typeQueued. - Fires
OutboundMailQueued. - Queues
SendMailJobwith the row's id + the DTO.
When the job runs, it:
- Builds a transient Laravel mailer using the
Mailbox's SMTP credentials (mailer name isblax-mail-<mailbox-id>— concurrent sends from different mailboxes don't fight over the same config key). - Sends through Symfony Mailer, stamps the canonical
Message-ID, injects the tracking pixel + link rewrites. - On success: row →
Sent, firesOutboundMailSent. - On all retries exhausted: row →
Failed, firesOutboundMailFailed.
Receiving mail
The poller (Blax\Mail\Services\ImapPoller) iterates every enabled mailbox, fetches messages above the watermark, persists them as inbound MailMessage rows, and fires InboundMailReceived for each.
Watermarking
mailbox.meta.last_imap_uid advances only when a UID processes cleanly. A mid-batch failure leaves the watermark where it was, so the next poll retries the same UIDs — no message loss on transient errors.
What lands on the row
| Column | Source |
|---|---|
message_id |
RFC 5322 Message-ID header, normalized to <id@host> (synthesized when missing) |
in_reply_to |
First In-Reply-To value (multi-value headers collapsed) |
references |
Raw References value |
subject, body_text, body_html, raw_headers |
Parsed from the IMAP message |
from_address, from_name |
Decoded address header |
to, cc, bcc |
Address lists (also persisted to MailRecipient rows for indexed lookups) |
received_at |
IMAP date header, falls back to now() |
meta.imap_uid |
The fetched UID for diagnostics |
Attachments are persisted to MailAttachment rows. When imap.attachments.download = true the bytes are streamed to the configured disk; oversized attachments (> max_bytes) skip the download but keep the metadata.
Threading
When imap.auto_thread = true (default), the poller's MessageThreader matches each inbound's In-Reply-To / References against existing outbound message_ids. On a hit:
- The inbound row's
thread_root_idpoints at the outbound parent. - The
parent_idcolumn points at the immediate ancestor in the thread. MailEvent::Threadedrecords the match.
To walk the whole thread:
use Blax\Mail\Queries\GetThreadQuery; $thread = app(GetThreadQuery::class) ->forMessage($message) ->execute(); // Collection<MailMessage>, ordered by created_at
Apps that prefer their own threading set BLAX_MAIL_AUTO_THREAD=false and subscribe to InboundMailReceived.
Tracking (open / click)
config('blax-mail.tracking.enabled') controls the full open/click pipeline. When enabled, outbound HTML is rewritten at dispatch time:
- A 1×1 transparent GIF
<img>pointing at/{route_prefix}/open/{token}.gifis appended. - Every
<a href>is rewritten to/{route_prefix}/click/{token}?u={signed-target}.
Both endpoints validate the token, record a MailEvent (Opened / Clicked), then redirect / serve the pixel. Past token_ttl_days the pixel still returns 200 OK (mail clients don't mark the message broken) but no event is logged.
| Route name | URI | Behaviour |
|---|---|---|
blax-mail.tracking.open |
GET {prefix}/open/{token}.gif |
1×1 GIF, fires MailOpened |
blax-mail.tracking.click |
GET {prefix}/click/{token}?u={target} |
302 to target, records MailEvent::Clicked |
The text body is never rewritten — plain-text alternatives stay untouched.
Events
| Event | Payload | When |
|---|---|---|
OutboundMailQueued |
MailMessage |
dispatch() persisted the row + queued the send |
OutboundMailSent |
MailMessage |
SMTP accepted the message |
OutboundMailFailed |
MailMessage, Throwable |
All retries exhausted |
InboundMailReceived |
MailMessage, ?MailMessage $threadParent |
Poller persisted an inbound row |
MailOpened |
MailMessage, ?string $userAgent, ?string $ip |
Tracking pixel hit |
Click events are currently persisted as
MailEvent::Clickedrows but no dedicatedMailClickedevent class is fired yet — subscribe to the underlying model events if you need the hook today.
CQRS queries
Three query objects in Blax\Mail\Queries. Resolve from the container, chain builders, call execute():
ListMessagesQuery
$inbox = app(ListMessagesQuery::class) ->forMailbox($box->id) ->inboundOnly() ->unread() ->since(now()->subWeek()) ->limit(50) ->execute(); // Collection<MailMessage> // or paginate: $page = app(ListMessagesQuery::class) ->forMailbox($box->id) ->outboundOnly() ->withStatus(MailStatus::Sent) ->paginate(25);
Builders: forMailbox(), direction(), inboundOnly(), outboundOnly(), withStatus(), unread(), forSubject($type, $id), since(), until(), limit(), execute(), paginate().
GetThreadQuery
$thread = app(GetThreadQuery::class) ->forMessage($message) ->execute(); // Collection<MailMessage>, ordered chronologically
FindMessageByMessageIdQuery
$msg = app(FindMessageByMessageIdQuery::class) ->execute('<msg-id@example.com>'); // ?MailMessage
Models
| Class | Table | Purpose |
|---|---|---|
Blax\Mail\Models\Mailbox |
mailboxes |
Per-identity SMTP + IMAP config + watermark |
Blax\Mail\Models\MailMessage |
mail_messages |
One row per sent / received message |
Blax\Mail\Models\MailRecipient |
mail_recipients |
Normalized address-per-row for indexed forSubject lookups |
Blax\Mail\Models\MailAttachment |
mail_attachments |
Filename, mime, size, storage path |
Blax\Mail\Models\MailEvent |
mail_events |
Audit log: Queued / Sent / Opened / Clicked / … |
MailMessage also exposes:
$message->mailbox; // BelongsTo Mailbox $message->recipients; // HasMany MailRecipient $message->attachments; // HasMany MailAttachment $message->events; // HasMany MailEvent (audit timeline) $message->subject; // MorphTo — resolves the polymorphic subject if set $message->thread(); // Whole thread as a Collection $message->parent(); // Immediate parent in the thread, or null $message->isInbound(); // bool $message->isOutbound(); // bool $message->markRead(); // status → Read
Swapping a model
// config/blax-mail.php 'models' => [ 'mail_message' => \App\Models\MyMailMessage::class, // extends Blax\Mail\Models\MailMessage ],
The package resolves every model via config('blax-mail.models.X'), so a subclass slots in without touching the core code.
Enums
Blax\Mail\Enums\MailDirection:
| Case | Value |
|---|---|
Outbound |
outbound |
Inbound |
inbound |
Blax\Mail\Enums\MailStatus:
| Case | Value | Notes |
|---|---|---|
Queued |
queued |
Outbound — persisted, awaiting SMTP |
Sending |
sending |
Outbound — SendMailJob is running |
Sent |
sent |
Outbound — SMTP accepted |
Delivered |
delivered |
Outbound — confirmed delivered (provider-dependent) |
Bounced |
bounced |
Outbound — provider reported bounce |
Failed |
failed |
Outbound — all retries exhausted |
Received |
received |
Inbound — fresh from poller |
Read |
read |
Inbound — markRead() was called |
Blax\Mail\Enums\MailEventType (audit log entries): Queued, Sent, Delivered, Bounced, Complaint, Failed, Opened, Clicked, Received, Threaded.
Console commands
# Poll every enabled mailbox once (typically invoked by the scheduler). php artisan blax-mail:poll # Restrict to one mailbox (matches by id, email, or name): php artisan blax-mail:poll --mailbox=support@example.com # Hard-delete soft-deleted messages older than retention.purge_days. php artisan blax-mail:cleanup
Scheduler
The package self-registers blax-mail:poll on the host scheduler via callAfterResolving(Schedule::class, …) — the binding only resolves inside schedule:run / schedule:work, so web requests and other commands pay nothing.
Defaults:
cron expression * * * * * every minute
without overlapping true mutex prevents queue-up
Override per environment:
BLAX_MAIL_POLL_CRON="*/5 * * * *" # poll every 5 minutes BLAX_MAIL_SCHEDULE_NO_OVERLAP=false # allow parallel polls BLAX_MAIL_SCHEDULE_ENABLED=false # disable auto-schedule, register manually
If you set BLAX_MAIL_SCHEDULE_ENABLED=false, register the command yourself:
// routes/console.php Schedule::command('blax-mail:poll')->everyTenMinutes()->withoutOverlapping();
Extending
React to inbound mail
use Blax\Mail\Events\InboundMailReceived; Event::listen(InboundMailReceived::class, function (InboundMailReceived $event) { // $event->message — Blax\Mail\Models\MailMessage (already persisted) // $event->threadParent — ?MailMessage (the matched outbound, or null) // Typical pattern: file the inbound onto your own domain pivot. // Walk threadParent → meta.app_mail_id → your domain Mail row, then clone // its M:N subject linkage onto the inbound so it surfaces on every // entity feed the original outbound was filed under. });
Custom threading
BLAX_MAIL_AUTO_THREAD=false
Then subscribe to InboundMailReceived and set thread_root_id / parent_id yourself.
Custom transport / storage
Bind your own implementation:
// AppServiceProvider::register $this->app->singleton(\Blax\Mail\Contracts\Dispatcher::class, MyDispatcher::class); $this->app->singleton(\Blax\Mail\Contracts\Poller::class, MyPoller::class);
Both contracts have one method (dispatch(OutboundMail): MailMessage, poll(Mailbox): int).
Security
Please report vulnerabilities by email: office@blax.at. We'll acknowledge within 72 hours.
Credits
License
MIT. See LICENSE.