laravel-mail maintained by blax-software
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.