diff --git a/composer.json b/composer.json index 7604c8e..f269718 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": "^8.2", - "illuminate/contracts": ">=10.0 <14.0", + "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", "laravel/helpers": "^1.7.0", "spatie/laravel-package-tools": "^1.15.0" }, @@ -41,13 +41,13 @@ }, "autoload": { "psr-4": { - "Backstage\\Mails\\": "src", - "Backstage\\Mails\\Database\\Factories\\": "database/factories" + "Backstage\\Mails\\Laravel\\": "src", + "Backstage\\Mails\\Laravel\\Database\\Factories\\": "database/factories" } }, "autoload-dev": { "psr-4": { - "Backstage\\Mails\\Tests\\": "tests" + "Backstage\\Mails\\Laravel\\Tests\\": "tests" }, "files": [ "helpers.php" @@ -74,7 +74,7 @@ "extra": { "laravel": { "providers": [ - "Backstage\\Mails\\MailsServiceProvider" + "Backstage\\Mails\\Laravel\\MailsServiceProvider" ] } }, diff --git a/config/mails.php b/config/mails.php index a4483d1..934e322 100644 --- a/config/mails.php +++ b/config/mails.php @@ -1,8 +1,8 @@ getProvider($messageSending); + + if (! $this->shouldTrackMails($provider)) { return $messageSending; } @@ -21,12 +23,6 @@ public function handle(MessageSending $messageSending): MessageSending $messageSending->message->getHeaders()->addTextHeader(config('mails.headers.uuid'), $uuid); - $provider = $this->getProvider($messageSending); - - if (! $this->shouldTrackMails($provider)) { - return $messageSending; - } - return MailProvider::with($provider)->attachUuidToMail($messageSending, $uuid); } diff --git a/src/Actions/LogMail.php b/src/Actions/LogMail.php index 0b581d1..e75d45b 100644 --- a/src/Actions/LogMail.php +++ b/src/Actions/LogMail.php @@ -1,10 +1,10 @@ $this->getCustomUuid($event), - 'mail_class' => $event->data['__laravel_mailable'] ?? null, + // 'mail_class' => $this->getMailClassHeaderValue($event), 'sent_at' => $event instanceof MessageSent ? now() : null, 'mailer' => $event->data['mailer'], 'stream_id' => $this->getStreamId($event), diff --git a/src/Actions/RegisterWebhooks.php b/src/Actions/RegisterWebhooks.php index 420e468..9cb7cf4 100644 --- a/src/Actions/RegisterWebhooks.php +++ b/src/Actions/RegisterWebhooks.php @@ -1,9 +1,9 @@ Provider::RESEND]); - - $events = []; - - if ((bool) $trackingConfig['deliveries']) { - $events[] = 'email.sent'; - $events[] = 'email.delivered'; - } - - if ((bool) $trackingConfig['opens']) { - $events[] = 'email.opened'; - } - - if ((bool) $trackingConfig['clicks']) { - $events[] = 'email.clicked'; - } - - if ((bool) $trackingConfig['bounces']) { - $events[] = 'email.bounced'; - $events[] = 'email.delivery_delayed'; - } - - if ((bool) $trackingConfig['complaints']) { - $events[] = 'email.complained'; - } - - $existingWebhooks = Http::withToken($apiKey) - ->get('https://api.resend.com/webhooks'); - - $existing = collect($existingWebhooks->json('data') ?? []) - ->firstWhere('endpoint', $webhookUrl); - - if ($existing) { - $components->info('Resend webhook already exists for this endpoint.'); - $components->info('Webhook ID: '.$existing['id']); - - return; - } - - $response = Http::withToken($apiKey) - ->post('https://api.resend.com/webhooks', [ - 'endpoint' => $webhookUrl, - 'events' => $events, - ]); - - if ($response->successful()) { - $signingSecret = $response->json('signing_secret'); - - $components->info('Created Resend webhook successfully.'); - $components->warn('Save this signing secret to your config as services.resend.webhook_signing_secret:'); - $components->info($signingSecret); - } else { - $components->error('Failed to create Resend webhook.'); - $components->error($response->json('message') ?? $response->body()); - } + $components->warn("Resend doesn't allow registering webhooks via the API. "); + $components->info('Please register your webhooks manually in the Resend dashboard.'); } public function verifyWebhookSignature(array $payload): bool { - if (app()->runningUnitTests()) { - return true; - } - - $secret = (string) config('services.resend.webhook_signing_secret'); - - if (empty($secret)) { - return true; - } - - $request = request(); - - $svixId = $request->header('svix-id'); - $svixTimestamp = $request->header('svix-timestamp'); - $svixSignature = $request->header('svix-signature'); - - if (empty($svixId) || empty($svixTimestamp) || empty($svixSignature)) { - return false; - } - - // Verify timestamp is within tolerance (5 minutes) - $tolerance = 5 * 60; - if (abs(time() - (int) $svixTimestamp) > $tolerance) { - return false; - } - - // Strip the whsec_ prefix and base64 decode the secret - $secretBytes = base64_decode(str_replace('whsec_', '', $secret)); - - // Construct the signed content: msg_id.timestamp.body - $body = (string) $request->getContent(); - $signedContent = "{$svixId}.{$svixTimestamp}.{$body}"; - - // Compute expected signature using HMAC-SHA256 - $expectedSignature = base64_encode(hash_hmac('sha256', $signedContent, $secretBytes, true)); - - // The svix-signature header may contain multiple signatures separated by spaces - $signatures = explode(' ', $svixSignature); - - foreach ($signatures as $signature) { - // Strip version prefix (v1,) - $parts = explode(',', $signature, 2); - $signatureValue = $parts[1] ?? $parts[0]; - - if (hash_equals($expectedSignature, $signatureValue)) { - return true; - } - } - - return false; + return true; } public function getUuidFromPayload(array $payload): ?string { - return collect($payload['data']['headers'] ?? []) + return collect($payload['data']['headers']) ->where('name', config('mails.headers.uuid')) ->first()['value'] ?? null; } @@ -159,7 +51,6 @@ public function dataMapping(): array 'ip_address' => 'data.click.ipAddress', 'link' => 'data.click.link', 'user_agent' => 'data.click.userAgent', - 'tag' => 'data.tags', ]; } diff --git a/src/Enums/EventType.php b/src/Enums/EventType.php index b585662..1b90dd0 100644 --- a/src/Enums/EventType.php +++ b/src/Enums/EventType.php @@ -1,6 +1,6 @@ whereNull('sent_at'); } - public function scopeForUuid(Builder $builder, string $uuid): Builder - { - return $builder->where('uuid', $uuid); - } - - public function scopeForMailClass(Builder $builder, string $mailClass): Builder - { - return $builder->where('mail_class', $mailClass); - } - protected function status(): Attribute { return Attribute::make(get: function () { diff --git a/src/Models/MailAttachment.php b/src/Models/MailAttachment.php index e901600..fddc88e 100644 --- a/src/Models/MailAttachment.php +++ b/src/Models/MailAttachment.php @@ -1,14 +1,15 @@ Storage::disk($this->disk)->get($this->storagePath)); } - public function downloadFileFromStorage(?string $filename = null): string + public function downloadFileFromStorage(?string $filename = null): StreamedResponse { - return Storage::disk($this->disk) - ->download( - $this->storagePath, - $filename ?? $this->filename, - [ - 'Content-Type' => $this->mime, - ] - ); + $disk = Storage::disk($this->disk); + + if (! $disk->exists($this->storagePath)) { + abort(404); + } + + $downloadFilename = $filename ?? $this->filename; + + return response()->streamDownload( + function () use ($disk) { + $stream = $disk->readStream($this->storagePath); + + if ($stream !== null) { + fpassthru($stream); + fclose($stream); + } + }, + $downloadFilename, + [ + 'Content-Type' => $this->mime, + ] + ); } } diff --git a/src/Models/MailEvent.php b/src/Models/MailEvent.php index ef27c33..0f1a0e5 100644 --- a/src/Models/MailEvent.php +++ b/src/Models/MailEvent.php @@ -1,10 +1,10 @@ 'Backstage\Mails\Events\Mail'.Str::studly($this->type->value)); + return Attribute::make(get: fn (): string => 'Backstage\Mails\Laravel\Events\Mail'.Str::studly($this->type->value)); } } diff --git a/src/Notifications/BounceNotification.php b/src/Notifications/BounceNotification.php index 55ee3d9..096f63d 100644 --- a/src/Notifications/BounceNotification.php +++ b/src/Notifications/BounceNotification.php @@ -1,9 +1,9 @@ Test HTML content

', - ); - } -} diff --git a/tests/MailClassTest.php b/tests/MailClassTest.php deleted file mode 100644 index ce9365d..0000000 --- a/tests/MailClassTest.php +++ /dev/null @@ -1,44 +0,0 @@ -send(new TestMailable); - - $mail = MailModel::latest()->first(); - - expect($mail)->not->toBeNull(); - expect($mail->mail_class)->toBe(TestMailable::class); -}); - -it('stores mail_class as null when sending with a closure', function (): void { - Mail::send([], [], function (Message $message): void { - $message->to('recipient@example.com') - ->from('from@example.com') - ->subject('Closure Mail') - ->html('

HTML

'); - }); - - $mail = MailModel::latest()->first(); - - expect($mail)->not->toBeNull(); - expect($mail->mail_class)->toBeNull(); -}); - -it('can query mails by mail_class using forMailClass scope', function (): void { - Mail::to('recipient@example.com')->send(new TestMailable); - - Mail::send([], [], function (Message $message): void { - $message->to('other@example.com') - ->from('from@example.com') - ->subject('Closure Mail') - ->html('

HTML

'); - }); - - expect(MailModel::count())->toBe(2); - expect(MailModel::forMailClass(TestMailable::class)->count())->toBe(1); - expect(MailModel::forMailClass(TestMailable::class)->first()->subject)->toBe('Test Mailable'); -}); diff --git a/tests/MailLogTest.php b/tests/MailLogTest.php index 51185ac..b1aa3a5 100644 --- a/tests/MailLogTest.php +++ b/tests/MailLogTest.php @@ -1,6 +1,6 @@ 'smtp', ], 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, 'url' => [ 'link' => 'https://example.com', 'title' => 'Omnivery', @@ -160,9 +159,6 @@ 'transport' => 'smtp', ], 'recipient' => 'nosuchemail@omnivery.com', - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], 'message' => [ 'size' => 5597, 'headers' => [ @@ -222,9 +218,6 @@ 'transport' => 'smtp', ], 'recipient' => 'nosuchemail@omnivery.com', - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], 'message' => [ 'size' => 5597, 'headers' => [ @@ -361,9 +354,6 @@ ], 'ip' => '123.123.123.123', 'recipient' => 'test@omnivery.com', - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], 'id' => 'OTk6MTA1MDI6b3BlbmVkOjE2NDk0MDgzMTE=', 'event' => 'opened', 'geolocation' => [ diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index ed47959..4adf2f3 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -1,7 +1,7 @@ config([ diff --git a/tests/Pest.php b/tests/Pest.php index b4f52d0..391fb01 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,13 +1,8 @@ use(RefreshDatabase::class) ->in(__DIR__); - -beforeEach(function (): void { - Mail::fake(); -}); diff --git a/tests/PostmarkTest.php b/tests/PostmarkTest.php index 46e4cd2..dbc2bdb 100644 --- a/tests/PostmarkTest.php +++ b/tests/PostmarkTest.php @@ -1,9 +1,9 @@ to('hey@danielhe4rt.dev') - ->from('local@computer.nl') - ->subject('Test') - ->text('Text') - ->html('

HTML

'); - }); - - $mail = MailModel::latest()->first(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::RESEND]), [ - 'created_at' => '2023-05-19T22:09:32Z', - 'data' => [ - 'created_at' => '2025-01-09 14:17:29.059104+00', - 'email_id' => 'dummy-id', - 'headers' => [ - [ - 'name' => config('mails.headers.uuid'), - 'value' => $mail->uuid, - ], - ], - 'from' => 'local@computer.nl', - 'subject' => 'Test', - 'to' => ['hey@danielhe4rt.dev'], - ], - 'type' => 'email.sent', - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::ACCEPTED->value, - ]); -}); - it('can receive incoming hard bounce webhook from resend', function (): void { Mail::send([], [], function (Message $message): void { $message->to('hey@danielhe4rt.dev') @@ -284,168 +245,3 @@ 'link' => 'https://resend.com', ]); }); - -it('can register webhooks via resend api', function (): void { - config()->set('services.resend.key', 're_test_123'); - - Http::fake([ - 'api.resend.com/webhooks' => Http::sequence() - ->push(['object' => 'list', 'data' => []]) - ->push([ - 'object' => 'webhook', - 'id' => 'wh_test_123', - 'signing_secret' => 'whsec_test_signing_secret', - ]), - ]); - - $driver = new ResendDriver; - - $output = new BufferedOutput; - $factory = new Factory($output); - - $driver->registerWebhooks($factory); - - Http::assertSentCount(2); - Http::assertSent(fn ($request) => $request->method() === 'GET' && $request->url() === 'https://api.resend.com/webhooks'); - Http::assertSent(fn ($request) => $request->method() === 'POST' - && $request->url() === 'https://api.resend.com/webhooks' - && ! empty($request['endpoint']) - && ! empty($request['events']) - ); -}); - -it('skips webhook registration when resend webhook already exists', function (): void { - config()->set('services.resend.key', 're_test_123'); - - $webhookUrl = URL::signedRoute('mails.webhook', ['provider' => Provider::RESEND]); - - Http::fake([ - 'api.resend.com/webhooks' => Http::response([ - 'object' => 'list', - 'data' => [ - [ - 'id' => 'wh_existing_123', - 'endpoint' => $webhookUrl, - 'events' => ['email.delivered'], - 'status' => 'enabled', - ], - ], - ]), - ]); - - $driver = new ResendDriver; - - $output = new BufferedOutput; - $factory = new Factory($output); - - $driver->registerWebhooks($factory); - - Http::assertSentCount(1); -}); - -it('verifies svix webhook signature correctly', function (): void { - $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; - $secretBytes = base64_decode(str_replace('whsec_', '', $secret)); - - $body = json_encode(['type' => 'email.delivered', 'data' => ['email_id' => 'test']]); - $msgId = 'msg_test_123'; - $timestamp = (string) time(); - $signedContent = "{$msgId}.{$timestamp}.{$body}"; - $signature = base64_encode(hash_hmac('sha256', $signedContent, $secretBytes, true)); - - config()->set('services.resend.webhook_signing_secret', $secret); - - $driver = new ResendDriver; - - // Simulate a request with Svix headers - $request = Request::create( - '/webhooks/mails/resend', - 'POST', - [], - [], - [], - [ - 'HTTP_SVIX_ID' => $msgId, - 'HTTP_SVIX_TIMESTAMP' => $timestamp, - 'HTTP_SVIX_SIGNATURE' => "v1,{$signature}", - 'CONTENT_TYPE' => 'application/json', - ], - $body, - ); - - app()->instance('request', $request); - - // Override runningUnitTests to actually test verification - $this->app->detectEnvironment(fn () => 'production'); - - expect($driver->verifyWebhookSignature(json_decode($body, true)))->toBeTrue(); -}); - -it('rejects invalid svix webhook signature', function (): void { - $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; - - $body = json_encode(['type' => 'email.delivered', 'data' => ['email_id' => 'test']]); - $msgId = 'msg_test_123'; - $timestamp = (string) time(); - - config()->set('services.resend.webhook_signing_secret', $secret); - - $driver = new ResendDriver; - - $request = Request::create( - '/webhooks/mails/resend', - 'POST', - [], - [], - [], - [ - 'HTTP_SVIX_ID' => $msgId, - 'HTTP_SVIX_TIMESTAMP' => $timestamp, - 'HTTP_SVIX_SIGNATURE' => 'v1,invalidsignature', - 'CONTENT_TYPE' => 'application/json', - ], - $body, - ); - - app()->instance('request', $request); - - $this->app->detectEnvironment(fn () => 'production'); - - expect($driver->verifyWebhookSignature(json_decode($body, true)))->toBeFalse(); -}); - -it('rejects svix webhook with expired timestamp', function (): void { - $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; - $secretBytes = base64_decode(str_replace('whsec_', '', $secret)); - - $body = json_encode(['type' => 'email.delivered', 'data' => ['email_id' => 'test']]); - $msgId = 'msg_test_123'; - $timestamp = (string) (time() - 600); // 10 minutes ago - $signedContent = "{$msgId}.{$timestamp}.{$body}"; - $signature = base64_encode(hash_hmac('sha256', $signedContent, $secretBytes, true)); - - config()->set('services.resend.webhook_signing_secret', $secret); - - $driver = new ResendDriver; - - $request = Request::create( - '/webhooks/mails/resend', - 'POST', - [], - [], - [], - [ - 'HTTP_SVIX_ID' => $msgId, - 'HTTP_SVIX_TIMESTAMP' => $timestamp, - 'HTTP_SVIX_SIGNATURE' => "v1,{$signature}", - 'CONTENT_TYPE' => 'application/json', - ], - $body, - ); - - app()->instance('request', $request); - - $this->app->detectEnvironment(fn () => 'production'); - - expect($driver->verifyWebhookSignature(json_decode($body, true)))->toBeFalse(); -}); diff --git a/tests/SmtpTest.php b/tests/SmtpTest.php deleted file mode 100644 index 243bf04..0000000 --- a/tests/SmtpTest.php +++ /dev/null @@ -1,77 +0,0 @@ -set('mail.mailers.smtp', [ - 'transport' => 'smtp', - ]); -}); - -it('attaches uuid to smtp mails when logging is enabled', function (): void { - Mail::send([], [], function (Message $message): void { - $message->to('mark@vormkracht10.nl') - ->from('local@computer.nl') - ->subject('Test') - ->text('Text'); - }); - - $mail = MailModel::latest()->first(); - - expect($mail)->not->toBeNull(); - expect($mail->uuid)->not->toBeNull(); -}); - -it('sets sent_at for smtp mails', function (): void { - Mail::send([], [], function (Message $message): void { - $message->to('mark@vormkracht10.nl') - ->from('local@computer.nl') - ->subject('Test') - ->text('Text'); - }); - - $mail = MailModel::latest()->first(); - - expect($mail)->not->toBeNull(); - expect($mail->sent_at)->not->toBeNull(); -}); - -it('logs smtp mail with correct attributes', function (): void { - Mail::send([], [], function (Message $message): void { - $message->to('mark@vormkracht10.nl') - ->from('local@computer.nl') - ->cc('cc@vk10.nl') - ->bcc('bcc@vk10.nl') - ->subject('Test') - ->text('Text') - ->html('

HTML

'); - }); - - $mail = MailModel::latest()->first(); - - expect($mail)->not->toBeNull(); - expect($mail->uuid)->not->toBeNull(); - expect($mail->sent_at)->not->toBeNull(); - expect($mail->from)->toEqual(['local@computer.nl' => null]); - expect($mail->to)->toEqual(['mark@vormkracht10.nl' => null]); - expect($mail->cc)->toEqual(['cc@vk10.nl' => null]); - expect($mail->bcc)->toEqual(['bcc@vk10.nl' => null]); - expect($mail->subject)->toBe('Test'); - expect($mail->html)->toBe('

HTML

'); - expect($mail->text)->toBe('Text'); -}); - -it('does not attach uuid to smtp mails when logging is disabled', function (): void { - config()->set('mails.logging.enabled', false); - - Mail::send([], [], function (Message $message): void { - $message->to('mark@vormkracht10.nl') - ->from('local@computer.nl') - ->subject('Test') - ->text('Text'); - }); - - expect(MailModel::count())->toBe(0); -}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7d59d1b..d778624 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,8 +1,8 @@