r0bdiabl0 / laravel-email-tracker
Multi-provider email tracking for Laravel - track opens, clicks, bounces, complaints across SES, Resend, Postal, and more
Package info
github.com/r0bdiabl0/laravel-email-tracker
pkg:composer/r0bdiabl0/laravel-email-tracker
Requires
- php: ^8.2
- aws/aws-php-sns-message-validator: ^1.7
- aws/aws-sdk-php: ^3.288
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/mail: ^11.0|^12.0|^13.0
- illuminate/notifications: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- nesbot/carbon: ^3.0
- nyholm/psr7: ^1.8
- ramsey/uuid: ^4.7
- symfony/psr-http-message-bridge: ^7.0
- voku/simple_html_dom: ^4.8
Requires (Dev)
- laravel/pint: ^1.13
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^11.0
- postal/postal: ^2.0
- resend/resend-php: ^1.0
Suggests
- postal/postal: Required for Postal API transport (^2.0)
- resend/resend-php: Required for Resend API transport (^1.0)
- symfony/mailgun-mailer: Required for Mailgun transport
- symfony/postmark-mailer: Required for Postmark transport
README
A multi-provider email tracking and bounce management package for Laravel 11+ that provides unified tracking for opens, clicks, bounces, complaints, and deliveries across AWS SES, Resend, Postal, Mailgun, SendGrid, and Postmark. Includes optional suppression to automatically skip sending to problematic addresses.
Table of Contents
- What This Package Does
- What This Package Does NOT Do
- Requirements
- Installation
- Configuration
- Basic Usage
- Webhook Setup
- Security Considerations
- One-Click Unsubscribe (RFC 8058)
- Events
- Suppression (Bounce Management)
- Database Schema
- Querying Data
- Migrating from juhasev/laravel-ses
- Admin Panel Plugins
- Laravel Boost AI Integration
- Extending
- Testing
- Troubleshooting
- Contributing
- License
- Credits
What This Package Does
- Tracks Sent Emails - Stores records of all emails sent through the package with their message IDs
- Open Tracking - Injects a 1x1 tracking pixel to detect when recipients open emails
- Link Click Tracking - Rewrites links to track when recipients click them, with click counts
- Bounce Handling - Receives and processes bounce notifications from email providers via webhooks
- Complaint Handling - Tracks spam complaints reported by recipients
- Delivery Confirmation - Records successful deliveries reported by email providers
- Batch Grouping - Organize emails into named batches for campaigns or bulk sends
- Multi-Provider Support - Unified interface across 6 major email providers
- Suppression - Optionally skip sending to previously bounced or complained addresses (bounce management)
- One-Click Unsubscribe - RFC 8058 compliant List-Unsubscribe headers for improved deliverability
- Event Dispatching - Laravel events for all tracking activities for your own listeners
What This Package Does NOT Do
- Does NOT send emails - This package tracks emails sent via Laravel's mail system. You still need to configure Laravel Mail with your provider (SES, Mailgun, etc.)
- Does NOT provide SMTP services - You need your own email provider account
- Does NOT guarantee open tracking accuracy - Many email clients block tracking pixels. Open tracking should be considered a lower-bound estimate
- Does NOT track replies - This package tracks delivery events, not incoming mail
- Does NOT provide analytics dashboards - It stores data in your database; you build your own reports or use tools like Filament
- Does NOT provide a template builder - You design emails using Laravel's Mailable and Blade views (which are fully supported and tracked)
- Does NOT replace your email provider's dashboard - It supplements it with data in your own database
Requirements
- PHP 8.2+
- Laravel 11.0+
- An email provider account (AWS SES, Resend, Postal, Mailgun, SendGrid, or Postmark)
Installation
composer require r0bdiabl0/laravel-email-tracker
Run the install command:
php artisan email-tracker:install
This will:
- Publish the configuration file to
config/email-tracker.php - Publish the migrations
- Optionally run the migrations
Configuration
Environment Variables
Add these to your .env file:
# ============================================================================= # CORE SETTINGS # ============================================================================= # Default provider (ses, resend, postal, mailgun, sendgrid, postmark) EMAIL_TRACKER_DEFAULT_PROVIDER=ses # Optional table prefix (leave empty for no prefix) EMAIL_TRACKER_TABLE_PREFIX= # Enable/disable route registration EMAIL_TRACKER_ROUTES_ENABLED=true # Route prefix for all email tracker routes (default: email-tracker) EMAIL_TRACKER_ROUTE_PREFIX=email-tracker # Debug logging (disable in production) EMAIL_TRACKER_DEBUG=false # Log message prefix EMAIL_TRACKER_LOG_PREFIX=EMAIL-TRACKER # ============================================================================= # PROVIDER SETTINGS # ============================================================================= # Enable/disable providers EMAIL_TRACKER_SES_ENABLED=true EMAIL_TRACKER_RESEND_ENABLED=false EMAIL_TRACKER_POSTAL_ENABLED=false EMAIL_TRACKER_MAILGUN_ENABLED=false EMAIL_TRACKER_SENDGRID_ENABLED=false EMAIL_TRACKER_POSTMARK_ENABLED=false # AWS SES specific settings EMAIL_TRACKER_SNS_VALIDATOR=true # Validate SNS message signatures (recommended) # Webhook signing secrets (provider-specific) RESEND_WEBHOOK_SECRET=whsec_... # Resend: Svix webhook signature MAILGUN_WEBHOOK_SIGNING_KEY=key-... # Mailgun: HMAC-SHA256 signing key SENDGRID_VERIFICATION_KEY="-----BEGIN..." # SendGrid: ECDSA public key (PEM format) POSTAL_WEBHOOK_KEY=your-secret-key # Postal: X-Postal-Webhook-Key header POSTMARK_WEBHOOK_TOKEN=your-token # Postmark: X-Postmark-Webhook-Token header # ============================================================================= # SUPPRESSION / BOUNCE MANAGEMENT (disabled by default) # ============================================================================= # Automatically skip sending to problematic addresses (recommended for production) EMAIL_TRACKER_SKIP_BOUNCED=false # Set true to suppress bounced addresses EMAIL_TRACKER_SKIP_COMPLAINED=false # Set true to suppress addresses that complained (spam) # ============================================================================= # ONE-CLICK UNSUBSCRIBE (RFC 8058) # ============================================================================= EMAIL_TRACKER_UNSUBSCRIBE_ENABLED=false # Enable List-Unsubscribe headers EMAIL_TRACKER_UNSUBSCRIBE_MAILTO= # Optional mailto: fallback address EMAIL_TRACKER_UNSUBSCRIBE_EXPIRATION=0 # Signature expiration in hours (0 = never) EMAIL_TRACKER_UNSUBSCRIBE_REDIRECT= # Redirect URL after unsubscribe (null = JSON) # ============================================================================= # METADATA / STORAGE OPTIONS # ============================================================================= # Store raw webhook payloads in metadata column (default: false) # When false, metadata is still available in events for real-time processing EMAIL_TRACKER_STORE_METADATA=false # ============================================================================= # OTHER OPTIONS # ============================================================================= # Enable legacy routes for backwards compatibility with juhasev/laravel-ses EMAIL_TRACKER_LEGACY_ROUTES=false
Table Names
By default, tables are created without a prefix:
sent_emailsemail_opensemail_bouncesemail_complaintsemail_linksbatches
With a prefix like tracker:
tracker_sent_emailstracker_email_bounces- etc.
Enable Providers
Enable only the providers you use:
// config/email-tracker.php 'providers' => [ 'ses' => [ 'enabled' => env('EMAIL_TRACKER_SES_ENABLED', true), 'sns_validator' => true, // Validate SNS message signatures ], 'resend' => [ 'enabled' => env('EMAIL_TRACKER_RESEND_ENABLED', false), 'webhook_secret' => env('RESEND_WEBHOOK_SECRET'), ], 'mailgun' => [ 'enabled' => env('EMAIL_TRACKER_MAILGUN_ENABLED', false), 'webhook_signing_key' => env('MAILGUN_WEBHOOK_SIGNING_KEY'), ], // ... etc. ],
Transport Configuration
The package provides custom Symfony transports for providers that need HTTP API access for full tracking support. Configure these in your config/mail.php:
// config/mail.php 'mailers' => [ // Resend API transport (recommended for full tracking) 'resend' => [ 'transport' => 'resend', 'key' => env('RESEND_API_KEY'), ], // Postal API transport (recommended for full tracking) 'postal' => [ 'transport' => 'postal', 'url' => env('POSTAL_URL'), 'key' => env('POSTAL_API_KEY'), ], // SES, Mailgun, Postmark use Laravel/Symfony built-in transports // SendGrid uses SMTP ],
Provider Transport Summary:
| Provider | Transport Type | SDK Required |
|---|---|---|
| AWS SES | Laravel built-in (ses) |
aws/aws-sdk-php (included) |
| Resend | Package transport (resend) |
resend/resend-php (optional) |
| Postal | Package transport (postal) |
postal/postal (optional) |
| Mailgun | Symfony built-in (mailgun) |
symfony/mailgun-mailer |
| Postmark | Symfony built-in (postmark) |
symfony/postmark-mailer |
| SendGrid | SMTP | None |
Install optional SDKs as needed:
# For Resend API transport composer require resend/resend-php # For Postal API transport composer require postal/postal # For Mailgun transport composer require symfony/mailgun-mailer # For Postmark transport composer require symfony/postmark-mailer
Using Multiple Providers
You can enable multiple providers simultaneously and switch between them per-send:
# Set your default provider EMAIL_TRACKER_DEFAULT_PROVIDER=ses # Enable multiple providers EMAIL_TRACKER_SES_ENABLED=true EMAIL_TRACKER_RESEND_ENABLED=true EMAIL_TRACKER_MAILGUN_ENABLED=true
use R0bdiabl0\EmailTracker\Facades\EmailTracker; // Uses the default provider (from EMAIL_TRACKER_DEFAULT_PROVIDER) EmailTracker::enableAllTracking() ->to('user@example.com') ->send(new WelcomeMail($user)); // Override to use a specific provider for this send // Automatically switches to the Resend transport EmailTracker::provider('resend') ->enableAllTracking() ->to('user@example.com') ->send(new WelcomeMail($user)); // Use Mailgun for transactional emails EmailTracker::provider('mailgun') ->enableAllTracking() ->to('user@example.com') ->send(new OrderConfirmation($order));
Each provider has its own webhook endpoint. When you receive bounce/complaint notifications, they'll be routed to the correct handler based on the URL:
- SES:
POST /email-tracker/webhook/ses - Resend:
POST /email-tracker/webhook/resend - Mailgun:
POST /email-tracker/webhook/mailgun - etc.
The provider column in the database tracks which service sent each email, allowing you to query statistics by provider.
Basic Usage
Sending Tracked Emails
use R0bdiabl0\EmailTracker\Facades\EmailTracker; // Enable all tracking (opens, links, bounces, complaints, deliveries) // Note: This does NOT enable unsubscribe headers - use enableUnsubscribeHeaders() separately EmailTracker::enableAllTracking() ->to('user@example.com') ->send(new WelcomeMail($user)); // With unsubscribe headers for bulk/marketing emails EmailTracker::enableAllTracking() ->enableUnsubscribeHeaders() // Add RFC 8058 List-Unsubscribe headers ->to('user@example.com') ->send(new MarketingMail($user)); // With batch grouping for campaigns EmailTracker::enableAllTracking() ->setBatch('welcome-campaign-2024') ->to('user@example.com') ->send(new WelcomeMail($user)); // Enable specific tracking only EmailTracker::enableOpenTracking() ->enableLinkTracking() ->to('user@example.com') ->send(new WelcomeMail($user)); // Specify provider explicitly EmailTracker::provider('resend') ->enableAllTracking() ->to('user@example.com') ->send(new WelcomeMail($user));
Using the TracksWithEmail Trait (Optional)
Add the trait to your Mailable for convenience methods:
use Illuminate\Mail\Mailable; use R0bdiabl0\EmailTracker\Traits\TracksWithEmail; class WelcomeMail extends Mailable { use TracksWithEmail; public function build() { return $this->view('emails.welcome'); } } // Static methods for quick sending WelcomeMail::sendTracked('user@example.com', batch: 'welcome'); WelcomeMail::queueTracked(['user@example.com'], batch: 'welcome', queue: 'emails');
Using the Notification Channel (Optional)
use Illuminate\Notifications\Notification; use R0bdiabl0\EmailTracker\Notifications\EmailTrackerChannel; class WelcomeNotification extends Notification { public function via($notifiable): array { return [EmailTrackerChannel::class]; } public function toEmailTracker($notifiable): Mailable { return new WelcomeMail($notifiable); } }
Webhook Setup
Your email provider will send event notifications (bounces, complaints, deliveries) to these webhook URLs. You must configure these URLs in each provider's dashboard.
Webhook URLs
| Provider | Webhook URL |
|---|---|
| AWS SES | https://your-app.com/email-tracker/webhook/ses/bouncehttps://your-app.com/email-tracker/webhook/ses/complainthttps://your-app.com/email-tracker/webhook/ses/delivery |
| Resend | https://your-app.com/email-tracker/webhook/resend |
| Postal | https://your-app.com/email-tracker/webhook/postal |
| Mailgun | https://your-app.com/email-tracker/webhook/mailgun |
| SendGrid | https://your-app.com/email-tracker/webhook/sendgrid |
| Postmark | https://your-app.com/email-tracker/webhook/postmark |
AWS SES Setup
- Create SNS topics for bounces, complaints, and deliveries in AWS Console
- Add HTTPS subscriptions pointing to your webhook URLs
- Configure your SES domain/email to publish to these SNS topics
- The package automatically validates SNS message signatures
# Example: Create SNS subscription via AWS CLI
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123456789:ses-bounces \
--protocol https \
--notification-endpoint https://your-app.com/email-tracker/webhook/ses/bounce
Resend Setup
- Go to Resend Dashboard > Webhooks
- Add a new webhook pointing to
https://your-app.com/email-tracker/webhook/resend - Select events:
email.bounced,email.complained,email.delivered - Copy the signing secret (starts with
whsec_) to your.env
Mailgun Setup
- Go to Mailgun Dashboard > Sending > Webhooks
- Add webhook URLs for Permanent Failures, Temporary Failures, and Delivered
- Copy your webhook signing key to your
.env
SendGrid Setup
- Go to SendGrid Dashboard > Settings > Mail Settings > Event Webhook
- Set the HTTP POST URL to
https://your-app.com/email-tracker/webhook/sendgrid - Select events: Bounced, Spam Reports, Delivered
- Enable Event Webhook Security and copy the verification key
Postmark Setup
- Go to Postmark > Servers > Your Server > Webhooks
- Add webhooks for Bounces, Spam Complaints, and Deliveries
- Set the webhook URL and optionally configure Basic Auth for security
Postal Setup
- Go to your Postal server admin panel
- Add a webhook endpoint pointing to
https://your-app.com/email-tracker/webhook/postal - Configure the shared secret key in your
.env
Security Considerations
Webhook Signature Validation
All providers support webhook signature validation to ensure requests are authentic:
| Provider | Validation Method | Required Config |
|---|---|---|
| AWS SES | SNS certificate validation | Automatic |
| Resend | Svix HMAC-SHA256 | webhook_secret |
| Mailgun | HMAC-SHA256 | webhook_signing_key |
| SendGrid | ECDSA P-256 | verification_key |
| Postmark | Header token or Basic Auth | webhook_token |
| Postal | Header token | webhook_key |
Important: In development, validation is skipped if no secret is configured. In production, always configure your webhook secrets.
Protecting Webhook Routes
The webhook routes are public by default (no auth middleware). This is required because email providers need to access them. Security is provided through signature validation.
If you need additional protection, you can:
- Configure IP allowlists in your web server (nginx/Apache)
- Add custom middleware in the config:
// config/email-tracker.php 'routes' => [ 'middleware' => ['throttle:60,1'], // Rate limiting ],
CSRF Protection
Webhook routes must be excluded from CSRF protection since they receive POST requests from external services. The package routes are loaded outside the web middleware group, but if your application applies CSRF middleware globally, you need to exclude the webhook routes.
Add to your bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'email-tracker/webhook/*', ]); })
Or in app/Http/Middleware/VerifyCsrfToken.php (Laravel 10):
protected $except = [ 'email-tracker/webhook/*', ];
One-Click Unsubscribe (RFC 8058)
The package supports RFC 8058 compliant one-click unsubscribe headers, which are now required by Gmail, Yahoo, and other major email providers for bulk senders. This feature improves deliverability and helps you comply with sender requirements.
How It Works
- When enabled, the package adds
List-UnsubscribeandList-Unsubscribe-Postheaders to your emails - Email clients show an "Unsubscribe" button in their UI
- When clicked, a POST request is sent to your app's signed unsubscribe endpoint
- The package validates the signature and fires an
EmailUnsubscribeEvent - You handle the business logic in your event listener
Enabling Unsubscribe Headers
Option 1: Global (All Tracked Emails)
EMAIL_TRACKER_UNSUBSCRIBE_ENABLED=true
Option 2: Per-Email
use R0bdiabl0\EmailTracker\Facades\EmailTracker; EmailTracker::enableAllTracking() ->enableUnsubscribeHeaders() ->to('user@example.com') ->send(new NewsletterMail($user));
Configuration
// config/email-tracker.php 'unsubscribe' => [ // Enable one-click unsubscribe headers globally 'enabled' => env('EMAIL_TRACKER_UNSUBSCRIBE_ENABLED', false), // Optional: Include a mailto: fallback (some older clients prefer this) 'mailto' => env('EMAIL_TRACKER_UNSUBSCRIBE_MAILTO'), // Signature expiration in hours (0 = no expiration) 'signature_expiration' => env('EMAIL_TRACKER_UNSUBSCRIBE_EXPIRATION', 0), // Redirect URL after unsubscribe (null = return JSON response) 'redirect_url' => env('EMAIL_TRACKER_UNSUBSCRIBE_REDIRECT'), ],
Handling Unsubscribe Events
Register a listener for the EmailUnsubscribeEvent:
use R0bdiabl0\EmailTracker\Events\EmailUnsubscribeEvent; // In EventServiceProvider protected $listen = [ EmailUnsubscribeEvent::class => [ \App\Listeners\HandleUnsubscribe::class, ], ];
namespace App\Listeners; use R0bdiabl0\EmailTracker\Events\EmailUnsubscribeEvent; class HandleUnsubscribe { public function handle(EmailUnsubscribeEvent $event): void { $email = $event->email; $messageId = $event->messageId; $sentEmail = $event->sentEmail; // May be null // Update user preferences User::where('email', $email) ->update(['marketing_emails' => false]); // Or remove from specific mailing list based on batch if ($sentEmail && $sentEmail->batch) { MailingListSubscription::where('email', $email) ->where('list', $sentEmail->batch->name) ->delete(); } Log::info("User unsubscribed", ['email' => $email]); } }
CSRF Protection
The unsubscribe endpoint needs to be excluded from CSRF protection (it receives POST requests from external email clients):
// bootstrap/app.php (Laravel 11+) ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'email-tracker/webhook/*', // Adjust if using custom EMAIL_TRACKER_ROUTE_PREFIX 'email-tracker/unsubscribe', ]); })
Note: If you configured a custom route prefix via
EMAIL_TRACKER_ROUTE_PREFIX, update the CSRF exclusion paths accordingly.
Security Recommendations
- Rate Limiting: Consider adding rate limiting middleware to your
HandleUnsubscribelistener or at the route level to prevent abuse:// In your event listener if (RateLimiter::tooManyAttempts('unsubscribe:' . $event->email, 5)) { Log::warning('Unsubscribe rate limit exceeded', ['email' => $event->email]); return; } RateLimiter::hit('unsubscribe:' . $event->email, 3600);
- Signature Expiration: For added security, set
signature_expirationto expire unsubscribe links after a reasonable time (e.g., 720 hours / 30 days)
What This Feature Does NOT Do
- Does NOT manage subscription lists - you define what "unsubscribe" means for your app
- Does NOT store unsubscribe preferences - you update your own user/subscription models
- Does NOT decide per-list vs global unsubscribe - your listener implements this logic
Events
The package dispatches events for all tracking activities. Listen to these in your EventServiceProvider:
use R0bdiabl0\EmailTracker\Events\EmailSentEvent; use R0bdiabl0\EmailTracker\Events\EmailDeliveryEvent; use R0bdiabl0\EmailTracker\Events\EmailBounceEvent; use R0bdiabl0\EmailTracker\Events\EmailComplaintEvent; use R0bdiabl0\EmailTracker\Events\EmailOpenEvent; use R0bdiabl0\EmailTracker\Events\EmailLinkClickEvent; use R0bdiabl0\EmailTracker\Events\EmailUnsubscribeEvent; protected $listen = [ EmailBounceEvent::class => [ \App\Listeners\HandleEmailBounce::class, ], EmailComplaintEvent::class => [ \App\Listeners\HandleEmailComplaint::class, ], EmailUnsubscribeEvent::class => [ \App\Listeners\HandleUnsubscribe::class, ], ];
Example Listener
namespace App\Listeners; use R0bdiabl0\EmailTracker\Events\EmailBounceEvent; class HandleEmailBounce { public function handle(EmailBounceEvent $event): void { $bounce = $event->emailBounce; $email = $bounce->email; $type = $bounce->type; // 'Permanent' or 'Transient' $metadata = $bounce->metadata; // Raw webhook payload if ($type === 'Permanent') { // Mark user as having invalid email User::where('email', $email)->update(['email_valid' => false]); } // Access provider-specific diagnostic information from metadata // For SES: $metadata['bounce']['bouncedRecipients'][0]['diagnosticCode'] // For Mailgun: $metadata['event-data']['delivery-status']['message'] // For SendGrid: $metadata['reason'] $diagnosticCode = $this->extractDiagnosticCode($metadata, $bounce->provider); // Log for monitoring Log::warning("Email bounced", [ 'email' => $email, 'type' => $type, 'provider' => $bounce->provider, 'diagnostic_code' => $diagnosticCode, ]); } private function extractDiagnosticCode(?array $metadata, string $provider): ?string { if (!$metadata) { return null; } return match ($provider) { 'ses' => $metadata['bounce']['bouncedRecipients'][0]['diagnosticCode'] ?? null, 'mailgun' => $metadata['event-data']['delivery-status']['message'] ?? null, 'sendgrid' => $metadata['reason'] ?? null, 'postmark' => $metadata['Description'] ?? null, default => null, }; } }
Suppression (Bounce Management)
Automatically skip sending to bounced or complained addresses. This is disabled by default - enable it to protect your sender reputation:
// config/email-tracker.php 'suppression' => [ 'skip_bounced' => true, // Skip permanently bounced addresses 'skip_complained' => true, // Skip addresses that filed complaints ],
When enabled, suppression works automatically across all sending methods:
EmailTracker::send()facadeTracksWithEmailtrait on MailablesEmailTrackerChannelfor Notifications
If a suppressed address is detected, an AddressSuppressedException is thrown with the email and reason.
Manual Suppression Checking
You can also check suppression manually:
use R0bdiabl0\EmailTracker\Services\EmailValidator; // Check if email should be blocked if (EmailValidator::shouldBlock('user@example.com')) { return; // Don't send } // Get specific counts $bounceCount = EmailValidator::getBounceCount('user@example.com'); $hasComplaint = EmailValidator::hasComplaint('user@example.com'); // Filter a list of emails $validEmails = EmailValidator::filterBlockedEmails($emailList);
Database Schema
The package creates the following tables (with optional prefix):
sent_emails
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| provider | string | Email provider (ses, resend, etc.) |
| message_id | string | Provider's message ID |
| string | Recipient email address | |
| batch_id | bigint | Optional batch reference |
| sent_at | timestamp | When email was sent |
| delivered_at | timestamp | When delivery was confirmed |
| bounce_tracking | boolean | Whether bounce tracking is enabled |
| complaint_tracking | boolean | Whether complaint tracking is enabled |
| delivery_tracking | boolean | Whether delivery tracking is enabled |
email_bounces
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| provider | string | Email provider |
| sent_email_id | bigint | Reference to sent email |
| type | string | Bounce type (Permanent/Transient) |
| string | Bounced email address | |
| bounced_at | timestamp | When bounce occurred |
| metadata | json | Raw webhook payload (for diagnostic details) |
email_complaints
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| provider | string | Email provider |
| sent_email_id | bigint | Reference to sent email |
| type | string | Complaint type (spam, etc.) |
| string | Complaining email address | |
| complained_at | timestamp | When complaint occurred |
| metadata | json | Raw webhook payload (for diagnostic details) |
email_opens
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| sent_email_id | bigint | Reference to sent email |
| beacon_identifier | string | Unique identifier for tracking pixel |
| opened_at | timestamp | When email was opened |
email_links
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| sent_email_id | bigint | Reference to sent email |
| link_identifier | string | Unique identifier for link tracking |
| original_url | text | Original link URL |
| clicked | boolean | Whether link has been clicked |
| click_count | integer | Number of clicks |
batches
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| name | string | Batch identifier |
Metadata Storage Considerations
The metadata column in email_bounces and email_complaints tables can store raw webhook payloads from email providers. This provides valuable diagnostic information but is disabled by default.
Configuration:
# Enable metadata storage (default: false) EMAIL_TRACKER_STORE_METADATA=true
Important: Even when store_metadata is false, the raw webhook payload is still available in event listeners via the metadata property. This allows you to process diagnostic information in real-time without persisting it to the database.
// In your event listener - metadata is ALWAYS available public function handle(EmailBounceEvent $event): void { $metadata = $event->emailBounce->metadata; // Available regardless of store_metadata config $diagnosticCode = $metadata['bounce']['bouncedRecipients'][0]['diagnosticCode'] ?? null; // Process in real-time... }
When to enable persistent storage:
- You need to analyze bounce/complaint patterns historically
- You want to debug delivery issues after the fact
- You're building reporting dashboards that query metadata
- You don't have real-time event listeners processing webhooks
When to keep disabled (default):
- You process events in real-time via listeners
- You store relevant data in your own application tables
- You want to minimize database storage
- You have PII concerns about storing raw payloads
What metadata contains:
- Full webhook payload from the email provider
- SMTP error codes and diagnostic messages
- Email addresses and timestamps
- Provider-specific debugging information
Storage considerations:
- Payloads vary by provider (typically 1-5 KB per record)
- High-volume senders should monitor database growth
- Consider implementing a cleanup job for old records
Privacy considerations:
- Metadata may contain email addresses (PII)
- Apply appropriate data retention policies
- Ensure database access controls are in place
Example cleanup job:
// Delete bounce/complaint records older than 90 days EmailBounce::where('bounced_at', '<', now()->subDays(90))->delete(); EmailComplaint::where('complained_at', '<', now()->subDays(90))->delete();
Querying Data
use R0bdiabl0\EmailTracker\Models\SentEmail; use R0bdiabl0\EmailTracker\Models\Batch; // Get all bounced emails $bounced = SentEmail::bounced()->get(); // Get all emails that received complaints $complained = SentEmail::complained()->get(); // Get emails by provider $sesEmails = SentEmail::forProvider('ses')->get(); // Get delivered emails $delivered = SentEmail::delivered()->get(); // Get emails for a specific address $userEmails = SentEmail::forEmail('user@example.com')->get(); // Get batch with all emails $batch = Batch::where('name', 'campaign-2024')->with('sentEmails')->first(); // Check if specific email bounced $email = SentEmail::where('message_id', $messageId)->first(); if ($email->wasBounced()) { // Handle bounce }
Migrating from juhasev/laravel-ses
If you're migrating from juhasev/laravel-ses:
# Preview what will change php artisan email-tracker:migrate-from-ses --dry-run # Run migration with table backup php artisan email-tracker:migrate-from-ses --backup # Also update namespaces in your code php artisan email-tracker:migrate-from-ses --backup --update-code
The migration will:
- Rename tables (remove
laravel_ses_prefix) - Add
providercolumn with default'ses' - Output new webhook URLs for AWS SNS configuration
Backwards Compatibility
The SesMail facade is aliased to EmailTracker:
// Still works! SesMail::enableAllTracking()->to($email)->send($mailable);
Enable legacy routes to keep old webhook URLs working:
EMAIL_TRACKER_LEGACY_ROUTES=true
Admin Panel Plugins
Filament Plugin
For Filament v3/v4 users, install the companion plugin for dashboard widgets, statistics, and resource pages:
composer require r0bdiabl0/laravel-email-tracker-filament
Features:
- Dashboard Widgets - Stats overview, delivery charts, health scores, recent activity
- Resource Pages - Browse, search, and filter sent emails, bounces, and complaints
- Statistics Service - Query aggregated stats for custom integrations
Register in your Filament panel provider:
use R0bdiabl0\EmailTrackerFilament\EmailTrackerFilamentPlugin; public function panel(Panel $panel): Panel { return $panel ->plugins([ EmailTrackerFilamentPlugin::make(), ]); }
See r0bdiabl0/laravel-email-tracker-filament for full documentation.
Nova Plugin
For Laravel Nova v4/v5 users, install the companion plugin for resource management:
composer require r0bdiabl0/laravel-email-tracker-nova
Features:
- Sent Emails Resource - Browse, search, filter by provider and status
- Bounces Resource - View bounce records with type badges
- Complaints Resource - Track spam complaints
- Read-Only - Safe viewing without accidental modifications
The resources are auto-registered. See r0bdiabl0/laravel-email-tracker-nova for customization options.
Laravel Boost AI Integration
This package includes Laravel Boost AI guidelines and skills to help AI assistants generate correct code for your email tracking implementation.
When you run php artisan boost:install in your Laravel application, Boost automatically loads:
- AI Guidelines - Package overview, API examples, and configuration reference
- Skills - Interactive commands for common tasks:
/send-tracked-email- Send emails with tracking, batches, and unsubscribe headers/handle-email-events- Create event listeners for bounces, complaints, opens, clicks/create-email-provider- Build custom provider integrations/setup-suppression- Configure bounce management
No additional configuration required - Boost discovers the package's AI resources automatically.
Extending
Custom Providers
This package is fully extensible. You can add support for any email provider by implementing your own webhook handler.
Step 1: Create your provider class
Extend AbstractProvider which implements EmailProviderInterface:
namespace App\EmailProviders; use Carbon\Carbon; use Illuminate\Http\Request; use R0bdiabl0\EmailTracker\DataTransferObjects\EmailEventData; use R0bdiabl0\EmailTracker\Enums\EmailEventType; use R0bdiabl0\EmailTracker\Providers\AbstractProvider; use Symfony\Component\HttpFoundation\Response; class CustomSmtpProvider extends AbstractProvider { /** * Unique provider name (used in routes and database). */ public function getName(): string { return 'custom-smtp'; } /** * Handle incoming webhook from your email provider. */ public function handleWebhook(Request $request, ?string $event = null): Response { $this->logRawPayload($request); // Validate webhook signature if (! $this->validateSignature($request)) { return response()->json(['error' => 'Invalid signature'], 403); } $payload = $request->all(); $eventType = $payload['event_type'] ?? 'unknown'; // Parse into standardized format $data = $this->parsePayload($payload); // Route to appropriate handler based on event type // The base class helpers expect EmailEventData objects return match ($eventType) { 'bounce' => $this->processBounceEvent($data), 'complaint' => $this->processComplaintEvent($data), 'delivered' => $this->processDeliveryEvent($data), default => response()->json(['success' => true]), }; } /** * Parse webhook payload into standardized EmailEventData. */ public function parsePayload(array $payload): EmailEventData { return new EmailEventData( messageId: $payload['message_id'] ?? '', email: $payload['recipient'] ?? '', provider: $this->getName(), eventType: $this->mapEventType($payload['event_type'] ?? ''), timestamp: isset($payload['timestamp']) ? Carbon::parse($payload['timestamp']) : null, bounceType: $payload['bounce_type'] ?? null, metadata: $payload, ); } /** * Validate webhook signature/authenticity. */ public function validateSignature(Request $request): bool { $secret = $this->getConfig('webhook_secret'); if (! $secret) { return true; // Skip validation if no secret configured } $signature = $request->header('X-Custom-Signature'); $expectedSignature = hash_hmac('sha256', $request->getContent(), $secret); return hash_equals($expectedSignature, $signature ?? ''); } /** * Map provider event types to EmailEventType enum. */ protected function mapEventType(string $event): EmailEventType { return match ($event) { 'bounce' => EmailEventType::Bounced, 'complaint' => EmailEventType::Complained, 'delivered' => EmailEventType::Delivered, 'opened' => EmailEventType::Opened, 'clicked' => EmailEventType::Clicked, default => EmailEventType::Sent, }; } }
Step 2: Register your provider
In your AppServiceProvider or a dedicated service provider:
use R0bdiabl0\EmailTracker\Facades\EmailTracker; use App\EmailProviders\CustomSmtpProvider; public function boot(): void { EmailTracker::registerProvider('custom-smtp', CustomSmtpProvider::class); }
Step 3: Add configuration (optional)
// config/email-tracker.php 'providers' => [ // ... built-in providers ... 'custom-smtp' => [ 'enabled' => env('EMAIL_TRACKER_CUSTOM_SMTP_ENABLED', true), 'webhook_secret' => env('EMAIL_TRACKER_CUSTOM_SMTP_SECRET'), ], ],
Step 4: Configure webhooks in your email provider
Your custom provider's webhook endpoint is automatically registered at:
POST https://your-app.com/email-tracker/webhook/custom-smtp
Configure this URL in your email provider's dashboard/settings:
- Set the webhook URL to
https://your-app.com/email-tracker/webhook/custom-smtp - Select event types to receive (bounces, complaints, deliveries, opens, clicks)
- Configure authentication - if your provider supports webhook signing:
- Copy the signing secret/key from your provider
- Add it to your
.env:EMAIL_TRACKER_CUSTOM_SMTP_SECRET=your-secret-here
- Test the webhook - most providers have a "send test" feature
The package handles routing automatically - any POST request to /email-tracker/webhook/{provider-name} will be routed to your provider's handleWebhook() method.
AbstractProvider Helper Methods
The AbstractProvider base class provides useful helper methods:
// Logging (respects EMAIL_TRACKER_DEBUG setting) $this->logDebug('Processing event'); $this->logError('Failed to process'); $this->logInfo('Event received'); $this->logRawPayload($request); // Log full webhook payload // Configuration access $secret = $this->getConfig('webhook_secret'); // Event processing helpers - pass an EmailEventData object // These create database records and fire events automatically $data = $this->parsePayload($payload); // You implement this $this->processBounceEvent($data); // Creates EmailBounce record $this->processComplaintEvent($data); // Creates EmailComplaint record $this->processDeliveryEvent($data); // Updates SentEmail.delivered_at
Example using the helper methods in your handleWebhook():
public function handleWebhook(Request $request, ?string $event = null): Response { $this->logRawPayload($request); if (! $this->validateSignature($request)) { return response()->json(['error' => 'Invalid signature'], 403); } $payload = $request->all(); $eventType = $payload['event_type'] ?? 'unknown'; // Parse into standardized format $data = $this->parsePayload($payload); // Use base class event processors return match ($eventType) { 'bounce' => $this->processBounceEvent($data), 'complaint' => $this->processComplaintEvent($data), 'delivered' => $this->processDeliveryEvent($data), default => response()->json(['success' => true]), }; }
Custom Models
Override default models:
// config/email-tracker.php 'models' => [ 'sent_email' => \App\Models\TrackedEmail::class, 'email_bounce' => \App\Models\CustomBounce::class, // ... ],
Your custom model should extend the package model or implement the contract:
namespace App\Models; use R0bdiabl0\EmailTracker\Models\SentEmail as BaseSentEmail; class TrackedEmail extends BaseSentEmail { // Add custom methods or relationships public function user() { return $this->belongsTo(User::class, 'email', 'email'); } }
Testing
# Run tests composer test # Run with coverage composer test-coverage # Static analysis composer analyse # Code formatting composer format
Troubleshooting
Webhooks not receiving data
- Verify the webhook URL is accessible from the internet
- Check your web server logs for incoming requests
- Enable debug logging:
EMAIL_TRACKER_DEBUG=true - Verify signature validation secrets are correct
- Check Laravel logs for validation errors
Open tracking not working
- Open tracking requires HTML emails (not plain text)
- Many email clients block tracking pixels by default
- Gmail, Apple Mail, and others may proxy images
- Consider open tracking as approximate data only
Message IDs not matching
- Ensure you're storing the message ID from the send response
- Different providers format message IDs differently
- Check that the same message ID format is used in webhooks
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create a feature branch
- Make your changes with tests
- Run
composer testandcomposer analyse - Submit a pull request
For bugs and feature requests, please open an issue.
License
The MIT License (MIT). Please see License File for more information.
Credits
- Robert Pettique - Author and maintainer
- Based on the excellent work from juhasev/laravel-ses