Skip to content

Instantly share code, notes, and snippets.

@autoize
Created December 31, 2019 15:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save autoize/ee2fba90941dd39979d7990351e3d46e to your computer and use it in GitHub Desktop.
Save autoize/ee2fba90941dd39979d7990351e3d46e to your computer and use it in GitHub Desktop.
Pull request #6977 for Amazon SES API support in Mautic 2.15.3
diff --git a/app/bundles/EmailBundle/Assets/js/config.js b/app/bundles/EmailBundle/Assets/js/config.js
index f233d59f3f..5951c08143 100644
--- a/app/bundles/EmailBundle/Assets/js/config.js
+++ b/app/bundles/EmailBundle/Assets/js/config.js
@@ -66,6 +66,7 @@ Mautic.testMonitoredEmailServerConnection = function(mailbox) {
Mautic.testEmailServerConnection = function() {
var data = {
amazon_region: mQuery('#config_emailconfig_mailer_amazon_region').val(),
+ amazon_api_region: mQuery('#config_emailconfig_mailer_amazon_api_region').val(),
api_key: mQuery('#config_emailconfig_mailer_api_key').val(),
authMode: mQuery('#config_emailconfig_mailer_auth_mode').val(),
encryption: mQuery('#config_emailconfig_mailer_encryption').val(),
diff --git a/app/bundles/EmailBundle/Config/config.php b/app/bundles/EmailBundle/Config/config.php
index 779b835f5f..5d81013847 100644
--- a/app/bundles/EmailBundle/Config/config.php
+++ b/app/bundles/EmailBundle/Config/config.php
@@ -373,6 +373,21 @@
'setPassword' => ['%mautic.mailer_password%'],
],
],
+ 'mautic.transport.amazon_api' => [
+ 'class' => 'Mautic\EmailBundle\Swiftmailer\Transport\AmazonApiTransport',
+ 'serviceAlias' => 'swiftmailer.mailer.transport.%s',
+ 'arguments' => [
+ 'mautic.http.connector',
+ 'monolog.logger.mautic',
+ 'translator',
+ 'mautic.email.model.transport_callback',
+ ],
+ 'methodCalls' => [
+ 'setUsername' => ['%mautic.mailer_user%'],
+ 'setPassword' => ['%mautic.mailer_password%'],
+ 'setRegion' => ['%mautic.mailer_amazon_api_region%'],
+ ],
+ ],
'mautic.transport.mandrill' => [
'class' => 'Mautic\EmailBundle\Swiftmailer\Transport\MandrillTransport',
'serviceAlias' => 'swiftmailer.mailer.transport.%s',
@@ -769,6 +784,7 @@
'mailer_encryption' => null, //tls or ssl,
'mailer_auth_mode' => null, //plain, login or cram-md5
'mailer_amazon_region' => 'email-smtp.us-east-1.amazonaws.com',
+ 'mailer_amazon_api_region' => 'us-east-1',
'mailer_custom_headers' => [],
'mailer_spool_type' => 'memory', //memory = immediate; file = queue
'mailer_spool_path' => '%kernel.root_dir%/spool',
diff --git a/app/bundles/EmailBundle/Controller/AjaxController.php b/app/bundles/EmailBundle/Controller/AjaxController.php
index 01c60915df..8886d7a10e 100644
--- a/app/bundles/EmailBundle/Controller/AjaxController.php
+++ b/app/bundles/EmailBundle/Controller/AjaxController.php
@@ -223,6 +223,9 @@ protected function testEmailServerConnectionAction(Request $request)
if ('mautic.transport.amazon' == $transport) {
$mailer->setHost($settings['amazon_region']);
}
+ if ('mautic.transport.amazon_api' == $transport) {
+ $mailer->setRegion($settings['amazon_api_region']);
+ }
}
}
diff --git a/app/bundles/EmailBundle/Form/Type/ConfigType.php b/app/bundles/EmailBundle/Form/Type/ConfigType.php
index 16d58d27ac..4cdd279df7 100644
--- a/app/bundles/EmailBundle/Form/Type/ConfigType.php
+++ b/app/bundles/EmailBundle/Form/Type/ConfigType.php
@@ -338,6 +338,27 @@ public function buildForm(FormBuilderInterface $builder, array $options)
]
);
+ $builder->add(
+ 'mailer_amazon_api_region',
+ 'choice',
+ [
+ 'choices' => [
+ 'eu-west-1' => 'mautic.email.config.mailer.amazon_api_region.eu_west_1',
+ 'us-east-1' => 'mautic.email.config.mailer.amazon_api_region.us_east_1',
+ 'us-west-2' => 'mautic.email.config.mailer.amazon_api_region.us_west_2',
+ ],
+ 'label' => 'mautic.email.config.mailer.amazon_api_region',
+ 'required' => false,
+ 'attr' => [
+ 'class' => 'form-control',
+ 'data-show-on' => '{"config_emailconfig_mailer_transport":['.$this->transportType->getAmazonApiService().']}',
+ 'tooltip' => 'mautic.email.config.mailer.amazon_api_region.tooltip',
+ 'onchange' => 'Mautic.disableSendTestEmailButton()',
+ ],
+ 'empty_value' => false,
+ ]
+ );
+
$builder->add(
'mailer_port',
'text',
diff --git a/app/bundles/EmailBundle/Model/TransportType.php b/app/bundles/EmailBundle/Model/TransportType.php
index 0f29c3fdda..ddb2d64eb3 100644
--- a/app/bundles/EmailBundle/Model/TransportType.php
+++ b/app/bundles/EmailBundle/Model/TransportType.php
@@ -17,6 +17,7 @@ class TransportType
*/
private $transportTypes = [
'mautic.transport.amazon' => 'mautic.email.config.mailer_transport.amazon',
+ 'mautic.transport.amazon_api' => 'mautic.email.config.mailer_transport.amazon_api',
'mautic.transport.elasticemail' => 'mautic.email.config.mailer_transport.elasticemail',
'gmail' => 'mautic.email.config.mailer_transport.gmail',
'mautic.transport.mandrill' => 'mautic.email.config.mailer_transport.mandrill',
@@ -52,6 +53,7 @@ class TransportType
'mautic.transport.sendgrid',
'mautic.transport.elasticemail',
'mautic.transport.amazon',
+ 'mautic.transport.amazon_api',
'mautic.transport.postmark',
'gmail',
// smtp is left out on purpose as the auth_mode will manage displaying this field
@@ -65,6 +67,7 @@ class TransportType
'mautic.transport.sendgrid',
'mautic.transport.elasticemail',
'mautic.transport.amazon',
+ 'mautic.transport.amazon_api',
'mautic.transport.postmark',
'gmail',
// smtp is left out on purpose as the auth_mode will manage displaying this field
@@ -204,6 +207,14 @@ public function getAmazonService()
return '"mautic.transport.amazon"';
}
+ /**
+ * @return string
+ */
+ public function getAmazonApiService()
+ {
+ return '"mautic.transport.amazon_api"';
+ }
+
/**
* @return string
*/
diff --git a/app/bundles/EmailBundle/Swiftmailer/Transport/AmazonApiTransport.php b/app/bundles/EmailBundle/Swiftmailer/Transport/AmazonApiTransport.php
new file mode 100644
index 0000000000..9acdfba395
--- /dev/null
+++ b/app/bundles/EmailBundle/Swiftmailer/Transport/AmazonApiTransport.php
@@ -0,0 +1,842 @@
+<?php
+
+/*
+ * @copyright 2014 Mautic Contributors. All rights reserved
+ * @author Mautic
+ *
+ * @link http://mautic.org
+ *
+ * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
+ */
+
+namespace Mautic\EmailBundle\Swiftmailer\Transport;
+
+use Aws\CommandPool;
+use Aws\Credentials\Credentials;
+use Aws\Exception\AwsException;
+use Aws\ResultInterface;
+use Aws\Ses\SesClient;
+use bandwidthThrottle\tokenBucket\BlockingConsumer;
+use bandwidthThrottle\tokenBucket\Rate;
+use bandwidthThrottle\tokenBucket\storage\SingleProcessStorage;
+use bandwidthThrottle\tokenBucket\storage\StorageException;
+use bandwidthThrottle\tokenBucket\TokenBucket;
+use Joomla\Http\Exception\UnexpectedResponseException;
+use Joomla\Http\Http;
+use Mautic\EmailBundle\Model\TransportCallback;
+use Mautic\EmailBundle\MonitoredEmail\Exception\BounceNotFound;
+use Mautic\EmailBundle\MonitoredEmail\Exception\UnsubscriptionNotFound;
+use Mautic\EmailBundle\MonitoredEmail\Message;
+use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\BouncedEmail;
+use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Category;
+use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Type;
+use Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscription\UnsubscribedEmail;
+use Mautic\LeadBundle\Entity\DoNotContact;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Translation\TranslatorInterface;
+
+/**
+ * Class AmazonApiTransport.
+ */
+class AmazonApiTransport extends AbstractTokenArrayTransport implements \Swift_Transport, InterfaceTokenTransport, CallbackTransportInterface, BounceProcessorInterface, UnsubscriptionProcessorInterface
+{
+ /**
+ * From address for SNS email.
+ */
+ const SNS_ADDRESS = 'no-reply@sns.amazonaws.com';
+
+ /**
+ * @var string
+ */
+ private $region;
+
+ /**
+ * @var string
+ */
+ private $username;
+
+ /**
+ * @var string
+ */
+ private $password;
+
+ /**
+ * @var int
+ */
+ private $concurrency;
+
+ /**
+ * @var SesClient
+ */
+ private $client;
+
+ /**
+ * @var Http
+ */
+ private $httpClient;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ /**
+ * @var TranslatorInterface
+ */
+ private $translator;
+
+ /**
+ * @var TransportCallback
+ */
+ private $transportCallback;
+
+ /**
+ * @var BlockingConsumer
+ */
+ private $createTemplateBucketConsumer;
+
+ /**
+ * @var BlockingConsumer
+ */
+ private $sendTemplateBucketConsumer;
+
+ /**
+ * @var array
+ */
+ private $templateCache;
+
+ /**
+ * AmazonApiTransport constructor.
+ *
+ * @param Http $httpClient
+ * @param LoggerInterface $logger
+ * @param TranslatorInterface $translator
+ * @param TransportCallback $transportCallback
+ */
+ public function __construct(Http $httpClient, LoggerInterface $logger, TranslatorInterface $translator, TransportCallback $transportCallback)
+ {
+ $this->logger = $logger;
+ $this->translator = $translator;
+ $this->httpClient = $httpClient;
+ $this->transportCallback = $transportCallback;
+
+ $this->templateCache = [];
+ }
+
+ public function __destruct()
+ {
+ if (count($this->templateCache)) {
+ $this->logger->debug('Deleting SES templates that were created in this session');
+ foreach ($this->templateCache as $templateName) {
+ $this->deleteSesTemplate($templateName);
+ }
+ }
+ }
+
+ /**
+ * @return string $region
+ */
+ public function getRegion()
+ {
+ return $this->region;
+ }
+
+ /**
+ * @param string $region
+ */
+ public function setRegion($region)
+ {
+ $this->region = $region;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getUsername()
+ {
+ return $this->username;
+ }
+
+ /**
+ * @param $username
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getPassword()
+ {
+ return $this->password;
+ }
+
+ /**
+ * @param $password
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password;
+ }
+
+ /**
+ * SES authorization and choice of region
+ * Initializing of TokenBucket.
+ *
+ * @throws \Exception
+ */
+ public function start()
+ {
+ if (!$this->started) {
+ $this->client = new SesClient([
+ 'credentials' => new Credentials(
+ $this->getUsername(),
+ $this->getPassword()
+ ),
+ 'region' => $this->getRegion(),
+ 'version' => '2010-12-01',
+ 'http' => [
+ 'verify' => false,
+ ],
+ ]);
+
+ /**
+ * AWS SES has a limit of how many messages can be sent in a 24h time slot. The remaining messages are calculated
+ * from the api. The transport will fail when the quota is exceeded.
+ */
+ $quota = $this->getSesSendQuota();
+ $this->concurrency = floor($quota->get('MaxSendRate'));
+ $emailQuotaRemaining = $quota->get('Max24HourSend') - $quota->get('SentLast24Hours');
+
+ if ($emailQuotaRemaining <= 0) {
+ $this->logger->error('Your AWS SES quota is currently exceeded, used '.$quota->get('SentLast24Hours').' of '.$quota->get('Max24HourSend'));
+ throw new \Exception('Your AWS SES quota is currently exceeded');
+ }
+
+ /*
+ * initialize throttle token buckets
+ */
+ $this->initializeThrottles();
+
+ $this->started = true;
+ }
+ }
+
+ /**
+ * @param \Swift_Mime_Message $message
+ * @param null $failedRecipients
+ *
+ * @return int count of recipients
+ */
+ public function send(\Swift_Mime_Message $message, &$failedRecipients = null)
+ {
+ $this->message = $message;
+
+ $failedRecipients = (array) $failedRecipients;
+
+ if ($evt = $this->getDispatcher()->createSendEvent($this, $message)) {
+ $this->getDispatcher()->dispatchEvent($evt, 'beforeSendPerformed');
+ if ($evt->bubbleCancelled()) {
+ return 0;
+ }
+ }
+ $count = $this->getBatchRecipientCount($message);
+
+ /*
+ * If there is an attachment, send mail using sendRawEmail method
+ * current sendBulkTemplatedEmail method doesn't support attachments
+ */
+ if (!empty($message->getAttachments())) {
+ return $this->sendRawEmail($message, $evt, $failedRecipients);
+ }
+
+ list($amazonTemplate, $amazonMessage) = $this->constructSesTemplateAndMessage($message);
+
+ try {
+ $this->start();
+
+ $this->createSesTemplate($amazonTemplate);
+
+ $this->sendSesBulkTemplatedEmail($count, $amazonMessage);
+
+ if ($evt) {
+ $evt->setResult(\Swift_Events_SendEvent::RESULT_SUCCESS);
+ $evt->setFailedRecipients($failedRecipients);
+ $this->getDispatcher()->dispatchEvent($evt, 'sendPerformed');
+ }
+
+ return $count;
+ } catch (AwsException $e) {
+ $this->triggerSendError($evt, $failedRecipients);
+ $message->generateId();
+
+ $this->throwException($e->getAwsErrorMessage());
+ } catch (Exception $e) {
+ $this->triggerSendError($evt, $failedRecipients);
+ $message->generateId();
+
+ $this->throwException($e->getMessage());
+ }
+
+ return 0;
+ }
+
+ /**
+ * @param \Swift_Events_SendEvent $evt
+ * @param array $failedRecipients
+ */
+ private function triggerSendError(\Swift_Events_SendEvent $evt, &$failedRecipients)
+ {
+ $failedRecipients = array_merge(
+ $failedRecipients,
+ array_keys((array) $this->message->getTo()),
+ array_keys((array) $this->message->getCc()),
+ array_keys((array) $this->message->getBcc())
+ );
+
+ if ($evt) {
+ $evt->setResult(\Swift_Events_SendEvent::RESULT_FAILED);
+ $evt->setFailedRecipients($failedRecipients);
+ $this->getDispatcher()->dispatchEvent($evt, 'sendPerformed');
+ }
+ }
+
+ /**
+ * Initialize the token buckets for throttling.
+ *
+ * @throws \Exception
+ */
+ private function initializeThrottles()
+ {
+ try {
+ /**
+ * SES limits creating templates to approximately one per second.
+ */
+ $storageCreate = new SingleProcessStorage();
+ $rateCreate = new Rate(1, Rate::SECOND);
+ $bucketCreate = new TokenBucket(1, $rateCreate, $storageCreate);
+ $this->createTemplateBucketConsumer = new BlockingConsumer($bucketCreate);
+ $bucketCreate->bootstrap(1);
+
+ /**
+ * SES limits sending emails based on requested account-level limits.
+ */
+ $storageSend = new SingleProcessStorage();
+ $rateSend = new Rate($this->concurrency, Rate::SECOND);
+ $bucketSend = new TokenBucket($this->concurrency, $rateSend, $storageSend);
+ $this->sendTemplateBucketConsumer = new BlockingConsumer($bucketSend);
+ $bucketSend->bootstrap($this->concurrency);
+ } catch (\InvalidArgumentException $e) {
+ $this->logger->error('error configuring token buckets: '.$e->getMessage());
+ throw new \Exception($e->getMessage());
+ } catch (StorageException $e) {
+ $this->logger->error('error bootstrapping token buckets: '.$e->getMessage());
+ throw new \Exception($e->getMessage());
+ } catch (Exception $e) {
+ $this->logger->error('error initializing token buckets: '.$e->getMessage());
+ throw $e;
+ }
+ }
+
+ /**
+ * Retrieve the send quota from SES.
+ *
+ * @return \Aws\Result
+ *
+ * @throws \Exception
+ *
+ * @see https://docs.aws.amazon.com/ses/latest/APIReference/API_GetSendQuota.html
+ */
+ private function getSesSendQuota()
+ {
+ $this->logger->debug('Retrieving SES quota');
+ try {
+ return $this->client->getSendQuota();
+ } catch (AwsException $e) {
+ $this->logger->error('Error retrieving AWS SES quota info: '.$e->getMessage());
+ throw new \Exception($e->getMessage());
+ }
+ }
+
+ /**
+ * @param array $template
+ *
+ * @return \Aws\Result|null
+ *
+ * @throws \Exception
+ *
+ * @see https://docs.aws.amazon.com/ses/latest/APIReference/API_CreateTemplate.html
+ */
+ private function createSesTemplate($template)
+ {
+ $templateName = $template['TemplateName'];
+
+ $this->logger->debug('Creating SES template: '.$templateName);
+
+ /*
+ * reuse an existing template if we have created one
+ */
+ if (array_search($templateName, $this->templateCache) !== false) {
+ $this->logger->debug('Template '.$templateName.' already exists in cache');
+
+ return null;
+ }
+
+ /*
+ * wait for a throttle token
+ */
+ $this->createTemplateBucketConsumer->consume(1);
+
+ try {
+ $result = $this->client->createTemplate(['Template' => $template]);
+ } catch (AwsException $e) {
+ switch ($e->getAwsErrorCode()) {
+ case 'AlreadyExists':
+ $this->logger->debug('Exception creating template: '.$templateName.', '.$e->getAwsErrorCode().', '.$e->getAwsErrorMessage().', ignoring');
+ break;
+ default:
+ $this->logger->error('Exception creating template: '.$templateName.', '.$e->getAwsErrorCode().', '.$e->getAwsErrorMessage());
+ throw new \Exception($e->getMessage());
+ }
+ }
+
+ /*
+ * store the name of this template so that we can delete it when we are done sending
+ */
+ $this->templateCache[] = $templateName;
+
+ return $result;
+ }
+
+ /**
+ * @param string $templateName
+ *
+ * @return \Aws\Result
+ *
+ * @throws \Exception
+ *
+ * @see https://docs.aws.amazon.com/ses/latest/APIReference/API_DeleteTemplate.html
+ */
+ private function deleteSesTemplate($templateName)
+ {
+ $this->logger->debug('Deleting SES template: '.$templateName);
+
+ try {
+ return $this->client->deleteTemplate(['TemplateName' => $templateName]);
+ } catch (AwsException $e) {
+ $this->logger->error('Exception deleting template: '.$templateName.', '.$e->getAwsErrorCode().', '.$e->getAwsErrorMessage());
+ throw new \Exception($e->getMessage());
+ }
+ }
+
+ /**
+ * @param int $count number of recipients for us to consume from the ticket bucket
+ * @param array $message
+ *
+ * @return \Aws\Result
+ *
+ * @throws \Exception
+ *
+ * @see https://docs.aws.amazon.com/ses/latest/APIReference/API_SendBulkTemplatedEmail.html
+ */
+ private function sendSesBulkTemplatedEmail($count, $message)
+ {
+ $this->logger->debug('Sending SES template: '.$message['Template'].' to '.$count.' recipients');
+
+ // wait for a throttle token
+ $this->sendTemplateBucketConsumer->consume($count);
+
+ try {
+ return $this->client->sendBulkTemplatedEmail($message);
+ } catch (AwsException $e) {
+ $this->logger->error('Exception sending email template: '.$e->getAwsErrorCode().', '.$e->getAwsErrorMessage());
+ throw new \Exception($e->getMessage());
+ }
+ }
+
+ /**
+ * Parse message into a template and recipients with their respective replacement tokens.
+ *
+ * @param \Swift_Mime_Message $message
+ *
+ * @return array of a template and a message
+ */
+ private function constructSesTemplateAndMessage(\Swift_Mime_Message $message)
+ {
+ $this->message = $message;
+ $metadata = $this->getMetadata();
+ $messageArray = [];
+
+ if (!empty($metadata)) {
+ $metadataSet = reset($metadata);
+ $emailId = $metadataSet['emailId'];
+ $tokens = (!empty($metadataSet['tokens'])) ? $metadataSet['tokens'] : [];
+ $mauticTokens = array_keys($tokens);
+ $tokenReplace = $amazonTokens = [];
+ foreach ($tokens as $search => $token) {
+ $tokenKey = preg_replace('/[^\da-z]/i', '_', trim($search, '{}'));
+ $tokenReplace[$search] = '{{'.$tokenKey.'}}';
+ $amazonTokens[$search] = $tokenKey;
+ }
+ $messageArray = $this->messageToArray($mauticTokens, $tokenReplace, true);
+ }
+
+ $CcAddresses = [];
+ if (count($messageArray['recipients']['cc']) > 0) {
+ $CcAddresses = array_keys($messageArray['recipients']['cc']);
+ }
+
+ $BccAddresses = [];
+ if (count($messageArray['recipients']['cc']) > 0) {
+ $BccAddresses = array_keys($messageArray['recipients']['bcc']);
+ }
+
+ //build amazon ses template array
+ $amazonTemplate = [
+ 'TemplateName' => 'MauticTemplate-'.$emailId.'-'.md5($messageArray['subject'].$messageArray['html']), //unique template name
+ 'SubjectPart' => $messageArray['subject'],
+ 'TextPart' => $messageArray['text'],
+ 'HtmlPart' => $messageArray['html'],
+ ];
+
+ $destinations = [];
+ foreach ($metadata as $recipient => $mailData) {
+ $ReplacementTemplateData = [];
+ foreach ($mailData['tokens'] as $token => $tokenData) {
+ $ReplacementTemplateData[$amazonTokens[$token]] = $tokenData;
+ }
+
+ $destinations[] = [
+ 'Destination' => [
+ 'BccAddresses' => $BccAddresses,
+ 'CcAddresses' => $CcAddresses,
+ 'ToAddresses' => [$recipient],
+ ],
+ 'ReplacementTemplateData' => \GuzzleHttp\json_encode($ReplacementTemplateData),
+ ];
+ }
+
+ //build amazon ses message array
+ $amazonMessage = [
+ 'DefaultTemplateData' => $destinations[0]['ReplacementTemplateData'],
+ 'Destinations' => $destinations,
+ 'Source' => $messageArray['from']['email'],
+ 'Template' => $amazonTemplate['TemplateName'],
+ ];
+
+ if (isset($messageArray['from']['name']) && trim($messageArray['from']['name']) !== '') {
+ $amazonMessage['Source'] = '"'.$messageArray['from']['name'].'" <'.$messageArray['from']['email'].'>';
+ }
+
+ return [$amazonTemplate, $amazonMessage];
+ }
+
+ /**
+ * @param \Swift_Mime_Message $message
+ * @param \Swift_Events_SendEvent @evt
+ * @param null $failedRecipients
+ *
+ * @return array
+ */
+ public function sendRawEmail(\Swift_Mime_Message $message, \Swift_Events_SendEvent $evt, &$failedRecipients = null)
+ {
+ try {
+ $this->start();
+ $commands = [];
+ foreach ($this->getAmazonMessage($message) as $rawEmail) {
+ $commands[] = $this->client->getCommand('sendRawEmail', $rawEmail);
+ }
+ $pool = new CommandPool($this->client, $commands, [
+ 'concurrency' => $this->concurrency,
+ 'fulfilled' => function (ResultInterface $result, $iteratorId) use ($commands, $evt, $failedRecipients) {
+ if ($evt) {
+ $evt->setResult(\Swift_Events_SendEvent::RESULT_SUCCESS);
+ $evt->setFailedRecipients($failedRecipients);
+ $this->getDispatcher()->dispatchEvent($evt, 'sendPerformed');
+ }
+ },
+ 'rejected' => function (AwsException $reason, $iteratorId) use ($commands, $evt) {
+ $this->triggerSendError($evt, []);
+ },
+ ]);
+ $promise = $pool->promise();
+ $promise->wait();
+
+ return count($commands);
+ } catch (\Exception $e) {
+ $this->triggerSendError($evt, $failedRecipients);
+ $message->generateId();
+ $this->throwException($e->getMessage());
+ }
+
+ return 1;
+ }
+
+ /**
+ * @param \Swift_Mime_Message $message
+ *
+ * @return array
+ */
+ public function getAmazonMessage(\Swift_Mime_Message $message)
+ {
+ $this->message = $message;
+ $metadata = $this->getMetadata();
+ $emailBody = $this->message->getBody();
+
+ if (!empty($metadata)) {
+ $metadataSet = reset($metadata);
+ $tokens = (!empty($metadataSet['tokens'])) ? $metadataSet['tokens'] : [];
+ $mauticTokens = array_keys($tokens);
+ }
+
+ foreach ($metadata as $recipient => $mailData) {
+ $this->message->setBody($emailBody);
+ $msg = $this->messageToArray($mauticTokens, $mailData['tokens'], true);
+ $rawMessage = $this->buildRawMessage($msg, $recipient);
+ $payload = [
+ 'Source' => $msg['from']['email'],
+ 'Destinations' => [$recipient],
+ 'RawMessage' => [
+ 'Data' => $rawMessage,
+ ],
+ ];
+
+ yield $payload;
+ }
+ }
+
+ /**
+ * @param type $msg
+ * @param type $recipient
+ *
+ * @return string
+ */
+ public function buildRawMessage($msg, $recipient)
+ {
+ $separator = md5(time());
+ $separator_multipart = md5($msg['subject'].time());
+ $message = "MIME-Version: 1.0\n";
+ $message .= 'Subject: '.$msg['subject']."\n";
+ $message .= 'From: '.$msg['from']['name'].' <'.$msg['from']['email'].">\n";
+ $message .= "To: $recipient\n";
+ if (count($msg['recipients']['cc']) > 0) {
+ $message .= 'Cc: '.implode(',', array_keys($msg['recipients']['cc']))."\n";
+ }
+ if (count($msg['recipients']['bcc']) > 0) {
+ $message .= 'Bcc: '.implode(',', array_keys($msg['recipients']['bcc']))."\n";
+ }
+ $message .= "Content-Type: multipart/mixed; boundary=\"$separator_multipart\"\n";
+ $message .= "\n--$separator_multipart\n";
+
+ $message .= "Content-Type: multipart/alternative; boundary=\"$separator\"\n";
+ if (isset($msg['text']) && strlen($msg['text']) > 0) {
+ $message .= "\n--$separator\n";
+ $message .= "Content-Type: text/plain; charset=\"UTF-8\"\n";
+ $message .= "Content-Transfer-Encoding: base64\n";
+ $message .= "\n".wordwrap(base64_encode($msg['text']), 78, "\n", true)."\n";
+ }
+ $message .= "\n--$separator\n";
+ $message .= "Content-Type: text/html; charset=\"UTF-8\"\n";
+ $message .= "\n".$msg['html']."\n";
+ $message .= "\n--$separator--\n";
+
+ foreach ($msg['attachments'] as $attachment) {
+ $message .= "--$separator_multipart\n";
+ $message .= 'Content-Type: '.$attachment['type'].'; name="'.$attachment['name']."\"\n";
+ $message .= 'Content-Disposition: attachment; filename="'.$attachment['name']."\"\n";
+ $message .= "Content-Transfer-Encoding: base64\n";
+ $message .= "\n".$attachment['content']."\n";
+ }
+
+ $message .= "--$separator_multipart--";
+
+ return $message;
+ }
+
+ /**
+ * @return int
+ */
+ public function getMaxBatchLimit()
+ {
+ return 10;
+ }
+
+ /**
+ * Returns a "transport" string to match the URL path /mailer/{transport}/callback.
+ *
+ * @return string
+ */
+ public function getCallbackPath()
+ {
+ return 'amazon_api';
+ }
+
+ /**
+ * @param \Swift_Message $message
+ * @param int $toBeAdded
+ * @param string $type
+ *
+ * @return int
+ */
+ public function getBatchRecipientCount(\Swift_Message $message, $toBeAdded = 1, $type = 'to')
+ {
+ return count($message->getTo()) + count($message->getCc()) + count($message->getBcc()) + $toBeAdded;
+ }
+
+ /**
+ * Handle bounces & complaints from Amazon.
+ *
+ * @param Request $request
+ *
+ * @return array
+ */
+ public function processCallbackRequest(Request $request)
+ {
+ $this->logger->error('Receiving webhook from Amazon');
+
+ $payload = json_decode($request->getContent(), true);
+
+ return $this->processJsonPayload($payload);
+ }
+
+ /**
+ * Process json request from Amazon SES.
+ *
+ * http://docs.aws.amazon.com/ses/latest/DeveloperGuide/best-practices-bounces-complaints.html
+ *
+ * @param array $payload from Amazon SES
+ */
+ public function processJsonPayload(array $payload)
+ {
+ if (!isset($payload['Type'])) {
+ throw new HttpException(400, "Key 'Type' not found in payload ");
+ }
+
+ if ($payload['Type'] == 'SubscriptionConfirmation') {
+ // Confirm Amazon SNS subscription by calling back the SubscribeURL from the playload
+ try {
+ $response = $this->httpClient->get($payload['SubscribeURL']);
+ if ($response->code == 200) {
+ $this->logger->info('Callback to SubscribeURL from Amazon SNS successfully');
+
+ return;
+ }
+
+ $reason = 'HTTP Code '.$response->code.', '.$response->body;
+ } catch (UnexpectedResponseException $e) {
+ $reason = $e->getMessage();
+ }
+
+ $this->logger->error('Callback to SubscribeURL from Amazon SNS failed, reason: '.$reason);
+
+ return;
+ }
+
+ if ($payload['Type'] == 'Notification') {
+ $message = json_decode($payload['Message'], true);
+
+ // only deal with hard bounces
+ if ($message['notificationType'] == 'Bounce' && $message['bounce']['bounceType'] == 'Permanent') {
+ // Get bounced recipients in an array
+ $bouncedRecipients = $message['bounce']['bouncedRecipients'];
+ foreach ($bouncedRecipients as $bouncedRecipient) {
+ $this->transportCallback->addFailureByAddress($bouncedRecipient['emailAddress'], $bouncedRecipient['diagnosticCode']);
+ $this->logger->debug("Mark email '".$bouncedRecipient['emailAddress']."' as bounced, reason: ".$bouncedRecipient['diagnosticCode']);
+ }
+
+ return;
+ }
+
+ // unsubscribe customer that complain about spam at their mail provider
+ if ($message['notificationType'] == 'Complaint') {
+ foreach ($message['complaint']['complainedRecipients'] as $complainedRecipient) {
+ $reason = null;
+ if (isset($message['complaint']['complaintFeedbackType'])) {
+ // http://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html#complaint-object
+ switch ($message['complaint']['complaintFeedbackType']) {
+ case 'abuse':
+ $reason = $this->translator->trans('mautic.email.complaint.reason.abuse');
+ break;
+ case 'fraud':
+ $reason = $this->translator->trans('mautic.email.complaint.reason.fraud');
+ break;
+ case 'virus':
+ $reason = $this->translator->trans('mautic.email.complaint.reason.virus');
+ break;
+ }
+ }
+
+ if ($reason == null) {
+ $reason = $this->translator->trans('mautic.email.complaint.reason.unknown');
+ }
+
+ $this->transportCallback->addFailureByAddress($complainedRecipient['emailAddress'], $reason, DoNotContact::UNSUBSCRIBED);
+
+ $this->logger->debug("Unsubscribe email '".$complainedRecipient['emailAddress']."'");
+ }
+ }
+ }
+ }
+
+ /**
+ * @param Message $message
+ *
+ * @throws BounceNotFound
+ */
+ public function processBounce(Message $message)
+ {
+ if (self::SNS_ADDRESS !== $message->fromAddress) {
+ throw new BounceNotFound();
+ }
+
+ $message = $this->getSnsPayload($message->textPlain);
+ if ('Bounce' !== $message['notificationType']) {
+ throw new BounceNotFound();
+ }
+
+ $bounce = new BouncedEmail();
+ $bounce->setContactEmail($message['bounce']['bouncedRecipients'][0]['emailAddress'])
+ ->setBounceAddress($message['mail']['source'])
+ ->setType(Type::UNKNOWN)
+ ->setRuleCategory(Category::UNKNOWN)
+ ->setRuleNumber('0013')
+ ->setIsFinal(true);
+
+ return $bounce;
+ }
+
+ /**
+ * @param Message $message
+ *
+ * @return UnsubscribedEmail
+ *
+ * @throws UnsubscriptionNotFound
+ */
+ public function processUnsubscription(Message $message)
+ {
+ if (self::SNS_ADDRESS !== $message->fromAddress) {
+ throw new UnsubscriptionNotFound();
+ }
+
+ $message = $this->getSnsPayload($message->textPlain);
+ if ('Complaint' !== $message['notificationType']) {
+ throw new UnsubscriptionNotFound();
+ }
+
+ return new UnsubscribedEmail($message['complaint']['complainedRecipients'][0]['emailAddress'], $message['mail']['source']);
+ }
+
+ /**
+ * @param string $body
+ *
+ * @return array
+ */
+ protected function getSnsPayload($body)
+ {
+ return json_decode(strtok($body, "\n"), true);
+ }
+}
diff --git a/app/bundles/EmailBundle/Translations/en_US/messages.ini b/app/bundles/EmailBundle/Translations/en_US/messages.ini
index 125238f705..1b32f9859e 100644
--- a/app/bundles/EmailBundle/Translations/en_US/messages.ini
+++ b/app/bundles/EmailBundle/Translations/en_US/messages.ini
@@ -82,6 +82,11 @@ mautic.email.config.mailer.amazon_host.eu_west_2="email-smtp.us-west-2.amazonaws
mautic.email.config.mailer.amazon_host.tooltip="Set the host for Amazon SES server"
mautic.email.config.mailer.amazon_host.us_east_1="email-smtp.us-east-1.amazonaws.com"
mautic.email.config.mailer.amazon_host="Amazon SES Host"
+mautic.email.config.mailer.amazon_api_region.eu_west_1="EU West (Ireland)"
+mautic.email.config.mailer.amazon_api_region.us_west_2="US West (Oregon)"
+mautic.email.config.mailer.amazon_api_region.us_east_1="US East (N. Virginia)"
+mautic.email.config.mailer.amazon_api_region.tooltip="Set the region for Amazon SES server"
+mautic.email.config.mailer.amazon_api_region="Amazon SES Region"
mautic.email.config.mailer.apikey.placeholder="Set the API key required to authenticate the selected mail service"
mautic.email.config.mailer.apikey.tooltop="Enter your API key here."
mautic.email.config.mailer.apikey="API Key"
@@ -138,7 +143,8 @@ mautic.email.config.mailer_encryption.ssl="SSL"
mautic.email.config.mailer_encryption.tls="TLS"
mautic.email.config.mailer_spool_type.file="Queue"
mautic.email.config.mailer_spool_type.memory="Send immediately"
-mautic.email.config.mailer_transport.amazon="Amazon SES"
+mautic.email.config.mailer_transport.amazon="Amazon SES - SMTP"
+mautic.email.config.mailer_transport.amazon_api="Amazon SES - API"
mautic.email.config.mailer_transport.gmail="Gmail"
mautic.email.config.mailer_transport.mail="PHP Mail"
mautic.email.config.mailer_transport.mandrill="Mandrill"
diff --git a/app/bundles/EmailBundle/Views/FormTheme/Config/_config_emailconfig_widget.html.php b/app/bundles/EmailBundle/Views/FormTheme/Config/_config_emailconfig_widget.html.php
index e35a9aea5c..263618a91c 100644
--- a/app/bundles/EmailBundle/Views/FormTheme/Config/_config_emailconfig_widget.html.php
+++ b/app/bundles/EmailBundle/Views/FormTheme/Config/_config_emailconfig_widget.html.php
@@ -53,6 +53,9 @@
<div class="row">
<?php echo $view['form']->rowIfExists($fields, 'mailer_amazon_region', $template); ?>
</div>
+ <div class="row">
+ <?php echo $view['form']->rowIfExists($fields, 'mailer_amazon_api_region', $template); ?>
+ </div>
<div class="row">
<?php echo $view['form']->rowIfExists($fields, 'mailer_host', $template); ?>
diff --git a/app/bundles/InstallBundle/Assets/install/install.js b/app/bundles/InstallBundle/Assets/install/install.js
index 9965b72ad9..ffd9ac5e0c 100644
--- a/app/bundles/InstallBundle/Assets/install/install.js
+++ b/app/bundles/InstallBundle/Assets/install/install.js
@@ -34,6 +34,12 @@ var MauticInstaller = {
mQuery('#authDetails').removeClass('hide');
}
}
+
+ if (mailer == 'mautic.transport.amazon_api') {
+ mQuery('#amazonApiRegion').removeClass('hide');
+ } else {
+ mQuery('#amazonApiRegion').addClass('hide');
+ }
},
toggleAuthDetails: function (auth) {
diff --git a/app/bundles/InstallBundle/Configurator/Form/EmailStepType.php b/app/bundles/InstallBundle/Configurator/Form/EmailStepType.php
index e43557318d..5ffb9d8feb 100644
--- a/app/bundles/InstallBundle/Configurator/Form/EmailStepType.php
+++ b/app/bundles/InstallBundle/Configurator/Form/EmailStepType.php
@@ -80,15 +80,16 @@ public function buildForm(FormBuilderInterface $builder, array $options)
'choice',
[
'choices' => [
- 'mail' => 'mautic.email.config.mailer_transport.mail',
- 'mautic.transport.mandrill' => 'mautic.email.config.mailer_transport.mandrill',
- 'mautic.transport.mailjet' => 'mautic.email.config.mailer_transport.mailjet',
- 'mautic.transport.sendgrid' => 'mautic.email.config.mailer_transport.sendgrid',
- 'mautic.transport.amazon' => 'mautic.email.config.mailer_transport.amazon',
- 'mautic.transport.postmark' => 'mautic.email.config.mailer_transport.postmark',
- 'gmail' => 'mautic.email.config.mailer_transport.gmail',
- 'smtp' => 'mautic.email.config.mailer_transport.smtp',
- 'sendmail' => 'mautic.email.config.mailer_transport.sendmail',
+ 'mail' => 'mautic.email.config.mailer_transport.mail',
+ 'mautic.transport.mandrill' => 'mautic.email.config.mailer_transport.mandrill',
+ 'mautic.transport.mailjet' => 'mautic.email.config.mailer_transport.mailjet',
+ 'mautic.transport.sendgrid' => 'mautic.email.config.mailer_transport.sendgrid',
+ 'mautic.transport.amazon' => 'mautic.email.config.mailer_transport.amazon',
+ 'mautic.transport.amazon_api' => 'mautic.email.config.mailer_transport.amazon_api',
+ 'mautic.transport.postmark' => 'mautic.email.config.mailer_transport.postmark',
+ 'gmail' => 'mautic.email.config.mailer_transport.gmail',
+ 'smtp' => 'mautic.email.config.mailer_transport.smtp',
+ 'sendmail' => 'mautic.email.config.mailer_transport.sendmail',
],
'label' => 'mautic.install.form.email.transport',
'label_attr' => ['class' => 'control-label'],
@@ -209,6 +210,26 @@ public function buildForm(FormBuilderInterface $builder, array $options)
$builder->add('mailer_spool_path', 'hidden');
+ $builder->add(
+ 'mailer_amazon_api_region',
+ 'choice',
+ [
+ 'choices' => [
+ 'eu-west-1' => 'mautic.email.config.mailer.amazon_api_region.eu_west_1',
+ 'us-east-1' => 'mautic.email.config.mailer.amazon_api_region.us_east_1',
+ 'us-west-2' => 'mautic.email.config.mailer.amazon_api_region.us_west_2',
+ ],
+ 'label' => 'mautic.email.config.mailer.amazon_api_region',
+ 'label_attr' => ['class' => 'control-label'],
+ 'required' => false,
+ 'attr' => [
+ 'class' => 'form-control',
+ 'tooltip' => 'mautic.email.config.mailer.amazon_api_region.tooltip',
+ ],
+ 'empty_value' => false,
+ ]
+ );
+
$builder->add(
'buttons',
'form_buttons',
diff --git a/app/bundles/InstallBundle/Configurator/Step/EmailStep.php b/app/bundles/InstallBundle/Configurator/Step/EmailStep.php
index 12b44ef322..153d50f201 100644
--- a/app/bundles/InstallBundle/Configurator/Step/EmailStep.php
+++ b/app/bundles/InstallBundle/Configurator/Step/EmailStep.php
@@ -90,6 +90,13 @@ class EmailStep implements StepInterface
*/
public $mailer_spool_type = 'memory'; // file|memory
+ /*
+ * Amazon API Region
+ *
+ * @var string
+ */
+ public $mailer_amazon_api_region = 'us-east-1';
+
/*
* Spool path
*
diff --git a/app/bundles/InstallBundle/Views/Install/email.html.php b/app/bundles/InstallBundle/Views/Install/email.html.php
index 47e0352ddd..6b8d04a210 100644
--- a/app/bundles/InstallBundle/Views/Install/email.html.php
+++ b/app/bundles/InstallBundle/Views/Install/email.html.php
@@ -78,6 +78,16 @@
</div>
</div>
</div>
+ <?php
+ $hide = ($mailer == 'amazon_api') ? '' : ' class="hide"';
+ ?>
+ <div id="amazonApiRegion"<?php echo $hide; ?>>
+ <div class="row">
+ <div class="col-sm-6">
+ <?php echo $view['form']->row($form['mailer_amazon_api_region']); ?>
+ </div>
+ </div>
+ </div>
<div class="row mt-20">
<div class="col-sm-9">
<?php echo $view->render('MauticInstallBundle:Install:navbar.html.php', ['step' => $index, 'count' => $count, 'completedSteps' => $completedSteps]); ?>
diff --git a/composer.json b/composer.json
index 4fc32a7259..82f043f6e6 100644
--- a/composer.json
+++ b/composer.json
@@ -106,12 +106,14 @@
"lightsaml/sp-bundle": "~1.0.3",
"symfony/expression-language": "~2.8",
"egeloen/ordered-form-bundle": "^3.0",
+ "guzzlehttp/psr7": "^1.4",
"symfony/dotenv": "^3.3",
"php-amqplib/rabbitmq-bundle": "^1.10",
"leezy/pheanstalk-bundle": "^3.2",
"simshaun/recurr": "^3.0",
"ramsey/uuid": "^3.7",
"sendgrid/sendgrid": "~6.0",
+ "bandwidth-throttle/token-bucket": "^2.0",
"noxlogic/ratelimit-bundle": "^1.11",
"sensio/framework-extra-bundle": "~3.0"
},
@ImCowboySibs
Copy link

I am receiving a fatal error "corrupt patch at line 1155"

@intmilan26
Copy link

"corrupt patch at line 1155"

@iurisilvio
Copy link

Same here, corrupt patch

@osidney
Copy link

osidney commented Jun 8, 2020

Same error here, applied in Mautic 2.16.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment