diff --git a/application/Espo/Core/Mail/Mail/Header/XQueueItemId.php b/application/Espo/Core/Mail/Mail/Header/XQueueItemId.php
new file mode 100644
index 0000000000..e4378ef1b1
--- /dev/null
+++ b/application/Espo/Core/Mail/Mail/Header/XQueueItemId.php
@@ -0,0 +1,53 @@
+fieldName;
+ }
+
+ public function setFieldName($value)
+ {
+ }
+
+ public function setEncoding($encoding)
+ {
+ return $this;
+ }
+
+ public function setId($id)
+ {
+ $this->id = $id;
+ }
+
+ public function getEncoding()
+ {
+ return 'ASCII';
+ }
+
+ public function toString()
+ {
+ return $this->fieldName . ': ' . $this->getFieldValue();
+ }
+
+ public function getFieldValue($format = \Zend\Mail\Header\HeaderInterface::FORMAT_RAW)
+ {
+ return $this->id;
+ }
+}
diff --git a/application/Espo/Core/Mail/Sender.php b/application/Espo/Core/Mail/Sender.php
index 45a56d6f45..76591e059e 100644
--- a/application/Espo/Core/Mail/Sender.php
+++ b/application/Espo/Core/Mail/Sender.php
@@ -133,7 +133,9 @@ class Sender
public function send(Email $email, $params = array(), &$message = null, $attachmetList = [])
{
- $message = new Message();
+ if (!$message) {
+ $message = new Message();
+ }
$config = $this->config;
$params = $this->params + $params;
diff --git a/application/Espo/Core/defaults/config.php b/application/Espo/Core/defaults/config.php
index 8ba084813e..f1b5a0ad88 100644
--- a/application/Espo/Core/defaults/config.php
+++ b/application/Espo/Core/defaults/config.php
@@ -103,6 +103,7 @@ return array (
'b2cMode' => false,
'restrictedMode' => false,
'theme' => 'Espo',
+ 'massEmailMaxPerHourCount' => 100,
'isInstalled' => false,
);
diff --git a/application/Espo/Core/defaults/systemConfig.php b/application/Espo/Core/defaults/systemConfig.php
index d31ac23cdb..4bb9545da1 100644
--- a/application/Espo/Core/defaults/systemConfig.php
+++ b/application/Espo/Core/defaults/systemConfig.php
@@ -116,7 +116,8 @@ return array (
'ldapTryUsernameSplit',
'ldapOptReferrals',
'ldapCreateEspoUser',
- 'maxEmailAccountCount'
+ 'maxEmailAccountCount',
+ 'massEmailMaxPerHourCount'
),
'isInstalled' => false,
);
diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/CampaignLogRecord.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/CampaignLogRecord.json
index 67f75dbc6a..ea359bc648 100644
--- a/application/Espo/Modules/Crm/Resources/i18n/en_US/CampaignLogRecord.json
+++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/CampaignLogRecord.json
@@ -6,7 +6,8 @@
"campaign": "Campaign",
"parent": "Target",
"object": "Object",
- "application": "Application"
+ "application": "Application",
+ "queueItem": "Queue Item"
},
"options": {
"action": {
diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/EmailQueueItem.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/EmailQueueItem.json
index cdcee57526..b51847c004 100644
--- a/application/Espo/Modules/Crm/Resources/i18n/en_US/EmailQueueItem.json
+++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/EmailQueueItem.json
@@ -4,7 +4,8 @@
"status": "Status",
"target": "Target",
"sentAt": "Date Sent",
- "attemptCount": "Attempts"
+ "attemptCount": "Attempts",
+ "emailAddress": "Email Address"
},
"links": {
"target": "Target",
diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignLogRecord.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignLogRecord.json
index fa8f1536e7..e8620782e7 100644
--- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignLogRecord.json
+++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignLogRecord.json
@@ -44,6 +44,9 @@
},
"object": {
"type": "linkParent"
+ },
+ "queueItem": {
+ "type": "link"
}
},
"links": {
@@ -56,6 +59,11 @@
"entity": "Campaign",
"foreign": "campaignLogRecords"
},
+ "queueItem": {
+ "type": "belongsTo",
+ "entity": "EmailQueueItem",
+ "noJoin": true
+ },
"parent": {
"type": "belongsToParent",
"entityList": ["Account", "Contact", "Lead", "Opportunity", "User"]
diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/EmailQueueItem.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/EmailQueueItem.json
index 9fe9d78dc6..6c5ba2c581 100644
--- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/EmailQueueItem.json
+++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/EmailQueueItem.json
@@ -21,6 +21,10 @@
"sentAt": {
"type": "datetime",
"readOnly": true
+ },
+ "emailAddress": {
+ "type": "varchar",
+ "readOnly": true
}
},
"links": {
diff --git a/application/Espo/Modules/Crm/Services/Campaign.php b/application/Espo/Modules/Crm/Services/Campaign.php
index f23eed239b..871cc6dc5e 100644
--- a/application/Espo/Modules/Crm/Services/Campaign.php
+++ b/application/Espo/Modules/Crm/Services/Campaign.php
@@ -95,5 +95,53 @@ class Campaign extends \Espo\Services\Record
}
}
+ public function logSent($campaignId, $queueItemId = null, Entity $target, Entity $emailOrEmailTemplate = null, $emailAddress, $actionDate = null)
+ {
+ if (empty($actionDate)) {
+ $actionDate = date('Y-m-d H:i:s');
+ }
+ $logRecord = $this->getEntityManager()->getEntity('CampaignLogRecord');
+ $logRecord->set(array(
+ 'campaignId' => $campaignId,
+ 'actionDate' => $actionDate,
+ 'parentId' => $target->id,
+ 'parentType' => $target->getEntityType(),
+ 'action' => 'Sent',
+ 'stringData' => $emailAddress,
+ 'queueItemId' => $queueItemId
+ ));
+
+ if ($emailOrEmailTemplate) {
+ $logRecord->set(array(
+ 'objectId' => $emailOrEmailTemplate->id,
+ 'objectType' => $emailOrEmailTemplate->getEntityType()
+ ));
+ }
+ $this->getEntityManager()->saveEntity($logRecord);
+ }
+
+ public function logBounced($campaignId, $queueItemId = null, Entity $target, $emailAddress, $isHard = false, $actionDate = null)
+ {
+ if (empty($actionDate)) {
+ $actionDate = date('Y-m-d H:i:s');
+ }
+ $logRecord = $this->getEntityManager()->getEntity('CampaignLogRecord');
+ $logRecord->set(array(
+ 'campaignId' => $campaignId,
+ 'actionDate' => $actionDate,
+ 'parentId' => $target->id,
+ 'parentType' => $target->getEntityType(),
+ 'action' => 'Bounced',
+ 'stringData' => $emailAddress,
+ 'queueItemId' => $queueItemId
+ ));
+ if ($isHard) {
+ $logRecord->set('stringAdditionalData', 'Hard');
+ } else {
+ $logRecord->set('stringAdditionalData', 'Soft');
+ }
+ $this->getEntityManager()->saveEntity($logRecord);
+ }
+
}
diff --git a/application/Espo/Modules/Crm/Services/MassEmail.php b/application/Espo/Modules/Crm/Services/MassEmail.php
index ac2cfcee9f..49c34d8e2b 100644
--- a/application/Espo/Modules/Crm/Services/MassEmail.php
+++ b/application/Espo/Modules/Crm/Services/MassEmail.php
@@ -30,6 +30,12 @@ class MassEmail extends \Espo\Services\Record
{
const MAX_ATTEMPT_COUNT = 3;
+ const MAX_PER_HOUR_COUNT = 100;
+
+ private $campaignService = null;
+
+ private $emailTemplateService = null;
+
protected function init()
{
$this->dependencies[] = 'container';
@@ -138,10 +144,25 @@ class MassEmail extends \Espo\Services\Record
public function processSending(Entity $massEmail)
{
+ $threshold = new \DateTime();
+ $threshold->modify('-1 hour');
+ $sentLastHourCount = $this->getEntityManager()->getRepository('EmailQueueItem')->where(array(
+ 'status' => 'Sent',
+ 'sentAt>' => $threshold->format('Y-m-d H:i:s')
+ ))->count();
+
+ $maxBatchSize = $this->getConfig()->get('massEmailMaxPerHourCount', self::MAX_PER_HOUR_COUNT);
+
+ if ($sentLastHourCount >= $maxBatchSize) {
+ return;
+ }
+
+ $maxBatchSize = $maxBatchSize - $sentLastHourCount;
+
$queueItemList = $this->getEntityManager()->getRepository('EmailQueueItem')->where(array(
'status' => 'Pending',
'massEmailId' => $massEmail->id
- ))->find();
+ ))->limit(0, $maxBatchSize)->find();
$templateId = $massEmail->get('emailTemplateId');
if (!$templateId) {
@@ -149,6 +170,11 @@ class MassEmail extends \Espo\Services\Record
return;
}
+ $campaign = null;
+ $campaignId = $massEmail->get('campaignId');
+ if ($campaignId) {
+ $campaign = $this->getEntityManager()->getEntity('Campaign', $campaignId);
+ }
$emailTemplate = $this->getEntityManager()->getEntity('EmailTemplate', $templateId);
if (!$emailTemplate) {
$this->setFailed($massEmail);
@@ -157,7 +183,7 @@ class MassEmail extends \Espo\Services\Record
$attachmetList = $emailTemplate->get('attachmets');
foreach ($queueItemList as $queueItem) {
- $this->sendQueueItem($queueItem, $massEmail, $emailTemplate, $attachmetList);
+ $this->sendQueueItem($queueItem, $massEmail, $emailTemplate, $attachmetList, $campaign);
}
$countLeft = $this->getEntityManager()->getRepository('EmailQueueItem')->where(array(
@@ -170,7 +196,7 @@ class MassEmail extends \Espo\Services\Record
}
}
- protected function sendQueueItem(Entity $queueItem, Entity $massEmail, Entity $emailTemplate, $attachmetList = [])
+ protected function sendQueueItem(Entity $queueItem, Entity $massEmail, Entity $emailTemplate, $attachmetList = [], $campaign = null)
{
$target = $this->getEntityManager()->getEntity($queueItem->get('targetType'), $queueItem->get('targetId'));
if (!$target || !$target->id || !$target->get('emailAddress')) {
@@ -214,12 +240,30 @@ class MassEmail extends \Espo\Services\Record
$attemptCount++;
$queueItem->set('attemptCount', $attemptCount);
- $message = false;
+ $message = new \Zend\Mail\Message();
+
+ $header = new \Espo\Core\Mail\Mail\Header\XQueueItemId();
+ $header->setId($queueItem->id);
+ $message->getHeaders()->addHeader($header);
+
$this->getMailSender()->useGlobal()->send($email, $params, $message, $attachmetList);
+ $emailObject = $emailTemplate;
+ if ($massEmail->get('storeSentEmails')) {
+ $this->getEntityManager()->saveEntity($email);
+ $emailObject = $email;
+ }
+
+ $queueItem->set('emailAddress', $target->get('emailAddress'));
+
$queueItem->set('status', 'Sent');
$queueItem->set('sentAt', date('Y-m-d H:i:s'));
$this->getEntityManager()->saveEntity($queueItem);
+
+ if ($campaign) {
+ $this->getCampaignService()->logSent($campaign->id, $queueItem->id, $target, $emailObject, $target->get('emailAddress'));
+ }
+
} catch (\Exception $e) {
if ($queueItem->get('attemptCount') >= self::MAX_ATTEMPT_COUNT) {
$queueItem->set('status', 'Failed');
@@ -239,5 +283,13 @@ class MassEmail extends \Espo\Services\Record
}
return $this->emailTemplateService;
}
+
+ protected function getCampaignService()
+ {
+ if (!$this->campaignService) {
+ $this->campaignService = $this->getServiceFactory()->create('Campaign');
+ }
+ return $this->campaignService;
+ }
}
diff --git a/application/Espo/ORM/Repositories/RDB.php b/application/Espo/ORM/Repositories/RDB.php
index 23316d4f12..6a7bb22081 100644
--- a/application/Espo/ORM/Repositories/RDB.php
+++ b/application/Espo/ORM/Repositories/RDB.php
@@ -308,7 +308,9 @@ class RDB extends \Espo\ORM\Repository
$this->handleSelectParams($params);
$params = $this->getSelectParams($params);
- return $this->getMapper()->count($this->seed, $params);
+ $count = $this->getMapper()->count($this->seed, $params);
+ $this->reset();
+ return $count;
}
public function max($field)
diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json
index da5b9dca18..80027b36d0 100644
--- a/application/Espo/Resources/i18n/en_US/Settings.json
+++ b/application/Espo/Resources/i18n/en_US/Settings.json
@@ -55,7 +55,8 @@
"displayListViewRecordCount": "Display Total Count (on List View)",
"theme": "Theme",
"userThemesDisabled": "Disable User Themes",
- "emailMessageMaxSize": "Email Max Size (Mb)"
+ "emailMessageMaxSize": "Email Max Size (Mb)",
+ "massEmailMaxPerHourCount": "Max count of emails sent per hour"
},
"options": {
"weekStart": {
@@ -77,6 +78,7 @@
"In-app Notifications": "In-app Notifications",
"Email Notifications": "Email Notifications",
"Currency Settings": "Currency Settings",
- "Currency Rates": "Currency Rates"
+ "Currency Rates": "Currency Rates",
+ "Mass Email"
}
}
diff --git a/application/Espo/Resources/layouts/Settings/outboundEmails.json b/application/Espo/Resources/layouts/Settings/outboundEmails.json
index 4e9e2c6c91..74cc93ac79 100644
--- a/application/Espo/Resources/layouts/Settings/outboundEmails.json
+++ b/application/Espo/Resources/layouts/Settings/outboundEmails.json
@@ -14,5 +14,11 @@
[{"name": "outboundEmailFromAddress"}, {"name": "outboundEmailIsShared"}],
[{"name": "outboundEmailFromName"}, false]
]
+ },
+ {
+ "label": "Mass Email",
+ "rows": [
+ [{"name": "massEmailMaxPerHourCount"}, false]
+ ]
}
]
diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json
index f9fa0d3ebb..a44b64fa24 100644
--- a/application/Espo/Resources/metadata/entityDefs/Settings.json
+++ b/application/Espo/Resources/metadata/entityDefs/Settings.json
@@ -252,6 +252,10 @@
"type": "float",
"min": 0,
"tooltip": true
+ },
+ "massEmailMaxPerHourCount": {
+ "type": "int",
+ "min": 0
}
}
}
diff --git a/application/Espo/Services/InboundEmail.php b/application/Espo/Services/InboundEmail.php
index a26b3018e1..07920ec97e 100644
--- a/application/Espo/Services/InboundEmail.php
+++ b/application/Espo/Services/InboundEmail.php
@@ -31,6 +31,8 @@ class InboundEmail extends \Espo\Services\Record
{
protected $internalFields = array('password');
+ private $campaignService = null;
+
const PORTION_LIMIT = 20;
public function createEntity($data)
@@ -255,6 +257,11 @@ class InboundEmail extends \Espo\Services\Record
$fromString = $message->from;
if (preg_match('/MAILER-DAEMON|POSTMASTER/i', $fromString)) {
$toSkip = true;
+ try {
+ $this->processBouncedMessage($message);
+ } catch (\Exception $e) {
+ $GLOBALS['log']->error('InboundEmail '.$emailAccount->id.' (Process Bounced Message: [' . $e->getCode() . '] ' .$e->getMessage());
+ }
}
}
if (!$toSkip) {
@@ -551,5 +558,52 @@ class InboundEmail extends \Espo\Services\Record
} catch (\Exception $e) {}
}
+
+ protected function processBouncedMessage(\Zend\Mail\Message $message)
+ {
+ $content = $message->getContent();
+ echo "bounced\n";
+
+ $isHard = false;
+ if (preg_match('/permanent[ ]*error/', $content)) {
+ $isHard = true;
+ }
+ if (preg_match('/X-QueueItemId: [a-z0-9\-]*/', $content, $m)) {
+ $queueItemId = preg_split('/X-QueueItemId: /', $m[0], -1, \PREG_SPLIT_NO_EMPTY);
+ if (!$queueItemId) return;
+ echo $queueItemId . "\n";
+
+ $queueItem = $this->getEntityManager()->getEntity('EmailQueueItem', $queueItemId);
+
+ if (!$queueItem) return;
+
+ $campaignId = $queueItem->get('campaignId');
+
+ $targetType = $queueItem->get('targetType');
+ $targetId = $queueItem->get('targetId');
+ $target = $this->getEntityManager()->getEntity($targetType, $targetId);
+
+ $emailAddress = $queueItem->get('emailAddress');
+
+ if ($isHard && $emailAddress) {
+ $emailAddressEntity = $this->getEntityManager()->getRepository('EmailAddress')->getByAddress($emailAddress);
+ $emailAddressEntity->set('invalid', true);
+ $this->getEntityManager()->saveEntity($emailAddressEntity);
+ }
+
+ if ($campaignId && $target) {
+ $this->getCampaignService()->logBounced($campaignId, $queueItemId, $target, $emailAddress, $isHard);
+ }
+ }
+ }
+
+ protected function getCampaignService()
+ {
+ if (!$this->campaignService) {
+ $this->campaignService = $this->getServiceFactory()->create('Campaign');
+ }
+ return $this->campaignService;
+ }
+
}
diff --git a/frontend/client/modules/crm/src/views/campaign-log-record/fields/data.js b/frontend/client/modules/crm/src/views/campaign-log-record/fields/data.js
index 84dc82b665..364f10def0 100644
--- a/frontend/client/modules/crm/src/views/campaign-log-record/fields/data.js
+++ b/frontend/client/modules/crm/src/views/campaign-log-record/fields/data.js
@@ -31,11 +31,17 @@ Espo.define('Crm:Views.CampaignLogRecord.Fields.Data', 'Views.Fields.Base', func
switch (action) {
case 'Sent':
- return this.model.get('stringData');
+ if (this.model.get('objectId') && this.model.get('objectType') && this.model.get('objectName')) {
+ return ''+this.model.get('objectName')+'';
+ }
+ return this.model.get('stringData') || '';
case 'Clicked':
- return '' + this.model.get('stringData') + '';
+ if (this.model.get('objectId') && this.model.get('objectType') && this.model.get('objectName')) {
+ return ''+this.model.get('objectName')+'';
+ }
+ return '' + (this.model.get('stringData') || '') + '';
case 'Opted Out':
- return '' + this.model.get('stringData') + '';
+ return '' + this.model.get('stringData') + '';
case 'Bounced':
var emailAddress = this.model.get('stringData');
var type = this.model.get('stringAdditionalData');