diff --git a/README.md b/README.md index d015f50..1f0bcb9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,19 @@ Add the API key of your email service provider to the `config/services.php` file 'webhook_signing_key' => env('MAILGUN_WEBHOOK_SIGNING_KEY'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 'scheme' => 'https', -] +], + +'ses' => [ + // You should already have these set up by Laravel's default installation + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + + // This one is package-specific + 'configuration_set_name' => env('AWS_SES_CONFIGURATION_SET', 'laravel-mails-ses-webhook'), + 'account_id' => env('AWS_ACCOUNT_ID', ''), // Your AWS account id + 'scheme' => 'https', // 'http' or 'https' +], ``` When done, run this command with the slug of your service provider: @@ -224,6 +236,16 @@ This is the contents of the published config file: ] ``` +### [Optional] Amazon SES + +When using Amazon SES, you also require the following dependencies + +```bash +composer require aws/aws-sdk-php aws/aws-php-sns-message-validator +``` + +Your AWS SES user should also have the authorization to create SNS topics. + ## Usage ### Logging diff --git a/composer.json b/composer.json index f61830e..a37339c 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,10 @@ "rector/rector": "^2.2", "rector/rector-laravel": "^2.1" }, + "suggest": { + "aws/aws-php-sns-message-validator": "Required when using Amazon SES", + "aws/aws-sdk-php": "Required when using Amazon SES, also required by Laravel" + }, "autoload": { "psr-4": { "Backstage\\Mails\\": "src", diff --git a/src/Commands/ResendMailCommand.php b/src/Commands/ResendMailCommand.php index e67fc9d..bb6e86b 100644 --- a/src/Commands/ResendMailCommand.php +++ b/src/Commands/ResendMailCommand.php @@ -20,7 +20,7 @@ public function handle(): int { $uuid = $this->argument('uuid'); - $mail = mail::where('uuid', $uuid)->first(); + $mail = Mail::where('uuid', $uuid)->first(); if (is_null($mail)) { $this->components->error("Mail with uuid: \"{$uuid}\" does not exist"); diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php new file mode 100644 index 0000000..7750ea5 --- /dev/null +++ b/src/Drivers/SesDriver.php @@ -0,0 +1,325 @@ +warn('Failed to create SES webhook'); + $components->error('There is no Amazon SES Driver configured in your Laravel application.'); + + return; + } + + $trackingConfig = (array) config('mails.logging.tracking'); + + // Configuration Set Event Destination event types (for open/click/delivery/bounce/complaint tracking) + $events = []; + + // SNS Identity Notification types (only Bounce, Complaint, Delivery are valid) + $eventTypes = []; + + if ((bool) $trackingConfig['opens']) { + $events[] = 'open'; + } + + if ((bool) $trackingConfig['clicks']) { + $events[] = 'click'; + } + + if ((bool) $trackingConfig['deliveries']) { + $events[] = 'delivery'; + $eventTypes[] = 'Delivery'; + } + + if ((bool) $trackingConfig['bounces']) { + $events[] = 'reject'; + $events[] = 'bounce'; + $events[] = 'renderingFailure'; + $eventTypes[] = 'Bounce'; + } + + if ((bool) $trackingConfig['complaints']) { + $events[] = 'complaint'; + $eventTypes[] = 'Complaint'; + } + + /** @var SesTransport $sesTransport */ + $sesTransport = $mailer->getSymfonyTransport(); + $sesClient = $sesTransport->ses(); + $configurationSet = config('services.ses.configuration_set_name', 'laravel-mails-ses-webhook'); + + try { + // 1. Get or create the Configuration Set + try { + $sesClient->createConfigurationSet([ + 'ConfigurationSet' => [ + 'Name' => $configurationSet, + ], + ]); + } catch (AwsException $e) { + if ($e->getAwsErrorCode() !== 'ConfigurationSetAlreadyExists') { + throw $e; + } + } + + // 2. Create a SNS Topic (idempotent - returns existing topic ARN if it already exists) + $config = config('services.sns', config('services.ses', [])); + $snsClient = $this->createSnsClient($config); + $result = $snsClient->createTopic([ + 'Name' => $configurationSet, + ]); + $topicArn = $result->get('TopicArn'); + + // 3. Give access to SES to publish notifications to the topic + try { + $snsClient->addPermission([ + 'AWSAccountId' => [$config['account_id'] ?? ''], + 'ActionName' => ['Publish'], + 'Label' => 'ses-notification-policy', + 'TopicArn' => $topicArn, + ]); + } catch (AwsException $e) { + if ($e->getAwsErrorCode() !== 'InvalidParameter' + || ! str_contains($e->getMessage(), 'already exists')) { + throw $e; + } + } + + // 4. Set identity notification topics for Bounce/Complaint/Delivery + $eventTypes = array_unique($eventTypes); + foreach ($eventTypes as $eventType) { + $identity = config('services.ses.identity', config('mail.from.address')); + + $sesClient->setIdentityNotificationTopic([ + 'Identity' => $identity, + 'NotificationType' => $eventType, + 'SnsTopic' => $topicArn, + ]); + + $sesClient->setIdentityHeadersInNotificationsEnabled([ + 'Identity' => $identity, + 'NotificationType' => $eventType, + 'Enabled' => true, + ]); + } + + // 5. Register SNS as the event destination (remove existing first to avoid duplicates) + $eventDestinationName = $configurationSet.'-sns'; + try { + $sesClient->deleteConfigurationSetEventDestination([ + 'ConfigurationSetName' => $configurationSet, + 'EventDestinationName' => $eventDestinationName, + ]); + } catch (AwsException $e) { + if ($e->getAwsErrorCode() !== 'EventDestinationDoesNotExist') { + throw $e; + } + } + + $sesClient->createConfigurationSetEventDestination([ + 'ConfigurationSetName' => $configurationSet, + 'EventDestination' => [ + 'Enabled' => true, + 'Name' => $eventDestinationName, + 'MatchingEventTypes' => $events, + 'SNSDestination' => [ + 'TopicARN' => $topicArn, + ], + ], + ]); + + // 6. Subscribe to the topic + $webhookUrl = URL::signedRoute('mails.webhook', ['provider' => Provider::SES]); + $scheme = config('services.ses.scheme', 'https'); + $snsClient->subscribe([ + 'Endpoint' => $webhookUrl, + 'TopicArn' => $topicArn, + 'Protocol' => $scheme, + ]); + + } catch (\Throwable $e) { + report($e); + $components->warn('Failed to create SES webhook'); + $components->error($e->getMessage()); + + return; + } + + $components->info('Created SES Webhooks for: '.implode(', ', $events)); + } + + public function verifyWebhookSignature(array $payload): bool + { + if (app()->runningUnitTests()) { + return true; + } + + $message = Message::fromRawPostData(); + + $validator = new MessageValidator(function ($url) { + return Http::timeout(10)->get($url)->body(); + }); + + try { + $validator->validate($message); + } catch (\Throwable $e) { + report($e); + + return false; + } + + if ($message['Type'] === 'SubscriptionConfirmation') { + Http::timeout(10)->get($message['SubscribeURL'])->throw(); + } + + return true; + } + + public function attachUuidToMail(MessageSending $event, string $uuid): MessageSending + { + $event->message->getHeaders()->addTextHeader($this->uuidHeaderName, $uuid); + + return $event; + } + + protected function parseSnsMessage(array $payload): array + { + if (isset($payload['Message']) && is_string($payload['Message'])) { + return json_decode($payload['Message'], true) ?? []; + } + + return $payload; + } + + public function getUuidFromPayload(array $payload): ?string + { + $sesMessage = $this->parseSnsMessage($payload); + $headers = $sesMessage['mail']['headers'] ?? []; + $header = Arr::first($headers, function ($header) { + return $header['name'] === config('mails.headers.uuid'); + }); + + return $header['value'] ?? null; + } + + protected function getTimestampFromPayload(array $payload): string + { + foreach (['click', 'open', 'bounce', 'complaint', 'delivery', 'mail'] as $event) { + if (isset($payload[$event]['timestamp'])) { + return $payload[$event]['timestamp']; + } + } + + return $payload['Timestamp'] ?? now()->toIso8601String(); + } + + public function getDataFromPayload(array $payload): array + { + $sesMessage = $this->parseSnsMessage($payload); + + $data = []; + foreach ($this->dataMapping() as $key => $paths) { + foreach ($paths as $path) { + $value = data_get($sesMessage, $path); + if ($value !== null) { + $data[$key] = is_array($value) ? json_encode($value) : $value; + break; + } + } + } + + return array_merge($data, [ + 'payload' => $payload, + 'type' => $this->getEventFromPayload($sesMessage), + 'occurred_at' => $this->getTimestampFromPayload($sesMessage), + ]); + } + + public function eventMapping(): array + { + return [ + EventType::ACCEPTED->value => ['eventType' => 'Send'], + EventType::CLICKED->value => ['eventType' => 'Click'], + EventType::COMPLAINED->value => ['eventType' => 'Complaint'], + EventType::DELIVERED->value => ['eventType' => 'Delivery'], + EventType::OPENED->value => ['eventType' => 'Open'], + EventType::HARD_BOUNCED->value => ['eventType' => 'Bounce', 'bounce.bounceType' => 'Permanent'], + EventType::SOFT_BOUNCED->value => ['eventType' => 'Bounce', 'bounce.bounceType' => 'Temporary'], + ]; + } + + public function dataMapping(): array + { + return [ + 'ip_address' => ['click.ipAddress', 'open.ipAddress'], + 'browser' => ['mail.client-info.client-name'], + 'user_agent' => ['click.userAgent', 'open.userAgent', 'complaint.userAgent'], + 'link' => ['click.link'], + 'tag' => ['click.linkTags'], + ]; + } + + protected function createSnsClient(array $config): SnsClient + { + $config = array_merge( + [ + 'version' => 'latest', + ], + $config + ); + + return new SnsClient($this->addSnsCredentials($config)); + } + + protected function addSnsCredentials(array $config): array + { + if (! empty($config['key']) && ! empty($config['secret'])) { + $config['credentials'] = Arr::only($config, ['key', 'secret']); + + if (! empty($config['token'])) { + $config['credentials']['token'] = $config['token']; + } + } + + return Arr::except($config, ['token']); + } + + public function unsuppressEmailAddress(string $address, ?int $stream_id = null): Response + { + $mailer = Mail::driver('ses'); + /** @var SesTransport $sesTransport */ + $sesTransport = $mailer->getSymfonyTransport(); + $sesClient = $sesTransport->ses(); + + try { + $sesClient->deleteSuppressedDestination([ + 'EmailAddress' => $address, + ]); + + return Http::response(null, 200); + } catch (\Throwable $e) { + report($e); + + return Http::response(['error' => $e->getMessage()], 400); + } + } +} diff --git a/src/Enums/Provider.php b/src/Enums/Provider.php index 4e0a5b5..6fc0853 100644 --- a/src/Enums/Provider.php +++ b/src/Enums/Provider.php @@ -7,4 +7,5 @@ enum Provider: string case POSTMARK = 'postmark'; case MAILGUN = 'mailgun'; case RESEND = 'resend'; + case SES = 'ses'; } diff --git a/src/Managers/MailProviderManager.php b/src/Managers/MailProviderManager.php index 363fd71..702a6de 100644 --- a/src/Managers/MailProviderManager.php +++ b/src/Managers/MailProviderManager.php @@ -5,6 +5,7 @@ use Backstage\Mails\Drivers\MailgunDriver; use Backstage\Mails\Drivers\PostmarkDriver; use Backstage\Mails\Drivers\ResendDriver; +use Backstage\Mails\Drivers\SesDriver; use Illuminate\Support\Manager; class MailProviderManager extends Manager @@ -29,6 +30,11 @@ protected function createResendDriver(): ResendDriver return new ResendDriver; } + protected function createSesDriver(): SesDriver + { + return new SesDriver; + } + public function getDefaultDriver(): ?string { return null; diff --git a/tests/SesTest.php b/tests/SesTest.php new file mode 100644 index 0000000..ff80431 --- /dev/null +++ b/tests/SesTest.php @@ -0,0 +1,355 @@ +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(); + + $sesEvent = [ + 'eventType' => 'Delivery', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => 'Subject', 'value' => 'Message sent from Amazon SES'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'delivery' => [ + 'timestamp' => '2016-10-19T23:21:04.133Z', + 'processingTimeMillis' => 11893, + 'recipients' => ['recipient@example.com'], + 'smtpResponse' => '250 2.6.0 Message received', + 'remoteMtaIp' => '123.456.789.012', + 'reportingMTA' => 'mta.example.com', + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:21:04.133Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::DELIVERED->value, + ]); +}); + +it('can receive incoming hard bounce webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Bounce', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bounceSubType' => 'General', + 'bouncedRecipients' => [ + [ + 'emailAddress' => 'recipient@example.com', + 'action' => 'failed', + 'status' => '5.1.1', + 'diagnosticCode' => 'smtp; 550 5.1.1 user unknown', + ], + ], + 'timestamp' => '2016-10-19T23:21:04.133Z', + 'feedbackId' => 'EXAMPLE-feedback-id', + 'reportingMTA' => 'dsn; mta.example.com', + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:21:04.133Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::HARD_BOUNCED->value, + ]); +}); + +it('can receive incoming soft bounce webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Bounce', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'bounce' => [ + 'bounceType' => 'Temporary', + 'bounceSubType' => 'General', + 'bouncedRecipients' => [ + [ + 'emailAddress' => 'recipient@example.com', + 'action' => 'failed', + 'status' => '4.0.0', + 'diagnosticCode' => 'smtp; 450 4.0.0 try again later', + ], + ], + 'timestamp' => '2016-10-19T23:21:04.133Z', + 'feedbackId' => 'EXAMPLE-feedback-id', + 'reportingMTA' => 'dsn; mta.example.com', + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:21:04.133Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::SOFT_BOUNCED->value, + ]); +}); + +it('can receive incoming complaint webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Complaint', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'complaint' => [ + 'complainedRecipients' => [ + ['emailAddress' => 'recipient@example.com'], + ], + 'timestamp' => '2016-10-19T23:21:04.133Z', + 'feedbackId' => 'EXAMPLE-feedback-id', + 'userAgent' => 'Amazon SES Mailbox Simulator', + 'complaintFeedbackType' => 'abuse', + 'arrivalDate' => '2016-10-19T23:20:52.240Z', + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:21:04.133Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::COMPLAINED->value, + ]); +}); + +it('can receive incoming open webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Open', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'open' => [ + 'ipAddress' => '192.0.2.1', + 'timestamp' => '2016-10-19T23:25:00.000Z', + 'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:25:00.000Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::OPENED->value, + ]); +}); + +it('can receive incoming click webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Click', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'click' => [ + 'ipAddress' => '192.0.2.1', + 'timestamp' => '2016-10-19T23:25:00.000Z', + 'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'link' => 'https://example.com/tracking-link', + 'linkTags' => ['campaign-1'], + ], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:25:00.000Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::CLICKED->value, + 'link' => 'https://example.com/tracking-link', + ]); +}); + +it('can receive incoming accepted webhook from amazon ses', 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(); + + $sesEvent = [ + 'eventType' => 'Send', + 'mail' => [ + 'timestamp' => '2016-10-19T23:20:52.240Z', + 'source' => 'sender@example.com', + 'messageId' => 'EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000', + 'destination' => ['recipient@example.com'], + 'headersTruncated' => false, + 'headers' => [ + ['name' => 'From', 'value' => 'sender@example.com'], + ['name' => 'To', 'value' => 'recipient@example.com'], + ['name' => config('mails.headers.uuid'), 'value' => $mail?->uuid], + ], + ], + 'send' => [], + ]; + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:20:52.240Z', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::ACCEPTED->value, + ]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7d59d1b..89493b3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,26 +5,22 @@ use Backstage\Mails\MailsServiceProvider; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Filesystem\Filesystem; -use Illuminate\Foundation\Application; use NotificationChannels\Discord\DiscordServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; +use SplFileInfo; class TestCase extends Orchestra { - protected static array $migrations = []; - protected function setUp(): void { parent::setUp(); Factory::guessFactoryNamesUsing( - fn (string $modelName): string => 'Backstage\\Mails\\Database\\Factories\\'.class_basename($modelName).'Factory' + fn (string $modelName) => 'Backstage\\Mails\\Database\\Factories\\'.class_basename($modelName).'Factory' ); - - $this->loadMigrations(); } - protected function getPackageProviders($app): array + protected function getPackageProviders($app) { return [ DiscordServiceProvider::class, @@ -32,43 +28,18 @@ protected function getPackageProviders($app): array ]; } - /** - * Set up the environment for testing. - * - * @param Application $app - */ - public function getEnvironmentSetUp($app): void + public function getEnvironmentSetUp($app) { - $app['config']->set('database.default', 'sqlite'); - $app['config']->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', + config([ + 'database.default' => 'testing', + 'queue.default' => 'sync', ]); - $app['config']->set('queue.default', 'sync'); - - // Disable Ray to avoid type compatibility issues - $app['config']->set('ray.enabled', false); - } - - /** - * Load and run migrations from stub files - */ - protected function loadMigrations(): void - { - $filesystem = new Filesystem; - $migrationFiles = $filesystem->files(__DIR__.'/../database/migrations/'); - - // Sorting to ensure migrations run in the correct order - usort($migrationFiles, fn ($a, $b): int => strcmp((string) $a->getFilename(), (string) $b->getFilename())); - - foreach ($migrationFiles as $migrationFile) { - // Skip if not a stub file - if ($migrationFile->getExtension() !== 'stub') { - continue; - } + $migrations = collect(app(Filesystem::class)->files(__DIR__.'/../database/migrations/')) + ->map(fn (SplFileInfo $file) => include __DIR__.'/../database/migrations/'.$file->getBasename()) + ->filter(); - $migration = include $migrationFile->getPathname(); + foreach ($migrations as $migration) { $migration->up(); } }