From aeeff8287471faa51bc32509e5d3480eec7e59d3 Mon Sep 17 00:00:00 2001 From: Daniele Rosario Date: Tue, 15 Apr 2025 18:50:58 +0200 Subject: [PATCH 01/12] wip --- README.md | 23 +++- composer.json | 4 + src/Drivers/SesDriver.php | 239 ++++++++++++++++++++++++++++++++++++++ src/Enums/Provider.php | 1 + 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/Drivers/SesDriver.php diff --git a/README.md b/README.md index d015f50..25c0ef2 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,20 @@ 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', + 'verify_signature' => true +], ``` When done, run this command with the slug of your service provider: @@ -224,6 +237,14 @@ 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 +``` + ## 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/Drivers/SesDriver.php b/src/Drivers/SesDriver.php new file mode 100644 index 0000000..e7b05a7 --- /dev/null +++ b/src/Drivers/SesDriver.php @@ -0,0 +1,239 @@ +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'); + + // send - The call was successful and Amazon SES is attempting to deliver the email. + // reject - Amazon SES determined that the email contained a virus and rejected it. + // bounce - The recipient's mail server permanently rejected the email. This corresponds to a hard bounce. + // complaint - The recipient marked the email as spam. + // delivery - Amazon SES successfully delivered the email to the recipient's mail server. + // open - The recipient received the email and opened it in their email client. + // click - The recipient clicked one or more links in the email. + // renderingFailure - Amazon SES did not send the email because of a template rendering issue. + $events = []; + $eventTypes = []; + + if ((bool)$trackingConfig['opens']) { + $events[] = 'open'; + $eventTypes[] = 'Delivery'; + } + + if ((bool)$trackingConfig['clicks']) { + $events[] = 'click'; + $eventTypes[] = 'Delivery'; + } + + 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'; + } + + $sesClient = $sesTransport->ses(); + $configurationSet = config('services.ses.configuration_set_name', 'laravel-mails-ses-webhook'); + + try { + // 1. Create Configuration Set + $sesClient->createConfigurationSet([ + 'ConfigurationSet' => [ + 'Name' => $configurationSet, + ], + ]); + + + // 2. Create a SNS Topic + $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. + $snsClient->addPermission([ + 'AWSAccountId' => $config['account_id'] ?? '', + 'ActionName' => 'Publish', + 'Label' => 'ses-notification-policy', + 'TopicArn' => $topicArn, + ]); + + // 4. Set the channels + $eventTypes = array_unique($eventTypes); + foreach ($eventTypes as $eventType) { + $sesClient->setIdentityNotificationTopic([ + 'Identity' => config('services.ses.identity', config('mail.from.address')), + 'NotificationType' => $eventType, + 'SnsTopic' => $topicArn, + ]); + } + + // 5. Register SNS as the event destination + $sesClient->createConfigurationSetEventDestination([ + 'ConfigurationSetName' => $configurationSet, + 'EventDestination' => [ + 'Enabled' => true, + 'Name' => $configurationSet, + 'MatchingEventTypes' => $events, + 'SNSDestination' => [ + 'TopicARN' => $topicArn, + ] + ] + ]); + + // 5. 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(", ", $eventTypes)); + } + + public function verifyWebhookSignature(array $payload): bool + { + dd($payload); + if (app()->runningUnitTests()) { + return true; + } + + if (empty($payload['signature']['timestamp']) || empty($payload['signature']['token']) || empty($payload['signature']['signature'])) { + return false; + } + + $hmac = hash_hmac('sha256', $payload['signature']['timestamp'] . $payload['signature']['token'], config('services.mailgun.webhook_signing_key')); + + if (function_exists('hash_equals')) { + return hash_equals($hmac, $payload['signature']['signature']); + } + + return $hmac === $payload['signature']['signature']; + } + + public function attachUuidToMail(MessageSending $event, string $uuid): MessageSending + { + $event->message->getHeaders()->addTextHeader('X-Mailgun-Variables', json_encode([config('mails.headers.uuid') => $uuid])); + + return $event; + } + + public function getUuidFromPayload(array $payload): ?string + { + return $payload['event-data']['user-variables'][$this->uuidHeaderName] ?? null; + } + + protected function getTimestampFromPayload(array $payload): string + { + return $payload['event-data']['timestamp']; + } + + public function eventMapping(): array + { + return [ + EventType::ACCEPTED->value => ['event-data.event' => 'accepted'], + EventType::CLICKED->value => ['event-data.event' => 'clicked'], + EventType::COMPLAINED->value => ['event-data.event' => 'complained'], + EventType::DELIVERED->value => ['event-data.event' => 'delivered'], + EventType::HARD_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'permanent'], + EventType::OPENED->value => ['event-data.event' => 'opened'], + EventType::SOFT_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'temporary'], + EventType::UNSUBSCRIBED->value => ['event-data.event' => 'unsubscribed'], + ]; + } + + public function dataMapping(): array + { + return [ + 'ip_address' => 'event-data.ip', + 'platform' => 'event-data.client-info.device-type', + 'os' => 'event-data.client-info.client-os', + 'browser' => 'event-data.client-info.client-name', + 'user_agent' => 'event-data.client-info.user-agent', + 'city' => 'event-data.geolocation.city', + 'country_code' => 'event-data.geolocation.country', + 'link' => 'event-data.url', + 'tag' => 'event-data.tags', + ]; + } + + public function unsuppressEmailAddress(string $address): Response + { + $client = Http::asJson() + ->withBasicAuth('api', config('services.mailgun.secret')) + ->baseUrl(config('services.mailgun.endpoint') . '/v3/'); + + return $client->delete(config('services.mailgun.domain') . '/unsubscribes/' . $address); + } + + 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']); + } +} 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'; } From 7440611075fb45844c0002516970d730c4fafeb7 Mon Sep 17 00:00:00 2001 From: Daniele Rosario Date: Tue, 15 Apr 2025 18:58:30 +0200 Subject: [PATCH 02/12] wip --- README.md | 6 +++--- src/Managers/MailProviderManager.php | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 25c0ef2..c010c23 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ Add the API key of your email service provider to the `config/services.php` file // 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', - 'verify_signature' => true + 'account_id' => env('AWS_ACCOUNT_ID', ''), // Your AWS account id + 'scheme' => 'https', // 'http' or 'https', + 'verify_signature' => true, ], ``` 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; From ed698a576b8b5bb0f7c96f1e0e280e258eecb925 Mon Sep 17 00:00:00 2001 From: Daniele Rosario Date: Wed, 16 Apr 2025 10:23:52 +0200 Subject: [PATCH 03/12] wip --- README.md | 2 + src/Drivers/SesDriver.php | 156 +++++++---- tests/SesTest.php | 534 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 636 insertions(+), 56 deletions(-) create mode 100644 tests/SesTest.php diff --git a/README.md b/README.md index c010c23..01735f5 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,8 @@ When using Amazon SES, you also require the following dependencies composer require aws/aws-sdk-php aws/aws-php-sns-message-validator ``` +You aws ses user should also have the authorization to create SNS topics + ## Usage ### Logging diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index e7b05a7..6fb8477 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -2,7 +2,9 @@ namespace Vormkracht10\Mails\Drivers; -use Aws\Ses\SesClient; +use Aws\Exception\AwsException; +use Aws\Sns\Message; +use Aws\Sns\MessageValidator; use Aws\Sns\SnsClient; use Illuminate\Http\Client\Response; use Illuminate\Mail\Events\MessageSending; @@ -19,9 +21,8 @@ class SesDriver extends MailDriver implements MailDriverContract { public function registerWebhooks($components): void { - /** @var SesTransport|null $sesTransport */ - $sesTransport = Mail::driver('ses'); - if ($sesTransport) { + $mailer = Mail::driver('ses'); + if ($mailer === null) { $components->warn("Failed to create Ses webhook"); $components->error("There is no Amazon SES Driver configured in your laravel application."); return; @@ -67,17 +68,25 @@ public function registerWebhooks($components): void $eventTypes[] = 'Complaint'; } + /** @var SesTransport $sesTransport */ + $sesTransport = $mailer->getSymfonyTransport(); $sesClient = $sesTransport->ses(); - $configurationSet = config('services.ses.configuration_set_name', 'laravel-mails-ses-webhook'); + $configurationSet = config('services.ses.options.ConfigurationSetName', 'laravel-mails-ses-webhook'); try { - // 1. Create Configuration Set - $sesClient->createConfigurationSet([ - 'ConfigurationSet' => [ - 'Name' => $configurationSet, - ], - ]); - + // 1. Get or create the Configuration Set + try { + $sesClient->createConfigurationSet([ + 'ConfigurationSet' => [ + 'Name' => $configurationSet, + ], + ]); + } catch (AwsException $e) { + // Already exists, move on! + if ($e->getAwsErrorCode() !== 'ConfigurationSetAlreadyExists') { + throw $e; + } + } // 2. Create a SNS Topic $config = config('services.sns', config('services.ses', [])); @@ -89,8 +98,8 @@ public function registerWebhooks($components): void // 3. Give access to SES to publish notifications to the topic. $snsClient->addPermission([ - 'AWSAccountId' => $config['account_id'] ?? '', - 'ActionName' => 'Publish', + 'AWSAccountId' => [$config['account_id'] ?? ''], + 'ActionName' => ['Publish'], 'Label' => 'ses-notification-policy', 'TopicArn' => $topicArn, ]); @@ -98,11 +107,20 @@ public function registerWebhooks($components): void // 4. Set the channels $eventTypes = array_unique($eventTypes); foreach ($eventTypes as $eventType) { + $identity = config('services.ses.identity', config('mail.from.address')); + // get notified for the various types of events via SNS $sesClient->setIdentityNotificationTopic([ - 'Identity' => config('services.ses.identity', config('mail.from.address')), + 'Identity' => $identity, 'NotificationType' => $eventType, 'SnsTopic' => $topicArn, ]); + + // Force SNS to include the SES mail headers in the notification + $sesClient->setIdentityHeadersInNotificationsEnabled([ + 'Identity' => $identity, + 'NotificationType' => $eventType, + 'Enabled' => true, + ]); } // 5. Register SNS as the event destination @@ -110,7 +128,7 @@ public function registerWebhooks($components): void 'ConfigurationSetName' => $configurationSet, 'EventDestination' => [ 'Enabled' => true, - 'Name' => $configurationSet, + 'Name' => $configurationSet . '-' . uniqid(), 'MatchingEventTypes' => $events, 'SNSDestination' => [ 'TopicARN' => $topicArn, @@ -139,79 +157,105 @@ public function registerWebhooks($components): void public function verifyWebhookSignature(array $payload): bool { - dd($payload); if (app()->runningUnitTests()) { return true; } - if (empty($payload['signature']['timestamp']) || empty($payload['signature']['token']) || empty($payload['signature']['signature'])) { - return false; - } + // Weird SNS thing, you need to read the raw post body + $message = Message::fromRawPostData(); - $hmac = hash_hmac('sha256', $payload['signature']['timestamp'] . $payload['signature']['token'], config('services.mailgun.webhook_signing_key')); + $validator = (new MessageValidator(function ($url) { + return Http::timeout(10)->get($url)->body(); + })); - if (function_exists('hash_equals')) { - return hash_equals($hmac, $payload['signature']['signature']); + try { + $validator->validate($message); + if ($message['Type'] === 'SubscriptionConfirmation') { + Http::timeout(10)->get($message['SubscribeURL'])->throw(); + } + return true; + } catch (\Throwable $e) { + report($e); + return false; } - - return $hmac === $payload['signature']['signature']; } public function attachUuidToMail(MessageSending $event, string $uuid): MessageSending { - $event->message->getHeaders()->addTextHeader('X-Mailgun-Variables', json_encode([config('mails.headers.uuid') => $uuid])); + $event->message->getHeaders()->addTextHeader($this->uuidHeaderName, $uuid); return $event; } public function getUuidFromPayload(array $payload): ?string { - return $payload['event-data']['user-variables'][$this->uuidHeaderName] ?? null; + $message = Message::fromRawPostData(); + $headers = $message['Message']['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 { - return $payload['event-data']['timestamp']; + foreach (['click', 'open', 'bounce', 'complaint', 'delivery', 'mail'] as $event) { + if (isset($payload['Message'][$event]['timestamp'])) { + return $payload['Message'][$event]['timestamp']; + } + } + return $payload['Message']['Timestamp']; + } + + public function getDataFromPayload(array $payload): array + { + $message = Message::fromRawPostData(); + $payload = $message->toArray(); + + return collect($this->dataMapping()) + ->mapWithKeys(function ($values, $key) use ($payload) { + foreach ($values as $value) { + $value = data_get($payload, $value); + if ($value !== null) { + return [$key => $value]; + } + } + return null; + }) + ->filter() + ->merge([ + 'payload' => $payload, + 'type' => $this->getEventFromPayload($payload), + 'occurred_at' => $this->getTimestampFromPayload($payload), + ]) + ->toArray(); } public function eventMapping(): array { return [ - EventType::ACCEPTED->value => ['event-data.event' => 'accepted'], - EventType::CLICKED->value => ['event-data.event' => 'clicked'], - EventType::COMPLAINED->value => ['event-data.event' => 'complained'], - EventType::DELIVERED->value => ['event-data.event' => 'delivered'], - EventType::HARD_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'permanent'], - EventType::OPENED->value => ['event-data.event' => 'opened'], - EventType::SOFT_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'temporary'], - EventType::UNSUBSCRIBED->value => ['event-data.event' => 'unsubscribed'], + EventType::ACCEPTED->value => ['Message.eventType' => 'Send'], + EventType::CLICKED->value => ['Message.eventType' => 'Click'], + EventType::COMPLAINED->value => ['Message.eventType' => 'Complaint'], + EventType::DELIVERED->value => ['Message.eventType' => 'Delivery'], + EventType::OPENED->value => ['Message.eventType' => 'Open'], + EventType::HARD_BOUNCED->value => ['Message.eventType' => 'Bounce', 'Message.bounce.bounceType' => 'Permanent'], + EventType::SOFT_BOUNCED->value => ['Message.eventType' => 'Bounce', 'Message.bounce.bounceType' => 'Temporary'], ]; } public function dataMapping(): array { return [ - 'ip_address' => 'event-data.ip', - 'platform' => 'event-data.client-info.device-type', - 'os' => 'event-data.client-info.client-os', - 'browser' => 'event-data.client-info.client-name', - 'user_agent' => 'event-data.client-info.user-agent', - 'city' => 'event-data.geolocation.city', - 'country_code' => 'event-data.geolocation.country', - 'link' => 'event-data.url', - 'tag' => 'event-data.tags', + 'ip_address' => ['Message.click.ipAddress', 'Message.open.ipAddress'], + 'browser' => ['Message.mail.client-info.client-name'], + 'user_agent' => ['Message.click.userAgent', 'Message.open.userAgent','Message.complaint.userAgent'], + 'link' => ['Message.click.link'], + 'tag' => ['Message.click.linkTags'], ]; } - public function unsuppressEmailAddress(string $address): Response - { - $client = Http::asJson() - ->withBasicAuth('api', config('services.mailgun.secret')) - ->baseUrl(config('services.mailgun.endpoint') . '/v3/'); - - return $client->delete(config('services.mailgun.domain') . '/unsubscribes/' . $address); - } - protected function createSnsClient(array $config): SnsClient { $config = array_merge( @@ -226,10 +270,10 @@ protected function createSnsClient(array $config): SnsClient protected function addSnsCredentials(array $config): array { - if (! empty($config['key']) && ! empty($config['secret'])) { + if (!empty($config['key']) && !empty($config['secret'])) { $config['credentials'] = Arr::only($config, ['key', 'secret']); - if (! empty($config['token'])) { + if (!empty($config['token'])) { $config['credentials']['token'] = $config['token']; } } diff --git a/tests/SesTest.php b/tests/SesTest.php new file mode 100644 index 0000000..2b19069 --- /dev/null +++ b/tests/SesTest.php @@ -0,0 +1,534 @@ +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(); + + $message = json_decode('{ + "eventType": "Delivery", + "mail": { + "timestamp": "2016-10-19T23:20:52.240Z", + "source": "sender@example.com", + "sourceArn": "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", + "sendingAccountId": "123456789012", + "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": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "text/html; charset=UTF-8" + }, + { + "name": "Content-Transfer-Encoding", + "value": "7bit" + } + ], + "commonHeaders": { + "from": [ + "sender@example.com" + ], + "to": [ + "recipient@example.com" + ], + "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", + "subject": "Message sent from Amazon SES" + }, + "tags": { + "ses:configuration-set": [ + "ConfigSet" + ], + "ses:source-ip": [ + "192.0.2.0" + ], + "ses:from-domain": [ + "example.com" + ], + "ses:caller-identity": [ + "ses_user" + ], + "ses:outgoing-ip": [ + "192.0.2.0" + ], + "myCustomTag1": [ + "myCustomTagValue1" + ], + "myCustomTag2": [ + "myCustomTagValue2" + ] + } + }, + "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" + } +}', true); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ + 'Message' => $message, + 'signature' => 'secrethmacsignature', + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::DELIVERED->value, + ]); +}); + +it('can receive incoming accept webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'signature' => [ + 'timestamp' => 1649408311, + 'token' => 'eventtoken', + 'signature' => 'secrethmacsignature', + ], + 'event-data' => [ + 'event' => 'accepted', + 'timestamp' => 1649408305, + 'id' => 'OTk6MTA1MDI6YWNjZXB0ZWQ6NTYyNTQ4NzY3', + 'recipient' => 'test@omnivery.com', + 'recipient-domain' => 'omnivery.com', + 'campaigns' => [], + 'tags' => ['accepted'], + 'user-variables' => [ + config('mails.headers.uuid') => $mail?->uuid, + ], + 'flags' => [ + 'is-system-test' => false, + 'is-test-mode' => false, + ], + 'envelope' => [ + 'sending-ip' => '123.123.123.123', + 'sender' => 'sender@omnivery.dev', + 'targets' => 'test@omnivery.com', + 'transport' => 'smtp', + ], + 'message' => [ + 'headers' => [ + 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', + 'subject' => 'Production test', + 'from' => '"Friendly Sender" ', + 'to' => 'test@omnivery.com', + 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', + ], + 'size' => 5637, + ], + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::ACCEPTED->value, + ]); +}); + +it('can receive incoming hard bounce webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'event-data' => [ + 'event' => 'failed', + 'severity' => 'permanent', + 'envelope' => [ + 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', + 'sending-ip' => '185.136.201.130', + 'targets' => 'nosuchemail@omnivery.com', + 'transport' => 'smtp', + ], + 'recipient' => 'nosuchemail@omnivery.com', + 'message' => [ + 'size' => 5597, + 'headers' => [ + 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', + 'subject' => 'Test message subject', + 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', + 'to' => 'nosuchemail@omnivery.com', + 'from' => '"Friendly Sender" ', + ], + ], + 'delivery-status' => [ + 'code' => 550, + 'bounce-class' => 'bad-mailbox', + 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', + 'mx-host' => 'mail.mailkit.eu', + 'tls' => true, + 'mx-ip' => '185.136.200.19', + 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', + ], + 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', + 'timestamp' => 1648727038.22387, + 'recipient-domain' => 'omnivery.com', + ], + 'signature' => [ + 'signature' => 'secrethmacsignature', + 'timestamp' => 1648727039, + 'token' => 'eventtoken', + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::HARD_BOUNCED->value, + ]); +}); + +it('can receive incoming soft bounce webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'event-data' => [ + 'event' => 'failed', + 'severity' => 'temporary', + 'envelope' => [ + 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', + 'sending-ip' => '185.136.201.130', + 'targets' => 'nosuchemail@omnivery.com', + 'transport' => 'smtp', + ], + 'recipient' => 'nosuchemail@omnivery.com', + 'message' => [ + 'size' => 5597, + 'headers' => [ + 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', + 'subject' => 'Test message subject', + 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', + 'to' => 'nosuchemail@omnivery.com', + 'from' => '"Friendly Sender" ', + ], + ], + 'delivery-status' => [ + 'code' => 550, + 'bounce-class' => 'bad-mailbox', + 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', + 'mx-host' => 'mail.mailkit.eu', + 'tls' => true, + 'mx-ip' => '185.136.200.19', + 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', + ], + 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', + 'timestamp' => 1648727038.22387, + 'recipient-domain' => 'omnivery.com', + ], + 'signature' => [ + 'signature' => 'secrethmacsignature', + 'timestamp' => 1648727039, + 'token' => 'eventtoken', + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::SOFT_BOUNCED->value, + ]); +}); + +it('can receive incoming complaint webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'signature' => [ + 'timestamp' => 1649408311, + 'token' => 'eventtoken', + 'signature' => 'secrethmacsignature', + ], + 'event-data' => [ + 'event' => 'complained', + 'timestamp' => 1649408305, + 'id' => 'OTk6MTA1MDI6Y29tcGxhaW5lZDo1NjI1NDg3Njc=', + 'recipient' => 'test@omnivery.com', + 'recipient-domain' => 'omnivery.com', + 'campaigns' => [], + 'tags' => ['complaint', 'feedback'], + 'user-variables' => [ + config('mails.headers.uuid') => $mail?->uuid, + ], + 'flags' => [ + 'is-system-test' => false, + 'is-test-mode' => false, + ], + 'complaint' => [ + 'complained-at' => 'Thu, 7 Apr 2022 13:34:17 +0000', + 'feedback-id' => 'feedback-id-12345', + 'user-agent' => 'Feedback Loop Processor', + ], + 'message' => [ + 'headers' => [ + 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', + 'subject' => 'Production test', + 'from' => '"Friendly Sender" ', + 'to' => 'test@omnivery.com', + 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', + ], + 'size' => 5637, + ], + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::COMPLAINED->value, + ]); +}); + +it('can receive incoming open webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'signature' => [ + 'signature' => 'secrethmacsignature', + 'token' => 'eventtoken', + 'timestamp' => 1649408311, + ], + 'event-data' => [ + 'recipient-domain' => 'omnivery.com', + 'timestamp' => 1649408305, + 'envelope' => [ + 'targets' => 'test@omnivery.com', + ], + 'message' => [ + 'headers' => [ + 'subject' => 'Production test', + 'to' => 'test@omnivery.com', + 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', + 'from' => '"Friendly Sender" ', + 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', + ], + ], + 'client-info' => [ + 'suspected-bot' => false, + 'device-type' => 'Personal computer', + 'client-name' => 'Thunderbird', + 'client-type' => 'Email client', + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Thunderbird/91.7.0', + 'client-os' => 'Windows 10', + ], + 'ip' => '123.123.123.123', + 'recipient' => 'test@omnivery.com', + 'id' => 'OTk6MTA1MDI6b3BlbmVkOjE2NDk0MDgzMTE=', + 'event' => 'opened', + 'geolocation' => [ + 'country_code' => 'ES', + 'continent_name' => 'Europe', + 'country_name' => 'Spain', + 'continent_code' => 'EU', + 'city' => 'Puerto de la Omnivery', + ], + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::OPENED->value, + ]); +}); + +it('can receive incoming click webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'signature' => [ + 'timestamp' => 1649408311, + 'token' => 'eventtoken', + 'signature' => 'secrethmacsignature', + ], + 'event-data' => [ + 'event' => 'clicked', + 'timestamp' => 1649408305, + 'id' => 'OTk6MTA1MDI6Y2xpY2tlZDo1NjI1NDg3Njc=', + 'recipient' => 'test@omnivery.com', + 'recipient-domain' => 'omnivery.com', + 'campaigns' => [], + 'user-variables' => [ + config('mails.headers.uuid') => $mail?->uuid, + ], + 'flags' => [ + 'is-system-test' => false, + 'is-test-mode' => false, + ], + 'ip' => '123.123.123.123', + 'geolocation' => [ + 'country' => 'Spain', + 'region' => 'ES', + 'city' => 'Puerto de la Omnivery', + ], + 'url' => 'https://example.com', + 'client-info' => [ + 'client-name' => 'Chrome', + 'client-type' => 'browser', + 'device-type' => 'desktop', + 'client-os' => 'Windows', + ], + 'message' => [ + 'headers' => [ + 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', + 'subject' => 'Production test', + 'from' => '"Friendly Sender" ', + 'to' => 'test@omnivery.com', + 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', + ], + 'size' => 5637, + ], + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::CLICKED->value, + 'link' => 'https://example.com', + ]); +}); + +it('can receive incoming unsubscribe webhook from mailgun', function () { + Mail::send([], [], function (Message $message) { + $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(); + + post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ + 'signature' => [ + 'timestamp' => 1649408311, + 'token' => 'eventtoken', + 'signature' => 'secrethmacsignature', + ], + 'event-data' => [ + 'event' => 'unsubscribed', + 'timestamp' => 1649408305, + 'id' => 'OTk6MTA1MDI6dW5zdWJzY3JpYmVkOjU2MjU0ODc2Nw==', + 'recipient' => 'test@omnivery.com', + 'recipient-domain' => 'omnivery.com', + 'campaigns' => [], + 'tags' => ['unsubscribed'], + 'user-variables' => [ + config('mails.headers.uuid') => $mail?->uuid, + ], + 'flags' => [ + 'is-system-test' => false, + 'is-test-mode' => false, + ], + 'unsubscribe' => [ + 'mailing-list' => 'newsletter@omnivery.com', + 'ip' => '123.123.123.123', + ], + 'message' => [ + 'headers' => [ + 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', + 'subject' => 'Production test', + 'from' => '"Friendly Sender" ', + 'to' => 'test@omnivery.com', + 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', + ], + 'size' => 5637, + ], + ], + ])->assertAccepted(); + + assertDatabaseHas((new MailEvent)->getTable(), [ + 'type' => EventType::UNSUBSCRIBED->value, + ]); +}); From 054cd29d4da297f7c84cd4c70965020831bb6e5e Mon Sep 17 00:00:00 2001 From: Daniele Rosario Date: Wed, 16 Apr 2025 10:25:43 +0200 Subject: [PATCH 04/12] test --- tests/SesTest.php | 830 +++++++++++++++++++++++----------------------- 1 file changed, 415 insertions(+), 415 deletions(-) diff --git a/tests/SesTest.php b/tests/SesTest.php index 2b19069..ea823b4 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -117,418 +117,418 @@ 'type' => EventType::DELIVERED->value, ]); }); - -it('can receive incoming accept webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'signature' => [ - 'timestamp' => 1649408311, - 'token' => 'eventtoken', - 'signature' => 'secrethmacsignature', - ], - 'event-data' => [ - 'event' => 'accepted', - 'timestamp' => 1649408305, - 'id' => 'OTk6MTA1MDI6YWNjZXB0ZWQ6NTYyNTQ4NzY3', - 'recipient' => 'test@omnivery.com', - 'recipient-domain' => 'omnivery.com', - 'campaigns' => [], - 'tags' => ['accepted'], - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], - 'flags' => [ - 'is-system-test' => false, - 'is-test-mode' => false, - ], - 'envelope' => [ - 'sending-ip' => '123.123.123.123', - 'sender' => 'sender@omnivery.dev', - 'targets' => 'test@omnivery.com', - 'transport' => 'smtp', - ], - 'message' => [ - 'headers' => [ - 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', - 'subject' => 'Production test', - 'from' => '"Friendly Sender" ', - 'to' => 'test@omnivery.com', - 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', - ], - 'size' => 5637, - ], - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::ACCEPTED->value, - ]); -}); - -it('can receive incoming hard bounce webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'event-data' => [ - 'event' => 'failed', - 'severity' => 'permanent', - 'envelope' => [ - 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', - 'sending-ip' => '185.136.201.130', - 'targets' => 'nosuchemail@omnivery.com', - 'transport' => 'smtp', - ], - 'recipient' => 'nosuchemail@omnivery.com', - 'message' => [ - 'size' => 5597, - 'headers' => [ - 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', - 'subject' => 'Test message subject', - 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', - 'to' => 'nosuchemail@omnivery.com', - 'from' => '"Friendly Sender" ', - ], - ], - 'delivery-status' => [ - 'code' => 550, - 'bounce-class' => 'bad-mailbox', - 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', - 'mx-host' => 'mail.mailkit.eu', - 'tls' => true, - 'mx-ip' => '185.136.200.19', - 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', - ], - 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', - 'timestamp' => 1648727038.22387, - 'recipient-domain' => 'omnivery.com', - ], - 'signature' => [ - 'signature' => 'secrethmacsignature', - 'timestamp' => 1648727039, - 'token' => 'eventtoken', - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::HARD_BOUNCED->value, - ]); -}); - -it('can receive incoming soft bounce webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'event-data' => [ - 'event' => 'failed', - 'severity' => 'temporary', - 'envelope' => [ - 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', - 'sending-ip' => '185.136.201.130', - 'targets' => 'nosuchemail@omnivery.com', - 'transport' => 'smtp', - ], - 'recipient' => 'nosuchemail@omnivery.com', - 'message' => [ - 'size' => 5597, - 'headers' => [ - 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', - 'subject' => 'Test message subject', - 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', - 'to' => 'nosuchemail@omnivery.com', - 'from' => '"Friendly Sender" ', - ], - ], - 'delivery-status' => [ - 'code' => 550, - 'bounce-class' => 'bad-mailbox', - 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', - 'mx-host' => 'mail.mailkit.eu', - 'tls' => true, - 'mx-ip' => '185.136.200.19', - 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', - ], - 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', - 'timestamp' => 1648727038.22387, - 'recipient-domain' => 'omnivery.com', - ], - 'signature' => [ - 'signature' => 'secrethmacsignature', - 'timestamp' => 1648727039, - 'token' => 'eventtoken', - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::SOFT_BOUNCED->value, - ]); -}); - -it('can receive incoming complaint webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'signature' => [ - 'timestamp' => 1649408311, - 'token' => 'eventtoken', - 'signature' => 'secrethmacsignature', - ], - 'event-data' => [ - 'event' => 'complained', - 'timestamp' => 1649408305, - 'id' => 'OTk6MTA1MDI6Y29tcGxhaW5lZDo1NjI1NDg3Njc=', - 'recipient' => 'test@omnivery.com', - 'recipient-domain' => 'omnivery.com', - 'campaigns' => [], - 'tags' => ['complaint', 'feedback'], - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], - 'flags' => [ - 'is-system-test' => false, - 'is-test-mode' => false, - ], - 'complaint' => [ - 'complained-at' => 'Thu, 7 Apr 2022 13:34:17 +0000', - 'feedback-id' => 'feedback-id-12345', - 'user-agent' => 'Feedback Loop Processor', - ], - 'message' => [ - 'headers' => [ - 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', - 'subject' => 'Production test', - 'from' => '"Friendly Sender" ', - 'to' => 'test@omnivery.com', - 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', - ], - 'size' => 5637, - ], - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::COMPLAINED->value, - ]); -}); - -it('can receive incoming open webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'signature' => [ - 'signature' => 'secrethmacsignature', - 'token' => 'eventtoken', - 'timestamp' => 1649408311, - ], - 'event-data' => [ - 'recipient-domain' => 'omnivery.com', - 'timestamp' => 1649408305, - 'envelope' => [ - 'targets' => 'test@omnivery.com', - ], - 'message' => [ - 'headers' => [ - 'subject' => 'Production test', - 'to' => 'test@omnivery.com', - 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', - 'from' => '"Friendly Sender" ', - 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', - ], - ], - 'client-info' => [ - 'suspected-bot' => false, - 'device-type' => 'Personal computer', - 'client-name' => 'Thunderbird', - 'client-type' => 'Email client', - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Thunderbird/91.7.0', - 'client-os' => 'Windows 10', - ], - 'ip' => '123.123.123.123', - 'recipient' => 'test@omnivery.com', - 'id' => 'OTk6MTA1MDI6b3BlbmVkOjE2NDk0MDgzMTE=', - 'event' => 'opened', - 'geolocation' => [ - 'country_code' => 'ES', - 'continent_name' => 'Europe', - 'country_name' => 'Spain', - 'continent_code' => 'EU', - 'city' => 'Puerto de la Omnivery', - ], - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::OPENED->value, - ]); -}); - -it('can receive incoming click webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'signature' => [ - 'timestamp' => 1649408311, - 'token' => 'eventtoken', - 'signature' => 'secrethmacsignature', - ], - 'event-data' => [ - 'event' => 'clicked', - 'timestamp' => 1649408305, - 'id' => 'OTk6MTA1MDI6Y2xpY2tlZDo1NjI1NDg3Njc=', - 'recipient' => 'test@omnivery.com', - 'recipient-domain' => 'omnivery.com', - 'campaigns' => [], - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], - 'flags' => [ - 'is-system-test' => false, - 'is-test-mode' => false, - ], - 'ip' => '123.123.123.123', - 'geolocation' => [ - 'country' => 'Spain', - 'region' => 'ES', - 'city' => 'Puerto de la Omnivery', - ], - 'url' => 'https://example.com', - 'client-info' => [ - 'client-name' => 'Chrome', - 'client-type' => 'browser', - 'device-type' => 'desktop', - 'client-os' => 'Windows', - ], - 'message' => [ - 'headers' => [ - 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', - 'subject' => 'Production test', - 'from' => '"Friendly Sender" ', - 'to' => 'test@omnivery.com', - 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', - ], - 'size' => 5637, - ], - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::CLICKED->value, - 'link' => 'https://example.com', - ]); -}); - -it('can receive incoming unsubscribe webhook from mailgun', function () { - Mail::send([], [], function (Message $message) { - $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(); - - post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ - 'signature' => [ - 'timestamp' => 1649408311, - 'token' => 'eventtoken', - 'signature' => 'secrethmacsignature', - ], - 'event-data' => [ - 'event' => 'unsubscribed', - 'timestamp' => 1649408305, - 'id' => 'OTk6MTA1MDI6dW5zdWJzY3JpYmVkOjU2MjU0ODc2Nw==', - 'recipient' => 'test@omnivery.com', - 'recipient-domain' => 'omnivery.com', - 'campaigns' => [], - 'tags' => ['unsubscribed'], - 'user-variables' => [ - config('mails.headers.uuid') => $mail?->uuid, - ], - 'flags' => [ - 'is-system-test' => false, - 'is-test-mode' => false, - ], - 'unsubscribe' => [ - 'mailing-list' => 'newsletter@omnivery.com', - 'ip' => '123.123.123.123', - ], - 'message' => [ - 'headers' => [ - 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', - 'subject' => 'Production test', - 'from' => '"Friendly Sender" ', - 'to' => 'test@omnivery.com', - 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', - ], - 'size' => 5637, - ], - ], - ])->assertAccepted(); - - assertDatabaseHas((new MailEvent)->getTable(), [ - 'type' => EventType::UNSUBSCRIBED->value, - ]); -}); +// +//it('can receive incoming accept webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'signature' => [ +// 'timestamp' => 1649408311, +// 'token' => 'eventtoken', +// 'signature' => 'secrethmacsignature', +// ], +// 'event-data' => [ +// 'event' => 'accepted', +// 'timestamp' => 1649408305, +// 'id' => 'OTk6MTA1MDI6YWNjZXB0ZWQ6NTYyNTQ4NzY3', +// 'recipient' => 'test@omnivery.com', +// 'recipient-domain' => 'omnivery.com', +// 'campaigns' => [], +// 'tags' => ['accepted'], +// 'user-variables' => [ +// config('mails.headers.uuid') => $mail?->uuid, +// ], +// 'flags' => [ +// 'is-system-test' => false, +// 'is-test-mode' => false, +// ], +// 'envelope' => [ +// 'sending-ip' => '123.123.123.123', +// 'sender' => 'sender@omnivery.dev', +// 'targets' => 'test@omnivery.com', +// 'transport' => 'smtp', +// ], +// 'message' => [ +// 'headers' => [ +// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', +// 'subject' => 'Production test', +// 'from' => '"Friendly Sender" ', +// 'to' => 'test@omnivery.com', +// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// ], +// 'size' => 5637, +// ], +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::ACCEPTED->value, +// ]); +//}); +// +//it('can receive incoming hard bounce webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'event-data' => [ +// 'event' => 'failed', +// 'severity' => 'permanent', +// 'envelope' => [ +// 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', +// 'sending-ip' => '185.136.201.130', +// 'targets' => 'nosuchemail@omnivery.com', +// 'transport' => 'smtp', +// ], +// 'recipient' => 'nosuchemail@omnivery.com', +// 'message' => [ +// 'size' => 5597, +// 'headers' => [ +// 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', +// 'subject' => 'Test message subject', +// 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', +// 'to' => 'nosuchemail@omnivery.com', +// 'from' => '"Friendly Sender" ', +// ], +// ], +// 'delivery-status' => [ +// 'code' => 550, +// 'bounce-class' => 'bad-mailbox', +// 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', +// 'mx-host' => 'mail.mailkit.eu', +// 'tls' => true, +// 'mx-ip' => '185.136.200.19', +// 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', +// ], +// 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', +// 'timestamp' => 1648727038.22387, +// 'recipient-domain' => 'omnivery.com', +// ], +// 'signature' => [ +// 'signature' => 'secrethmacsignature', +// 'timestamp' => 1648727039, +// 'token' => 'eventtoken', +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::HARD_BOUNCED->value, +// ]); +//}); +// +//it('can receive incoming soft bounce webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'event-data' => [ +// 'event' => 'failed', +// 'severity' => 'temporary', +// 'envelope' => [ +// 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', +// 'sending-ip' => '185.136.201.130', +// 'targets' => 'nosuchemail@omnivery.com', +// 'transport' => 'smtp', +// ], +// 'recipient' => 'nosuchemail@omnivery.com', +// 'message' => [ +// 'size' => 5597, +// 'headers' => [ +// 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', +// 'subject' => 'Test message subject', +// 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', +// 'to' => 'nosuchemail@omnivery.com', +// 'from' => '"Friendly Sender" ', +// ], +// ], +// 'delivery-status' => [ +// 'code' => 550, +// 'bounce-class' => 'bad-mailbox', +// 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', +// 'mx-host' => 'mail.mailkit.eu', +// 'tls' => true, +// 'mx-ip' => '185.136.200.19', +// 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', +// ], +// 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', +// 'timestamp' => 1648727038.22387, +// 'recipient-domain' => 'omnivery.com', +// ], +// 'signature' => [ +// 'signature' => 'secrethmacsignature', +// 'timestamp' => 1648727039, +// 'token' => 'eventtoken', +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::SOFT_BOUNCED->value, +// ]); +//}); +// +//it('can receive incoming complaint webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'signature' => [ +// 'timestamp' => 1649408311, +// 'token' => 'eventtoken', +// 'signature' => 'secrethmacsignature', +// ], +// 'event-data' => [ +// 'event' => 'complained', +// 'timestamp' => 1649408305, +// 'id' => 'OTk6MTA1MDI6Y29tcGxhaW5lZDo1NjI1NDg3Njc=', +// 'recipient' => 'test@omnivery.com', +// 'recipient-domain' => 'omnivery.com', +// 'campaigns' => [], +// 'tags' => ['complaint', 'feedback'], +// 'user-variables' => [ +// config('mails.headers.uuid') => $mail?->uuid, +// ], +// 'flags' => [ +// 'is-system-test' => false, +// 'is-test-mode' => false, +// ], +// 'complaint' => [ +// 'complained-at' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// 'feedback-id' => 'feedback-id-12345', +// 'user-agent' => 'Feedback Loop Processor', +// ], +// 'message' => [ +// 'headers' => [ +// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', +// 'subject' => 'Production test', +// 'from' => '"Friendly Sender" ', +// 'to' => 'test@omnivery.com', +// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// ], +// 'size' => 5637, +// ], +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::COMPLAINED->value, +// ]); +//}); +// +//it('can receive incoming open webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'signature' => [ +// 'signature' => 'secrethmacsignature', +// 'token' => 'eventtoken', +// 'timestamp' => 1649408311, +// ], +// 'event-data' => [ +// 'recipient-domain' => 'omnivery.com', +// 'timestamp' => 1649408305, +// 'envelope' => [ +// 'targets' => 'test@omnivery.com', +// ], +// 'message' => [ +// 'headers' => [ +// 'subject' => 'Production test', +// 'to' => 'test@omnivery.com', +// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', +// 'from' => '"Friendly Sender" ', +// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// ], +// ], +// 'client-info' => [ +// 'suspected-bot' => false, +// 'device-type' => 'Personal computer', +// 'client-name' => 'Thunderbird', +// 'client-type' => 'Email client', +// 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Thunderbird/91.7.0', +// 'client-os' => 'Windows 10', +// ], +// 'ip' => '123.123.123.123', +// 'recipient' => 'test@omnivery.com', +// 'id' => 'OTk6MTA1MDI6b3BlbmVkOjE2NDk0MDgzMTE=', +// 'event' => 'opened', +// 'geolocation' => [ +// 'country_code' => 'ES', +// 'continent_name' => 'Europe', +// 'country_name' => 'Spain', +// 'continent_code' => 'EU', +// 'city' => 'Puerto de la Omnivery', +// ], +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::OPENED->value, +// ]); +//}); +// +//it('can receive incoming click webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'signature' => [ +// 'timestamp' => 1649408311, +// 'token' => 'eventtoken', +// 'signature' => 'secrethmacsignature', +// ], +// 'event-data' => [ +// 'event' => 'clicked', +// 'timestamp' => 1649408305, +// 'id' => 'OTk6MTA1MDI6Y2xpY2tlZDo1NjI1NDg3Njc=', +// 'recipient' => 'test@omnivery.com', +// 'recipient-domain' => 'omnivery.com', +// 'campaigns' => [], +// 'user-variables' => [ +// config('mails.headers.uuid') => $mail?->uuid, +// ], +// 'flags' => [ +// 'is-system-test' => false, +// 'is-test-mode' => false, +// ], +// 'ip' => '123.123.123.123', +// 'geolocation' => [ +// 'country' => 'Spain', +// 'region' => 'ES', +// 'city' => 'Puerto de la Omnivery', +// ], +// 'url' => 'https://example.com', +// 'client-info' => [ +// 'client-name' => 'Chrome', +// 'client-type' => 'browser', +// 'device-type' => 'desktop', +// 'client-os' => 'Windows', +// ], +// 'message' => [ +// 'headers' => [ +// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', +// 'subject' => 'Production test', +// 'from' => '"Friendly Sender" ', +// 'to' => 'test@omnivery.com', +// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// ], +// 'size' => 5637, +// ], +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::CLICKED->value, +// 'link' => 'https://example.com', +// ]); +//}); +// +//it('can receive incoming unsubscribe webhook from mailgun', function () { +// Mail::send([], [], function (Message $message) { +// $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(); +// +// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ +// 'signature' => [ +// 'timestamp' => 1649408311, +// 'token' => 'eventtoken', +// 'signature' => 'secrethmacsignature', +// ], +// 'event-data' => [ +// 'event' => 'unsubscribed', +// 'timestamp' => 1649408305, +// 'id' => 'OTk6MTA1MDI6dW5zdWJzY3JpYmVkOjU2MjU0ODc2Nw==', +// 'recipient' => 'test@omnivery.com', +// 'recipient-domain' => 'omnivery.com', +// 'campaigns' => [], +// 'tags' => ['unsubscribed'], +// 'user-variables' => [ +// config('mails.headers.uuid') => $mail?->uuid, +// ], +// 'flags' => [ +// 'is-system-test' => false, +// 'is-test-mode' => false, +// ], +// 'unsubscribe' => [ +// 'mailing-list' => 'newsletter@omnivery.com', +// 'ip' => '123.123.123.123', +// ], +// 'message' => [ +// 'headers' => [ +// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', +// 'subject' => 'Production test', +// 'from' => '"Friendly Sender" ', +// 'to' => 'test@omnivery.com', +// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', +// ], +// 'size' => 5637, +// ], +// ], +// ])->assertAccepted(); +// +// assertDatabaseHas((new MailEvent)->getTable(), [ +// 'type' => EventType::UNSUBSCRIBED->value, +// ]); +//}); From 616bb7ba3082420f4ce1497ea0f98bf8cc9d5fec Mon Sep 17 00:00:00 2001 From: Mark van Eijk Date: Mon, 19 Jan 2026 17:15:04 +0100 Subject: [PATCH 05/12] wip --- src/Drivers/SesDriver.php | 83 +++++++++++++++++++++------------------ tests/SesTest.php | 10 ++++- tests/TestCase.php | 53 ++++++------------------- 3 files changed, 65 insertions(+), 81 deletions(-) diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index 6fb8477..bc59010 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -6,7 +6,6 @@ use Aws\Sns\Message; use Aws\Sns\MessageValidator; use Aws\Sns\SnsClient; -use Illuminate\Http\Client\Response; use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Transport\SesTransport; use Illuminate\Support\Arr; @@ -136,7 +135,7 @@ public function registerWebhooks($components): void ] ]); - // 5. Subscribe to the topic + // 6. Subscribe to the topic $webhookUrl = URL::signedRoute('mails.webhook', ['provider' => Provider::SES]); $scheme = config('services.ses.scheme', 'https'); $snsClient->subscribe([ @@ -187,10 +186,19 @@ public function attachUuidToMail(MessageSending $event, string $uuid): MessageSe return $event; } + protected function parseSnsMessage(array $payload): array + { + // The SNS Message field contains a JSON string with the actual SES event + if (isset($payload['Message']) && is_string($payload['Message'])) { + return json_decode($payload['Message'], true) ?? []; + } + return $payload; + } + public function getUuidFromPayload(array $payload): ?string { - $message = Message::fromRawPostData(); - $headers = $message['Message']['mail']['headers'] ?? []; + $sesMessage = $this->parseSnsMessage($payload); + $headers = $sesMessage['mail']['headers'] ?? []; $header = Arr::first($headers, function ($header) { return $header['name'] === config('mails.headers.uuid'); }); @@ -200,59 +208,58 @@ public function getUuidFromPayload(array $payload): ?string protected function getTimestampFromPayload(array $payload): string { + // Work with SES message structure (already parsed by parseSnsMessage) foreach (['click', 'open', 'bounce', 'complaint', 'delivery', 'mail'] as $event) { - if (isset($payload['Message'][$event]['timestamp'])) { - return $payload['Message'][$event]['timestamp']; + if (isset($payload[$event]['timestamp'])) { + return $payload[$event]['timestamp']; } } - return $payload['Message']['Timestamp']; + return $payload['Timestamp'] ?? now()->toIso8601String(); } public function getDataFromPayload(array $payload): array { - $message = Message::fromRawPostData(); - $payload = $message->toArray(); - - return collect($this->dataMapping()) - ->mapWithKeys(function ($values, $key) use ($payload) { - foreach ($values as $value) { - $value = data_get($payload, $value); - if ($value !== null) { - return [$key => $value]; - } + $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] = $value; + break; } - return null; - }) - ->filter() - ->merge([ - 'payload' => $payload, - 'type' => $this->getEventFromPayload($payload), - 'occurred_at' => $this->getTimestampFromPayload($payload), - ]) - ->toArray(); + } + } + + return array_merge($data, [ + 'payload' => $payload, + 'type' => $this->getEventFromPayload($sesMessage), + 'occurred_at' => $this->getTimestampFromPayload($sesMessage), + ]); } public function eventMapping(): array { return [ - EventType::ACCEPTED->value => ['Message.eventType' => 'Send'], - EventType::CLICKED->value => ['Message.eventType' => 'Click'], - EventType::COMPLAINED->value => ['Message.eventType' => 'Complaint'], - EventType::DELIVERED->value => ['Message.eventType' => 'Delivery'], - EventType::OPENED->value => ['Message.eventType' => 'Open'], - EventType::HARD_BOUNCED->value => ['Message.eventType' => 'Bounce', 'Message.bounce.bounceType' => 'Permanent'], - EventType::SOFT_BOUNCED->value => ['Message.eventType' => 'Bounce', 'Message.bounce.bounceType' => 'Temporary'], + 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' => ['Message.click.ipAddress', 'Message.open.ipAddress'], - 'browser' => ['Message.mail.client-info.client-name'], - 'user_agent' => ['Message.click.userAgent', 'Message.open.userAgent','Message.complaint.userAgent'], - 'link' => ['Message.click.link'], - 'tag' => ['Message.click.linkTags'], + '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'], ]; } diff --git a/tests/SesTest.php b/tests/SesTest.php index ea823b4..eff33de 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -24,7 +24,7 @@ $mail = MailModel::latest()->first(); - $message = json_decode('{ + $sesEvent = json_decode('{ "eventType": "Delivery", "mail": { "timestamp": "2016-10-19T23:20:52.240Z", @@ -60,6 +60,10 @@ { "name": "Content-Transfer-Encoding", "value": "7bit" + }, + { + "name": "X-Laravel-Mail-UUID", + "value": "' . $mail->uuid . '" } ], "commonHeaders": { @@ -109,7 +113,9 @@ }', true); post(URL::signedRoute('mails.webhook', ['provider' => Provider::SES]), [ - 'Message' => $message, + 'Type' => 'Notification', + 'Message' => json_encode($sesEvent), + 'Timestamp' => '2016-10-19T23:21:04.133Z', 'signature' => 'secrethmacsignature', ])->assertAccepted(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7d59d1b..380f1c7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,29 +2,25 @@ namespace Backstage\Mails\Tests; -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; +use Backstage\Mails\MailsServiceProvider; 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(); } } From 5b896ec5e88e4f86e044657847a072cefaebafa7 Mon Sep 17 00:00:00 2001 From: Mark van Eijk Date: Mon, 19 Jan 2026 17:21:47 +0100 Subject: [PATCH 06/12] wip --- tests/SesTest.php | 30 +++++++++++++++--------------- tests/TestCase.php | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/SesTest.php b/tests/SesTest.php index eff33de..e3a29bb 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -63,7 +63,7 @@ }, { "name": "X-Laravel-Mail-UUID", - "value": "' . $mail->uuid . '" + "value": "'.$mail->uuid.'" } ], "commonHeaders": { @@ -124,7 +124,7 @@ ]); }); // -//it('can receive incoming accept webhook from mailgun', function () { +// it('can receive incoming accept webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -180,9 +180,9 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::ACCEPTED->value, // ]); -//}); +// }); // -//it('can receive incoming hard bounce webhook from mailgun', function () { +// it('can receive incoming hard bounce webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -239,9 +239,9 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::HARD_BOUNCED->value, // ]); -//}); +// }); // -//it('can receive incoming soft bounce webhook from mailgun', function () { +// it('can receive incoming soft bounce webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -298,9 +298,9 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::SOFT_BOUNCED->value, // ]); -//}); +// }); // -//it('can receive incoming complaint webhook from mailgun', function () { +// it('can receive incoming complaint webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -355,9 +355,9 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::COMPLAINED->value, // ]); -//}); +// }); // -//it('can receive incoming open webhook from mailgun', function () { +// it('can receive incoming open webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -416,9 +416,9 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::OPENED->value, // ]); -//}); +// }); // -//it('can receive incoming click webhook from mailgun', function () { +// it('can receive incoming click webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -481,9 +481,9 @@ // 'type' => EventType::CLICKED->value, // 'link' => 'https://example.com', // ]); -//}); +// }); // -//it('can receive incoming unsubscribe webhook from mailgun', function () { +// it('can receive incoming unsubscribe webhook from mailgun', function () { // Mail::send([], [], function (Message $message) { // $message->to('mark@vormkracht10.nl') // ->from('local@computer.nl') @@ -537,4 +537,4 @@ // assertDatabaseHas((new MailEvent)->getTable(), [ // 'type' => EventType::UNSUBSCRIBED->value, // ]); -//}); +// }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 380f1c7..89493b3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,12 +2,12 @@ namespace Backstage\Mails\Tests; +use Backstage\Mails\MailsServiceProvider; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Filesystem\Filesystem; use NotificationChannels\Discord\DiscordServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; use SplFileInfo; -use Backstage\Mails\MailsServiceProvider; class TestCase extends Orchestra { From 7463637afaf50952e0d7b3ad112b5286d3845b83 Mon Sep 17 00:00:00 2001 From: markvaneijk <1925388+markvaneijk@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:22:07 +0000 Subject: [PATCH 07/12] Fix styling --- src/Drivers/SesDriver.php | 40 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index bc59010..68aa749 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -22,12 +22,13 @@ public function registerWebhooks($components): void { $mailer = Mail::driver('ses'); if ($mailer === null) { - $components->warn("Failed to create Ses webhook"); - $components->error("There is no Amazon SES Driver configured in your laravel application."); + $components->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'); + $trackingConfig = (array) config('mails.logging.tracking'); // send - The call was successful and Amazon SES is attempting to deliver the email. // reject - Amazon SES determined that the email contained a virus and rejected it. @@ -40,29 +41,29 @@ public function registerWebhooks($components): void $events = []; $eventTypes = []; - if ((bool)$trackingConfig['opens']) { + if ((bool) $trackingConfig['opens']) { $events[] = 'open'; $eventTypes[] = 'Delivery'; } - if ((bool)$trackingConfig['clicks']) { + if ((bool) $trackingConfig['clicks']) { $events[] = 'click'; $eventTypes[] = 'Delivery'; } - if ((bool)$trackingConfig['deliveries']) { + if ((bool) $trackingConfig['deliveries']) { $events[] = 'delivery'; $eventTypes[] = 'Delivery'; } - if ((bool)$trackingConfig['bounces']) { + if ((bool) $trackingConfig['bounces']) { $events[] = 'reject'; $events[] = 'bounce'; $events[] = 'renderingFailure'; $eventTypes[] = 'Bounce'; } - if ((bool)$trackingConfig['complaints']) { + if ((bool) $trackingConfig['complaints']) { $events[] = 'complaint'; $eventTypes[] = 'Complaint'; } @@ -127,12 +128,12 @@ public function registerWebhooks($components): void 'ConfigurationSetName' => $configurationSet, 'EventDestination' => [ 'Enabled' => true, - 'Name' => $configurationSet . '-' . uniqid(), + 'Name' => $configurationSet.'-'.uniqid(), 'MatchingEventTypes' => $events, 'SNSDestination' => [ 'TopicARN' => $topicArn, - ] - ] + ], + ], ]); // 6. Subscribe to the topic @@ -141,17 +142,18 @@ public function registerWebhooks($components): void $snsClient->subscribe([ 'Endpoint' => $webhookUrl, 'TopicArn' => $topicArn, - 'Protocol' => $scheme + 'Protocol' => $scheme, ]); } catch (\Throwable $e) { report($e); - $components->warn("Failed to create Ses webhook"); + $components->warn('Failed to create Ses webhook'); $components->error($e->getMessage()); + return; } - $components->info("Created SES Webhooks for: " . implode(", ", $eventTypes)); + $components->info('Created SES Webhooks for: '.implode(', ', $eventTypes)); } public function verifyWebhookSignature(array $payload): bool @@ -172,9 +174,11 @@ public function verifyWebhookSignature(array $payload): bool if ($message['Type'] === 'SubscriptionConfirmation') { Http::timeout(10)->get($message['SubscribeURL'])->throw(); } + return true; } catch (\Throwable $e) { report($e); + return false; } } @@ -192,6 +196,7 @@ protected function parseSnsMessage(array $payload): array if (isset($payload['Message']) && is_string($payload['Message'])) { return json_decode($payload['Message'], true) ?? []; } + return $payload; } @@ -214,6 +219,7 @@ protected function getTimestampFromPayload(array $payload): string return $payload[$event]['timestamp']; } } + return $payload['Timestamp'] ?? now()->toIso8601String(); } @@ -267,7 +273,7 @@ protected function createSnsClient(array $config): SnsClient { $config = array_merge( [ - 'version' => 'latest' + 'version' => 'latest', ], $config ); @@ -277,10 +283,10 @@ protected function createSnsClient(array $config): SnsClient protected function addSnsCredentials(array $config): array { - if (!empty($config['key']) && !empty($config['secret'])) { + if (! empty($config['key']) && ! empty($config['secret'])) { $config['credentials'] = Arr::only($config, ['key', 'secret']); - if (!empty($config['token'])) { + if (! empty($config['token'])) { $config['credentials']['token'] = $config['token']; } } From 7b95f9fb320e602ee5cb2f2a25bf403e5edcf927 Mon Sep 17 00:00:00 2001 From: Mark van Eijk Date: Tue, 20 Jan 2026 08:20:15 +0100 Subject: [PATCH 08/12] Update SesDriver.php --- src/Drivers/SesDriver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index 68aa749..49cb251 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -1,6 +1,6 @@ Date: Tue, 20 Jan 2026 10:52:32 +0100 Subject: [PATCH 09/12] fix tests --- src/Drivers/SesDriver.php | 27 ++++++++++++++++++++++++--- tests/SesTest.php | 8 ++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index 49cb251..864303a 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -6,15 +6,16 @@ use Aws\Sns\Message; use Aws\Sns\MessageValidator; use Aws\Sns\SnsClient; +use Illuminate\Http\Client\Response; use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Transport\SesTransport; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\URL; -use Vormkracht10\Mails\Contracts\MailDriverContract; -use Vormkracht10\Mails\Enums\EventType; -use Vormkracht10\Mails\Enums\Provider; +use Backstage\Mails\Contracts\MailDriverContract; +use Backstage\Mails\Enums\EventType; +use Backstage\Mails\Enums\Provider; class SesDriver extends MailDriver implements MailDriverContract { @@ -293,4 +294,24 @@ protected function addSnsCredentials(array $config): array 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/tests/SesTest.php b/tests/SesTest.php index e3a29bb..ebc9d2a 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -3,10 +3,10 @@ use Illuminate\Mail\Message; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\URL; -use Vormkracht10\Mails\Enums\EventType; -use Vormkracht10\Mails\Enums\Provider; -use Vormkracht10\Mails\Models\Mail as MailModel; -use Vormkracht10\Mails\Models\MailEvent; +use Backstage\Mails\Enums\EventType; +use Backstage\Mails\Enums\Provider; +use Backstage\Mails\Models\Mail as MailModel; +use Backstage\Mails\Models\MailEvent; use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\post; From 7a15a1babbc11bc496bba3f69873d98af8e8c0f3 Mon Sep 17 00:00:00 2001 From: markvaneijk <1925388+markvaneijk@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:52:49 +0000 Subject: [PATCH 10/12] Fix styling --- src/Drivers/SesDriver.php | 6 +++--- tests/SesTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index 864303a..8c1c977 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -6,6 +6,9 @@ use Aws\Sns\Message; use Aws\Sns\MessageValidator; use Aws\Sns\SnsClient; +use Backstage\Mails\Contracts\MailDriverContract; +use Backstage\Mails\Enums\EventType; +use Backstage\Mails\Enums\Provider; use Illuminate\Http\Client\Response; use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Transport\SesTransport; @@ -13,9 +16,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\URL; -use Backstage\Mails\Contracts\MailDriverContract; -use Backstage\Mails\Enums\EventType; -use Backstage\Mails\Enums\Provider; class SesDriver extends MailDriver implements MailDriverContract { diff --git a/tests/SesTest.php b/tests/SesTest.php index ebc9d2a..4c35dba 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -1,12 +1,12 @@ Date: Tue, 24 Mar 2026 21:22:20 +0100 Subject: [PATCH 11/12] fix: correct SES driver namespace, missing interface method, and config bugs - Fix namespace from Vormkracht10 to Backstage in SesDriver and SesTest - Add missing unsuppressEmailAddress() required by MailDriverContract - Fix config path to match README (services.ses.configuration_set_name) - Fix addPermission crash on re-run by catching existing label error - Fix event destination accumulation by using deterministic name with cleanup - Remove wrong Delivery event type for opens/clicks in registerWebhooks - Separate subscription confirmation from signature verification logic - Replace commented-out Mailgun tests with 7 proper SES event type tests - Fix README typo and remove unused verify_signature config Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 5 +- src/Drivers/SesDriver.php | 88 ++-- tests/SesTest.php | 825 +++++++++++++++----------------------- 3 files changed, 370 insertions(+), 548 deletions(-) diff --git a/README.md b/README.md index 01735f5..1f0bcb9 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,7 @@ Add the API key of your email service provider to the `config/services.php` file // 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', - 'verify_signature' => true, + 'scheme' => 'https', // 'http' or 'https' ], ``` @@ -245,7 +244,7 @@ When using Amazon SES, you also require the following dependencies composer require aws/aws-sdk-php aws/aws-php-sns-message-validator ``` -You aws ses user should also have the authorization to create SNS topics +Your AWS SES user should also have the authorization to create SNS topics. ## Usage diff --git a/src/Drivers/SesDriver.php b/src/Drivers/SesDriver.php index 8c1c977..7750ea5 100644 --- a/src/Drivers/SesDriver.php +++ b/src/Drivers/SesDriver.php @@ -23,33 +23,26 @@ public function registerWebhooks($components): void { $mailer = Mail::driver('ses'); if ($mailer === null) { - $components->warn('Failed to create Ses webhook'); - $components->error('There is no Amazon SES Driver configured in your laravel application.'); + $components->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'); - // send - The call was successful and Amazon SES is attempting to deliver the email. - // reject - Amazon SES determined that the email contained a virus and rejected it. - // bounce - The recipient's mail server permanently rejected the email. This corresponds to a hard bounce. - // complaint - The recipient marked the email as spam. - // delivery - Amazon SES successfully delivered the email to the recipient's mail server. - // open - The recipient received the email and opened it in their email client. - // click - The recipient clicked one or more links in the email. - // renderingFailure - Amazon SES did not send the email because of a template rendering issue. + // 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'; - $eventTypes[] = 'Delivery'; } if ((bool) $trackingConfig['clicks']) { $events[] = 'click'; - $eventTypes[] = 'Delivery'; } if ((bool) $trackingConfig['deliveries']) { @@ -72,7 +65,7 @@ public function registerWebhooks($components): void /** @var SesTransport $sesTransport */ $sesTransport = $mailer->getSymfonyTransport(); $sesClient = $sesTransport->ses(); - $configurationSet = config('services.ses.options.ConfigurationSetName', 'laravel-mails-ses-webhook'); + $configurationSet = config('services.ses.configuration_set_name', 'laravel-mails-ses-webhook'); try { // 1. Get or create the Configuration Set @@ -83,13 +76,12 @@ public function registerWebhooks($components): void ], ]); } catch (AwsException $e) { - // Already exists, move on! if ($e->getAwsErrorCode() !== 'ConfigurationSetAlreadyExists') { throw $e; } } - // 2. Create a SNS Topic + // 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([ @@ -97,26 +89,32 @@ public function registerWebhooks($components): void ]); $topicArn = $result->get('TopicArn'); - // 3. Give access to SES to publish notifications to the topic. - $snsClient->addPermission([ - 'AWSAccountId' => [$config['account_id'] ?? ''], - 'ActionName' => ['Publish'], - 'Label' => 'ses-notification-policy', - 'TopicArn' => $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 the channels + // 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')); - // get notified for the various types of events via SNS + $sesClient->setIdentityNotificationTopic([ 'Identity' => $identity, 'NotificationType' => $eventType, 'SnsTopic' => $topicArn, ]); - // Force SNS to include the SES mail headers in the notification $sesClient->setIdentityHeadersInNotificationsEnabled([ 'Identity' => $identity, 'NotificationType' => $eventType, @@ -124,12 +122,24 @@ public function registerWebhooks($components): void ]); } - // 5. Register SNS as the event destination + // 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' => $configurationSet.'-'.uniqid(), + 'Name' => $eventDestinationName, 'MatchingEventTypes' => $events, 'SNSDestination' => [ 'TopicARN' => $topicArn, @@ -148,13 +158,13 @@ public function registerWebhooks($components): void } catch (\Throwable $e) { report($e); - $components->warn('Failed to create Ses webhook'); + $components->warn('Failed to create SES webhook'); $components->error($e->getMessage()); return; } - $components->info('Created SES Webhooks for: '.implode(', ', $eventTypes)); + $components->info('Created SES Webhooks for: '.implode(', ', $events)); } public function verifyWebhookSignature(array $payload): bool @@ -163,25 +173,25 @@ public function verifyWebhookSignature(array $payload): bool return true; } - // Weird SNS thing, you need to read the raw post body $message = Message::fromRawPostData(); - $validator = (new MessageValidator(function ($url) { + $validator = new MessageValidator(function ($url) { return Http::timeout(10)->get($url)->body(); - })); + }); try { $validator->validate($message); - if ($message['Type'] === 'SubscriptionConfirmation') { - Http::timeout(10)->get($message['SubscribeURL'])->throw(); - } - - return true; } 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 @@ -193,7 +203,6 @@ public function attachUuidToMail(MessageSending $event, string $uuid): MessageSe protected function parseSnsMessage(array $payload): array { - // The SNS Message field contains a JSON string with the actual SES event if (isset($payload['Message']) && is_string($payload['Message'])) { return json_decode($payload['Message'], true) ?? []; } @@ -214,7 +223,6 @@ public function getUuidFromPayload(array $payload): ?string protected function getTimestampFromPayload(array $payload): string { - // Work with SES message structure (already parsed by parseSnsMessage) foreach (['click', 'open', 'bounce', 'complaint', 'delivery', 'mail'] as $event) { if (isset($payload[$event]['timestamp'])) { return $payload[$event]['timestamp']; @@ -233,7 +241,7 @@ public function getDataFromPayload(array $payload): array foreach ($paths as $path) { $value = data_get($sesMessage, $path); if ($value !== null) { - $data[$key] = $value; + $data[$key] = is_array($value) ? json_encode($value) : $value; break; } } diff --git a/tests/SesTest.php b/tests/SesTest.php index 4c35dba..ff80431 100644 --- a/tests/SesTest.php +++ b/tests/SesTest.php @@ -11,8 +11,8 @@ use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\post; -it('can receive incoming delivery webhook from amazon ses', function () { - Mail::send([], [], function (Message $message) { +it('can receive incoming delivery 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') @@ -24,517 +24,332 @@ $mail = MailModel::latest()->first(); - $sesEvent = json_decode('{ - "eventType": "Delivery", - "mail": { - "timestamp": "2016-10-19T23:20:52.240Z", - "source": "sender@example.com", - "sourceArn": "arn:aws:ses:us-east-1:123456789012:identity/sender@example.com", - "sendingAccountId": "123456789012", - "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": "MIME-Version", - "value": "1.0" - }, - { - "name": "Content-Type", - "value": "text/html; charset=UTF-8" - }, - { - "name": "Content-Transfer-Encoding", - "value": "7bit" - }, - { - "name": "X-Laravel-Mail-UUID", - "value": "'.$mail->uuid.'" - } - ], - "commonHeaders": { - "from": [ - "sender@example.com" - ], - "to": [ - "recipient@example.com" - ], - "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", - "subject": "Message sent from Amazon SES" - }, - "tags": { - "ses:configuration-set": [ - "ConfigSet" - ], - "ses:source-ip": [ - "192.0.2.0" - ], - "ses:from-domain": [ - "example.com" - ], - "ses:caller-identity": [ - "ses_user" - ], - "ses:outgoing-ip": [ - "192.0.2.0" - ], - "myCustomTag1": [ - "myCustomTagValue1" - ], - "myCustomTag2": [ - "myCustomTagValue2" - ] - } - }, - "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" - } -}', true); + $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', - 'signature' => 'secrethmacsignature', ])->assertAccepted(); assertDatabaseHas((new MailEvent)->getTable(), [ 'type' => EventType::DELIVERED->value, ]); }); -// -// it('can receive incoming accept webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'signature' => [ -// 'timestamp' => 1649408311, -// 'token' => 'eventtoken', -// 'signature' => 'secrethmacsignature', -// ], -// 'event-data' => [ -// 'event' => 'accepted', -// 'timestamp' => 1649408305, -// 'id' => 'OTk6MTA1MDI6YWNjZXB0ZWQ6NTYyNTQ4NzY3', -// 'recipient' => 'test@omnivery.com', -// 'recipient-domain' => 'omnivery.com', -// 'campaigns' => [], -// 'tags' => ['accepted'], -// 'user-variables' => [ -// config('mails.headers.uuid') => $mail?->uuid, -// ], -// 'flags' => [ -// 'is-system-test' => false, -// 'is-test-mode' => false, -// ], -// 'envelope' => [ -// 'sending-ip' => '123.123.123.123', -// 'sender' => 'sender@omnivery.dev', -// 'targets' => 'test@omnivery.com', -// 'transport' => 'smtp', -// ], -// 'message' => [ -// 'headers' => [ -// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', -// 'subject' => 'Production test', -// 'from' => '"Friendly Sender" ', -// 'to' => 'test@omnivery.com', -// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// ], -// 'size' => 5637, -// ], -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::ACCEPTED->value, -// ]); -// }); -// -// it('can receive incoming hard bounce webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'event-data' => [ -// 'event' => 'failed', -// 'severity' => 'permanent', -// 'envelope' => [ -// 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', -// 'sending-ip' => '185.136.201.130', -// 'targets' => 'nosuchemail@omnivery.com', -// 'transport' => 'smtp', -// ], -// 'recipient' => 'nosuchemail@omnivery.com', -// 'message' => [ -// 'size' => 5597, -// 'headers' => [ -// 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', -// 'subject' => 'Test message subject', -// 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', -// 'to' => 'nosuchemail@omnivery.com', -// 'from' => '"Friendly Sender" ', -// ], -// ], -// 'delivery-status' => [ -// 'code' => 550, -// 'bounce-class' => 'bad-mailbox', -// 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', -// 'mx-host' => 'mail.mailkit.eu', -// 'tls' => true, -// 'mx-ip' => '185.136.200.19', -// 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', -// ], -// 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', -// 'timestamp' => 1648727038.22387, -// 'recipient-domain' => 'omnivery.com', -// ], -// 'signature' => [ -// 'signature' => 'secrethmacsignature', -// 'timestamp' => 1648727039, -// 'token' => 'eventtoken', -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::HARD_BOUNCED->value, -// ]); -// }); -// -// it('can receive incoming soft bounce webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'event-data' => [ -// 'event' => 'failed', -// 'severity' => 'temporary', -// 'envelope' => [ -// 'sender' => 'bounce-d9bee8ac-b0e7-11ec-8086-57d93b186f66@notify.omnivery.com', -// 'sending-ip' => '185.136.201.130', -// 'targets' => 'nosuchemail@omnivery.com', -// 'transport' => 'smtp', -// ], -// 'recipient' => 'nosuchemail@omnivery.com', -// 'message' => [ -// 'size' => 5597, -// 'headers' => [ -// 'message-id' => 'd9bee8ac-b0e7-11ec-8086-57d93b186f66', -// 'subject' => 'Test message subject', -// 'date' => 'Thu, 31 Mar 2022 11:43:57 +0000', -// 'to' => 'nosuchemail@omnivery.com', -// 'from' => '"Friendly Sender" ', -// ], -// ], -// 'delivery-status' => [ -// 'code' => 550, -// 'bounce-class' => 'bad-mailbox', -// 'description' => '550 5.1.1 : Recipient address rejected: User unknown in virtual mailbox table', -// 'mx-host' => 'mail.mailkit.eu', -// 'tls' => true, -// 'mx-ip' => '185.136.200.19', -// 'message' => ': Recipient address rejected: User unknown in virtual mailbox table', -// ], -// 'id' => 'MTozOmhhcmRib3VuY2U6MTY0ODcyNzAzOQ==', -// 'timestamp' => 1648727038.22387, -// 'recipient-domain' => 'omnivery.com', -// ], -// 'signature' => [ -// 'signature' => 'secrethmacsignature', -// 'timestamp' => 1648727039, -// 'token' => 'eventtoken', -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::SOFT_BOUNCED->value, -// ]); -// }); -// -// it('can receive incoming complaint webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'signature' => [ -// 'timestamp' => 1649408311, -// 'token' => 'eventtoken', -// 'signature' => 'secrethmacsignature', -// ], -// 'event-data' => [ -// 'event' => 'complained', -// 'timestamp' => 1649408305, -// 'id' => 'OTk6MTA1MDI6Y29tcGxhaW5lZDo1NjI1NDg3Njc=', -// 'recipient' => 'test@omnivery.com', -// 'recipient-domain' => 'omnivery.com', -// 'campaigns' => [], -// 'tags' => ['complaint', 'feedback'], -// 'user-variables' => [ -// config('mails.headers.uuid') => $mail?->uuid, -// ], -// 'flags' => [ -// 'is-system-test' => false, -// 'is-test-mode' => false, -// ], -// 'complaint' => [ -// 'complained-at' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// 'feedback-id' => 'feedback-id-12345', -// 'user-agent' => 'Feedback Loop Processor', -// ], -// 'message' => [ -// 'headers' => [ -// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', -// 'subject' => 'Production test', -// 'from' => '"Friendly Sender" ', -// 'to' => 'test@omnivery.com', -// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// ], -// 'size' => 5637, -// ], -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::COMPLAINED->value, -// ]); -// }); -// -// it('can receive incoming open webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'signature' => [ -// 'signature' => 'secrethmacsignature', -// 'token' => 'eventtoken', -// 'timestamp' => 1649408311, -// ], -// 'event-data' => [ -// 'recipient-domain' => 'omnivery.com', -// 'timestamp' => 1649408305, -// 'envelope' => [ -// 'targets' => 'test@omnivery.com', -// ], -// 'message' => [ -// 'headers' => [ -// 'subject' => 'Production test', -// 'to' => 'test@omnivery.com', -// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', -// 'from' => '"Friendly Sender" ', -// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// ], -// ], -// 'client-info' => [ -// 'suspected-bot' => false, -// 'device-type' => 'Personal computer', -// 'client-name' => 'Thunderbird', -// 'client-type' => 'Email client', -// 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Thunderbird/91.7.0', -// 'client-os' => 'Windows 10', -// ], -// 'ip' => '123.123.123.123', -// 'recipient' => 'test@omnivery.com', -// 'id' => 'OTk6MTA1MDI6b3BlbmVkOjE2NDk0MDgzMTE=', -// 'event' => 'opened', -// 'geolocation' => [ -// 'country_code' => 'ES', -// 'continent_name' => 'Europe', -// 'country_name' => 'Spain', -// 'continent_code' => 'EU', -// 'city' => 'Puerto de la Omnivery', -// ], -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::OPENED->value, -// ]); -// }); -// -// it('can receive incoming click webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'signature' => [ -// 'timestamp' => 1649408311, -// 'token' => 'eventtoken', -// 'signature' => 'secrethmacsignature', -// ], -// 'event-data' => [ -// 'event' => 'clicked', -// 'timestamp' => 1649408305, -// 'id' => 'OTk6MTA1MDI6Y2xpY2tlZDo1NjI1NDg3Njc=', -// 'recipient' => 'test@omnivery.com', -// 'recipient-domain' => 'omnivery.com', -// 'campaigns' => [], -// 'user-variables' => [ -// config('mails.headers.uuid') => $mail?->uuid, -// ], -// 'flags' => [ -// 'is-system-test' => false, -// 'is-test-mode' => false, -// ], -// 'ip' => '123.123.123.123', -// 'geolocation' => [ -// 'country' => 'Spain', -// 'region' => 'ES', -// 'city' => 'Puerto de la Omnivery', -// ], -// 'url' => 'https://example.com', -// 'client-info' => [ -// 'client-name' => 'Chrome', -// 'client-type' => 'browser', -// 'device-type' => 'desktop', -// 'client-os' => 'Windows', -// ], -// 'message' => [ -// 'headers' => [ -// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', -// 'subject' => 'Production test', -// 'from' => '"Friendly Sender" ', -// 'to' => 'test@omnivery.com', -// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// ], -// 'size' => 5637, -// ], -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::CLICKED->value, -// 'link' => 'https://example.com', -// ]); -// }); -// -// it('can receive incoming unsubscribe webhook from mailgun', function () { -// Mail::send([], [], function (Message $message) { -// $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(); -// -// post(URL::signedRoute('mails.webhook', ['provider' => Provider::MAILGUN]), [ -// 'signature' => [ -// 'timestamp' => 1649408311, -// 'token' => 'eventtoken', -// 'signature' => 'secrethmacsignature', -// ], -// 'event-data' => [ -// 'event' => 'unsubscribed', -// 'timestamp' => 1649408305, -// 'id' => 'OTk6MTA1MDI6dW5zdWJzY3JpYmVkOjU2MjU0ODc2Nw==', -// 'recipient' => 'test@omnivery.com', -// 'recipient-domain' => 'omnivery.com', -// 'campaigns' => [], -// 'tags' => ['unsubscribed'], -// 'user-variables' => [ -// config('mails.headers.uuid') => $mail?->uuid, -// ], -// 'flags' => [ -// 'is-system-test' => false, -// 'is-test-mode' => false, -// ], -// 'unsubscribe' => [ -// 'mailing-list' => 'newsletter@omnivery.com', -// 'ip' => '123.123.123.123', -// ], -// 'message' => [ -// 'headers' => [ -// 'message-id' => '6d261932-b677-11ec-aa58-03210c12f2eb', -// 'subject' => 'Production test', -// 'from' => '"Friendly Sender" ', -// 'to' => 'test@omnivery.com', -// 'date' => 'Thu, 7 Apr 2022 13:34:17 +0000', -// ], -// 'size' => 5637, -// ], -// ], -// ])->assertAccepted(); -// -// assertDatabaseHas((new MailEvent)->getTable(), [ -// 'type' => EventType::UNSUBSCRIBED->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, + ]); +}); From 32178290425405d8eb0ee674dd6858f76105a1d2 Mon Sep 17 00:00:00 2001 From: markvaneijk <1925388+markvaneijk@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:27:21 +0000 Subject: [PATCH 12/12] Fix styling --- src/Commands/ResendMailCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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");