laravel-advanced-email maintained by grim-reapper
Laravel Advanced Email
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.