diff --git a/application/Espo/Modules/Crm/Classes/RecordHooks/Campaign/BeforeUpdate.php b/application/Espo/Modules/Crm/Classes/RecordHooks/Campaign/BeforeUpdate.php new file mode 100644 index 0000000000..6154c05a03 --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/RecordHooks/Campaign/BeforeUpdate.php @@ -0,0 +1,77 @@ +. + * + * 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 + */ +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) + ); + } + } +} diff --git a/application/Espo/Modules/Crm/Classes/RecordHooks/CampaignTrackingUrl/BeforeCreate.php b/application/Espo/Modules/Crm/Classes/RecordHooks/CampaignTrackingUrl/BeforeCreate.php index 5645c44b67..1151ea91d9 100644 --- a/application/Espo/Modules/Crm/Classes/RecordHooks/CampaignTrackingUrl/BeforeCreate.php +++ b/application/Espo/Modules/Crm/Classes/RecordHooks/CampaignTrackingUrl/BeforeCreate.php @@ -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."); } } diff --git a/application/Espo/Modules/Crm/Classes/RecordHooks/MassEmail/BeforeCreate.php b/application/Espo/Modules/Crm/Classes/RecordHooks/MassEmail/BeforeCreate.php index 43ae863635..bf0cb961af 100644 --- a/application/Espo/Modules/Crm/Classes/RecordHooks/MassEmail/BeforeCreate.php +++ b/application/Espo/Modules/Crm/Classes/RecordHooks/MassEmail/BeforeCreate.php @@ -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."); } } diff --git a/application/Espo/Modules/Crm/Entities/Campaign.php b/application/Espo/Modules/Crm/Entities/Campaign.php index 6dd23d0455..1f591e119e 100644 --- a/application/Espo/Modules/Crm/Entities/Campaign.php +++ b/application/Espo/Modules/Crm/Entities/Campaign.php @@ -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'; diff --git a/application/Espo/Modules/Crm/Entities/CampaignTrackingUrl.php b/application/Espo/Modules/Crm/Entities/CampaignTrackingUrl.php index 8233709b29..44a15e7e0a 100644 --- a/application/Espo/Modules/Crm/Entities/CampaignTrackingUrl.php +++ b/application/Espo/Modules/Crm/Entities/CampaignTrackingUrl.php @@ -94,4 +94,10 @@ class CampaignTrackingUrl extends Entity { return !$this->isNew(); } + + public function getCampaign(): ?Campaign + { + /** @var ?Campaign */ + return $this->relations->getOne('campaign'); + } } diff --git a/application/Espo/Modules/Crm/Entities/EmailQueueItem.php b/application/Espo/Modules/Crm/Entities/EmailQueueItem.php index fe564248b0..a615140ebf 100644 --- a/application/Espo/Modules/Crm/Entities/EmailQueueItem.php +++ b/application/Espo/Modules/Crm/Entities/EmailQueueItem.php @@ -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; diff --git a/application/Espo/Modules/Crm/Entities/MassEmail.php b/application/Espo/Modules/Crm/Entities/MassEmail.php index 5464d2a83d..51942f3939 100644 --- a/application/Espo/Modules/Crm/Entities/MassEmail.php +++ b/application/Espo/Modules/Crm/Entities/MassEmail.php @@ -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 + */ + public function getTargetLists(): EntityCollection + { + /** @var EntityCollection */ + return $this->relations->getMany('targetLists'); + } + + /** + * @return EntityCollection + */ + public function getExcludingTargetLists(): EntityCollection + { + /** @var EntityCollection */ + return $this->relations->getMany('excludingTargetLists'); + } + public function getEmailTemplate(): ?EmailTemplate { /** @var ?EmailTemplate */ diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/Campaign.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/Campaign.json index 9df767083d..d661ac0bed 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/en_US/Campaign.json +++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/Campaign.json @@ -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." }, diff --git a/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detail.json b/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detail.json index a142660d4c..6cd3be5647 100644 --- a/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detail.json +++ b/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detail.json @@ -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 @@ ] ] } -] \ No newline at end of file +] diff --git a/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detailSmall.json b/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detailSmall.json index a42b52b53f..60b896a80c 100644 --- a/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detailSmall.json +++ b/application/Espo/Modules/Crm/Resources/layouts/MassEmail/detailSmall.json @@ -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 @@ ] ] } -] \ No newline at end of file +] diff --git a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Campaign.json b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Campaign.json index 50f2d7bace..6de160245f 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Campaign.json +++ b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Campaign.json @@ -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" + ] } ] } diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Campaign.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Campaign.json index b59e7a2297..8aaa625588 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Campaign.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Campaign.json @@ -29,12 +29,14 @@ "options": [ "Email", "Newsletter", + "Informational Email", "Web", "Television", "Radio", "Mail" ], "default": "Email", + "maxLength": 64, "customizationOptionsDisabled": true, "customizationOptionsReferenceDisabled": true }, diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignTrackingUrl.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignTrackingUrl.json index af351bf7c5..31d294afbd 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignTrackingUrl.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/CampaignTrackingUrl.json @@ -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", diff --git a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Campaign.json b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Campaign.json index 9d942a2722..f4d17959e0 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Campaign.json +++ b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Campaign.json @@ -1,5 +1,8 @@ { "readLoaderClassNameList": [ "Espo\\Modules\\Crm\\Classes\\FieldProcessing\\Campaign\\StatsLoader" + ], + "beforeUpdateHookClassNameList": [ + "Espo\\Modules\\Crm\\Classes\\RecordHooks\\Campaign\\BeforeUpdate" ] } diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/DefaultMessageHeadersPreparator.php b/application/Espo/Modules/Crm/Tools/MassEmail/DefaultMessageHeadersPreparator.php index 51e43376b5..ee61682cb3 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/DefaultMessageHeadersPreparator.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/DefaultMessageHeadersPreparator.php @@ -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>"); + } } diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/MessagePreparator/Data.php b/application/Espo/Modules/Crm/Tools/MassEmail/MessagePreparator/Data.php index 40ad6915a2..553e5bb11c 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/MessagePreparator/Data.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/MessagePreparator/Data.php @@ -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; + } } diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php b/application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php index c6b8e03daf..38f69afc00 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/QueueCreator.php @@ -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 $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 $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; + } } diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php index ade21e13da..e52fb84bb9 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php @@ -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 */ + /** @var Collection */ 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 $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 .= "

" . $optOutLink; - } else { - $body .= "\n\n" . $optOutUrl; - } - } - - $trackImageAlt = $this->defaultLanguage->translateLabel('Campaign', 'scopeNames'); - - $trackOpenedUrl = $this->getSiteUrl() . '?entryPoint=campaignTrackOpened&id=' . $queueItem->getId(); - - /** @noinspection HtmlDeprecatedAttribute */ - $trackOpenedHtml = "\"$trackImageAlt\""; - - if ( - $massEmail->getCampaignId() && - $this->config->get('massEmailOpenTracking') - ) { - if ($emailData->isHtml()) { - $body .= '
' . $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 $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 .= "

" . $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 = "\"$alt\""; + + $body .= '
' . $trackOpenedHtml; + + return $body; + } + + private function isInformational(MassEmail $massEmail): bool + { + return $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL; + } } diff --git a/application/Espo/Modules/Crm/Tools/MassEmail/UnsubscribeService.php b/application/Espo/Modules/Crm/Tools/MassEmail/UnsubscribeService.php index d54fc3a5b3..1dfa9e30ba 100644 --- a/application/Espo/Modules/Crm/Tools/MassEmail/UnsubscribeService.php +++ b/application/Espo/Modules/Crm/Tools/MassEmail/UnsubscribeService.php @@ -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 $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) { diff --git a/client/modules/crm/src/views/campaign/record/panels/campaign-stats.js b/client/modules/crm/src/views/campaign/record/panels/campaign-stats.js index 401e3f1677..2ed09b84ae 100644 --- a/client/modules/crm/src/views/campaign/record/panels/campaign-stats.js +++ b/client/modules/crm/src/views/campaign/record/panels/campaign-stats.js @@ -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()); + } +}