Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Add new mailer transport for AWS SES V2 API #45977

Merged
merged 3 commits into from
Feb 8, 2023
Merged
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
27 changes: 26 additions & 1 deletion src/Illuminate/Mail/MailManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
namespace Illuminate\Mail;

use Aws\Ses\SesClient;
use Aws\SesV2\SesV2Client;
use Closure;
use Illuminate\Contracts\Mail\Factory as FactoryContract;
use Illuminate\Log\LogManager;
use Illuminate\Mail\Transport\ArrayTransport;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Mail\Transport\SesTransport;
use Illuminate\Mail\Transport\SesV2Transport;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
Expand Down Expand Up @@ -154,7 +156,8 @@ public function createSymfonyTransport(array $config)
return call_user_func($this->customCreators[$transport], $config);
}

if (trim($transport ?? '') === '' || ! method_exists($this, $method = 'create'.ucfirst($transport).'Transport')) {
if (trim($transport ?? '') === '' ||
! method_exists($this, $method = 'create'.ucfirst(Str::camel($transport)).'Transport')) {
throw new InvalidArgumentException("Unsupported mail transport [{$transport}].");
}

Expand Down Expand Up @@ -250,6 +253,28 @@ protected function createSesTransport(array $config)
);
}

/**
* Create an instance of the Symfony Amazon SES V2 Transport driver.
*
* @param array $config
* @return \Illuminate\Mail\Transport\Se2VwTransport
*/
protected function createSesV2Transport(array $config)
{
$config = array_merge(
$this->app['config']->get('services.ses', []),
['version' => 'latest'],
$config
);

$config = Arr::except($config, ['transport']);

return new SesV2Transport(
new SesV2Client($this->addSesCredentials($config)),
$config['options'] ?? []
);
}

/**
* Add the SES credentials to the configuration array.
*
Expand Down
20 changes: 10 additions & 10 deletions src/Illuminate/Mail/Transport/SesTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,6 @@ protected function doSend(SentMessage $message): void
$message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId);
}

/**
* Get the string representation of the transport.
*
* @return string
*/
public function __toString(): string
{
return 'ses';
}

/**
* Get the Amazon SES client for the SesTransport instance.
*
Expand Down Expand Up @@ -128,4 +118,14 @@ public function setOptions(array $options)
{
return $this->options = $options;
}

/**
* Get the string representation of the transport.
*
* @return string
*/
public function __toString(): string
{
return 'ses';
}
}
135 changes: 135 additions & 0 deletions src/Illuminate/Mail/Transport/SesV2Transport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace Illuminate\Mail\Transport;

use Aws\Exception\AwsException;
use Aws\SesV2\SesV2Client;
use Exception;
use Symfony\Component\Mailer\Header\MetadataHeader;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Message;

class SesV2Transport extends AbstractTransport
{
/**
* The Amazon SES V2 instance.
*
* @var \Aws\SesV2\SesV2Client
*/
protected $ses;

/**
* The Amazon SES transmission options.
*
* @var array
*/
protected $options = [];

/**
* Create a new SES V2 transport instance.
*
* @param \Aws\SesV2\SesV2Client $ses
* @param array $options
* @return void
*/
public function __construct(SesV2Client $ses, $options = [])
{
$this->ses = $ses;
$this->options = $options;

parent::__construct();
}

/**
* {@inheritDoc}
*/
protected function doSend(SentMessage $message): void
{
$options = $this->options;

if ($message->getOriginalMessage() instanceof Message) {
foreach ($message->getOriginalMessage()->getHeaders()->all() as $header) {
if ($header instanceof MetadataHeader) {
$options['Tags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()];
}
}
}

try {
$result = $this->ses->sendEmail(
array_merge(
$options, [
'ReplyToAddresses' => [$message->getEnvelope()->getSender()->toString()],
'Destination' => [
'ToAddresses' => collect($message->getEnvelope()->getRecipients())
->map
->toString()
->values()
->all(),
],
'Content' => [
'Raw' => [
'Data' => $message->toString(),
],
],
]
)
);
} catch (AwsException $e) {
$reason = $e->getAwsErrorMessage() ?? $e->getMessage();

throw new Exception(
sprintf('Request to AWS SES V2 API failed. Reason: %s.', $reason),
is_int($e->getCode()) ? $e->getCode() : 0,
$e
);
}

$messageId = $result->get('MessageId');

$message->getOriginalMessage()->getHeaders()->addHeader('X-Message-ID', $messageId);
$message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId);
}

/**
* Get the Amazon SES V2 client for the SesV2Transport instance.
*
* @return \Aws\SesV2\SesV2Client
*/
public function ses()
{
return $this->ses;
}

/**
* Get the transmission options being used by the transport.
*
* @return array
*/
public function getOptions()
{
return $this->options;
}

/**
* Set the transmission options being used by the transport.
*
* @param array $options
* @return array
*/
public function setOptions(array $options)
{
return $this->options = $options;
}

/**
* Get the string representation of the transport.
*
* @return string
*/
public function __toString(): string
{
return 'ses-v2';
}
}
130 changes: 130 additions & 0 deletions tests/Mail/MailSesV2TransportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

namespace Illuminate\Tests\Mail;

use Aws\SesV2\SesV2Client;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Mail\MailManager;
use Illuminate\Mail\Transport\SesV2Transport;
use Illuminate\View\Factory;
use Mockery as m;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Header\MetadataHeader;
use Symfony\Component\Mime\Email;

class MailSesV2TransportTest extends TestCase
{
protected function tearDown(): void
{
m::close();

parent::tearDown();
}

public function testGetTransport()
{
$container = new Container;

$container->singleton('config', function () {
return new Repository([
'services.ses' => [
'key' => 'foo',
'secret' => 'bar',
'region' => 'us-east-1',
],
]);
});

$manager = new MailManager($container);

/** @var \Illuminate\Mail\Transport\SesV2Transport $transport */
$transport = $manager->createSymfonyTransport(['transport' => 'ses-v2']);

$ses = $transport->ses();

$this->assertSame('us-east-1', $ses->getRegion());

$this->assertSame('ses-v2', (string) $transport);
}

public function testSend()
{
$message = new Email();
$message->subject('Foo subject');
$message->text('Bar body');
$message->sender('myself@example.com');
$message->to('me@example.com');
$message->bcc('you@example.com');
$message->getHeaders()->add(new MetadataHeader('FooTag', 'TagValue'));

$client = m::mock(SesV2Client::class);
$sesResult = m::mock();
$sesResult->shouldReceive('get')
->with('MessageId')
->once()
->andReturn('ses-message-id');
$client->shouldReceive('sendEmail')->once()
->with(m::on(function ($arg) {
return count($arg['ReplyToAddresses']) === 1 &&
$arg['ReplyToAddresses'][0] === 'myself@example.com' &&
$arg['Destination']['ToAddresses'] === ['me@example.com', 'you@example.com'] &&
$arg['Tags'] === [['Name' => 'FooTag', 'Value' => 'TagValue']];
}))
->andReturn($sesResult);

(new SesV2Transport($client))->send($message);
}

public function testSesV2LocalConfiguration()
{
$container = new Container;

$container->singleton('config', function () {
return new Repository([
'mail' => [
'mailers' => [
'ses' => [
'transport' => 'ses-v2',
'region' => 'eu-west-1',
'options' => [
'ConfigurationSetName' => 'Laravel',
'Tags' => [
['Name' => 'Laravel', 'Value' => 'Framework'],
],
],
],
],
],
'services' => [
'ses' => [
'region' => 'us-east-1',
],
],
]);
});

$container->instance('view', $this->createMock(Factory::class));

$container->bind('events', function () {
return null;
});

$manager = new MailManager($container);

/** @var \Illuminate\Mail\Mailer $mailer */
$mailer = $manager->mailer('ses');

/** @var \Illuminate\Mail\Transport\SesV2Transport $transport */
$transport = $mailer->getSymfonyTransport();

$this->assertSame('eu-west-1', $transport->ses()->getRegion());

$this->assertSame([
'ConfigurationSetName' => 'Laravel',
'Tags' => [
['Name' => 'Laravel', 'Value' => 'Framework'],
],
], $transport->getOptions());
}
}