jeffersongoncalves/laravel-mail
Complete email management for Laravel: logs outgoing mail, database Blade templates with translations and versioning, webhook delivery tracking (SES/SendGrid/Postmark/Mailgun/Resend), open/click pixel tracking, suppression list, CSS inlining, List-Unsubscribe, preview, stats, retries.

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.
MessageSent eventspatie/laravel-translatableprovider_event_idList-Unsubscribe and List-Unsubscribe-Post headersHasMailLogs traitmail:send-test, mail:templates, mail:stats, mail:prune, mail:retry, mail:unsuppresscomposer require jeffersongoncalves/laravel-mail
Publish and run the migrations:
php artisan vendor:publish --tag="laravel-mail-migrations"
php artisan migrate
Optionally 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=false
LARAVEL_MAIL_STORE_HTML=true
LARAVEL_MAIL_STORE_TEXT=true
Create 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 MailTemplateVersion
Preview 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 true
Wrap 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 default
When 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-ClickTrack email delivery status in real-time via provider webhooks. Supports: Amazon SES, SendGrid, Postmark, Mailgun, and Resend.
LARAVEL_MAIL_TRACKING_ENABLED=true
Enable 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:
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
],
],
InjectTrackingPixel listener modifies the HTML body:
<img> tag with a 1x1 transparent GIF before </body><a href="..."> links to route through the package's click endpointopened eventclicked event) 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.
signing_key (or APP_KEY as fallback)http or https schemes (blocks javascript:, data:, etc.)mailto:, tel:, sms:, and anchor (#) links are not rewrittenCache-Control: no-store to prevent cachingPixel 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:
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.com
View 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 aggregations
Send 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=50
Hard 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 days
Keep 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 days
php artisan mail:templates
Associate 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); // MailLog
use JeffersonGoncalves\LaravelMail\Facades\MailStats;
MailStats::sent($from, $to);
MailStats::deliveryRate($from, $to);
MailStats::dailyStats($from, $to);
composer test
Please 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.
How can I help you explore Laravel packages today?