Skip to content

Commit 2248188

Browse files
[9.x] Add new mailer transport for AWS SES V2 API (#45977)
* feat(mailer): add AWS SES V2 transport * fix: code style * formatting --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent 08675c1 commit 2248188

File tree

4 files changed

+301
-11
lines changed

4 files changed

+301
-11
lines changed

src/Illuminate/Mail/MailManager.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
namespace Illuminate\Mail;
44

55
use Aws\Ses\SesClient;
6+
use Aws\SesV2\SesV2Client;
67
use Closure;
78
use Illuminate\Contracts\Mail\Factory as FactoryContract;
89
use Illuminate\Log\LogManager;
910
use Illuminate\Mail\Transport\ArrayTransport;
1011
use Illuminate\Mail\Transport\LogTransport;
1112
use Illuminate\Mail\Transport\SesTransport;
13+
use Illuminate\Mail\Transport\SesV2Transport;
1214
use Illuminate\Support\Arr;
1315
use Illuminate\Support\Str;
1416
use InvalidArgumentException;
@@ -154,7 +156,8 @@ public function createSymfonyTransport(array $config)
154156
return call_user_func($this->customCreators[$transport], $config);
155157
}
156158

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

@@ -250,6 +253,28 @@ protected function createSesTransport(array $config)
250253
);
251254
}
252255

256+
/**
257+
* Create an instance of the Symfony Amazon SES V2 Transport driver.
258+
*
259+
* @param array $config
260+
* @return \Illuminate\Mail\Transport\Se2VwTransport
261+
*/
262+
protected function createSesV2Transport(array $config)
263+
{
264+
$config = array_merge(
265+
$this->app['config']->get('services.ses', []),
266+
['version' => 'latest'],
267+
$config
268+
);
269+
270+
$config = Arr::except($config, ['transport']);
271+
272+
return new SesV2Transport(
273+
new SesV2Client($this->addSesCredentials($config)),
274+
$config['options'] ?? []
275+
);
276+
}
277+
253278
/**
254279
* Add the SES credentials to the configuration array.
255280
*

src/Illuminate/Mail/Transport/SesTransport.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,6 @@ protected function doSend(SentMessage $message): void
8888
$message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId);
8989
}
9090

91-
/**
92-
* Get the string representation of the transport.
93-
*
94-
* @return string
95-
*/
96-
public function __toString(): string
97-
{
98-
return 'ses';
99-
}
100-
10191
/**
10292
* Get the Amazon SES client for the SesTransport instance.
10393
*
@@ -128,4 +118,14 @@ public function setOptions(array $options)
128118
{
129119
return $this->options = $options;
130120
}
121+
122+
/**
123+
* Get the string representation of the transport.
124+
*
125+
* @return string
126+
*/
127+
public function __toString(): string
128+
{
129+
return 'ses';
130+
}
131131
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace Illuminate\Mail\Transport;
4+
5+
use Aws\Exception\AwsException;
6+
use Aws\SesV2\SesV2Client;
7+
use Exception;
8+
use Symfony\Component\Mailer\Header\MetadataHeader;
9+
use Symfony\Component\Mailer\SentMessage;
10+
use Symfony\Component\Mailer\Transport\AbstractTransport;
11+
use Symfony\Component\Mime\Message;
12+
13+
class SesV2Transport extends AbstractTransport
14+
{
15+
/**
16+
* The Amazon SES V2 instance.
17+
*
18+
* @var \Aws\SesV2\SesV2Client
19+
*/
20+
protected $ses;
21+
22+
/**
23+
* The Amazon SES transmission options.
24+
*
25+
* @var array
26+
*/
27+
protected $options = [];
28+
29+
/**
30+
* Create a new SES V2 transport instance.
31+
*
32+
* @param \Aws\SesV2\SesV2Client $ses
33+
* @param array $options
34+
* @return void
35+
*/
36+
public function __construct(SesV2Client $ses, $options = [])
37+
{
38+
$this->ses = $ses;
39+
$this->options = $options;
40+
41+
parent::__construct();
42+
}
43+
44+
/**
45+
* {@inheritDoc}
46+
*/
47+
protected function doSend(SentMessage $message): void
48+
{
49+
$options = $this->options;
50+
51+
if ($message->getOriginalMessage() instanceof Message) {
52+
foreach ($message->getOriginalMessage()->getHeaders()->all() as $header) {
53+
if ($header instanceof MetadataHeader) {
54+
$options['Tags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()];
55+
}
56+
}
57+
}
58+
59+
try {
60+
$result = $this->ses->sendEmail(
61+
array_merge(
62+
$options, [
63+
'ReplyToAddresses' => [$message->getEnvelope()->getSender()->toString()],
64+
'Destination' => [
65+
'ToAddresses' => collect($message->getEnvelope()->getRecipients())
66+
->map
67+
->toString()
68+
->values()
69+
->all(),
70+
],
71+
'Content' => [
72+
'Raw' => [
73+
'Data' => $message->toString(),
74+
],
75+
],
76+
]
77+
)
78+
);
79+
} catch (AwsException $e) {
80+
$reason = $e->getAwsErrorMessage() ?? $e->getMessage();
81+
82+
throw new Exception(
83+
sprintf('Request to AWS SES V2 API failed. Reason: %s.', $reason),
84+
is_int($e->getCode()) ? $e->getCode() : 0,
85+
$e
86+
);
87+
}
88+
89+
$messageId = $result->get('MessageId');
90+
91+
$message->getOriginalMessage()->getHeaders()->addHeader('X-Message-ID', $messageId);
92+
$message->getOriginalMessage()->getHeaders()->addHeader('X-SES-Message-ID', $messageId);
93+
}
94+
95+
/**
96+
* Get the Amazon SES V2 client for the SesV2Transport instance.
97+
*
98+
* @return \Aws\SesV2\SesV2Client
99+
*/
100+
public function ses()
101+
{
102+
return $this->ses;
103+
}
104+
105+
/**
106+
* Get the transmission options being used by the transport.
107+
*
108+
* @return array
109+
*/
110+
public function getOptions()
111+
{
112+
return $this->options;
113+
}
114+
115+
/**
116+
* Set the transmission options being used by the transport.
117+
*
118+
* @param array $options
119+
* @return array
120+
*/
121+
public function setOptions(array $options)
122+
{
123+
return $this->options = $options;
124+
}
125+
126+
/**
127+
* Get the string representation of the transport.
128+
*
129+
* @return string
130+
*/
131+
public function __toString(): string
132+
{
133+
return 'ses-v2';
134+
}
135+
}

tests/Mail/MailSesV2TransportTest.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Mail;
4+
5+
use Aws\SesV2\SesV2Client;
6+
use Illuminate\Config\Repository;
7+
use Illuminate\Container\Container;
8+
use Illuminate\Mail\MailManager;
9+
use Illuminate\Mail\Transport\SesV2Transport;
10+
use Illuminate\View\Factory;
11+
use Mockery as m;
12+
use PHPUnit\Framework\TestCase;
13+
use Symfony\Component\Mailer\Header\MetadataHeader;
14+
use Symfony\Component\Mime\Email;
15+
16+
class MailSesV2TransportTest extends TestCase
17+
{
18+
protected function tearDown(): void
19+
{
20+
m::close();
21+
22+
parent::tearDown();
23+
}
24+
25+
public function testGetTransport()
26+
{
27+
$container = new Container;
28+
29+
$container->singleton('config', function () {
30+
return new Repository([
31+
'services.ses' => [
32+
'key' => 'foo',
33+
'secret' => 'bar',
34+
'region' => 'us-east-1',
35+
],
36+
]);
37+
});
38+
39+
$manager = new MailManager($container);
40+
41+
/** @var \Illuminate\Mail\Transport\SesV2Transport $transport */
42+
$transport = $manager->createSymfonyTransport(['transport' => 'ses-v2']);
43+
44+
$ses = $transport->ses();
45+
46+
$this->assertSame('us-east-1', $ses->getRegion());
47+
48+
$this->assertSame('ses-v2', (string) $transport);
49+
}
50+
51+
public function testSend()
52+
{
53+
$message = new Email();
54+
$message->subject('Foo subject');
55+
$message->text('Bar body');
56+
$message->sender('[email protected]');
57+
$message->to('[email protected]');
58+
$message->bcc('[email protected]');
59+
$message->getHeaders()->add(new MetadataHeader('FooTag', 'TagValue'));
60+
61+
$client = m::mock(SesV2Client::class);
62+
$sesResult = m::mock();
63+
$sesResult->shouldReceive('get')
64+
->with('MessageId')
65+
->once()
66+
->andReturn('ses-message-id');
67+
$client->shouldReceive('sendEmail')->once()
68+
->with(m::on(function ($arg) {
69+
return count($arg['ReplyToAddresses']) === 1 &&
70+
$arg['ReplyToAddresses'][0] === '[email protected]' &&
71+
$arg['Destination']['ToAddresses'] === ['[email protected]', '[email protected]'] &&
72+
$arg['Tags'] === [['Name' => 'FooTag', 'Value' => 'TagValue']];
73+
}))
74+
->andReturn($sesResult);
75+
76+
(new SesV2Transport($client))->send($message);
77+
}
78+
79+
public function testSesV2LocalConfiguration()
80+
{
81+
$container = new Container;
82+
83+
$container->singleton('config', function () {
84+
return new Repository([
85+
'mail' => [
86+
'mailers' => [
87+
'ses' => [
88+
'transport' => 'ses-v2',
89+
'region' => 'eu-west-1',
90+
'options' => [
91+
'ConfigurationSetName' => 'Laravel',
92+
'Tags' => [
93+
['Name' => 'Laravel', 'Value' => 'Framework'],
94+
],
95+
],
96+
],
97+
],
98+
],
99+
'services' => [
100+
'ses' => [
101+
'region' => 'us-east-1',
102+
],
103+
],
104+
]);
105+
});
106+
107+
$container->instance('view', $this->createMock(Factory::class));
108+
109+
$container->bind('events', function () {
110+
return null;
111+
});
112+
113+
$manager = new MailManager($container);
114+
115+
/** @var \Illuminate\Mail\Mailer $mailer */
116+
$mailer = $manager->mailer('ses');
117+
118+
/** @var \Illuminate\Mail\Transport\SesV2Transport $transport */
119+
$transport = $mailer->getSymfonyTransport();
120+
121+
$this->assertSame('eu-west-1', $transport->ses()->getRegion());
122+
123+
$this->assertSame([
124+
'ConfigurationSetName' => 'Laravel',
125+
'Tags' => [
126+
['Name' => 'Laravel', 'Value' => 'Framework'],
127+
],
128+
], $transport->getOptions());
129+
}
130+
}

0 commit comments

Comments
 (0)