Issues (3627)

Swiftmailer/Transport/AmazonApiTransport.php (3 issues)

1
<?php
2
3
/*
4
 * @copyright   2020 Mautic Contributors. All rights reserved
5
 * @author      Mautic
6
 *
7
 * @link        http://mautic.org
8
 *
9
 * @license     GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
10
 *
11
 */
12
13
namespace Mautic\EmailBundle\Swiftmailer\Transport;
14
15
use Aws\CommandPool;
16
use Aws\Credentials\Credentials;
17
use Aws\Exception\AwsException;
18
use Aws\ResultInterface;
19
use Aws\SesV2\Exception\SesV2Exception;
20
use Aws\SesV2\SesV2Client;
21
use Mautic\EmailBundle\Helper\MailHelper;
22
use Mautic\EmailBundle\Helper\PlainTextMessageHelper;
23
use Mautic\EmailBundle\Swiftmailer\Amazon\AmazonCallback;
24
use Psr\Log\LoggerInterface;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\Translation\TranslatorInterface;
27
28
class AmazonApiTransport extends AbstractTokenArrayTransport implements \Swift_Transport, TokenTransportInterface, CallbackTransportInterface
29
{
30
    /**
31
     * @var string|null
32
     */
33
    private $region;
34
35
    /**
36
     * @var string|null
37
     */
38
    private $username;
39
40
    /**
41
     * @var string|null
42
     */
43
    private $password;
44
45
    /**
46
     * @var TranslatorInterface
47
     */
48
    private $translator;
49
50
    /**
51
     * @var LoggerInterface
52
     */
53
    private $logger;
54
55
    /**
56
     * @var SesV2Client
57
     */
58
    private $amazonClient;
59
60
    /**
61
     * @var AmazonCallback
62
     */
63
    private $amazonCallback;
64
65
    /**
66
     * @var int
67
     */
68
    private $concurrency;
69
70
    /**
71
     * @var Aws\CommandInterface | Psr\Http\Message\RequestInterface
72
     */
73
    private $handler;
74
75
    /**
76
     * @var bool
77
     */
78
    private $debug = false;
79
80
    /**
81
     * @param string $region
82
     * @param string $otherRegion
83
     */
84
    public function setRegion($region, $otherRegion = null)
85
    {
86
        $this->region = ('other' === $region) ? $otherRegion : $region;
87
    }
88
89
    /**
90
     * @return string|null
91
     */
92
    public function getRegion()
93
    {
94
        return $this->region;
95
    }
96
97
    /**
98
     * @param $username
99
     */
100
    public function setUsername($username)
101
    {
102
        $this->username = $username;
103
    }
104
105
    /**
106
     * @return string|null
107
     */
108
    public function getUsername()
109
    {
110
        return $this->username;
111
    }
112
113
    /**
114
     * @param $password
115
     */
116
    public function setPassword($password)
117
    {
118
        $this->password = $password;
119
    }
120
121
    /**
122
     * @return string|null
123
     */
124
    public function getPassword()
125
    {
126
        return $this->password;
127
    }
128
129
    /**
130
     * @param $handler
131
     */
132
    public function setHandler($handler)
133
    {
134
        $this->handler = $handler;
135
    }
136
137
    /**
138
     * @return object|null
139
     */
140
    public function getHandler()
141
    {
142
        return $this->handler;
143
    }
144
145
    /**
146
     * @param $debug
147
     */
148
    public function setDebug($debug)
149
    {
150
        $this->debug = $debug;
151
    }
152
153
    /**
154
     * @return bool|null
155
     */
156
    public function getDebug()
157
    {
158
        return $this->debug;
159
    }
160
161
    public function __construct(
162
        TranslatorInterface $translator,
163
        AmazonCallback $amazonCallback,
164
        LoggerInterface $logger
165
    ) {
166
        $this->amazonCallback    = $amazonCallback;
167
        $this->translator        = $translator;
168
        $this->logger            = $logger;
169
    }
170
171
    public function start()
172
    {
173
        if (empty($this->region) || empty($this->username) || empty($this->password)) {
174
            $this->throwException($this->translator->trans('mautic.email.api_key_required', [], 'validators'));
175
        }
176
177
        if (!$this->started) {
178
            $this->amazonClient  = $this->createAmazonClient();
179
180
            $account             = $this->amazonClient->getAccount();
181
            $emailQuotaRemaining = $account->get('SendQuota')['Max24HourSend'] - $account->get('SendQuota')['SentLast24Hours'];
182
183
            if (!$account->get('SendingEnabled')) {
184
                $this->logger->error('Your AWS SES is not enabled for sending');
185
                throw new \Exception('Your AWS SES is not enabled for sending');
186
            }
187
188
            if (!$account->get('ProductionAccessEnabled')) {
189
                $this->logger->info('Your AWS SES is in sandbox mode, consider moving it to production state');
190
            }
191
192
            if ($emailQuotaRemaining <= 0) {
193
                $this->logger->error('Your AWS SES quota is currently exceeded, used '.$account->get('SentLast24Hours').' of '.$account->get('Max24HourSend'));
194
                throw new \Exception('Your AWS SES quota is currently exceeded');
195
            }
196
197
            $this->concurrency   = floor($account->get('SendQuota')['MaxSendRate']);
198
199
            $this->started = true;
200
        }
201
    }
202
203
    public function createAmazonClient()
204
    {
205
        $config  = [
206
            'version'     => '2019-09-27',
207
            'region'      => $this->region,
208
            'credentials' => new Credentials(
209
                $this->username,
210
                $this->password
211
            ),
212
        ];
213
214
        if ($this->handler) {
215
            $config['handler'] = $this->handler;
216
        }
217
218
        if ($this->debug) {
219
            $config['debug'] = [
220
                'logfn' => function ($msg) {
221
                    $this->logger->debug($msg);
222
                },
223
                'http'        => true,
224
                'stream_size' => '0',
225
            ];
226
        }
227
228
        return new SesV2Client($config);
229
    }
230
231
    /**
232
     * @param null $failedRecipients
233
     *
234
     * @return int Number of messages sent
235
     *
236
     * @throws \Exception
237
     */
238
    public function send(\Swift_Mime_SimpleMessage $toSendMessage, &$failedRecipients = null)
239
    {
240
        $this->message    = $toSendMessage;
241
        $failedRecipients = (array) $failedRecipients;
242
243
        if ($evt = $this->getDispatcher()->createSendEvent($this, $toSendMessage)) {
244
            $this->getDispatcher()->dispatchEvent($evt, 'beforeSendPerformed');
245
            if ($evt->bubbleCancelled()) {
246
                return 0;
247
            }
248
        }
249
        $count = $this->getBatchRecipientCount($toSendMessage);
250
251
        try {
252
            $this->start();
253
            $commands = [];
254
            foreach ($this->getAmazonMessage($toSendMessage) as $rawEmail) {
255
                $commands[] = $this->amazonClient->getCommand('sendEmail', $rawEmail);
256
            }
257
            $pool = new CommandPool($this->amazonClient, $commands, [
258
                'concurrency' => $this->concurrency,
259
                'fulfilled'   => function (ResultInterface $result, $iteratorId) use ($evt, $failedRecipients) {
260
                    if ($evt) {
0 ignored issues
show
$evt is of type Swift_Events_SendEvent, thus it always evaluated to true.
Loading history...
261
                        // $this->logger->info("SES Result: " . $result->get('MessageId'));
262
                        $evt->setResult(\Swift_Events_SendEvent::RESULT_SUCCESS);
263
                        $evt->setFailedRecipients($failedRecipients);
264
                        $this->getDispatcher()->dispatchEvent($evt, 'sendPerformed');
265
                    }
266
                },
267
                'rejected' => function (AwsException $reason, $iteratorId) use ($evt) {
0 ignored issues
show
The parameter $iteratorId is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

267
                'rejected' => function (AwsException $reason, /** @scrutinizer ignore-unused */ $iteratorId) use ($evt) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
268
                    $failedRecipients = [];
269
                    $this->triggerSendError($evt, $failedRecipients, $reason->getAwsErrorMessage());
270
                },
271
            ]);
272
            $promise = $pool->promise();
273
            $promise->wait();
274
275
            return count($commands);
276
        } catch (SesV2Exception $e) {
277
            $this->triggerSendError($evt, $failedRecipients, $e->getMessage());
278
            $this->throwException($e->getMessage());
279
        } catch (\Exception $e) {
280
            $this->triggerSendError($evt, $failedRecipients, $e->getMessage());
281
            $this->throwException($e->getMessage());
282
        }
283
284
        return 1;
285
    }
286
287
    private function triggerSendError(\Swift_Events_SendEvent $evt, &$failedRecipients, $reason)
288
    {
289
        $this->logger->error('SES API Error: '.$reason);
290
291
        $failedRecipients = array_merge(
292
            $failedRecipients,
293
            array_keys((array) $this->message->getTo()),
294
            array_keys((array) $this->message->getCc()),
295
            array_keys((array) $this->message->getBcc())
296
        );
297
298
        if ($evt) {
299
            $evt->setResult(\Swift_Events_SendEvent::RESULT_FAILED);
300
            $evt->setFailedRecipients($failedRecipients);
301
            $this->getDispatcher()->dispatchEvent($evt, 'sendPerformed');
302
        }
303
    }
304
305
    /*
306
     * @return array Amazon Send Message
307
     *
308
     * @throws \Exception
309
     */
310
    public function getAmazonMessage(\Swift_Mime_SimpleMessage $message)
311
    {
312
        /**
313
         * Three ways to send the email
314
         * Simple:  A standard email message. When you create this type of message, you specify the sender, the recipient, and the message body, and Amazon SES assembles the message for you.
315
         * Raw : A raw, MIME-formatted email message. When you send this type of email, you have to specify all of the message headers, as well as the message body. You can use this message type to send messages that contain attachments. The message that you specify has to be a valid MIME message.
316
         * Template: A message that contains personalization tags. When you send this type of email, Amazon SES API v2 automatically replaces the tags with values that you specify.
317
         *
318
         * In Mautic we need to use RAW all the time because we inject custom headers all the time, so templates and simple are not useful
319
         * If in the future AWS allow custom headers in templates or simple emails we should consider them
320
         *
321
         * Since we are using SES RAW method, we need to create a seperate message each time we send out an email
322
         * In case SES changes their API this is the only function that needs to be changed to accomrdate sending a template
323
         */
324
        $this->message    = $message;
0 ignored issues
show
Documentation Bug introduced by
$message is of type Swift_Mime_SimpleMessage, but the property $message was declared to be of type Swift_Message. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
325
        $metadata         = $this->getMetadata();
326
        $emailBody        = $this->message->getBody();
327
        $emailSubject     = $this->message->getSubject();
328
        $emailText        = PlainTextMessageHelper::getPlainTextFromMessage($message);
329
        $sesArray         = [];
330
331
        if (empty($metadata)) {
332
            /**
333
             * This is a queued message, all the information are included
334
             * in the $message object
335
             * just construct the $sesArray.
336
             */
337
            $from      = $message->getFrom();
338
            $fromEmail = current(array_keys($from));
339
            $fromName  = $from[$fromEmail];
340
341
            $sesArray['FromEmailAddress'] =  (!empty($fromName)) ? $fromName.' <'.$fromEmail.'>' : $fromEmail;
342
            $to                           = $message->getTo();
343
            if (!empty($to)) {
344
                $sesArray['Destination']['ToAddresses'] = array_keys($to);
345
            }
346
347
            $cc = $message->getCc();
348
            if (!empty($cc)) {
349
                $sesArray['Destination']['CcAddresses'] = array_keys($cc);
350
            }
351
            $bcc = $message->getBcc();
352
            if (!empty($bcc)) {
353
                $sesArray['Destination']['BccAddresses'] = array_keys($bcc);
354
            }
355
            $replyTo = $message->getReplyTo();
356
            if (!empty($replyTo)) {
357
                $sesArray['ReplyToAddresses'] = [key($replyTo)];
358
            }
359
            $headers            = $message->getHeaders();
360
            if ($headers->has('X-SES-CONFIGURATION-SET')) {
361
                $sesArray['ConfigurationSetName'] = $headers->get('X-SES-CONFIGURATION-SET');
362
            }
363
364
            $sesArray['Content']['Raw']['Data'] = $message->toString();
365
            yield $sesArray;
366
        } else {
367
            /**
368
             * This is a message with tokens.
369
             */
370
            $mauticTokens  = [];
371
            $metadataSet   = reset($metadata);
372
            $tokens        = (!empty($metadataSet['tokens'])) ? $metadataSet['tokens'] : [];
373
            $mauticTokens  = array_keys($tokens);
374
            foreach ($metadata as $recipient => $mailData) {
375
                // Reset the parts of the email that has tokens
376
                $this->message->setSubject($emailSubject);
377
                $this->message->setBody($emailBody);
378
                $this->setPlainTextToMessage($this->message, $emailText);
379
                // Convert the message to array to get the values
380
                $tokenizedMessage                                = $this->messageToArray($mauticTokens, $mailData['tokens'], false);
381
                $toSendMessage                                   = (new \Swift_Message());
382
                $toSendMessage->setSubject($tokenizedMessage['subject']);
383
                $toSendMessage->setFrom([$tokenizedMessage['from']['email'] => $tokenizedMessage['from']['name']]);
384
                $sesArray['FromEmailAddress'] =  (!empty($tokenizedMessage['from']['name'])) ? $tokenizedMessage['from']['name'].' <'.$tokenizedMessage['from']['email'].'>' : $tokenizedMessage['from']['email'];
385
                $toSendMessage->setTo([$recipient]);
386
                $sesArray['Destination']['ToAddresses'] = [$recipient];
387
                if (isset($tokenizedMessage['text']) && strlen($tokenizedMessage['text']) > 0) {
388
                    $toSendMessage->addPart($tokenizedMessage['text'], 'text/plain');
389
                }
390
391
                if (isset($tokenizedMessage['html']) && strlen($tokenizedMessage['html']) > 0) {
392
                    $toSendMessage->addPart($tokenizedMessage['html'], 'text/html');
393
                }
394
                if (isset($tokenizedMessage['headers'])) {
395
                    $headers = $toSendMessage->getHeaders();
396
                    foreach ($tokenizedMessage['headers'] as $key => $value) {
397
                        $headers->addTextHeader($key, $value);
398
                    }
399
                }
400
401
                if (count($tokenizedMessage['recipients']['cc']) > 0) {
402
                    $cc = array_keys($tokenizedMessage['recipients']['cc']);
403
                    $toSendMessage->setCc($cc);
404
                    $sesArray['Destination']['CcAddresses'] = $cc;
405
                }
406
407
                if (count($tokenizedMessage['recipients']['bcc']) > 0) {
408
                    $bcc = array_keys($tokenizedMessage['recipients']['bcc']);
409
                    $toSendMessage->setBcc($bcc);
410
                    $sesArray['Destination']['BccAddresses'] = $bcc;
411
                }
412
413
                if (isset($tokenizedMessage['replyTo'])) {
414
                    $toSendMessage->setReplyTo([$tokenizedMessage['replyTo']['email']]);
415
                    $sesArray['ReplyToAddresses'] = [$tokenizedMessage['replyTo']['email']];
416
                }
417
                if (isset($tokenizedMessage['headers']['X-SES-CONFIGURATION-SET'])) {
418
                    $sesArray['ConfigurationSetName'] = $tokenizedMessage['headers']['X-SES-CONFIGURATION-SET'];
419
                }
420
421
                if (count($tokenizedMessage['file_attachments']) > 0) {
422
                    foreach ($tokenizedMessage['file_attachments'] as $attachment) {
423
                        $fileAttach = \Swift_Attachment::fromPath($attachment['filePath']);
424
                        $fileAttach->setFilename($attachment['fileName']);
425
                        $fileAttach->setContentType($attachment['contentType']);
426
                        $toSendMessage->attach($fileAttach);
427
                    }
428
                }
429
                if (count($tokenizedMessage['binary_attachments']) > 0) {
430
                    foreach ($tokenizedMessage['binary_attachments'] as $attachment) {
431
                        $fileAttach = new \Swift_Attachment($attachment['content'], $attachment['name'], $attachment['type']);
432
                        $toSendMessage->attach($fileAttach);
433
                    }
434
                }
435
                $sesArray['Content']['Raw']['Data'] = $toSendMessage->toString();
436
                yield $sesArray;
437
            }
438
        }
439
    }
440
441
    /**
442
     * Set plain text to a message.
443
     *
444
     * @return bool
445
     */
446
    private function setPlainTextToMessage(\Swift_Mime_SimpleMessage $message, $text)
447
    {
448
        $children = (array) $message->getChildren();
449
450
        foreach ($children as $child) {
451
            $childType = $child->getContentType();
452
            if ('text/plain' === $childType && $child instanceof \Swift_MimePart) {
453
                $child->setBody($text);
454
            }
455
        }
456
    }
457
458
    /**
459
     * @return int
460
     */
461
    public function getMaxBatchLimit()
462
    {
463
        return 0;
464
    }
465
466
    /**
467
     * @param int    $toBeAdded
468
     * @param string $type
469
     */
470
    public function getBatchRecipientCount(\Swift_Message $toSendMessage, $toBeAdded = 1, $type = 'to'): int
471
    {
472
        // These getters could return null
473
        $toCount  = $toSendMessage->getTo() ? count($toSendMessage->getTo()) : 0;
474
        $ccCount  = $toSendMessage->getCc() ? count($toSendMessage->getCc()) : 0;
475
        $bccCount = $toSendMessage->getBcc() ? count($toSendMessage->getBcc()) : 0;
476
477
        return $toCount + $ccCount + $bccCount + $toBeAdded;
478
    }
479
480
    /**
481
     * Returns a "transport" string to match the URL path /mailer/{transport}/callback.
482
     *
483
     * @return mixed
484
     */
485
    public function getCallbackPath()
486
    {
487
        return 'amazon_api';
488
    }
489
490
    /**
491
     * Handle response.
492
     */
493
    public function processCallbackRequest(Request $request)
494
    {
495
        $this->amazonCallback->processCallbackRequest($request);
496
    }
497
498
    /**
499
     * @return bool
500
     */
501
    public function ping()
502
    {
503
        return true;
504
    }
505
}
506