diff --git a/README.md b/README.md index 9111f5e..7604d2c 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,20 @@ [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/basementdevs/filament-better-mails/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/basementdevs/filament-better-mails/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) [![Total Downloads](https://img.shields.io/packagist/dt/basementdevs/filament-better-mails.svg?style=flat-square)](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, + ]; + } +}