jeffersongoncalves/laravel-mail

Complete email management for Laravel: logging, database templates with translation, delivery tracking via webhooks (SES, SendGrid, Postmark, Mailgun, Resend), preview, resend, and analytics.

Maintainers

Package info

github.com/jeffersongoncalves/laravel-mail

pkg:composer/jeffersongoncalves/laravel-mail

Fund package maintenance!

jeffersongoncalves

Statistics

Installs: 168

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

1.3.0 2026-04-06 02:45 UTC

This package is auto-updated.

Last update: 2026-04-06 02:46:07 UTC


README

Laravel Mail

Laravel Mail

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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.

Features

  • Email Logging — Automatically logs all outgoing emails via MessageSent event
  • 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-Unsubscribe and List-Unsubscribe-Post headers
  • 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 HasMailLogs trait
  • Multi-Tenant — Optional tenant scoping for all tables
  • Customizable — Override models, table names, and database connection
  • CLI Commandsmail:send-test, mail:templates, mail:stats, mail:prune, mail:retry, mail:unsuppress

Installation

composer 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"

Email Logging

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.

Disable Logging

LARAVEL_MAIL_LOGGING_ENABLED=false

Control What Gets Stored

LARAVEL_MAIL_STORE_HTML=true
LARAVEL_MAIL_STORE_TEXT=true

Database Templates

Create email templates in the database with multi-locale support via spatie/laravel-translatable and Blade rendering.

Creating a Template

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'],
    ],
]);

Using Templates in Mailables

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],
        );
    }
}

Translation API

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 !');

Template Versioning

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

Template Preview

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

Layouts

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>']);

Inline CSS

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.

List-Unsubscribe Headers

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

Delivery Tracking via Webhooks

Track email delivery status in real-time via provider webhooks. Supports: Amazon SES, SendGrid, Postmark, Mailgun, and Resend.

Enable Tracking

LARAVEL_MAIL_TRACKING_ENABLED=true

Configure Provider

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'),
        ],
    ],
],

Webhook URLs

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

Tracked Events

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

Webhook Idempotency

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.

Listening to Tracking Events

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.
});

Signature Validation

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).

Pixel Tracking (Provider-Independent)

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.

Enable Pixel 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
    ],
],

How It Works

  1. When an email is sent, the InjectTrackingPixel listener 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
  2. When the recipient opens the email, the pixel image is loaded from your server, registering an opened event
  3. When the recipient clicks a link, it passes through your server (registering a clicked event) and redirects to the original URL

Tracking URLs

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.

Security

  • URLs are signed with HMAC-SHA256 using the configured signing_key (or APP_KEY as fallback)
  • Click redirects validate that the target URL uses http or https schemes (blocks javascript:, data:, etc.)
  • mailto:, tel:, sms:, and anchor (#) links are not rewritten
  • Pixel responses include Cache-Control: no-store to prevent caching

Coexistence with Provider Webhooks

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).

Suppression List

Automatically suppress email addresses that hard bounce or receive spam complaints. Suppressed addresses are blocked from receiving future emails.

Enable Suppression

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)

Manual Suppression Management

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();

Unsuppress Command

php artisan mail:unsuppress user@example.com

Browser Preview

View sent emails and templates directly in the browser.

Enable Preview

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
],

Preview URLs

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.

Statistics

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

Notification Channel

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 Failed Emails

Retry emails that failed to send or soft-bounced.

Enable Retry

// config/laravel-mail.php
'retry' => [
    'enabled' => true,
    'max_attempts' => 3,
],

Retry Programmatically

use JeffersonGoncalves\LaravelMail\Actions\RetryFailedMailAction;

$action = new RetryFailedMailAction();
$action->execute($failedMailLog); // Returns true on success, false if max attempts reached

Retry via Command

# 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 Emails

Resend any previously logged email:

use JeffersonGoncalves\LaravelMail\Actions\ResendMailAction;

$action = new ResendMailAction();
$action->execute($mailLog);

Attachment File Storage

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.

Pruning Old Logs

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

Per-Status Retention Policies

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();

CLI Commands

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 Test Email

# 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"}'

View Statistics

php artisan mail:stats            # Last 7 days (default)
php artisan mail:stats --days=30  # Last 30 days

List Templates

php artisan mail:templates

Polymorphic Association

Associate mail logs with any model:

use JeffersonGoncalves\LaravelMail\Traits\HasMailLogs;

class User extends Model
{
    use HasMailLogs;
}

$user->mailLogs()->latest()->get();

Multi-Tenancy

// config/laravel-mail.php
'tenant' => [
    'enabled' => true,
    'column' => 'tenant_id',
],

Custom Models

// 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,
],

Custom Table Names

// 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',
    ],
],

Facades

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);

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.