grim-reapper / laravel-advanced-email
An advanced email package for Laravel offering queuing, Blade/HTML templates, attachments, and dynamic configuration.
Package info
github.com/grim-reapper/laravel-advanced-email
pkg:composer/grim-reapper/laravel-advanced-email
Requires
- php: ^8.2
- illuminate/broadcasting: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/filesystem: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/mail: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/queue: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- laravel/sanctum: ^3.0 || ^4.0
- orchestra/testbench: ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^9.0 || ^10.0 || ^11.0 || ^12.0
Suggests
- laravel/sanctum: Required if you enable advanced_email.api and keep the default auth:sanctum middleware (swap it out in config for Passport/a custom API-key guard/etc. if you'd rather not use Sanctum).
This package is auto-updated.
Last update: 2026-07-17 12:40:04 UTC
README
A powerful package that enhances Laravel's email capabilities with advanced features for enterprise-level email management. Works standalone in single-tenant apps out of the box, and gains full tenant isolation with a couple lines of config in multi-tenant apps — see Multi-Tenancy below.
Features
- Template Management: Database-driven email templates with versioning support
- Advanced Scheduling: Schedule one-time and recurring emails with conditions, retries, and expiry
- A/B Testing: Run subject/content variants and automatically declare a winner by open or click rate
- Multi-Provider Support: Automatic failover between multiple email providers
- Email Tracking: Track email opens and link clicks for analytics
- Comprehensive Analytics: Detailed reporting on email performance, with a bundled (optional) dashboard
- Attachment Handling: Multiple ways to attach files to emails
- Multi-Tenancy: Optional, framework-agnostic tenant isolation — opt in without touching any code that doesn't need it
- Bounce/Complaint Webhooks: Ingests real delivery events from SES, Mailgun, Postmark, SendGrid, Mailjet, SMTP2GO, or any other ESP via a config-driven generic driver
- Suppression List: Auto-suppresses hard bounces/complaints, honors unsubscribes, checked before every send
- Send-Rate Limiting: Optional throttling built on Laravel's own rate limiter
- GDPR Export/Erasure: Find, export, or redact/delete everything stored about a recipient
- JSON REST API: Optional, fully authenticated API for non-Blade frontends (React, Next.js, mobile, ...)
Requirements
- PHP 8.2+
- Laravel 9, 10, 11, 12, or 13
Installation
composer require grim-reapper/laravel-advanced-email
Publish the configuration:
php artisan vendor:publish --provider="GrimReapper\AdvancedEmail\AdvancedEmailServiceProvider" --tag="config"
Run the migrations:
php artisan migrate
Basic Usage
use GrimReapper\AdvancedEmail\Facades\Email; Email::to('recipient@example.com') ->subject('Welcome to Our Application') ->html('<h1>Welcome!</h1><p>Thank you for signing up.</p>') ->send();
A/B Testing
Create a test with a few variants (each can override subject and/or content):
use GrimReapper\AdvancedEmail\Models\EmailAbTest; use GrimReapper\AdvancedEmail\Models\EmailAbTestVariant; $test = EmailAbTest::create([ 'uuid' => \Illuminate\Support\Str::uuid(), 'name' => 'Welcome subject line', 'status' => 'running', 'winner_selection_strategy' => 'automatic_best_performing', 'decision_metric' => 'open_rate', // or 'click_rate' 'test_duration_hours' => 48, ]); EmailAbTestVariant::create(['email_ab_test_id' => $test->id, 'name' => 'A', 'subject' => 'Welcome!', 'html_content' => '...', 'weight' => 50]); EmailAbTestVariant::create(['email_ab_test_id' => $test->id, 'name' => 'B', 'subject' => "You're in", 'html_content' => '...', 'weight' => 50]);
Send for that test — ->abTest() picks a variant via weighted-random selection (respecting each variant's weight); ->abTestVariant() lets you target one directly:
Email::to($user->email)->from('hello@example.com')->abTest($test)->send();
The variant's subject/content is used as a default (an explicit ->subject()/->html() still wins). Sending increments the variant's sent_count; opening/clicking the resulting email increments open_count/click_count automatically via the tracking routes. Run php artisan email:process-ab-tests (e.g. on a schedule) to declare a winner once test_duration_hours has elapsed — it sets status to completed and marks the best-performing variant is_winner.
Multi-Tenancy
By default, this package behaves exactly like a single-tenant package — nothing below is required. To isolate email logs, templates, and scheduled emails per tenant, enable it in config/advanced_email.php (or via env vars) and tell the package how to find the "current" tenant:
// config/advanced_email.php 'multitenancy' => [ 'enabled' => true, 'resolver' => \App\Support\TenantResolver::class, // or leave null and bind it yourself 'column' => 'tenant_id', 'strict' => false, ],
Implement the resolver — it's a single method, with no dependency on any specific tenancy package:
namespace App\Support; use GrimReapper\AdvancedEmail\Contracts\TenantResolver; class TenantResolver implements TenantResolver { public function resolveId(): int|string|null { // Any of these, depending on how your app tracks the current tenant: return auth()->user()?->tenant_id; // return tenancy()->tenant?->id; // stancl/tenancy // return \Spatie\Multitenancy\Models\Tenant::current()?->id; // spatie/laravel-multitenancy } }
Or bind it directly in your own service provider instead of setting resolver in config (this always wins, regardless of registration order):
$this->app->bind(\GrimReapper\AdvancedEmail\Contracts\TenantResolver::class, TenantResolver::class);
Once enabled, EmailLog, EmailTemplate, EmailTemplateVersion, ScheduledEmail, and the A/B testing models are automatically scoped to the resolved tenant (a resolver returning null — the default, and always true outside HTTP context — means no scoping is applied, so console commands and background jobs correctly operate across all tenants). Every write is auto-attributed to the current tenant unless you set one explicitly:
Email::to('user@example.com') ->subject('Invoice') ->html($body) ->tenant($someOtherTenantId) // explicit override, wins over the resolver ->send();
Admin/cross-tenant tooling can bypass scoping on a per-query basis:
EmailLog::withoutTenancy()->count(); // all tenants EmailLog::forTenant($specificId)->get(); // one specific tenant, regardless of the current one
Bounce/Complaint Webhooks
Point your ESP's webhook at POST /email-tracking/webhooks/{provider} and turn it on in config:
// config/advanced_email.php 'webhooks' => [ 'providers' => [ 'mailgun' => [ 'driver' => \GrimReapper\AdvancedEmail\Webhooks\Drivers\MailgunDriver::class, 'enabled' => true, 'signing_key' => env('MAILGUN_WEBHOOK_SIGNING_KEY'), ], // ses, postmark, sendgrid, mailjet, smtp2go — same shape, see the shipped config file for each provider's specific option(s). ], ],
Every provider entry is enabled => false by default — an unconfigured or disabled provider 404s rather than accepting unverified requests. Each built-in driver verifies the request using that ESP's actual documented scheme (HMAC-SHA256 for Mailgun, ECDSA for SendGrid, SNS message signatures for SES with SSRF-guarded certificate fetching, shared-secret/basic-auth for Postmark/Mailjet/SMTP2GO). Verified events update EmailLog.status, auto-suppress hard bounces and complaints, and fire EmailBounced/EmailComplaint/EmailDelivered/EmailUnsubscribed events you can listen for. Every event is also recorded in email_webhook_events for a full audit trail regardless of whether it matched a known send.
Not using one of the six built-in providers? Use the config-driven GenericDriver — no code required, just a field-mapping config:
'my_esp' => [ 'driver' => \GrimReapper\AdvancedEmail\Webhooks\Drivers\GenericDriver::class, 'enabled' => true, 'secret' => env('MY_ESP_WEBHOOK_SECRET'), 'secret_header' => 'X-Webhook-Secret', 'events_path' => 'events', // dot-path to an array of events, or null if the payload IS one event 'field_map' => ['type' => 'event_type', 'message_id' => 'message.id', 'recipient' => 'recipient_email', 'timestamp' => 'occurred_at'], 'type_map' => ['delivery' => 'delivered', 'hard_bounce' => 'bounced', 'complaint' => 'complained'], ],
Or implement GrimReapper\AdvancedEmail\Contracts\WebhookDriver yourself for full control over parsing/verification.
Suppression List
Checked automatically before every send()/queue() — no setup needed beyond advanced_email.suppression.enabled (on by default):
use GrimReapper\AdvancedEmail\Contracts\SuppressionRepository; app(SuppressionRepository::class)->suppress('bounced@example.com', 'manual'); app(SuppressionRepository::class)->isSuppressed('bounced@example.com'); // true Email::to(['bounced@example.com', 'ok@example.com'])->from('hello@example.com')->subject('Hi')->html('...')->send(); // 'filter' mode (default): sends only to ok@example.com // 'block' mode: refuses the whole send
Bounce/complaint webhooks (above) suppress automatically. Recipient-initiated unsubscribes go through a signed link:
Email::unsubscribeUrl('user@example.com'); // put this in your own template/footer
Critical/system mail that must never be suppressed can opt out per-send: ->bypassSuppression().
Send-Rate Limiting
Off by default. Turn it on and it applies automatically — queued sends (->queue()) release back onto the queue when throttled (via Laravel's own RateLimited job middleware); synchronous sends (->send()) throw RateLimitExceededException since there's no queue to wait on:
// config/advanced_email.php 'rate_limiting' => [ 'enabled' => true, 'max_attempts' => 60, 'decay_seconds' => 60, 'key' => 'mailer', // or 'tenant', or 'mailer_tenant' ],
->bypassRateLimit() on a per-send basis for mail that must never be throttled.
GDPR Export/Erasure
php artisan email:gdpr-export user@example.com --output=export.json
php artisan email:gdpr-erase user@example.com --mode=anonymize # or --mode=delete
Or call it directly from your own code (e.g. an account-deletion flow):
use GrimReapper\AdvancedEmail\Services\PrivacyService; $data = app(PrivacyService::class)->export('user@example.com'); app(PrivacyService::class)->erase('user@example.com', tenantId: null, mode: 'anonymize');
anonymize redacts just the matching address within to/cc/bcc, keeping subjects/timestamps/engagement metrics intact for aggregate reporting; delete hard-deletes matching rows. Both search EmailLog, ScheduledEmail, EmailSuppression, and EmailWebhookEvent, scoped to a tenant or across all of them.
Frontend Framework Integration (React, Next.js, mobile, ...)
This package is backend-only (it's a Laravel package) — if your frontend isn't Blade, don't reach for the bundled dashboard. Two options, usable together:
1. The JSON REST API — off by default (advanced_email.api.enabled), since it's a new PII-bearing surface:
// config/advanced_email.php 'api' => [ 'enabled' => true, 'middleware' => ['api', 'auth:sanctum'], // swap for Passport, a custom API-key guard, whatever your app already uses ],
This gives you normal authenticated JSON endpoints under /api/advanced-email/... for email logs (read-only), templates (+ versions), A/B tests (+ variants), and suppressions — build your React/Next.js admin UI against them like any other API. laravel/sanctum is a suggested, not required, dependency — the default middleware assumes it (it ships with a standard Laravel install), but you're free to point middleware at anything else.
Every list endpoint (logs, templates, versions, A/B tests, variants, suppressions) is paginated by default, driven by advanced_email.api.pagination:
'pagination' => [ 'enabled' => true, // false returns the full unpaginated collection instead 'per_page' => 25, // default page size; override per-request with ?per_page= 'max_per_page' => 100, // hard ceiling on ?per_page=, regardless of what's requested ],
2. Optional live updates via broadcasting — instead of polling the API, subscribe to a channel with Laravel Echo for real-time status:
// config/advanced_email.php 'broadcasting' => ['enabled' => true, 'channel' => 'advanced-email'],
// Your React/Next.js app, via Laravel Echo Echo.private(`advanced-email.${tenantId}`).listen('.sent', (e) => { /* ... */ }) .listen('.bounced', (e) => { /* ... */ });
Define the channel's authorization callback in your own routes/channels.php — the package can't do that for you since it's inherently app-specific.
Using Your Own Models
Every model the package uses internally — EmailLog, EmailLink, EmailTemplate, EmailTemplateVersion, ScheduledEmail, EmailAbTest, EmailAbTestVariant, EmailSuppression, EmailWebhookEvent — is resolved through GrimReapper\AdvancedEmail\Support\Models rather than referenced directly anywhere in the package's services, controllers, jobs, or commands. That means you can point any of them at your own subclass — to add relationships to your own app's models, extra casts/accessors, custom scopes, or override behavior — without patching this package:
// config/advanced_email.php 'models' => [ 'email_template' => \App\Models\Email\Template::class, // must extend GrimReapper\AdvancedEmail\Models\EmailTemplate // ... the rest default to the package's own models when left null ],
EmailLog is the one exception — it's swapped via the pre-existing logging.database.model key instead of a key here, since that already served this exact purpose:
'logging' => [ 'database' => [ 'model' => \App\Models\Email\Log::class, // must extend GrimReapper\AdvancedEmail\Models\EmailLog ], ],
Your replacement class must extend the corresponding package model — it's still expected to have the same table/columns; this only changes which class gets instantiated/queried, not the underlying schema.
Testing
composer install
composer test
Documentation
For detailed documentation, please refer to the following guides:
License
The MIT License (MIT). Please see License File for more information.