Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,19 @@ Add the API key of your email service provider to the `config/services.php` file
'webhook_signing_key' => env('MAILGUN_WEBHOOK_SIGNING_KEY'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
'scheme' => 'https',
]
],

'ses' => [
// You should already have these set up by Laravel's default installation
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),

// This one is package-specific
'configuration_set_name' => env('AWS_SES_CONFIGURATION_SET', 'laravel-mails-ses-webhook'),
'account_id' => env('AWS_ACCOUNT_ID', ''), // Your AWS account id
'scheme' => 'https', // 'http' or 'https'
],
```

When done, run this command with the slug of your service provider:
Expand Down Expand Up @@ -224,6 +236,16 @@ This is the contents of the published config file:
]
```

### [Optional] Amazon SES

When using Amazon SES, you also require the following dependencies

```bash
composer require aws/aws-sdk-php aws/aws-php-sns-message-validator
```

Your AWS SES user should also have the authorization to create SNS topics.

## Usage

### Logging
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/ResendMailCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
325 changes: 325 additions & 0 deletions src/Drivers/SesDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
<?php

namespace Backstage\Mails\Drivers;

use Aws\Exception\AwsException;
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;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;

class SesDriver extends MailDriver implements MailDriverContract
{
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.');

return;
}

$trackingConfig = (array) config('mails.logging.tracking');

// Configuration Set Event Destination event types (for open/click/delivery/bounce/complaint tracking)
$events = [];

// SNS Identity Notification types (only Bounce, Complaint, Delivery are valid)
$eventTypes = [];

if ((bool) $trackingConfig['opens']) {
$events[] = 'open';
}

if ((bool) $trackingConfig['clicks']) {
$events[] = 'click';
}

if ((bool) $trackingConfig['deliveries']) {
$events[] = 'delivery';
$eventTypes[] = 'Delivery';
}

if ((bool) $trackingConfig['bounces']) {
$events[] = 'reject';
$events[] = 'bounce';
$events[] = 'renderingFailure';
$eventTypes[] = 'Bounce';
}

if ((bool) $trackingConfig['complaints']) {
$events[] = 'complaint';
$eventTypes[] = 'Complaint';
}

/** @var SesTransport $sesTransport */
$sesTransport = $mailer->getSymfonyTransport();
$sesClient = $sesTransport->ses();
$configurationSet = config('services.ses.configuration_set_name', 'laravel-mails-ses-webhook');

try {
// 1. Get or create the Configuration Set
try {
$sesClient->createConfigurationSet([
'ConfigurationSet' => [
'Name' => $configurationSet,
],
]);
} catch (AwsException $e) {
if ($e->getAwsErrorCode() !== 'ConfigurationSetAlreadyExists') {
throw $e;
}
}

// 2. Create a SNS Topic (idempotent - returns existing topic ARN if it already exists)
$config = config('services.sns', config('services.ses', []));
$snsClient = $this->createSnsClient($config);
$result = $snsClient->createTopic([
'Name' => $configurationSet,
]);
$topicArn = $result->get('TopicArn');

// 3. Give access to SES to publish notifications to the topic
try {
$snsClient->addPermission([
'AWSAccountId' => [$config['account_id'] ?? ''],
'ActionName' => ['Publish'],
'Label' => 'ses-notification-policy',
'TopicArn' => $topicArn,
]);
} catch (AwsException $e) {
if ($e->getAwsErrorCode() !== 'InvalidParameter'
|| ! str_contains($e->getMessage(), 'already exists')) {
throw $e;
}
}

// 4. Set identity notification topics for Bounce/Complaint/Delivery
$eventTypes = array_unique($eventTypes);
foreach ($eventTypes as $eventType) {
$identity = config('services.ses.identity', config('mail.from.address'));

$sesClient->setIdentityNotificationTopic([
'Identity' => $identity,
'NotificationType' => $eventType,
'SnsTopic' => $topicArn,
]);

$sesClient->setIdentityHeadersInNotificationsEnabled([
'Identity' => $identity,
'NotificationType' => $eventType,
'Enabled' => true,
]);
}

// 5. Register SNS as the event destination (remove existing first to avoid duplicates)
$eventDestinationName = $configurationSet.'-sns';
try {
$sesClient->deleteConfigurationSetEventDestination([
'ConfigurationSetName' => $configurationSet,
'EventDestinationName' => $eventDestinationName,
]);
} catch (AwsException $e) {
if ($e->getAwsErrorCode() !== 'EventDestinationDoesNotExist') {
throw $e;
}
}

$sesClient->createConfigurationSetEventDestination([
'ConfigurationSetName' => $configurationSet,
'EventDestination' => [
'Enabled' => true,
'Name' => $eventDestinationName,
'MatchingEventTypes' => $events,
'SNSDestination' => [
'TopicARN' => $topicArn,
],
],
]);

// 6. Subscribe to the topic
$webhookUrl = URL::signedRoute('mails.webhook', ['provider' => Provider::SES]);
$scheme = config('services.ses.scheme', 'https');
$snsClient->subscribe([
'Endpoint' => $webhookUrl,
'TopicArn' => $topicArn,
'Protocol' => $scheme,
]);

} catch (\Throwable $e) {
report($e);
$components->warn('Failed to create SES webhook');
$components->error($e->getMessage());

return;
}

$components->info('Created SES Webhooks for: '.implode(', ', $events));
}

public function verifyWebhookSignature(array $payload): bool
{
if (app()->runningUnitTests()) {
return true;
}

$message = Message::fromRawPostData();

$validator = new MessageValidator(function ($url) {
return Http::timeout(10)->get($url)->body();
});

try {
$validator->validate($message);
} catch (\Throwable $e) {
report($e);

return false;
}

if ($message['Type'] === 'SubscriptionConfirmation') {
Http::timeout(10)->get($message['SubscribeURL'])->throw();
}

return true;
}

public function attachUuidToMail(MessageSending $event, string $uuid): MessageSending
{
$event->message->getHeaders()->addTextHeader($this->uuidHeaderName, $uuid);

return $event;
}

protected function parseSnsMessage(array $payload): array
{
if (isset($payload['Message']) && is_string($payload['Message'])) {
return json_decode($payload['Message'], true) ?? [];
}

return $payload;
}

public function getUuidFromPayload(array $payload): ?string
{
$sesMessage = $this->parseSnsMessage($payload);
$headers = $sesMessage['mail']['headers'] ?? [];
$header = Arr::first($headers, function ($header) {
return $header['name'] === config('mails.headers.uuid');
});

return $header['value'] ?? null;
}

protected function getTimestampFromPayload(array $payload): string
{
foreach (['click', 'open', 'bounce', 'complaint', 'delivery', 'mail'] as $event) {
if (isset($payload[$event]['timestamp'])) {
return $payload[$event]['timestamp'];
}
}

return $payload['Timestamp'] ?? now()->toIso8601String();
}

public function getDataFromPayload(array $payload): array
{
$sesMessage = $this->parseSnsMessage($payload);

$data = [];
foreach ($this->dataMapping() as $key => $paths) {
foreach ($paths as $path) {
$value = data_get($sesMessage, $path);
if ($value !== null) {
$data[$key] = is_array($value) ? json_encode($value) : $value;
break;
}
}
}

return array_merge($data, [
'payload' => $payload,
'type' => $this->getEventFromPayload($sesMessage),
'occurred_at' => $this->getTimestampFromPayload($sesMessage),
]);
}

public function eventMapping(): array
{
return [
EventType::ACCEPTED->value => ['eventType' => 'Send'],
EventType::CLICKED->value => ['eventType' => 'Click'],
EventType::COMPLAINED->value => ['eventType' => 'Complaint'],
EventType::DELIVERED->value => ['eventType' => 'Delivery'],
EventType::OPENED->value => ['eventType' => 'Open'],
EventType::HARD_BOUNCED->value => ['eventType' => 'Bounce', 'bounce.bounceType' => 'Permanent'],
EventType::SOFT_BOUNCED->value => ['eventType' => 'Bounce', 'bounce.bounceType' => 'Temporary'],
];
}

public function dataMapping(): array
{
return [
'ip_address' => ['click.ipAddress', 'open.ipAddress'],
'browser' => ['mail.client-info.client-name'],
'user_agent' => ['click.userAgent', 'open.userAgent', 'complaint.userAgent'],
'link' => ['click.link'],
'tag' => ['click.linkTags'],
];
}

protected function createSnsClient(array $config): SnsClient
{
$config = array_merge(
[
'version' => 'latest',
],
$config
);

return new SnsClient($this->addSnsCredentials($config));
}

protected function addSnsCredentials(array $config): array
{
if (! empty($config['key']) && ! empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret']);

if (! empty($config['token'])) {
$config['credentials']['token'] = $config['token'];
}
}

return Arr::except($config, ['token']);
}

public function unsuppressEmailAddress(string $address, ?int $stream_id = null): Response
{
$mailer = Mail::driver('ses');
/** @var SesTransport $sesTransport */
$sesTransport = $mailer->getSymfonyTransport();
$sesClient = $sesTransport->ses();

try {
$sesClient->deleteSuppressedDestination([
'EmailAddress' => $address,
]);

return Http::response(null, 200);
} catch (\Throwable $e) {
report($e);

return Http::response(['error' => $e->getMessage()], 400);
}
}
}
1 change: 1 addition & 0 deletions src/Enums/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ enum Provider: string
case POSTMARK = 'postmark';
case MAILGUN = 'mailgun';
case RESEND = 'resend';
case SES = 'ses';
}
Loading