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) { |
||
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) { |
||
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) { |
||
0 ignored issues
–
show
introduced
by
Loading history...
|
|||
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; |
||
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 |