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: 27

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

3.0.0 2026-07-17 12:36 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.