This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
espocrm-base/application/Espo/Modules/Crm/Tools/MassEmail/SendingProcessor.php
T
Yuri Kuznetsov 427d2dcf52 ref
2023-04-21 17:24:54 +03:00

539 lines
17 KiB
PHP

<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Modules\Crm\Tools\MassEmail;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\SmtpParams;
use Espo\Entities\Attachment;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\ORM\Collection;
use Laminas\Mail\Message;
use Espo\Entities\EmailTemplate;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Entities\EmailAddress;
use Espo\Core\Exceptions\Error;
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;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
use Espo\ORM\Entity;
use Espo\Tools\EmailTemplate\Data as TemplateData;
use Espo\Tools\EmailTemplate\Params as TemplateParams;
use Espo\Tools\EmailTemplate\Processor as TemplateProcessor;
use Exception;
use DateTime;
class SendingProcessor
{
private const MAX_ATTEMPT_COUNT = 3;
private const MAX_PER_HOUR_COUNT = 10000;
private Config $config;
private EntityManager $entityManager;
private Language $defaultLanguage;
private EmailSender $emailSender;
private Log $log;
private AccountFactory $accountFactory;
private CampaignService $campaignService;
private MessageHeadersPreparator $headersPreparator;
private TemplateProcessor $templateProcessor;
public function __construct(
Config $config,
EntityManager $entityManager,
Language $defaultLanguage,
EmailSender $emailSender,
Log $log,
AccountFactory $accountFactory,
CampaignService $campaignService,
MessageHeadersPreparator $headersPreparator,
TemplateProcessor $templateProcessor
) {
$this->config = $config;
$this->entityManager = $entityManager;
$this->defaultLanguage = $defaultLanguage;
$this->emailSender = $emailSender;
$this->log = $log;
$this->accountFactory = $accountFactory;
$this->campaignService = $campaignService;
$this->headersPreparator = $headersPreparator;
$this->templateProcessor = $templateProcessor;
}
/**
* @throws Error
* @throws NoSmtp
*/
public function process(MassEmail $massEmail, bool $isTest = false): void
{
$maxBatchSize = $this->config->get('massEmailMaxPerHourCount', self::MAX_PER_HOUR_COUNT);
if (!$isTest) {
$threshold = new DateTime();
$threshold->modify('-1 hour');
$sentLastHourCount = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_SENT,
'sentAt>' => $threshold->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
])
->count();
if ($sentLastHourCount >= $maxBatchSize) {
return;
}
$maxBatchSize = $maxBatchSize - $sentLastHourCount;
}
$queueItemList = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => $isTest,
])
->limit(0, $maxBatchSize)
->find();
$templateId = $massEmail->getEmailTemplateId();
if (!$templateId) {
$this->setFailed($massEmail);
return;
}
$campaign = null;
$campaignId = $massEmail->getCampaignId();
if ($campaignId) {
$campaign = $this->entityManager->getEntityById(Campaign::ENTITY_TYPE, $campaignId);
}
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($templateId);
if (!$emailTemplate) {
$this->setFailed($massEmail);
return;
}
/** @var Collection<Attachment> $attachmentList */
$attachmentList = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getRelation($emailTemplate, 'attachments')
->find();
$smtpParams = null;
$senderParams = SenderParams::create();
$inboundEmailId = $massEmail->getInboundEmailId();
if ($inboundEmailId) {
$account = $this->accountFactory->create($inboundEmailId);
$smtpParams = $account->getSmtpParams();
if (
!$account->isAvailableForSending() ||
!$account->getEntity()->smtpIsForMassEmail() ||
!$smtpParams
) {
throw new Error("Mass Email: Group email account {$inboundEmailId} can't be used for mass email.");
}
if ($account->getEntity()->getReplyToAddress()) {
$senderParams = $senderParams
->withReplyToAddress(
$account->getEntity()->getReplyToAddress()
);
}
}
foreach ($queueItemList as $queueItem) {
$this->sendQueueItem(
$queueItem,
$massEmail,
$emailTemplate,
$attachmentList,
$campaign,
$isTest,
$smtpParams,
$senderParams
);
}
if ($isTest) {
return;
}
$countLeft = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => false,
])
->count();
if ($countLeft === 0) {
$massEmail->set('status', MassEmail::STATUS_COMPLETE);
$this->entityManager->saveEntity($massEmail);
}
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function getPreparedEmail(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
Entity $target,
iterable $trackingUrlList = []
): ?Email {
$emailAddress = $target->get('emailAddress');
if (!$emailAddress) {
return null;
}
$emailData = $this->templateProcessor->process(
$emailTemplate,
TemplateParams::create()
->withApplyAcl(false) // @todo Revise.
->withCopyAttachments(false), // @todo Revise.
TemplateData::create()
->withParent($target)
);
$body = $emailData->getBody();
$optOutUrl = $this->getSiteUrl() . '?entryPoint=unsubscribe&id=' . $queueItem->getId();
$optOutLink =
'<a href="' . $optOutUrl . '">' .
$this->defaultLanguage->translateLabel('Unsubscribe', 'labels', Campaign::ENTITY_TYPE) .
'</a>';
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
foreach ($trackingUrlList as $trackingUrl) {
$url = $this->getSiteUrl() .
'?entryPoint=campaignUrl&id=' . $trackingUrl->getId() . '&queueItemId=' . $queueItem->getId();
$body = str_replace($trackingUrl->get('urlToUse'), $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();
$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;
}
}
/** @var Email $email */
$email = $this->entityManager
->getRDBRepositoryByClass(Email::class)
->getNew();
$email
->addToAddress($emailAddress)
->setSubject($emailData->getSubject())
->setBody($body)
->setIsHtml($emailData->isHtml())
->setAttachmentIdList($emailData->getAttachmentIdList());
if ($massEmail->getFromAddress()) {
$email->setFromAddress($massEmail->getFromAddress());
}
$replyToAddress = $massEmail->getReplyToAddress();
if ($replyToAddress) {
$email->addReplyToAddress($replyToAddress);
}
return $email;
}
private function prepareQueueItemMessage(
EmailQueueItem $queueItem,
Sender $sender,
Message $message,
SenderParams $senderParams
): void {
$id = $queueItem->getId();
$this->headersPreparator->prepare($message->getHeaders(), new Data($id, $senderParams));
$fromAddress = $senderParams->getFromAddress();
if (
$this->config->get('massEmailVerp') &&
$fromAddress &&
strpos($fromAddress, '@')
) {
$bounceAddress = explode('@', $fromAddress)[0] . '+bounce-qid-' . $id .
'@' . explode('@', $fromAddress)[1];
$sender->withEnvelopeOptions(['from' => $bounceAddress]);
}
}
private function setFailed(MassEmail $massEmail): void
{
$massEmail->set('status', MassEmail::STATUS_FAILED);
$this->entityManager->saveEntity($massEmail);
$queueItemList = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
])
->find();
foreach ($queueItemList as $queueItem) {
$queueItem->set('status', EmailQueueItem::STATUS_FAILED);
$this->entityManager->saveEntity($queueItem);
}
}
/**
* @param Collection<Attachment> $attachmentList
*/
private function sendQueueItem(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
$attachmentList,
?Campaign $campaign,
bool $isTest,
?SmtpParams $smtpParams,
SenderParams $senderParams
): void {
/** @var ?EmailQueueItem $queueItemFetched */
$queueItemFetched = $this->entityManager->getEntityById($queueItem->getEntityType(), $queueItem->getId());
if (
!$queueItemFetched ||
$queueItemFetched->getStatus() !== EmailQueueItem::STATUS_PENDING
) {
return;
}
$queueItem->set('status', EmailQueueItem::STATUS_SENDING);
$this->entityManager->saveEntity($queueItem);
$target = $this->entityManager->getEntityById($queueItem->getTargetType(), $queueItem->getTargetId());
$emailAddress = $target ? $target->get('emailAddress') : null;
if (
!$target ||
!$target->hasId() ||
!$emailAddress
) {
$queueItem->set('status', EmailQueueItem::STATUS_FAILED);
$this->entityManager->saveEntity($queueItem);
return;
}
/** @var EmailAddressRepository $emailAddressRepository */
$emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
$emailAddressRecord = $emailAddressRepository->getByAddress($emailAddress);
if (
$emailAddressRecord && (
$emailAddressRecord->isInvalid() ||
$emailAddressRecord->isOptedOut()
)
) {
$queueItem->set('status', EmailQueueItem::STATUS_FAILED);
$this->entityManager->saveEntity($queueItem);
return;
}
/** @var CampaignTrackingUrl[] $trackingUrlList */
$trackingUrlList = [];
if ($campaign) {
/** @var Collection<CampaignTrackingUrl> $trackingUrlList */
$trackingUrlList = $this->entityManager
->getRDBRepositoryByClass(Campaign::class)
->getRelation($campaign, 'trackingUrls')
->find();
}
$email = $this->getPreparedEmail($queueItem, $massEmail, $emailTemplate, $target, $trackingUrlList);
if (!$email) {
return;
}
if ($email->get('replyToAddress')) { // @todo Revise.
$senderParams = $senderParams->withReplyToAddress(null);
}
if ($campaign) {
$email->setLinkMultipleIdList('teams', $campaign->getLinkMultipleIdList('teams'));
}
$senderParams = $senderParams->withFromAddress(
$massEmail->getFromAddress() ??
$this->config->get('outboundEmailFromAddress')
);
if ($massEmail->getFromName()) {
$senderParams = $senderParams->withFromName($massEmail->getFromName());
}
if ($massEmail->getReplyToName()) {
$senderParams = $senderParams->withReplyToName($massEmail->getReplyToName());
}
try {
$attemptCount = $queueItem->getAttemptCount();
$attemptCount++;
$queueItem->set('attemptCount', $attemptCount);
$sender = $this->emailSender->create();
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
$message = new Message();
$this->prepareQueueItemMessage($queueItem, $sender, $message, $senderParams);
$sender
->withParams($senderParams)
->withMessage($message)
->withAttachments($attachmentList)
->send($email);
}
catch (Exception $e) {
$maxAttemptCount = $this->config->get('massEmailMaxAttemptCount', self::MAX_ATTEMPT_COUNT);
$queueItem->getAttemptCount() >= $maxAttemptCount ?
$queueItem->set('status', EmailQueueItem::STATUS_FAILED) :
$queueItem->set('status', EmailQueueItem::STATUS_PENDING);
$this->entityManager->saveEntity($queueItem);
$this->log->error("Mass Email, send item: {$e->getCode()}, " . $e->getMessage());
return;
}
$emailObject = $emailTemplate;
if ($massEmail->storeSentEmails() && !$isTest) {
$this->entityManager->saveEntity($email);
$emailObject = $email;
}
$queueItem->set('emailAddress', $target->get('emailAddress'));
$queueItem->set('status', EmailQueueItem::STATUS_SENT);
$queueItem->set('sentAt', date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT));
$this->entityManager->saveEntity($queueItem);
if ($campaign) {
$this->campaignService->logSent($campaign->getId(), $queueItem, $emailObject);
}
}
private function getSiteUrl(): string
{
return
$this->config->get('massEmailSiteUrl') ??
$this->config->get('siteUrl');
}
}