blax-software/laravel-mail
Composer 安装命令:
composer require 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.
README 文档
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.
Star History
blax-software/laravel-mail 适用场景与选型建议
blax-software/laravel-mail 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 8 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 05 月 13 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「mail」 「email」 「smtp」 「imap」 「laravel」 「tracking」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 blax-software/laravel-mail 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 blax-software/laravel-mail 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 blax-software/laravel-mail 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
Courier offers a convenient and painless solution for creating emails tailored for your Kirby website.
Mail libraries used by Zimbra Api
Extensible library for building notifications and sending them via different delivery channels.
The Message Submission Agent Diagnostics tool (msadiag) facilitates testing the compatibility of third party message submission agents.
Email+ extends Kirby's email capabilities by adding support for multiple email services using the same Kirby email API.
PHPMailer helper class for sending emails using SMTP
统计信息
- 总下载量: 8
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 53
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-05-13