grim-reapper/laravel-advanced-email

An advanced email package for Laravel offering queuing, Blade/HTML templates, attachments, and dynamic configuration.

Maintainers

Package info

github.com/grim-reapper/laravel-advanced-email

pkg:composer/grim-reapper/laravel-advanced-email

Transparency log

Statistics

Installs: 30

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

3.1.0 2026-09-02 14:12 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>')
    ->with(['name' => $user->name])   // {{ name }} / ##name## / [[name]] in the body/subject
    ->send();

Placeholder values are substituted as plain text. They are not evaluated as Blade — turning inline ->html() content into a Blade template is a code-execution risk and is off by default (advanced_email.templates.render_blade); enable it only for fully trusted, developer-authored content. Use ->view('emails.welcome', [...]) for real Blade templates, or database templates for content managed at runtime. Set advanced_email.templates.escape_html_placeholders if placeholder values can contain untrusted input.

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' => [
            'driver' => \GrimReapper\AdvancedEmail\Webhooks\Drivers\SesSnsDriver::class,
            'enabled' => true,
            // STRONGLY RECOMMENDED — a valid SNS signature only proves the message
            // came from *some* SNS topic, not necessarily yours. Without this,
            // any AWS account could publish forged bounces to your endpoint.
            'topic_arn' => env('ADVANCED_EMAIL_WEBHOOK_SES_TOPIC_ARN'),
        ],
        // 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 + a topic_arn allow-list for SES with SSRF-guarded certificate fetching, shared-secret/basic-auth for Postmark/Mailjet/SMTP2GO). Signed providers (Mailgun, SendGrid) also reject stale timestamps to prevent replay (webhooks.tolerance, default 300s). 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.

There are also optional JSON API endpoints (POST /api/advanced-email/gdpr/{export,erase}), gated behind both advanced_email.api.gdpr.enabled and a non-empty advanced_email.api.gdpr.middleware authorization gate. If you enable the feature without configuring a gate the routes are deliberately not registered (an error is logged) — "some authenticated user" isn't a strong enough bar for reading or erasing anyone's data by email address. Over the API the tenant is always taken from your bound TenantResolver, never from the request body.

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.