Complete email management for Laravel: logging, database templates with translation (spatie/laravel-translatable), delivery tracking via webhooks (SES, SendGrid, Postmark, Mailgun, Resend), pixel tracking (provider-independent open & click tracking), suppression list, inline CSS, List-Unsubscribe headers, browser preview, statistics, notification channel, retry, attachment storage, and analytics.
- Email Logging — Automatically logs all outgoing emails via
MessageSentevent - Database Templates — Store email templates with Blade rendering and multi-locale translation via
spatie/laravel-translatable - Template Versioning — Automatic version history on every content change
- Delivery Tracking — Webhook handlers for 5 providers (SES, SendGrid, Postmark, Mailgun, Resend) with HMAC validation
- Pixel Tracking — Provider-independent open and click tracking via injected 1x1 transparent pixel and link rewriting (works with any mailer including plain SMTP)
- Webhook Idempotency — Duplicate webhook deliveries are detected and ignored via
provider_event_id - Tracking Events — Laravel events dispatched on delivery, bounce, complaint, open, click, and deferral
- Suppression List — Auto-suppress hard bounces and complaints, block sending to suppressed addresses
- Inline CSS — Automatic CSS inlining for email client compatibility (Outlook, Gmail, etc.)
- List-Unsubscribe Headers — Gmail/Yahoo compliance with
List-UnsubscribeandList-Unsubscribe-Postheaders - Browser Preview — View sent emails and templates in the browser via signed URLs
- Statistics — Query helpers for sent, delivered, bounced, opened, clicked counts and daily aggregations
- Notification Channel — Send database templates via Laravel Notifications
- Retry Failed Emails — Retry failed or soft-bounced emails with max attempts control
- Resend Emails — Resend any previously sent email from the log
- Attachment File Storage — Optionally store email attachment files to disk (S3, local, etc.)
- Pruning — Artisan command to clean up old mail logs with per-status retention policies
- Polymorphic Association — Associate mail logs with any model via
HasMailLogstrait - Multi-Tenant — Optional tenant scoping for all tables
- Customizable — Override models, table names, and database connection
- CLI Commands —
mail:send-test,mail:templates,mail:stats,mail:prune,mail:retry,mail:unsuppress
composer require jeffersongoncalves/laravel-mailPublish and run the migrations:
php artisan vendor:publish --tag="laravel-mail-migrations"
php artisan migrateOptionally publish the config file:
php artisan vendor:publish --tag="laravel-mail-config"Logging is enabled by default. Every email sent through Laravel's Mail facade is automatically logged to the mail_logs table.
// Just send emails normally — they are logged automatically
Mail::to('user@example.com')->send(new WelcomeMail($user));Each log entry captures: mailer, subject, from/to/cc/bcc/reply-to, HTML body, text body, headers, attachments metadata, and provider message ID.
When using TemplateMailable, the mail_template_id is automatically associated with the log entry.
LARAVEL_MAIL_LOGGING_ENABLED=falseLARAVEL_MAIL_STORE_HTML=true
LARAVEL_MAIL_STORE_TEXT=trueCreate email templates in the database with multi-locale support via spatie/laravel-translatable and Blade rendering.
use JeffersonGoncalves\LaravelMail\Models\MailTemplate;
MailTemplate::create([
'key' => 'welcome',
'name' => 'Welcome Email',
'subject' => ['en' => 'Welcome, {{ $name }}!', 'pt_BR' => 'Bem-vindo, {{ $name }}!'],
'html_body' => [
'en' => '<h1>Hello {{ $name }}</h1><p>Welcome to our platform.</p>',
'pt_BR' => '<h1>Ola {{ $name }}</h1><p>Bem-vindo a nossa plataforma.</p>',
],
'variables' => [
['name' => 'name', 'type' => 'string', 'example' => 'John'],
],
]);Extend TemplateMailable to create mailables that fetch content from the database:
use JeffersonGoncalves\LaravelMail\Mail\TemplateMailable;
use Illuminate\Mail\Mailables\Content;
class WelcomeEmail extends TemplateMailable
{
public function __construct(
public User $user,
) {}
public function templateKey(): string
{
return 'welcome';
}
public function templateData(): array
{
return ['name' => $this->user->name];
}
protected function fallbackSubject(): string
{
return 'Welcome!';
}
protected function fallbackContent(): Content
{
return new Content(
view: 'emails.welcome',
with: ['user' => $this->user],
);
}
}The MailTemplate model uses spatie/laravel-translatable. You can access translations via:
// Get for current locale
$template->subject; // Returns string for app()->getLocale()
// Get for specific locale
$template->getSubjectForLocale('pt_BR');
$template->getHtmlBodyForLocale('en');
$template->getTextBodyForLocale('es');
// Get all translations
$template->getTranslations('subject'); // ['en' => '...', 'pt_BR' => '...']
// Set translation
$template->setTranslation('subject', 'fr', 'Bienvenue !');Every content change (subject, html_body, text_body) automatically creates a version snapshot:
$template->update([
'subject' => ['en' => 'Updated Welcome, {{ $name }}!'],
'html_body' => ['en' => '<h1>New design for {{ $name }}</h1>'],
]);
// Version 2 is automatically created
$template->versions; // Collection of MailTemplateVersionPreview a template with example data without sending:
use JeffersonGoncalves\LaravelMail\Actions\PreviewTemplateAction;
$action = new PreviewTemplateAction();
$preview = $action->execute($template, ['name' => 'Alice'], 'en');
// Returns: ['subject' => '...', 'html' => '...', 'text' => '...']
// HTML is automatically CSS-inlined when templates.inline_css is trueWrap template content in a shared layout:
// config/laravel-mail.php
'templates' => [
'default_layout' => '<html><body>{!! $slot !!}</body></html>',
],
// Or per template
$template->update(['layout' => '<html><body>{!! $slot !!}</body></html>']);Automatically inlines CSS styles for email client compatibility (Outlook, Gmail, Yahoo, etc.). Uses tijsverkoyen/css-to-inline-styles which is already included with Laravel.
LARAVEL_MAIL_INLINE_CSS=true # Enabled by defaultWhen enabled, any <style> tags in your template HTML will be converted to inline style attributes on the corresponding elements. This applies to both TemplateMailable sending and PreviewTemplateAction rendering.
Add List-Unsubscribe and List-Unsubscribe-Post headers for Gmail/Yahoo compliance (required since 2024 for bulk senders).
// config/laravel-mail.php
'templates' => [
'unsubscribe' => [
'enabled' => true,
'url' => 'https://yourapp.com/unsubscribe/{email}', // {email} is replaced with recipient
'mailto' => 'unsubscribe@yourapp.com',
],
],When enabled, all emails sent via TemplateMailable will include:
List-Unsubscribe: <https://yourapp.com/unsubscribe/user%40example.com>, <mailto:unsubscribe@yourapp.com?subject=unsubscribe>List-Unsubscribe-Post: List-Unsubscribe=One-Click
Track email delivery status in real-time via provider webhooks. Supports: Amazon SES, SendGrid, Postmark, Mailgun, and Resend.
LARAVEL_MAIL_TRACKING_ENABLED=trueEnable the providers you use in config/laravel-mail.php:
'tracking' => [
'enabled' => true,
'route_prefix' => 'webhooks/mail',
'providers' => [
'ses' => [
'enabled' => true,
],
'sendgrid' => [
'enabled' => true,
'signing_secret' => env('LARAVEL_MAIL_SENDGRID_SIGNING_SECRET'),
],
'postmark' => [
'enabled' => true,
'username' => env('LARAVEL_MAIL_POSTMARK_WEBHOOK_USERNAME'),
'password' => env('LARAVEL_MAIL_POSTMARK_WEBHOOK_PASSWORD'),
],
'mailgun' => [
'enabled' => true,
'signing_key' => env('LARAVEL_MAIL_MAILGUN_SIGNING_KEY'),
],
'resend' => [
'enabled' => true,
'signing_secret' => env('LARAVEL_MAIL_RESEND_SIGNING_SECRET'),
],
],
],Configure these URLs in your email provider's dashboard:
| Provider | Webhook URL |
|---|---|
| Amazon SES | https://yourapp.com/webhooks/mail/ses |
| SendGrid | https://yourapp.com/webhooks/mail/sendgrid |
| Postmark | https://yourapp.com/webhooks/mail/postmark |
| Mailgun | https://yourapp.com/webhooks/mail/mailgun |
| Resend | https://yourapp.com/webhooks/mail/resend |
| Event | Description | Updates Status | Laravel Event |
|---|---|---|---|
delivered |
Email successfully delivered | sent -> delivered |
MailDelivered |
bounced |
Email bounced (hard/soft) | -> bounced |
MailBounced |
complained |
Recipient marked as spam | -> complained |
MailComplained |
opened |
Email was opened | No | MailOpened |
clicked |
Link was clicked | No | MailClicked |
deferred |
Delivery was delayed | No | MailDeferred |
Duplicate webhook deliveries from providers are automatically detected and ignored. Each webhook handler extracts a unique provider_event_id from the payload (e.g., SNS MessageId for SES, sg_event_id for SendGrid, svix-id for Resend). If an event with the same ID already exists, it is skipped — no duplicate tracking events, no duplicate Laravel events dispatched.
React to delivery events in your application:
use JeffersonGoncalves\LaravelMail\Events\MailBounced;
use JeffersonGoncalves\LaravelMail\Events\MailComplained;
// In a listener or EventServiceProvider
Event::listen(MailBounced::class, function (MailBounced $event) {
// $event->mailLog — the MailLog model
// $event->trackingEvent — the MailTrackingEvent model
Log::warning("Email bounced: {$event->trackingEvent->recipient}");
});
Event::listen(MailComplained::class, function (MailComplained $event) {
// Disable the user's account, send alert, etc.
});Each provider uses its own authentication method:
- SES — SNS certificate URL verification
- SendGrid — Timestamp-based validation (ECDSA)
- Postmark — HTTP Basic Auth
- Mailgun — HMAC SHA256 signature
- Resend — Svix HMAC SHA256 signature
When no signing secret is configured, validation is skipped (useful for development).
Track email opens and clicks without relying on email provider webhooks. Works with any mailer including plain SMTP. The package injects a 1x1 transparent GIF pixel for open tracking and rewrites links for click tracking.
LARAVEL_MAIL_PIXEL_OPEN_TRACKING=true
LARAVEL_MAIL_PIXEL_CLICK_TRACKING=true// config/laravel-mail.php
'tracking' => [
'pixel' => [
'open_tracking' => true, // Inject tracking pixel in emails
'click_tracking' => true, // Rewrite links for click tracking
'route_prefix' => 'mail/t',
'route_middleware' => [],
'signing_key' => env('LARAVEL_MAIL_PIXEL_SIGNING_KEY'), // null = uses APP_KEY
],
],- When an email is sent, the
InjectTrackingPixellistener modifies the HTML body:- Open tracking: Injects a
<img>tag with a 1x1 transparent GIF before</body> - Click tracking: Rewrites all
<a href="...">links to route through the package's click endpoint
- Open tracking: Injects a
- When the recipient opens the email, the pixel image is loaded from your server, registering an
openedevent - When the recipient clicks a link, it passes through your server (registering a
clickedevent) and redirects to the original URL
| Endpoint | Purpose |
|---|---|
GET /mail/t/pixel/{id}?sig={hmac} |
Serves 1x1 transparent GIF, records open event |
GET /mail/t/click/{id}?url={base64}&sig={hmac} |
Records click event, redirects to original URL |
All URLs are signed with HMAC-SHA256 to prevent forgery. Invalid signatures are silently ignored for pixel requests (still serves the GIF) and rejected with 403 for click requests.
- URLs are signed with HMAC-SHA256 using the configured
signing_key(orAPP_KEYas fallback) - Click redirects validate that the target URL uses
httporhttpsschemes (blocksjavascript:,data:, etc.) mailto:,tel:,sms:, and anchor (#) links are not rewritten- Pixel responses include
Cache-Control: no-storeto prevent caching
Pixel tracking works alongside webhook-based tracking. Both register events in the mail_tracking_events table with different providers (pixel vs ses/sendgrid/etc.), and both dispatch the same Laravel events (MailOpened, MailClicked).
Automatically suppress email addresses that hard bounce or receive spam complaints. Suppressed addresses are blocked from receiving future emails.
LARAVEL_MAIL_SUPPRESSION_ENABLED=true// config/laravel-mail.php
'suppression' => [
'enabled' => true,
'auto_suppress_hard_bounces' => true,
'auto_suppress_complaints' => true,
],When enabled, the package automatically:
- Adds hard-bounced addresses to the suppression list
- Adds complained addresses to the suppression list
- Blocks sending to any suppressed address (cancels the email before it's sent)
use JeffersonGoncalves\LaravelMail\Models\MailSuppression;
use JeffersonGoncalves\LaravelMail\Enums\SuppressionReason;
// Manually suppress an address
MailSuppression::create([
'email' => 'user@example.com',
'reason' => SuppressionReason::Manual,
'suppressed_at' => now(),
]);
// Check if an address is suppressed
$isSuppressed = MailSuppression::where('email', 'user@example.com')->exists();php artisan mail:unsuppress user@example.comView sent emails and templates directly in the browser.
LARAVEL_MAIL_PREVIEW_ENABLED=true// config/laravel-mail.php
'preview' => [
'enabled' => true,
'route_prefix' => 'mail/preview',
'route_middleware' => ['web'],
'signed_urls' => true, // Require signed URLs for security
],Access preview URLs via model accessors:
$mailLog->preview_url; // GET /mail/preview/mail-log/{id}?signature=...
$template->preview_url; // GET /mail/preview/template/{id}?signature=...When signed_urls is enabled, URLs are cryptographically signed and cannot be tampered with. When disabled, plain URLs are generated.
Query email statistics with the MailStats facade:
use JeffersonGoncalves\LaravelMail\Facades\MailStats;
use Illuminate\Support\Carbon;
$from = Carbon::now()->subDays(30);
$to = Carbon::now();
MailStats::sent($from, $to); // int
MailStats::delivered($from, $to); // int
MailStats::bounced($from, $to); // int
MailStats::complained($from, $to); // int
MailStats::opened($from, $to); // int (from tracking events)
MailStats::clicked($from, $to); // int (from tracking events)
MailStats::deliveryRate($from, $to); // float (percentage)
MailStats::bounceRate($from, $to); // float (percentage)
MailStats::dailyStats($from, $to); // Collection of daily aggregationsSend database templates via Laravel Notifications:
use JeffersonGoncalves\LaravelMail\Channels\TemplateMailChannel;
use Illuminate\Notifications\Notification;
class WelcomeNotification extends Notification
{
public function via($notifiable): array
{
return [TemplateMailChannel::class];
}
public function toTemplateMail($notifiable): array
{
return [
'template_key' => 'welcome',
'data' => ['name' => $notifiable->name],
'locale' => 'en',
];
}
}Retry emails that failed to send or soft-bounced.
// config/laravel-mail.php
'retry' => [
'enabled' => true,
'max_attempts' => 3,
],use JeffersonGoncalves\LaravelMail\Actions\RetryFailedMailAction;
$action = new RetryFailedMailAction();
$action->execute($failedMailLog); // Returns true on success, false if max attempts reached# Retry failed emails from the last 24 hours
php artisan mail:retry
# Retry soft-bounced emails from the last 48 hours
php artisan mail:retry --status=bounced --hours=48
# Limit the number of retries
php artisan mail:retry --limit=50Hard bounces are automatically skipped when retrying bounced emails.
Resend any previously logged email:
use JeffersonGoncalves\LaravelMail\Actions\ResendMailAction;
$action = new ResendMailAction();
$action->execute($mailLog);Optionally store email attachment files to disk for later retrieval:
LARAVEL_MAIL_STORE_ATTACHMENT_FILES=true
LARAVEL_MAIL_ATTACHMENTS_DISK=local # or s3, etc.// config/laravel-mail.php
'logging' => [
'store_attachment_files' => true,
'attachments_disk' => 'local',
'attachments_path' => 'mail-attachments',
],When enabled, each attachment is stored to the configured disk and the path and disk are added to the attachment metadata in the mail log. When pruning, stored files are automatically cleaned up.
Clean up old mail logs:
php artisan mail:prune # Prune logs older than 30 days (default)
php artisan mail:prune --days=7 # Prune logs older than 7 daysKeep different statuses for different durations:
// config/laravel-mail.php
'prune' => [
'enabled' => true,
'older_than_days' => 30, // default fallback
'policies' => [
'delivered' => 30, // delete delivered after 30 days
'bounced' => 90, // keep bounced for 90 days
'complained' => 365, // keep complaints for 1 year
],
],Schedule it:
Schedule::command('mail:prune')->daily();| Command | Description |
|---|---|
mail:prune |
Delete old mail logs (supports --days option) |
mail:retry |
Retry failed/bounced emails (supports --status, --hours, --limit) |
mail:unsuppress {email} |
Remove an email from the suppression list |
mail:send-test {key} {email} |
Send a test email using a template (supports --locale, --data) |
mail:templates |
List all mail templates in a table |
mail:stats |
Show email statistics (supports --days) |
# Send with example data from template variables
php artisan mail:send-test welcome user@example.com
# With specific locale
php artisan mail:send-test welcome user@example.com --locale=pt_BR
# With custom data
php artisan mail:send-test welcome user@example.com --data='{"name":"Alice"}'php artisan mail:stats # Last 7 days (default)
php artisan mail:stats --days=30 # Last 30 daysphp artisan mail:templatesAssociate mail logs with any model:
use JeffersonGoncalves\LaravelMail\Traits\HasMailLogs;
class User extends Model
{
use HasMailLogs;
}
$user->mailLogs()->latest()->get();// config/laravel-mail.php
'tenant' => [
'enabled' => true,
'column' => 'tenant_id',
],// config/laravel-mail.php
'models' => [
'mail_log' => \App\Models\MailLog::class,
'mail_template' => \App\Models\MailTemplate::class,
'mail_template_version' => \App\Models\MailTemplateVersion::class,
'mail_tracking_event' => \App\Models\MailTrackingEvent::class,
'mail_suppression' => \App\Models\MailSuppression::class,
],// config/laravel-mail.php
'database' => [
'connection' => null,
'tables' => [
'mail_logs' => 'mail_logs',
'mail_templates' => 'mail_templates',
'mail_template_versions' => 'mail_template_versions',
'mail_tracking_events' => 'mail_tracking_events',
'mail_suppressions' => 'mail_suppressions',
],
],use JeffersonGoncalves\LaravelMail\Facades\LaravelMail;
LaravelMail::isLoggingEnabled(); // bool
LaravelMail::isTrackingEnabled(); // bool
LaravelMail::findByProviderMessageId('msg-id-123'); // ?MailLog
LaravelMail::updateStatus($mailLog, MailStatus::Delivered); // MailLoguse JeffersonGoncalves\LaravelMail\Facades\MailStats;
MailStats::sent($from, $to);
MailStats::deliveryRate($from, $to);
MailStats::dailyStats($from, $to);composer testPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.
