diff --git a/README.md b/README.md
index 9111f5e..7604d2c 100644
--- a/README.md
+++ b/README.md
@@ -5,15 +5,20 @@
[](https://github.com/basementdevs/filament-better-mails/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain)
[](https://packagist.org/packages/basementdevs/filament-better-mails)
-This is where your description should go. Limit it to a paragraph or two. Consider adding a small example.
+A Laravel package that provides a complete implementation to track mails, starting to send the mail and receiving back mail's events by webhooks, currently the supported mailer is ```Resend```, and a Filament v4 resource to view sent mails in your admin panel. It ships with:
-## Support us
+## Overview of the stack
+- Language: PHP 8.3
+- Framework: Laravel (Illuminate 12.x APIs)
+- Admin: Filament v4
+- Package manager: Composer
+- Testing: Pest + Orchestra Testbench
-[
](https://spatie.be/github-ad-click/filament-better-mails)
-
-We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us).
-
-We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards).
+## Requirements
+- PHP ^8.3
+- Laravel 12.x compatible application
+- Filament ^4.0 installed in the host app
+- Composer
## Installation
@@ -40,9 +45,59 @@ This is the contents of the published config file:
```php
return [
+ 'mails' => [
+ 'models' => [
+ 'mail' => BetterEmail::class,
+ 'event' => BetterEmailEvent::class,
+ 'attachment' => BetterEmailAttachment::class,
+ ],
+ 'database' => [
+ 'tables' => [
+ 'mails' => 'mails',
+ 'attachments' => 'mail_attachments',
+ 'events' => 'mail_events',
+ 'polymorph' => 'mailables',
+ ],
+ 'pruning' => [
+ 'enabled' => false,
+ 'after' => 30, // days
+ ],
+ ],
+
+ 'headers' => [
+ 'key' => 'X-Better-Mails-Event-ID'
+ ],
+
+ 'logging' => [
+ 'attachments' => [
+ 'enabled' => env('MAILS_LOGGING_ATTACHMENTS_ENABLED', true),
+ 'disk' => env('FILESYSTEM_DISK', 'local'),
+ 'root' => 'mails/attachments',
+ ],
+ ]
+ ],
+ 'webhooks' => [
+ 'provider' => env('MAILS_WEBHOOK_PROVIDER', 'resend'),
+
+ 'drivers' => [
+ 'resend' => [
+ 'driver' => ResendDriver::class,
+ 'key_secret' => env('RESEND_WEBHOOK_SECRET', ''),
+ ],
+ ]
+ ]
];
```
+### Environment variables
+Resend credentials and defaults are required by your application. The following are commonly used; verify in your project:
+
+```env
+MAIL_MAILER=resend
+RESEND_API_KEY=
+RESEND_WEBHOOK_SECRET=
+```
+
Optionally, you can publish the views using
```bash
@@ -51,11 +106,15 @@ php artisan vendor:publish --tag="filament-better-mails-views"
## Usage
+To use the Filament Resource you must add the ```FilamentBetterEmailPlugin``` at your panel provider.
```php
-$BetterMails = new Basement\BetterMails();
-echo $BetterMails->echoPhrase('Hello, Basement!');
-```
+use Basement\BetterMails\Filament\FilamentBetterEmailPlugin;
+ ->plugins([
+ FilamentBetterEmailPlugin::make(),
+ ])
+```
+The resource track all mails sent based on their status, also has a widget and actions to resend this mails again.
## Testing
```bash
@@ -77,6 +136,7 @@ Please review [our security policy](../../security/policy) on how to report secu
## Credits
- [Daniel Reis](https://github.com/basementdevs)
+- [RichardGL11](https://github.com/RichardGL11)
- [All Contributors](../../contributors)
## License
diff --git a/composer.json b/composer.json
index 81de2d8..ba8f747 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,7 @@
"php": "^8.3",
"filament/filament": "^4.0",
"illuminate/contracts": "^12.0",
- "resend/resend-laravel": "^0.22",
+ "resend/resend-laravel": "^0.22.0",
"spatie/laravel-package-tools": "^1.16"
},
"require-dev": {
diff --git a/config/filament-better-mails.php b/config/filament-better-mails.php
index 671deb6..01d10d7 100644
--- a/config/filament-better-mails.php
+++ b/config/filament-better-mails.php
@@ -4,6 +4,7 @@
use Basement\BetterMails\Core\Models\BetterEmail;
use Basement\BetterMails\Core\Models\BetterEmailAttachment;
use Basement\BetterMails\Core\Models\BetterEmailEvent;
+use Basement\BetterMails\Resend\ResendDriver;
return [
'mails' => [
@@ -26,7 +27,7 @@
],
'headers' => [
- 'key' => 'X-Better-Mails-Event-Id'
+ 'key' => 'X-Better-Mails-Event-ID'
],
'logging' => [
@@ -36,5 +37,15 @@
'root' => 'mails/attachments',
],
]
+ ],
+ 'webhooks' => [
+ 'provider' => env('MAILS_WEBHOOK_PROVIDER', 'resend'),
+
+ 'drivers' => [
+ 'resend' => [
+ 'driver' => ResendDriver::class,
+ 'key_secret' => env('RESEND_WEBHOOK_SECRET'),
+ ],
+ ]
]
];
diff --git a/database/factories/BetterMailEventFactory.php b/database/factories/BetterEmailEventFactory.php
similarity index 53%
rename from database/factories/BetterMailEventFactory.php
rename to database/factories/BetterEmailEventFactory.php
index 4d1a308..446ee55 100644
--- a/database/factories/BetterMailEventFactory.php
+++ b/database/factories/BetterEmailEventFactory.php
@@ -2,25 +2,28 @@
namespace Basement\BetterMails\Database\Factories;
+use Basement\BetterMails\Core\Enums\MailEventTypeEnum;
+use Basement\BetterMails\Core\Models\BetterEmail;
use Basement\BetterMails\Core\Models\BetterEmailEvent;
use Illuminate\Database\Eloquent\Factories\Factory;
-class BetterMailEventFactory extends Factory
+final class BetterEmailEventFactory extends Factory
{
protected $model = BetterEmailEvent::class;
public function definition(): array
{
return [
- 'type' => 'delivered',
+ 'mail_id' => BetterEmail::factory(),
+ 'type' => MailEventTypeEnum::Sent,
'payload' => [],
];
}
- public function bounce(): Factory
+ public function withEvent(MailEventTypeEnum $type): Factory
{
return $this->state(fn () => [
- 'type' => 'hard_bounced',
+ 'type' => $type,
]);
}
}
diff --git a/database/factories/BetterMailFactory.php b/database/factories/BetterMailFactory.php
index 0b76b5e..b49c73b 100644
--- a/database/factories/BetterMailFactory.php
+++ b/database/factories/BetterMailFactory.php
@@ -55,4 +55,11 @@ public function hasBcc(): BetterMailFactory
],
]);
}
+
+ public function withDriver(SupportedMailProvidersEnum $transport): static
+ {
+ return $this->state(fn () => [
+ 'transport' => $transport,
+ ]);
+ }
}
diff --git a/routes/filament-better-mails-route.php b/routes/filament-better-mails-route.php
new file mode 100644
index 0000000..8609fd2
--- /dev/null
+++ b/routes/filament-better-mails-route.php
@@ -0,0 +1,11 @@
+whereIn('provider', SupportedMailProvidersEnum::cases())
+ ->withoutMiddleware(VerifyCsrfToken::class)
+ ->name('filament-better-mails.webhook.store');
diff --git a/src/Core/AbstractMailDriver.php b/src/Core/AbstractMailDriver.php
index 82b1c47..389fb6e 100644
--- a/src/Core/AbstractMailDriver.php
+++ b/src/Core/AbstractMailDriver.php
@@ -4,4 +4,7 @@
use Basement\BetterMails\Core\Contracts\BetterDriverContract;
-abstract class AbstractMailDriver implements BetterDriverContract {}
+abstract class AbstractMailDriver implements BetterDriverContract
+{
+ abstract public function handle(array $data): void;
+}
diff --git a/src/Core/Concerns/HasMail.php b/src/Core/Concerns/HasMail.php
new file mode 100644
index 0000000..1bd4c3c
--- /dev/null
+++ b/src/Core/Concerns/HasMail.php
@@ -0,0 +1,13 @@
+where('uuid', $uuid)->firstOrFail();
+ }
+}
diff --git a/src/Core/Contracts/BetterDriverContract.php b/src/Core/Contracts/BetterDriverContract.php
index ccc70a0..42674c9 100644
--- a/src/Core/Contracts/BetterDriverContract.php
+++ b/src/Core/Contracts/BetterDriverContract.php
@@ -2,4 +2,7 @@
namespace Basement\BetterMails\Core\Contracts;
-interface BetterDriverContract {}
+interface BetterDriverContract
+{
+ public function handle(array $data): void;
+}
diff --git a/src/Core/Contracts/BetterMailDTOContract.php b/src/Core/Contracts/BetterMailDTOContract.php
new file mode 100644
index 0000000..9738181
--- /dev/null
+++ b/src/Core/Contracts/BetterMailDTOContract.php
@@ -0,0 +1,8 @@
+ [VerifyWebhookSignatureAdapter::class, VerifyHeaderWebhookSignature::class],
+ };
+ }
}
diff --git a/src/Core/Events/TODO.md b/src/Core/Events/TODO.md
index dcdf6df..b84f579 100644
--- a/src/Core/Events/TODO.md
+++ b/src/Core/Events/TODO.md
@@ -2,15 +2,15 @@
## Delivery Events
-- [ ] Implement "delivered" event handling
+- [x] Implement "delivered" event handling
- [ ] Implement "bounced" event handling
- [ ] Implement "deferred" event handling
- [ ] Implement "blocked" event handling
## Engagement Events
-- [ ] Implement "opened" event handling
-- [ ] Implement "clicked" event handling
+- [x] Implement "opened" event handling
+- [x] Implement "clicked" event handling
- [ ] Implement "unsubscribed" event handling
- [ ] Implement "complained" (spam report) event handling
diff --git a/src/Core/Exceptions/MailException.php b/src/Core/Exceptions/MailException.php
new file mode 100644
index 0000000..b6bbd97
--- /dev/null
+++ b/src/Core/Exceptions/MailException.php
@@ -0,0 +1,16 @@
+through($provider->getMiddleware())
+ ->thenReturn();
+
+ $driver->handle($request->all());
+ }
+}
diff --git a/src/Core/Http/Middleware/AbstractMailMiddleware.php b/src/Core/Http/Middleware/AbstractMailMiddleware.php
new file mode 100644
index 0000000..c621cf3
--- /dev/null
+++ b/src/Core/Http/Middleware/AbstractMailMiddleware.php
@@ -0,0 +1,32 @@
+findMail($event->dto->id);
+
+ $mail->clicked();
+
+ $mail->events()->create([
+ 'type' => MailEventTypeEnum::Clicked,
+ 'occurred_at' => now(),
+ ]);
+ }
+}
diff --git a/src/Core/Listeners/External/ComplainedMailListener.php b/src/Core/Listeners/External/ComplainedMailListener.php
new file mode 100644
index 0000000..caf08ad
--- /dev/null
+++ b/src/Core/Listeners/External/ComplainedMailListener.php
@@ -0,0 +1,24 @@
+findMail($event->dto->id);
+
+ $mail->complained();
+
+ $mail->events()->create([
+ 'type' => MailEventTypeEnum::Complained,
+ 'occurred_at' => now(),
+ ]);
+ }
+}
diff --git a/src/Core/Listeners/External/DeliveredMailListener.php b/src/Core/Listeners/External/DeliveredMailListener.php
new file mode 100644
index 0000000..d249ea0
--- /dev/null
+++ b/src/Core/Listeners/External/DeliveredMailListener.php
@@ -0,0 +1,24 @@
+findMail($event->dto->id);
+
+ $mail->delivered();
+
+ $mail->events()->create([
+ 'type' => MailEventTypeEnum::Delivered,
+ 'occurred_at' => now(),
+ ]);
+ }
+}
diff --git a/src/Core/Listeners/External/FailedMailListener.php b/src/Core/Listeners/External/FailedMailListener.php
new file mode 100644
index 0000000..02e2fed
--- /dev/null
+++ b/src/Core/Listeners/External/FailedMailListener.php
@@ -0,0 +1,10 @@
+findMail($event->dto->id);
+
+ $mail->hardBounced();
+
+ $mail->events()->create([
+ 'type' => MailEventTypeEnum::HardBounced,
+ 'occurred_at' => now(),
+ ]);
+ }
+}
diff --git a/src/Core/Listeners/External/OpenedMailListener.php b/src/Core/Listeners/External/OpenedMailListener.php
new file mode 100644
index 0000000..877aeab
--- /dev/null
+++ b/src/Core/Listeners/External/OpenedMailListener.php
@@ -0,0 +1,24 @@
+findMail($event->dto->id);
+
+ $mail->opened();
+
+ $mail->events()->create([
+ 'type' => MailEventTypeEnum::Opened,
+ 'occurred_at' => now(),
+ ]);
+ }
+}
diff --git a/src/Core/Listeners/External/ReceivedMailListener.php b/src/Core/Listeners/External/ReceivedMailListener.php
new file mode 100644
index 0000000..fa46416
--- /dev/null
+++ b/src/Core/Listeners/External/ReceivedMailListener.php
@@ -0,0 +1,10 @@
+where('created_at', '<=', now()->subDays($pruneAfter));
}
+ public function latestEvent(): HasOne
+ {
+ return $this->hasOne(BetterEmailEvent::class, 'mail_id')->latestOfMany('occurred_at');
+ }
+
public function attachments(): HasMany
{
return $this->hasMany(config('filament-better-mails.mails.models.attachment'), 'mail_id');
@@ -128,6 +135,46 @@ public function sent(): void
$this->update(['sent_at' => now()]);
}
+ public function delivered(): void
+ {
+ $this->update(['delivered_at' => now()]);
+ }
+
+ public function opened(): void
+ {
+ $this->update([
+ 'last_opened_at' => now(),
+ 'opens' => $this->opens + 1,
+ ]);
+ }
+
+ public function clicked(): void
+ {
+ $this->update([
+ 'last_clicked_at' => now(),
+ 'clicks' => $this->clicks + 1,
+ ]);
+ }
+
+ public function complained(): void
+ {
+ $this->update(['complained_at' => now()]);
+ }
+
+ public function softBounced(): void
+ {
+ $this->update(['soft_bounced_at' => now()]);
+ $this->events()->create([
+ 'type' => MailEventTypeEnum::SoftBounced,
+ 'occurred_at' => now(),
+ ]);
+ }
+
+ public function hardBounced(): void
+ {
+ $this->update(['hard_bounced_at' => now()]);
+ }
+
protected static function newFactory(): BetterMailFactory
{
return BetterMailFactory::new();
diff --git a/src/Core/Models/BetterEmailEvent.php b/src/Core/Models/BetterEmailEvent.php
index 3606106..d4b3104 100644
--- a/src/Core/Models/BetterEmailEvent.php
+++ b/src/Core/Models/BetterEmailEvent.php
@@ -3,7 +3,7 @@
namespace Basement\BetterMails\Core\Models;
use Basement\BetterMails\Core\Enums\MailEventTypeEnum;
-use Basement\BetterMails\Database\Factories\BetterMailFactory;
+use Basement\BetterMails\Database\Factories\BetterEmailEventFactory;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
@@ -69,38 +69,38 @@ public function mail(): BelongsTo
return $this->belongsTo(BetterEmail::class, 'mail_id');
}
- protected static function newFactory(): BetterMailFactory
+ protected static function newFactory(): BetterEmailEventFactory
{
- return BetterMailFactory::new();
+ return BetterEmailEventFactory::new();
}
#[Scope]
protected function softBounced(Builder $query): Builder
{
- return $query->where('type', MailEventTypeEnum::SoftBounced);
+ return $query->where('type', MailEventTypeEnum::SoftBounced)->latest();
}
#[Scope]
protected function hardBounced(Builder $query): Builder
{
- return $query->where('type', MailEventTypeEnum::HardBounced);
+ return $query->where('type', MailEventTypeEnum::HardBounced)->latest();
}
#[Scope]
protected function opened(Builder $query): Builder
{
- return $query->where('type', MailEventTypeEnum::Opened);
+ return $query->where('type', MailEventTypeEnum::Opened)->latest();
}
#[Scope]
protected function delivered(Builder $query): Builder
{
- return $query->where('type', MailEventTypeEnum::Delivered);
+ return $query->where('type', MailEventTypeEnum::Delivered)->latest();
}
#[Scope]
protected function clicked(Builder $query): Builder
{
- return $query->where('type', MailEventTypeEnum::Clicked);
+ return $query->where('type', MailEventTypeEnum::Clicked)->latest();
}
}
diff --git a/src/Filament/Pages/ListBetterEmails.php b/src/Filament/Pages/ListBetterEmails.php
index a660312..92c0f0f 100644
--- a/src/Filament/Pages/ListBetterEmails.php
+++ b/src/Filament/Pages/ListBetterEmails.php
@@ -39,8 +39,8 @@ public function getTabs(): array
->icon($event->getIcon())
->label($event->getLabel())
->badgeColor($event->getColor())
- ->modifyQueryUsing(fn ($query) => $query->whereHas('events', fn ($q) => $q->where('type', $event)))
- ->badge(fn () => BetterEmail::whereHas('events', fn ($q) => $q->where('type', $event))->count())
+ ->modifyQueryUsing(fn ($query) => $query->whereHas('latestEvent', fn ($q) => $q->where('type', $event)))
+ ->badge(fn () => BetterEmail::whereHas('latestEvent', fn ($q) => $q->where('type', $event))->count())
)->toArray(),
];
}
diff --git a/src/FilamentBetterMailsServiceProvider.php b/src/FilamentBetterMailsServiceProvider.php
index 420957c..269b13a 100644
--- a/src/FilamentBetterMailsServiceProvider.php
+++ b/src/FilamentBetterMailsServiceProvider.php
@@ -2,8 +2,23 @@
namespace Basement\BetterMails;
+use Basement\BetterMails\Core\Contracts\BetterDriverContract;
use Basement\BetterMails\Core\Listeners\AfterSendingMailListener;
use Basement\BetterMails\Core\Listeners\BeforeSendingMailListener;
+use Basement\BetterMails\Core\Listeners\External\ClickedMailListener;
+use Basement\BetterMails\Core\Listeners\External\ComplainedMailListener;
+use Basement\BetterMails\Core\Listeners\External\DeliveredMailListener;
+use Basement\BetterMails\Core\Listeners\External\FailedMailListener;
+use Basement\BetterMails\Core\Listeners\External\HardBouncedMailListener;
+use Basement\BetterMails\Core\Listeners\External\OpenedMailListener;
+use Basement\BetterMails\Core\Listeners\External\ReceivedMailListener;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailClickedEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailComplainedEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailDeliveredEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailFailedEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailHardBouncedEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailOpenedEvent;
+use Basement\BetterMails\Resend\Email\Events\ResendEmailReceivedEvent;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Event;
@@ -26,10 +41,17 @@ public function configurePackage(Package $package): void
->discoversMigrations();
}
+ /**
+ * @throws \Exception
+ */
public function boot(): void
{
+ $this->publish();
$this->loadListeners();
+ $this->loadResendListeners();
+ $this->loadProviderConfig();
$this->loadViewsFrom(__DIR__.'/../resources/views', 'basement-better-mails');
+ $this->loadRoutesFrom(__DIR__.'/../routes/filament-better-mails-route.php');
}
private function loadListeners(): void
@@ -37,4 +59,44 @@ private function loadListeners(): void
Event::listen(MessageSending::class, BeforeSendingMailListener::class);
Event::listen(MessageSent::class, AfterSendingMailListener::class);
}
+
+ private function loadProviderConfig(): void
+ {
+ $provider = config('filament-better-mails.webhooks.provider');
+
+ $config = config("filament-better-mails.webhooks.drivers.{$provider}");
+
+ if (! $config) {
+ throw new \Exception('Invalid provider configuration');
+ }
+
+ $this->app->bind(BetterDriverContract::class, $config['driver']);
+ }
+
+ private function loadResendListeners(): void
+ {
+ Event::listen(ResendEmailDeliveredEvent::class, DeliveredMailListener::class);
+ Event::listen(ResendEmailOpenedEvent::class, OpenedMailListener::class);
+ Event::listen(ResendEmailClickedEvent::class, ClickedMailListener::class);
+ Event::listen(ResendEmailComplainedEvent::class, ComplainedMailListener::class);
+ Event::listen(ResendEmailHardBouncedEvent::class, HardBouncedMailListener::class);
+ Event::listen(ResendEmailReceivedEvent::class, ReceivedMailListener::class);
+ Event::listen(ResendEmailFailedEvent::class, FailedMailListener::class);
+ // TODO: sent, delivered_delayed
+ }
+
+ private function publish(): void
+ {
+ $this->publishes([
+ __DIR__.'/../config/filament-better-mails.php' => config_path('filament-better-mails.php'),
+ ], 'filament-better-mails-config');
+
+ $this->publishes([
+ __DIR__.'/../database/migrations/' => database_path('migrations'),
+ ], 'filament-better-mails-migrations');
+
+ $this->publishes([
+ __DIR__.'/../resources/views' => resource_path('views'),
+ ], 'filament-better-mails-views');
+ }
}
diff --git a/src/Resend/Email/DTOs/ResendWebhookReceivedDTO.php b/src/Resend/Email/DTOs/ResendWebhookReceivedDTO.php
deleted file mode 100644
index e7a4cae..0000000
--- a/src/Resend/Email/DTOs/ResendWebhookReceivedDTO.php
+++ /dev/null
@@ -1,6 +0,0 @@
- $this->id,
+ 'type' => $this->event->value,
+ 'payload' => $this->payload,
+ ];
+ }
+}
diff --git a/src/Resend/Email/Events/ResendEmailClickedEvent.php b/src/Resend/Email/Events/ResendEmailClickedEvent.php
new file mode 100644
index 0000000..5b39044
--- /dev/null
+++ b/src/Resend/Email/Events/ResendEmailClickedEvent.php
@@ -0,0 +1,18 @@
+input('data.headers');
+
+ parent::validateHeaderKey($headers, 'name', 'value');
+
+ return $next($request);
+ }
+}
diff --git a/src/Resend/Email/Middleware/VerifyResendWebhookSignature.php b/src/Resend/Email/Middleware/VerifyResendWebhookSignature.php
deleted file mode 100644
index 164fdf4..0000000
--- a/src/Resend/Email/Middleware/VerifyResendWebhookSignature.php
+++ /dev/null
@@ -1,5 +0,0 @@
-handle($request, $next);
+ }
+}
diff --git a/src/Resend/Email/ResendEventsEnum.php b/src/Resend/Email/ResendEventsEnum.php
new file mode 100644
index 0000000..787e700
--- /dev/null
+++ b/src/Resend/Email/ResendEventsEnum.php
@@ -0,0 +1,16 @@
+event) {
+ ResendEventsEnum::EmailSent => ResendEmailSentEvent::dispatch($dto),
+ ResendEventsEnum::EmailDelivered => ResendEmailDeliveredEvent::dispatch($dto),
+ ResendEventsEnum::EmailDeliveryDelayed => ResendEmailDeliveryDelayedEvent::dispatch($dto),
+ ResendEventsEnum::EmailComplained => ResendEmailComplainedEvent::dispatch($dto),
+ ResendEventsEnum::EmailBounced => ResendEmailHardBouncedEvent::dispatch($dto),
+ ResendEventsEnum::EmailOpened => ResendEmailOpenedEvent::dispatch($dto),
+ ResendEventsEnum::EmailClicked => ResendEmailClickedEvent::dispatch($dto),
+ ResendEventsEnum::EmailReceived => ResendEmailReceivedEvent::dispatch($dto),
+ ResendEventsEnum::EmailFailed => ResendEmailFailedEvent::dispatch($dto),
+ };
+ }
}
diff --git a/tests/Feature/Resend/Driver/ResendDriverTest.php b/tests/Feature/Resend/Driver/ResendDriverTest.php
new file mode 100644
index 0000000..8850911
--- /dev/null
+++ b/tests/Feature/Resend/Driver/ResendDriverTest.php
@@ -0,0 +1,161 @@
+uuid = '6e289259-7918-483a-97d1-78f05dadca13';
+ $this->mail = BetterEmail::factory()
+ ->has(BetterEmailEvent::factory(), 'events')
+ ->withDriver(SupportedMailProvidersEnum::Resend)
+ ->create([
+ 'uuid' => $this->uuid,
+ ]);
+ $this->withoutMiddleware(VerifyWebhookSignatureAdapter::class);
+});
+
+it('should be able to update the emails status to delivered via webhook', function (): void {
+ $response = postJson(
+ route('filament-better-mails.webhook.store', [
+ 'provider' => SupportedMailProvidersEnum::Resend
+ ]),
+ ResendWebhookDataProvider::withMailEvent(
+ uuid: $this->uuid,
+ event: ResendEventsEnum::EmailDelivered
+ )
+ );
+
+ $response->assertOk();
+
+ $this->mail->refresh();
+
+ expect($this->mail->events->first()->type->value)
+ ->toBe(MailEventTypeEnum::Delivered->value);
+
+ assertDatabaseHas(BetterEmail::class, [
+ 'uuid' => $this->mail->uuid,
+ 'delivered_at' => now(),
+ ]);
+});
+
+it('should be able to update email status to complained', function () {
+ $response = postJson(
+ route('filament-better-mails.webhook.store', [
+ 'provider' => SupportedMailProvidersEnum::Resend
+ ]),
+ ResendWebhookDataProvider::withMailEvent(
+ uuid: $this->uuid,
+ event: ResendEventsEnum::EmailComplained
+ )
+ );
+
+ $response->assertOk();
+
+ $this->mail->refresh();
+
+ expect($this->mail->events->first()->type->value)
+ ->toBe(MailEventTypeEnum::Complained->value);
+
+ assertDatabaseHas(BetterEmail::class, [
+ 'uuid' => $this->mail->uuid,
+ 'complained_at' => now(),
+ ]);
+
+ assertDatabaseHas(BetterEmailEvent::class, [
+ 'occurred_at' => now(),
+ ]);
+});
+it('should be able to update email status to clicked', function () {
+ $response = postJson(
+ route('filament-better-mails.webhook.store', [
+ 'provider' => SupportedMailProvidersEnum::Resend
+ ]),
+ ResendWebhookDataProvider::withMailEvent(
+ uuid: $this->uuid,
+ event: ResendEventsEnum::EmailClicked
+ )
+ );
+
+ $response->assertOk();
+
+ $this->mail->refresh();
+
+ expect($this->mail->events->first()->type->value)
+ ->toBe(MailEventTypeEnum::Clicked->value);
+
+ assertDatabaseHas(BetterEmail::class, [
+ 'uuid' => $this->mail->uuid,
+ 'clicks' => 1,
+ 'last_clicked_at' => now(),
+ ]);
+
+ assertDatabaseHas(BetterEmailEvent::class, [
+ 'occurred_at' => now(),
+ ]);
+});
+it('should be able to update email status to opened', function () {
+ $response = postJson(
+ route('filament-better-mails.webhook.store', [
+ 'provider' => SupportedMailProvidersEnum::Resend
+ ]),
+ ResendWebhookDataProvider::withMailEvent(
+ uuid: $this->uuid,
+ event: ResendEventsEnum::EmailOpened
+ )
+ );
+
+ $response->assertOk();
+
+ $this->mail->refresh();
+
+ expect($this->mail->events->first()->type->value)
+ ->toBe(MailEventTypeEnum::Opened->value);
+
+ assertDatabaseHas(BetterEmail::class, [
+ 'uuid' => $this->mail->uuid,
+ 'opens' => 1,
+ 'last_opened_at' => now(),
+ ]);
+
+ assertDatabaseHas(BetterEmailEvent::class, [
+ 'occurred_at' => now(),
+ ]);
+});
+
+it('should be able to update email status to bounced', function () {
+ $response = postJson(
+ route('filament-better-mails.webhook.store', [
+ 'provider' => SupportedMailProvidersEnum::Resend
+ ]),
+ ResendWebhookDataProvider::withMailEvent(
+ uuid: $this->uuid,
+ event: ResendEventsEnum::EmailBounced
+ )
+ );
+
+ $response->assertOk();
+
+ $this->mail->refresh();
+
+ expect($this->mail->events->first()->type->value)
+ ->toBe(MailEventTypeEnum::HardBounced->value);
+
+ assertDatabaseHas(BetterEmail::class, [
+ 'uuid' => $this->mail->uuid,
+ 'hard_bounced_at' => now(),
+ ]);
+
+ assertDatabaseHas(BetterEmailEvent::class, [
+ 'occurred_at' => now(),
+ ]);
+});
diff --git a/tests/Feature/Resend/MIddleware/VerifyResendWebhookSignatureTest.php b/tests/Feature/Resend/MIddleware/VerifyResendWebhookSignatureTest.php
new file mode 100644
index 0000000..682c6a0
--- /dev/null
+++ b/tests/Feature/Resend/MIddleware/VerifyResendWebhookSignatureTest.php
@@ -0,0 +1,36 @@
+withoutMiddleware(VerifyWebhookSignatureAdapter::class);
+ withoutExceptionHandling();
+ postJson(route('filament-better-mails.webhook.store', ['provider' => SupportedMailProvidersEnum::Resend]), [
+ 'data' => [
+ 'created_at' => '2025-10-05 23:59:51.98696+00',
+ 'email_id' => 'b5482019-eef5-4afd-89ae-b93acb1dda7f',
+ 'from' => '"Laravel" ',
+ 'headers' => [
+ [
+ 'name' => 'wrong_header',
+ 'value' => 'no_one',
+ ],
+ ],
+ 'subject' => 'Fuedase Mail',
+ 'to' => [
+ 'delivered@resend.dev',
+ ]
+ ],
+ 'type' => 'email.delivered',
+ ]);
+
+})->throws(
+ MailException::class,
+ 'Uuid mail signature not found on body request',
+ 403
+);
diff --git a/tests/Fixtures/Resend/ResendWebhookDataProvider.php b/tests/Fixtures/Resend/ResendWebhookDataProvider.php
new file mode 100644
index 0000000..06191c3
--- /dev/null
+++ b/tests/Fixtures/Resend/ResendWebhookDataProvider.php
@@ -0,0 +1,30 @@
+ [
+ 'created_at' => '2025-10-05 23:59:51.98696+00',
+ 'email_id' => 'b5482019-eef5-4afd-89ae-b93acb1dda7f',
+ 'from' => '"Laravel" ',
+ 'headers' => [
+ [
+ 'name' => config('filament-better-mails.mails.headers.key'),
+ 'value' => $uuid,
+ ],
+ ],
+ 'subject' => 'Fuedase Mail',
+ 'to' => [
+ 'delivered@resend.dev',
+ ]
+ ],
+ 'type' => $event->value,
+ ];
+ }
+}