campaign informational email

This commit is contained in:
Yuri Kuznetsov
2025-02-24 18:08:48 +02:00
parent 5b1647a42f
commit eb556b978b
20 changed files with 577 additions and 279 deletions
@@ -0,0 +1,77 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Modules\Crm\Classes\RecordHooks\Campaign;
use Espo\Core\Exceptions\Error\Body;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @implements SaveHook<Campaign>
*/
class BeforeUpdate implements SaveHook
{
public function __construct(
private EntityManager $entityManager,
) {}
public function process(Entity $entity): void
{
$this->checkType($entity);
}
/**
* @throws Forbidden
*/
private function checkType(Campaign $entity): void
{
if (!$entity->isAttributeChanged('type')) {
return;
}
$massEmail = $this->entityManager
->getRDBRepositoryByClass(MassEmail::class)
->where(['campaignId' => $entity->getId()])
->findOne();
if ($massEmail) {
throw Forbidden::createWithBody(
'Cannot change type.',
Body::create()->withMessageTranslation('cannotChangeType', Campaign::ENTITY_TYPE)
);
}
}
}
@@ -32,6 +32,7 @@ namespace Espo\Modules\Crm\Classes\RecordHooks\CampaignTrackingUrl;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
use Espo\ORM\Entity;
@@ -49,5 +50,24 @@ class BeforeCreate implements SaveHook
if (!$this->acl->check($entity, Acl\Table::ACTION_EDIT)) {
throw new Forbidden("No 'edit' access.");
}
$this->checkCampaign($entity);
}
/**
* @throws Forbidden
*/
private function checkCampaign(CampaignTrackingUrl $entity): void
{
if (
!$entity->getCampaign() || in_array($entity->getCampaign()->getType(), [
Campaign::TYPE_EMAIL,
Campaign::TYPE_NEWSLETTER,
])
) {
return;
}
throw new Forbidden("Cannot create tacking URL for non-email campaign.");
}
}
@@ -33,6 +33,7 @@ use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\ORM\Entity;
@@ -51,6 +52,24 @@ class BeforeCreate implements SaveHook
throw new Forbidden("No 'edit' access.");
}
$this->checkCampaign($entity);
}
/**
* @throws Forbidden
*/
private function checkCampaign(MassEmail $entity): void
{
if (
!$entity->getCampaign() || in_array($entity->getCampaign()->getType(), [
Campaign::TYPE_EMAIL,
Campaign::TYPE_NEWSLETTER,
Campaign::TYPE_INFORMATIONAL_EMAIL,
])
) {
return;
}
throw new Forbidden("Cannot create mass email for non-email campaign.");
}
}
@@ -41,6 +41,8 @@ class Campaign extends Entity
public const ENTITY_TYPE = 'Campaign';
public const TYPE_EMAIL = 'Email';
public const TYPE_NEWSLETTER = 'Newsletter';
public const TYPE_INFORMATIONAL_EMAIL = 'Informational Email';
public const TYPE_MAIL = 'Mail';
public const STATUS_ACTIVE = 'Active';
@@ -94,4 +94,10 @@ class CampaignTrackingUrl extends Entity
{
return !$this->isNew();
}
public function getCampaign(): ?Campaign
{
/** @var ?Campaign */
return $this->relations->getOne('campaign');
}
}
@@ -31,6 +31,7 @@ namespace Espo\Modules\Crm\Entities;
use Espo\Core\ORM\Entity;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use UnexpectedValueException;
class EmailQueueItem extends Entity
{
@@ -61,7 +62,7 @@ class EmailQueueItem extends Entity
$value = $this->get('targetType');
if (!is_string($value)) {
throw new \UnexpectedValueException();
throw new UnexpectedValueException();
}
return $value;
@@ -72,7 +73,7 @@ class EmailQueueItem extends Entity
$value = $this->get('targetId');
if (!is_string($value)) {
throw new \UnexpectedValueException();
throw new UnexpectedValueException();
}
return $value;
@@ -31,6 +31,7 @@ namespace Espo\Modules\Crm\Entities;
use Espo\Core\ORM\Entity;
use Espo\Entities\EmailTemplate;
use Espo\ORM\EntityCollection;
class MassEmail extends Entity
{
@@ -52,6 +53,24 @@ class MassEmail extends Entity
return $this->get('emailTemplateId');
}
/**
* @return EntityCollection<TargetList>
*/
public function getTargetLists(): EntityCollection
{
/** @var EntityCollection<TargetList> */
return $this->relations->getMany('targetLists');
}
/**
* @return EntityCollection<TargetList>
*/
public function getExcludingTargetLists(): EntityCollection
{
/** @var EntityCollection<TargetList> */
return $this->relations->getMany('excludingTargetLists');
}
public function getEmailTemplate(): ?EmailTemplate
{
/** @var ?EmailTemplate */
@@ -46,6 +46,7 @@
"options": {
"type": {
"Email": "Email",
"Informational Email": "Informational Email",
"Web": "Web",
"Television": "Television",
"Radio": "Radio",
@@ -78,6 +79,7 @@
"active": "Active"
},
"messages": {
"cannotChangeType": "Cannot change type.",
"unsubscribed": "You have been unsubscribed from our mailing list.",
"subscribedAgain": "You are subscribed again."
},
@@ -1,6 +1,5 @@
[
{
"label": "",
"rows": [
[
{"name": "name"},
@@ -9,18 +8,27 @@
[
{"name": "campaign"},
{"name": "startAt"}
],
]
]
},
{
"rows": [
[
{"name": "targetLists"},
{"name": "excludingTargetLists"}
],
[
false,
{"name": "optOutEntirely"}
{"name": "emailTemplate"},
false
],
[
{"name": "emailTemplate"}, {"name": "storeSentEmails"}
],
{"name": "storeSentEmails"},
{"name": "optOutEntirely"}
]
]
},
{
"rows": [
[
{"name": "smtpAccount"},
false
@@ -35,4 +43,4 @@
]
]
}
]
]
@@ -1,6 +1,5 @@
[
{
"label": "",
"rows": [
[
{"name": "name"}
@@ -9,23 +8,31 @@
{"name": "status"}
],
[
{"name": "startAt", "fullWidth": true}
{"name": "startAt"}
]
]
},
{
"rows": [
[
{"name": "targetLists"}
],
[
{"name": "targetLists", "fullWidth": true}
{"name": "excludingTargetLists"}
],
[
{"name": "excludingTargetLists", "fullWidth": true}
],
[
{"name": "emailTemplate", "fullWidth": true}
{"name": "emailTemplate"}
],
[
{"name": "storeSentEmails"},
{"name": "optOutEntirely"}
],
]
]
},
{
"rows": [
[
{"name": "smtpAccount", "fullWidth": true}
{"name": "smtpAccount"}
],
[
{"name": "fromAddress"},
@@ -37,4 +44,4 @@
]
]
}
]
]
@@ -41,15 +41,16 @@
"detail": "crm:views/campaign/detail"
},
"sidePanels":{
"detail":[
{
"name":"statistics",
"label":"Statistics",
"view":"crm:views/campaign/record/panels/campaign-stats",
"hidden": false,
"isForm": true
}
]
"detail":[
{
"name": "statistics",
"label": "Statistics",
"view": "crm:views/campaign/record/panels/campaign-stats",
"hidden": false,
"isForm": true,
"notRefreshable": true
}
]
},
"relationshipPanels": {
"campaignLogRecords": {
@@ -57,7 +58,7 @@
"layout": "listForCampaign",
"rowActionsView": "views/record/row-actions/remove-only",
"selectDisabled": false,
"createDisabled": false
"createDisabled": true
},
"massEmails": {
"createAttributeMap": {
@@ -97,6 +98,11 @@
"attribute": "type",
"value": "Newsletter"
},
{
"type": "equals",
"attribute": "type",
"value": "Informational Email"
},
{
"type": "equals",
"attribute": "type",
@@ -123,6 +129,11 @@
"attribute": "type",
"value": "Newsletter"
},
{
"type": "equals",
"attribute": "type",
"value": "Informational Email"
},
{
"type": "equals",
"attribute": "type",
@@ -196,7 +207,11 @@
{
"type": "in",
"attribute": "type",
"value": ["Email", "Newsletter"]
"value": [
"Email",
"Newsletter",
"Informational Email"
]
}
]
}
@@ -29,12 +29,14 @@
"options": [
"Email",
"Newsletter",
"Informational Email",
"Web",
"Television",
"Radio",
"Mail"
],
"default": "Email",
"maxLength": 64,
"customizationOptionsDisabled": true,
"customizationOptionsReferenceDisabled": true
},
@@ -15,12 +15,17 @@
"readOnly": true
},
"campaign": {
"type": "link"
"type": "link",
"readOnlyAfterCreate": true
},
"action": {
"type": "enum",
"options": ["Redirect", "Show Message"],
"default": "Redirect"
"options": [
"Redirect",
"Show Message"
],
"default": "Redirect",
"maxLength": 12
},
"message": {
"type": "text",
@@ -1,5 +1,8 @@
{
"readLoaderClassNameList": [
"Espo\\Modules\\Crm\\Classes\\FieldProcessing\\Campaign\\StatsLoader"
],
"beforeUpdateHookClassNameList": [
"Espo\\Modules\\Crm\\Classes\\RecordHooks\\Campaign\\BeforeUpdate"
]
}
@@ -31,6 +31,7 @@ namespace Espo\Modules\Crm\Tools\MassEmail;
use Espo\Core\Mail\Mail\Header\XQueueItemId;
use Espo\Core\Utils\Config;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Laminas\Mail\Headers;
@@ -49,12 +50,7 @@ class DefaultMessageHeadersPreparator implements MessageHeadersPreparator
$headers->addHeader($header);
$headers->addHeaderLine('Precedence', 'bulk');
if (!$this->config->get('massEmailDisableMandatoryOptOutLink')) {
$url = "{$this->getSiteUrl()}/api/v1/Campaign/unsubscribe/$id";
$headers->addHeaderLine('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
$headers->addHeaderLine('List-Unsubscribe', "<$url>");
}
$this->addMandatoryOptOut($headers, $data);
}
private function getSiteUrl(): string
@@ -63,4 +59,24 @@ class DefaultMessageHeadersPreparator implements MessageHeadersPreparator
return rtrim($url, '/');
}
private function addMandatoryOptOut(Headers $headers, Data $data): void
{
$campaignType = $data->getQueueItem()->getMassEmail()?->getCampaign()?->getType();
if ($campaignType === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return;
}
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
return;
}
$id = $data->getId();
$url = "{$this->getSiteUrl()}/api/v1/Campaign/unsubscribe/$id";
$headers->addHeaderLine('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
$headers->addHeaderLine('List-Unsubscribe', "<$url>");
}
}
@@ -30,12 +30,14 @@
namespace Espo\Modules\Crm\Tools\MassEmail\MessagePreparator;
use Espo\Core\Mail\SenderParams;
use Espo\Modules\Crm\Entities\EmailQueueItem;
class Data
{
public function __construct(
private string $id,
private SenderParams $senderParams
private SenderParams $senderParams,
private EmailQueueItem $queueItem,
) {}
public function getId(): string
@@ -48,4 +50,12 @@ class Data
{
return $this->senderParams;
}
/**
* @since 9.1.0
*/
public function getQueueItem(): EmailQueueItem
{
return $this->queueItem;
}
}
@@ -29,6 +29,7 @@
namespace Espo\Modules\Crm\Tools\MassEmail;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Log;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\ORM\Collection;
@@ -42,6 +43,7 @@ use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Core\Exceptions\Error;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use stdClass;
class QueueCreator
{
@@ -94,88 +96,16 @@ class QueueCreator
return;
}
$withOptedOut = $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
if (!$isTest) {
$this->cleanupQueueItems($massEmail);
}
$metTargetHash = [];
$metEmailAddressHash = [];
$itemList = [];
if (!$isTest) {
/** @var Collection<TargetList> $excludingTargetListList */
$excludingTargetListList = $this->entityManager
->getRDBRepositoryByClass(MassEmail::class)
->getRelation($massEmail, 'excludingTargetLists')
->find();
foreach ($excludingTargetListList as $excludingTargetList) {
foreach ($this->targetLinkList as $link) {
$excludingList = $this->entityManager
->getRDBRepositoryByClass(TargetList::class)
->getRelation($excludingTargetList, $link)
->sth()
->select([Attribute::ID, 'emailAddress'])
->find();
foreach ($excludingList as $excludingTarget) {
$hashId = $excludingTarget->getEntityType() . '-'. $excludingTarget->getId();
$metTargetHash[$hashId] = true;
$emailAddress = $excludingTarget->get('emailAddress');
if ($emailAddress) {
$metEmailAddressHash[$emailAddress] = true;
}
}
}
}
/** @var Collection<TargetList> $targetListCollection */
$targetListCollection = $this->entityManager
->getRDBRepositoryByClass(MassEmail::class)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListCollection as $targetList) {
foreach ($this->targetLinkList as $link) {
$recordList = $this->entityManager
->getRDBRepositoryByClass(TargetList::class)
->getRelation($targetList, $link)
->select([Attribute::ID, 'emailAddress'])
->sth()
->where(['@relation.optedOut' => false])
->find();
foreach ($recordList as $record) {
$hashId = $record->getEntityType() . '-'. $record->getId();
$emailAddress = $record->get('emailAddress');
if (!$emailAddress) {
continue;
}
if (!empty($metEmailAddressHash[$emailAddress])) {
continue;
}
if (!empty($metTargetHash[$hashId])) {
continue;
}
$item = $record->getValueMap();
$item->entityType = $record->getEntityType();
$itemList[] = $item;
$metTargetHash[$hashId] = true;
$metEmailAddressHash[$emailAddress] = true;
}
}
}
$itemList = $this->getItemList($massEmail, $withOptedOut);
}
foreach ($additionalTargetList as $record) {
@@ -199,7 +129,7 @@ class QueueCreator
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($emailAddressRecord) {
if ($emailAddressRecord && !$withOptedOut) {
if (
$emailAddressRecord->isInvalid() ||
$emailAddressRecord->isOptedOut()
@@ -221,15 +151,17 @@ class QueueCreator
$this->entityManager->saveEntity($queueItem);
}
if (!$isTest) {
$massEmail->set('status', MassEmail::STATUS_IN_PROCESS);
if (empty($itemList)) {
$massEmail->set('status', MassEmail::STATUS_COMPLETE);
}
$this->entityManager->saveEntity($massEmail);
if ($isTest) {
return;
}
$massEmail->setStatus(MassEmail::STATUS_IN_PROCESS);
if ($itemList === []) {
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
}
$this->entityManager->saveEntity($massEmail);
}
private function getEmailAddressRepository(): EmailAddressRepository
@@ -244,4 +176,83 @@ class QueueCreator
$massEmail->getCampaign() &&
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
}
/**
* @return stdClass[]
*/
private function getItemList(MassEmail $massEmail, bool $withOptedOut): array
{
$metTargetHash = [];
$metEmailAddressHash = [];
$itemList = [];
foreach ($massEmail->getExcludingTargetLists() as $excludingTargetList) {
foreach ($this->targetLinkList as $link) {
$targets = $this->entityManager
->getRelation($excludingTargetList, $link)
->sth()
->select([Attribute::ID, Field::EMAIL_ADDRESS])
->find();
foreach ($targets as $target) {
$hashId = $target->getEntityType() . '-' . $target->getId();
$metTargetHash[$hashId] = true;
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if ($emailAddress) {
$metEmailAddressHash[$emailAddress] = true;
}
}
}
}
foreach ($massEmail->getTargetLists() as $targetList) {
foreach ($this->targetLinkList as $link) {
$where = [];
if (!$withOptedOut) {
$where = ['@relation.optedOut' => false];
}
$records = $this->entityManager
->getRelation($targetList, $link)
->select([Attribute::ID, Field::EMAIL_ADDRESS])
->sth()
->where($where)
->find();
foreach ($records as $record) {
$hashId = $record->getEntityType() . '-' . $record->getId();
$emailAddress = $record->get(Field::EMAIL_ADDRESS);
if (!$emailAddress) {
continue;
}
if (!empty($metEmailAddressHash[$emailAddress])) {
continue;
}
if (!empty($metTargetHash[$hashId])) {
continue;
}
$item = $record->getValueMap();
$item->entityType = $record->getEntityType();
$itemList[] = $item;
$metTargetHash[$hashId] = true;
$metEmailAddressHash[$emailAddress] = true;
}
}
}
return $itemList;
}
}
@@ -29,10 +29,11 @@
namespace Espo\Modules\Crm\Tools\MassEmail;
use Espo\Core\Mail\ConfigDataProvider;
use Espo\ORM\EntityCollection;
use Laminas\Mail\Message;
use Espo\Core\Field\DateTime;
use Espo\Core\Mail\ConfigDataProvider;
use Espo\ORM\EntityCollection;
use Espo\Core\Name\Field;
use Espo\Tools\EmailTemplate\Result;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory;
@@ -50,7 +51,6 @@ use Espo\Core\Mail\EmailSender;
use Espo\Core\Mail\Sender;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Entities\Email;
@@ -65,7 +65,6 @@ use Espo\Tools\EmailTemplate\Params as TemplateParams;
use Espo\Tools\EmailTemplate\Processor as TemplateProcessor;
use Exception;
use DateTime;
class SendingProcessor
{
@@ -83,6 +82,7 @@ class SendingProcessor
private MessageHeadersPreparator $headersPreparator,
private TemplateProcessor $templateProcessor,
private ConfigDataProvider $configDataProvider,
private Config\ApplicationConfig $applicationConfig,
) {}
/**
@@ -113,7 +113,6 @@ class SendingProcessor
return;
}
$campaign = $massEmail->getCampaign();
$attachmentList = $emailTemplate->getAttachments();
[$smtpParams, $senderParams] = $this->getSenderParams($massEmail);
$queueItemList = $this->getQueueItems($massEmail, $isTest, $maxSize);
@@ -124,7 +123,6 @@ class SendingProcessor
massEmail: $massEmail,
emailTemplate: $emailTemplate,
attachmentList: $attachmentList,
campaign: $campaign,
isTest: $isTest,
smtpParams: $smtpParams,
senderParams: $senderParams,
@@ -168,11 +166,9 @@ class SendingProcessor
->withParent($target)
);
$body = $this->prepareBody($emailData, $queueItem, $trackingUrlList, $massEmail);
$body = $this->prepareBody($massEmail, $queueItem, $emailData, $trackingUrlList);
$email = $this->entityManager
->getRDBRepositoryByClass(Email::class)
->getNew();
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email
->addToAddress($emailAddress)
@@ -203,7 +199,7 @@ class SendingProcessor
$id = $queueItem->getId();
$this->headersPreparator->prepare($message->getHeaders(), new Data($id, $senderParams));
$this->headersPreparator->prepare($message->getHeaders(), new Data($id, $senderParams, $queueItem));
$fromAddress = $senderParams->getFromAddress();
@@ -246,10 +242,9 @@ class SendingProcessor
MassEmail $massEmail,
EmailTemplate $emailTemplate,
EntityCollection $attachmentList,
?Campaign $campaign,
bool $isTest,
?SmtpParams $smtpParams,
SenderParams $senderParams
SenderParams $senderParams,
): void {
if ($this->isNotPending($queueItem)) {
@@ -284,18 +279,18 @@ class SendingProcessor
}
$email = $this->getPreparedEmail(
$queueItem,
$massEmail,
$emailTemplate,
$target,
$this->getTrackingUrls($campaign)
queueItem: $queueItem,
massEmail: $massEmail,
emailTemplate: $emailTemplate,
target: $target,
trackingUrlList: $this->getTrackingUrls($massEmail->getCampaign()),
);
if (!$email) {
return;
}
$senderParams = $this->prepareItemSenderParams($email, $senderParams, $campaign, $massEmail);
$senderParams = $this->prepareItemSenderParams($email, $senderParams, $massEmail);
$queueItem->incrementAttemptCount();
@@ -331,16 +326,15 @@ class SendingProcessor
$this->setItemSent($queueItem, $emailAddress);
if ($campaign) {
$this->campaignService->logSent($campaign->getId(), $queueItem, $emailObject);
if ($massEmail->getCampaign()) {
$this->campaignService->logSent($massEmail->getCampaign()->getId(), $queueItem, $emailObject);
}
}
private function getSiteUrl(): string
{
return
$this->config->get('massEmailSiteUrl') ??
$this->config->get('siteUrl');
return $this->config->get('massEmailSiteUrl') ??
$this->applicationConfig->getSiteUrl();
}
/**
@@ -448,14 +442,13 @@ class SendingProcessor
$batchMaxSize = $this->config->get('massEmailMaxPerBatchCount');
if (!$isTest) {
$threshold = new DateTime();
$threshold->modify('-1 hour');
$threshold = DateTime::createNow()->addHours(-1);
$sentLastHourCount = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_SENT,
'sentAt>' => $threshold->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
'sentAt>' => $threshold->toString(),
])
->count();
@@ -510,13 +503,12 @@ class SendingProcessor
*/
private function getTrackingUrls(?Campaign $campaign): iterable
{
if (!$campaign) {
if (!$campaign || $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return [];
}
/** @var Collection<CampaignTrackingUrl> */
/** @var Collection<CampaignTrackingUrl> */
return $this->entityManager
->getRDBRepositoryByClass(Campaign::class)
->getRelation($campaign, 'trackingUrls')
->find();
}
@@ -524,10 +516,11 @@ class SendingProcessor
private function prepareItemSenderParams(
Email $email,
SenderParams $senderParams,
?Campaign $campaign,
MassEmail $massEmail
): SenderParams {
$campaign = $massEmail->getCampaign();
if ($email->get('replyToAddress')) { // @todo Revise.
$senderParams = $senderParams->withReplyToAddress(null);
}
@@ -567,6 +560,7 @@ class SendingProcessor
private function getTrackUrl(mixed $trackingUrl, EmailQueueItem $queueItem): string
{
$siteUrl = $this->getSiteUrl();
$id1 = $trackingUrl->getId();
$id2 = $queueItem->getId();
@@ -577,55 +571,26 @@ class SendingProcessor
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function prepareBody(
Result $emailData,
MassEmail $massEmail,
EmailQueueItem $queueItem,
$trackingUrlList,
MassEmail $massEmail
Result $emailData,
iterable $trackingUrlList,
): string {
$body = $emailData->getBody();
$body = $this->addBodyLinks(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $emailData->getBody(),
trackingUrlList: $trackingUrlList,
);
$optOutUrl = $this->getOptOutUrl($queueItem);
$optOutLink = $this->getOptOutLink($optOutUrl);
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
$body = str_replace('{queueItemId}', $queueItem->getId(), $body);
foreach ($trackingUrlList as $trackingUrl) {
$url = $this->getTrackUrl($trackingUrl, $queueItem);
$body = str_replace($trackingUrl->getUrlToUse(), $url, $body);
}
if (
!$this->config->get('massEmailDisableMandatoryOptOutLink') &&
stripos($body, '?entryPoint=unsubscribe&id') === false
) {
if ($emailData->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
}
$trackImageAlt = $this->defaultLanguage->translateLabel('Campaign', 'scopeNames');
$trackOpenedUrl = $this->getSiteUrl() . '?entryPoint=campaignTrackOpened&id=' . $queueItem->getId();
/** @noinspection HtmlDeprecatedAttribute */
$trackOpenedHtml = "<img alt=\"$trackImageAlt\" width=\"1\" height=\"1\" border=\"0\" src=\"$trackOpenedUrl\">";
if (
$massEmail->getCampaignId() &&
$this->config->get('massEmailOpenTracking')
) {
if ($emailData->isHtml()) {
$body .= '<br>' . $trackOpenedHtml;
}
}
return $body;
return $this->addBodyTracking(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $body,
);
}
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
@@ -634,4 +599,105 @@ class SendingProcessor
$massEmail->getCampaign() &&
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function addBodyLinks(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body,
iterable $trackingUrlList,
): string {
$optOutUrl = $this->getOptOutUrl($queueItem);
$optOutLink = $this->getOptOutLink($optOutUrl);
if (!$this->isInformational($massEmail)) {
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
}
$body = str_replace('{queueItemId}', $queueItem->getId(), $body);
foreach ($trackingUrlList as $trackingUrl) {
$url = $this->getTrackUrl($trackingUrl, $queueItem);
$body = str_replace($trackingUrl->getUrlToUse(), $url, $body);
}
return $this->addMandatoryBodyOptOutLink($massEmail, $queueItem, $emailData, $body);
}
private function addMandatoryBodyOptOutLink(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body,
): string {
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
return $body;
}
if ($this->isInformational($massEmail)) {
return $body;
}
if (stripos($body, '?entryPoint=unsubscribe&id') !== false) {
return $body;
}
$optOutUrl = $this->getOptOutUrl($queueItem);
$optOutLink = $this->getOptOutLink($optOutUrl);
if ($emailData->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
return $body;
}
private function addBodyTracking(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body
): string {
if (!$massEmail->getCampaign()) {
return $body;
}
if ($massEmail->getCampaign()->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return $body;
}
if (!$this->config->get('massEmailOpenTracking')) {
return $body;
}
if (!$emailData->isHtml()) {
return $body;
}
$alt = $this->defaultLanguage->translateLabel('Campaign', 'scopeNames');
$url = "{$this->getSiteUrl()}?entryPoint=campaignTrackOpened&id={$queueItem->getId()}";
/** @noinspection HtmlDeprecatedAttribute */
$trackOpenedHtml = "<img alt=\"$alt\" width=\"1\" height=\"1\" border=\"0\" src=\"$url\">";
$body .= '<br>' . $trackOpenedHtml;
return $body;
}
private function isInformational(MassEmail $massEmail): bool
{
return $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
}
}
@@ -235,7 +235,7 @@ class UnsubscribeService
[,, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
@@ -248,16 +248,8 @@ class UnsubscribeService
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
foreach ($massEmail->getTargetLists() as $targetList) {
$relation = $this->entityManager->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
return true;
@@ -289,8 +281,7 @@ class UnsubscribeService
*/
private function getRecords(string $queueItemId): array
{
/** @var ?EmailQueueItem $queueItem */
$queueItem = $this->entityManager->getEntityById(EmailQueueItem::ENTITY_TYPE, $queueItemId);
$queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId);
if (!$queueItem) {
throw new NotFound("No item.");
@@ -303,10 +294,13 @@ class UnsubscribeService
}
$campaign = $massEmail->getCampaign();
$targetType = $queueItem->getTargetType();
$targetId = $queueItem->getTargetId();
if ($campaign && $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
throw new NotFound("Campaign is informational.");
}
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
@@ -26,90 +26,105 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
define('crm:views/campaign/record/panels/campaign-stats', ['views/record/panels/side'], function (Dep) {
import SidePanelView from 'views/record/panels/side';
return Dep.extend({
// noinspection JSUnusedGlobalSymbols
export default class extends SidePanelView {
controlStatsFields: function () {
var type = this.model.get('type');
var fieldList;
controlStatsFields() {
const type = this.model.attributes.type;
switch (type) {
case 'Email':
case 'Newsletter':
fieldList = [
'sentCount',
'openedCount',
'clickedCount',
'optedOutCount', 'bouncedCount', 'leadCreatedCount', 'optedInCount', 'revenue'];
break;
let fieldList;
case 'Web':
fieldList = ['leadCreatedCount', 'optedInCount', 'revenue'];
switch (type) {
case 'Email':
case 'Newsletter':
fieldList = [
'sentCount',
'openedCount',
'clickedCount',
'optedOutCount',
'bouncedCount',
'leadCreatedCount',
'optedInCount',
'revenue',
];
break;
break;
case 'Television':
case 'Radio':
fieldList = ['leadCreatedCount', 'revenue'];
case 'Informational Email':
fieldList = [
'sentCount',
'bouncedCount',
];
break;
break;
case 'Mail':
fieldList = ['sentCount', 'leadCreatedCount', 'optedInCount', 'revenue'];
case 'Web':
fieldList = ['leadCreatedCount', 'optedInCount', 'revenue'];
break;
break;
default:
fieldList = ['leadCreatedCount', 'revenue'];
}
case 'Television':
case 'Radio':
fieldList = ['leadCreatedCount', 'revenue'];
if (!this.getConfig().get('massEmailOpenTracking')) {
var i = fieldList.indexOf('openedCount')
break;
if (~i) {
fieldList.splice(i, 1);
}
case 'Mail':
fieldList = ['sentCount', 'leadCreatedCount', 'optedInCount', 'revenue'];
break;
default:
fieldList = ['leadCreatedCount', 'revenue'];
}
if (!this.getConfig().get('massEmailOpenTracking')) {
const i = fieldList.indexOf('openedCount');
if (i > -1) {
fieldList.splice(i, 1);
}
}
this.statsFieldList.forEach(item => {
this.options.recordViewObject.hideField(item);
});
this.statsFieldList.forEach(item => {
this.options.recordViewObject.hideField(item);
});
fieldList.forEach(item => {
this.options.recordViewObject.showField(item);
});
fieldList.forEach(item => {
this.options.recordViewObject.showField(item);
});
if (!this.getAcl().checkScope('Lead')) {
this.options.recordViewObject.hideField('leadCreatedCount');
}
if (!this.getAcl().checkScope('Lead')) {
this.options.recordViewObject.hideField('leadCreatedCount', true);
}
if (!this.getAcl().checkScope('Opportunity')) {
this.options.recordViewObject.hideField('revenue');
}
},
if (!this.getAcl().checkScope('Opportunity')) {
this.options.recordViewObject.hideField('revenue', true);
}
}
setupFields: function () {
this.fieldList = [
'sentCount',
'openedCount',
'clickedCount', 'optedOutCount', 'bouncedCount', 'leadCreatedCount', 'optedInCount', 'revenue'];
setupFields() {
this.fieldList = [
'sentCount',
'openedCount',
'clickedCount',
'optedOutCount',
'bouncedCount',
'leadCreatedCount',
'optedInCount',
'revenue',
];
this.statsFieldList = this.fieldList;
},
this.statsFieldList = this.fieldList;
}
setup: function () {
Dep.prototype.setup.call(this);
setup() {
super.setup();
this.controlStatsFields();
this.controlStatsFields();
this.listenTo(this.model, 'change:type', () => {
this.controlStatsFields();
});
},
actionRefresh: function () {
this.model.fetch();
},
});
});
this.listenTo(this.model, 'change:type', () => this.controlStatsFields());
}
}