Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
/** @noinspection PhpUnused */
class DeleteUnsubscribe implements Action
{
public function __construct(private UnsubscribeService $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
if ($hash && $emailAddress) {
$this->service->subscribeAgainWithHash($emailAddress, $hash);
return ResponseComposer::json(true);
}
if (!$id) {
throw new BadRequest();
}
$this->service->subscribeAgain($id);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Modules\Crm\Tools\MassEmail\UnsubscribeService;
/** @noinspection PhpUnused */
class PostUnsubscribe implements Action
{
public function __construct(private UnsubscribeService $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
if ($hash && $emailAddress) {
$this->service->unsubscribeWithHash($emailAddress, $hash);
return ResponseComposer::json(true);
}
if (!$id) {
throw new BadRequest();
}
$this->service->unsubscribe($id);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,91 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Core\Utils\Config;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
class DefaultMessageHeadersPreparator implements MessageHeadersPreparator
{
public function __construct(
private Config $config,
private Config\ApplicationConfig $applicationConfig,
) {}
public function prepare(Headers $headers, Data $data): void
{
$headers->addTextHeader('X-Queue-Item-Id', $data->getId());
$headers->addTextHeader('Precedence', 'bulk');
$campaignType = $this->getCampaignType($data);
if (
$campaignType === Campaign::TYPE_INFORMATIONAL_EMAIL ||
$campaignType === Campaign::TYPE_NEWSLETTER
) {
$headers->addTextHeader('Auto-Submitted', 'auto-generated');
$headers->addTextHeader('X-Auto-Response-Suppress', 'AutoReply');
}
$this->addMandatoryOptOut($headers, $data);
}
private function getSiteUrl(): string
{
$url = $this->config->get('massEmailSiteUrl') ?? $this->applicationConfig->getSiteUrl();
return rtrim($url, '/');
}
private function addMandatoryOptOut(Headers $headers, Data $data): void
{
if ($this->getCampaignType($data) === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return;
}
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
return;
}
$id = $data->getId();
$url = "{$this->getSiteUrl()}/api/v1/Campaign/unsubscribe/$id";
$headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
$headers->addTextHeader('List-Unsubscribe', "<$url>");
}
private function getCampaignType(Data $data): ?string
{
return $data->getQueueItem()->getMassEmail()?->getCampaign()?->getType();
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
/**
* Applies additional headers to a mass email message.
*/
interface MessageHeadersPreparator
{
public function prepare(Headers $headers, Data $data): void;
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\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 EmailQueueItem $queueItem,
) {}
public function getId(): string
{
return $this->id;
}
/** @noinspection PhpUnused */
public function getSenderParams(): SenderParams
{
return $this->senderParams;
}
/**
* @since 9.1.0
*/
public function getQueueItem(): EmailQueueItem
{
return $this->queueItem;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail\MessagePreparator;
use Espo\Core\Mail\Sender;
class Headers
{
public function __construct(
private Sender $sender,
) {}
public function addTextHeader(string $name, string $value): void
{
$this->sender->withAddedHeader($name, $value);
}
}

View File

@@ -0,0 +1,255 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Log;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\ORM\Entity;
use Espo\ORM\Name\Attribute;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Entities\EmailAddress;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Core\Exceptions\Error;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use stdClass;
class QueueCreator
{
private const ERASED_PREFIX = 'ERASED:';
/** @var string[] */
protected array $targetLinkList;
public function __construct(
protected EntityManager $entityManager,
private Metadata $metadata,
private Log $log,
) {
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
}
private function cleanupQueueItems(MassEmail $massEmail): void
{
$delete = $this->entityManager
->getQueryBuilder()
->delete()
->from(EmailQueueItem::ENTITY_TYPE)
->where([
'massEmailId' => $massEmail->getId(),
'status' => [
EmailQueueItem::STATUS_PENDING,
EmailQueueItem::STATUS_FAILED,
],
])
->build();
$this->entityManager->getQueryExecutor()->execute($delete);
}
/**
* @param iterable<Entity> $additionalTargetList
* @throws Error
*/
public function create(MassEmail $massEmail, bool $isTest = false, iterable $additionalTargetList = []): void
{
if (!$isTest && $massEmail->getStatus() !== MassEmail::STATUS_PENDING) {
throw new Error("Mass Email {$massEmail->getId()} should has status 'Pending'.");
}
if ($this->toSkipAsInactive($massEmail, $isTest)) {
$this->log->notice("Skipping mass email {id} queue creation for inactive campaign.", [
'id' => $massEmail->getId(),
]);
return;
}
$withOptedOut = $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
if (!$isTest) {
$this->cleanupQueueItems($massEmail);
}
$itemList = [];
if (!$isTest) {
$itemList = $this->getItemList($massEmail, $withOptedOut);
}
foreach ($additionalTargetList as $record) {
$item = $record->getValueMap();
$item->entityType = $record->getEntityType();
$itemList[] = $item;
}
foreach ($itemList as $item) {
$emailAddress = $item->emailAddress ?? null;
if (!$emailAddress) {
continue;
}
if (str_starts_with($emailAddress, self::ERASED_PREFIX)) {
continue;
}
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($emailAddressRecord && !$withOptedOut && $emailAddressRecord->isOptedOut()) {
continue;
}
if ($emailAddressRecord && $emailAddressRecord->isInvalid()) {
continue;
}
$queueItem = $this->entityManager->getNewEntity(EmailQueueItem::ENTITY_TYPE);
$queueItem->set([
'massEmailId' => $massEmail->getId(),
'status' => EmailQueueItem::STATUS_PENDING,
'targetId' => $item->id,
'targetType' => $item->entityType,
'isTest' => $isTest,
]);
$this->entityManager->saveEntity($queueItem);
}
if ($isTest) {
return;
}
$massEmail->setStatus(MassEmail::STATUS_IN_PROCESS);
if ($itemList === []) {
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
}
$this->entityManager->saveEntity($massEmail);
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
{
return !$isTest &&
$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;
}
}

View File

@@ -0,0 +1,710 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
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;
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 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\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;
class SendingProcessor
{
private const MAX_ATTEMPT_COUNT = 3;
private const MAX_PER_HOUR_COUNT = 10000;
public function __construct(
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,
private ConfigDataProvider $configDataProvider,
private Config\ApplicationConfig $applicationConfig,
) {}
/**
* @throws Error
* @throws NoSmtp
*/
public function process(MassEmail $massEmail, bool $isTest = false): void
{
if ($this->toSkipAsInactive($massEmail, $isTest)) {
$this->log->notice("Skipping mass email {id} queue for inactive campaign.", [
'id' => $massEmail->getId(),
]);
return;
}
$maxSize = 0;
if ($this->handleMaxSize($isTest, $maxSize)) {
return;
}
$emailTemplate = $massEmail->getEmailTemplate();
if (!$emailTemplate) {
$this->setFailed($massEmail);
return;
}
$attachmentList = $emailTemplate->getAttachments();
[$smtpParams, $senderParams] = $this->getSenderParams($massEmail);
$queueItemList = $this->getQueueItems($massEmail, $isTest, $maxSize);
foreach ($queueItemList as $queueItem) {
$this->sendQueueItem(
queueItem: $queueItem,
massEmail: $massEmail,
emailTemplate: $emailTemplate,
attachmentList: $attachmentList,
isTest: $isTest,
smtpParams: $smtpParams,
senderParams: $senderParams,
);
}
if ($isTest) {
return;
}
if ($this->getCountLeft($massEmail) !== 0) {
return;
}
$this->setComplete($massEmail);
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function getPreparedEmail(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
Entity $target,
iterable $trackingUrlList = []
): ?Email {
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if (!$emailAddress) {
return null;
}
$emailData = $this->templateProcessor->process(
$emailTemplate,
TemplateParams::create()
->withApplyAcl(false) // @todo Revise.
->withCopyAttachments(false), // @todo Revise.
TemplateData::create()
->withParent($target)
);
$body = $this->prepareBody($massEmail, $queueItem, $emailData, $trackingUrlList);
$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,
SenderParams $senderParams,
): void {
$id = $queueItem->getId();
$headers = new Headers($sender);
$this->headersPreparator->prepare($headers, new Data($id, $senderParams, $queueItem));
$fromAddress = $senderParams->getFromAddress();
if (
$this->config->get('massEmailVerp') &&
$fromAddress &&
strpos($fromAddress, '@')
) {
$bounceAddress = explode('@', $fromAddress)[0] . '+bounce-qid-' . $id .
'@' . explode('@', $fromAddress)[1];
$sender->withEnvelopeFromAddress($bounceAddress);
}
}
private function setFailed(MassEmail $massEmail): void
{
$massEmail->setStatus(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) {
$this->setItemFailed($queueItem);
}
}
/**
* @param EntityCollection<Attachment> $attachmentList
*/
private function sendQueueItem(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
EntityCollection $attachmentList,
bool $isTest,
?SmtpParams $smtpParams,
SenderParams $senderParams,
): void {
if ($this->isNotPending($queueItem)) {
return;
}
$this->setItemSending($queueItem);
$target = $this->entityManager->getEntityById($queueItem->getTargetType(), $queueItem->getTargetId());
$emailAddress = $target?->get(Field::EMAIL_ADDRESS);
if (
!$target ||
!$target->hasId() ||
!$emailAddress
) {
$this->setItemFailed($queueItem);
return;
}
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($emailAddressRecord) {
if ($emailAddressRecord->isInvalid()) {
$this->setItemFailed($queueItem);
return;
}
if (
$emailAddressRecord->isOptedOut() &&
$massEmail->getCampaign()?->getType() !== Campaign::TYPE_INFORMATIONAL_EMAIL
) {
$this->setItemFailed($queueItem);
return;
}
}
$email = $this->getPreparedEmail(
queueItem: $queueItem,
massEmail: $massEmail,
emailTemplate: $emailTemplate,
target: $target,
trackingUrlList: $this->getTrackingUrls($massEmail->getCampaign()),
);
if (!$email) {
return;
}
$senderParams = $this->prepareItemSenderParams($email, $senderParams, $massEmail);
$queueItem->incrementAttemptCount();
$sender = $this->emailSender->create();
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
try {
$this->prepareQueueItemMessage($queueItem, $sender, $senderParams);
$sender
->withParams($senderParams)
->withAttachments($attachmentList)
->send($email);
} catch (Exception $e) {
$this->processException($queueItem, $e);
return;
}
$emailObject = $emailTemplate;
if ($massEmail->storeSentEmails() && !$isTest) {
$this->entityManager->saveEntity($email);
$emailObject = $email;
}
$this->setItemSent($queueItem, $emailAddress);
if ($massEmail->getCampaign()) {
$this->campaignService->logSent($massEmail->getCampaign()->getId(), $queueItem, $emailObject);
}
}
private function getSiteUrl(): string
{
return $this->config->get('massEmailSiteUrl') ??
$this->applicationConfig->getSiteUrl();
}
/**
* @throws Error
* @throws NoSmtp
* @return array{?SmtpParams, SenderParams}
*/
private function getSenderParams(MassEmail $massEmail): array
{
$smtpParams = null;
$senderParams = SenderParams::create();
$inboundEmailId = $massEmail->getInboundEmailId();
if (!$inboundEmailId) {
return [$smtpParams, $senderParams];
}
$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());
}
return [$smtpParams, $senderParams];
}
private function getCountLeft(MassEmail $massEmail): int
{
return $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => false,
])
->count();
}
private function processException(EmailQueueItem $queueItem, Exception $e): void
{
$maxAttemptCount = $this->config->get('massEmailMaxAttemptCount', self::MAX_ATTEMPT_COUNT);
$queueItem->getAttemptCount() >= $maxAttemptCount ?
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED) :
$queueItem->setStatus(EmailQueueItem::STATUS_PENDING);
$this->entityManager->saveEntity($queueItem);
$this->log->error("Mass Email, send item: {$e->getCode()}, {$e->getMessage()}");
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
private function isNotPending(EmailQueueItem $queueItem): bool
{
/** @var ?EmailQueueItem $queueItemFetched */
$queueItemFetched = $this->entityManager->getEntityById(EmailQueueItem::ENTITY_TYPE, $queueItem->getId());
if (!$queueItemFetched) {
return true;
}
return $queueItemFetched->getStatus() !== EmailQueueItem::STATUS_PENDING;
}
/**
* @return Collection<EmailQueueItem>
*/
private function getQueueItems(MassEmail $massEmail, bool $isTest, int $maxSize): Collection
{
return $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->sth()
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => $isTest,
])
->limit(0, $maxSize)
->find();
}
/**
* @return bool Whether to skip.
*/
private function handleMaxSize(bool $isTest, int &$maxSize): bool
{
$hourMaxSize = $this->config->get('massEmailMaxPerHourCount', self::MAX_PER_HOUR_COUNT);
$batchMaxSize = $this->config->get('massEmailMaxPerBatchCount');
if (!$isTest) {
$threshold = DateTime::createNow()->addHours(-1);
$sentLastHourCount = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_SENT,
'sentAt>' => $threshold->toString(),
])
->count();
if ($sentLastHourCount >= $hourMaxSize) {
return true;
}
$hourMaxSize = $hourMaxSize - $sentLastHourCount;
}
$maxSize = $hourMaxSize;
if ($batchMaxSize) {
$maxSize = min($batchMaxSize, $maxSize);
}
return false;
}
private function setComplete(MassEmail $massEmail): void
{
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
$this->entityManager->saveEntity($massEmail);
}
private function setItemSending(EmailQueueItem $queueItem): void
{
$queueItem->setStatus(EmailQueueItem::STATUS_SENDING);
$this->entityManager->saveEntity($queueItem);
}
private function setItemFailed(EmailQueueItem $queueItem): void
{
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED);
$this->entityManager->saveEntity($queueItem);
}
private function setItemSent(EmailQueueItem $queueItem, string $emailAddress): void
{
$queueItem->setEmailAddress($emailAddress);
$queueItem->setStatus(EmailQueueItem::STATUS_SENT);
$queueItem->setSentAtNow();
$this->entityManager->saveEntity($queueItem);
}
/**
* @return iterable<CampaignTrackingUrl>
*/
private function getTrackingUrls(?Campaign $campaign): iterable
{
if (!$campaign || $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return [];
}
/** @var Collection<CampaignTrackingUrl> */
return $this->entityManager
->getRelation($campaign, 'trackingUrls')
->find();
}
private function prepareItemSenderParams(
Email $email,
SenderParams $senderParams,
MassEmail $massEmail
): SenderParams {
$campaign = $massEmail->getCampaign();
if ($email->get('replyToAddress')) { // @todo Revise.
$senderParams = $senderParams->withReplyToAddress(null);
}
if ($campaign) {
$email->setLinkMultipleIdList(Field::TEAMS, $campaign->getLinkMultipleIdList(Field::TEAMS));
}
$senderParams = $senderParams->withFromAddress(
$massEmail->getFromAddress() ??
$this->configDataProvider->getSystemOutboundAddress()
);
if ($massEmail->getFromName()) {
$senderParams = $senderParams->withFromName($massEmail->getFromName());
}
if ($massEmail->getReplyToName()) {
$senderParams = $senderParams->withReplyToName($massEmail->getReplyToName());
}
return $senderParams;
}
private function getOptOutUrl(EmailQueueItem $queueItem): string
{
return "{$this->getSiteUrl()}?entryPoint=unsubscribe&id={$queueItem->getId()}";
}
private function getOptOutLink(string $optOutUrl): string
{
$label = $this->defaultLanguage->translateLabel('Unsubscribe', 'labels', Campaign::ENTITY_TYPE);
return "<a href=\"$optOutUrl\">$label</a>";
}
private function getTrackUrl(mixed $trackingUrl, EmailQueueItem $queueItem): string
{
$siteUrl = $this->getSiteUrl();
$id1 = $trackingUrl->getId();
$id2 = $queueItem->getId();
return "$siteUrl?entryPoint=campaignUrl&id=$id1&queueItemId=$id2";
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function prepareBody(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
iterable $trackingUrlList,
): string {
$body = $this->addBodyLinks(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $emailData->getBody(),
trackingUrlList: $trackingUrlList,
);
return $this->addBodyTracking(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $body,
);
}
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
{
return !$isTest &&
$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;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Entities\InboundEmail;
use Espo\Modules\Crm\Entities\MassEmail as MassEmailEntity;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use stdClass;
class Service
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private QueueCreator $queueCreator,
private SendingProcessor $sendingProcessor
) {}
/**
* SMTP data for the front-end.
*
* @return stdClass[]
* @throws Forbidden
*/
public function getSmtpAccountDataList(): array
{
if (
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_CREATE) &&
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_EDIT)
) {
throw new Forbidden();
}
$dataList = [];
/** @var Collection<InboundEmail> $inboundEmailList */
$inboundEmailList = $this->entityManager
->getRDBRepository(InboundEmail::ENTITY_TYPE)
->where([
'useSmtp' => true,
'status' => InboundEmail::STATUS_ACTIVE,
'smtpIsForMassEmail' => true,
['emailAddress!=' => ''],
['emailAddress!=' => null],
])
->find();
foreach ($inboundEmailList as $inboundEmail) {
$item = (object) [];
$key = 'inboundEmail:' . $inboundEmail->getId();
$item->key = $key;
$item->emailAddress = $inboundEmail->getEmailAddress();
$item->fromName = $inboundEmail->getFromName();
$dataList[] = $item;
}
return $dataList;
}
/**
* Send test.
*
* @param stdClass[] $targetDataList
* @throws BadRequest
* @throws Error
* @throws Forbidden
* @throws NotFound
* @throws NoSmtp
*/
public function processTest(string $id, array $targetDataList): void
{
$targetList = [];
if (count($targetDataList) === 0) {
throw new BadRequest("Empty target list.");
}
foreach ($targetDataList as $item) {
if (empty($item->id) || empty($item->type)) {
throw new BadRequest();
}
$targetId = $item->id;
$targetType = $item->type;
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new Error("Target not found.");
}
if (!$this->acl->check($target, Table::ACTION_READ)) {
throw new Forbidden();
}
$targetList[] = $target;
}
/** @var ?MassEmailEntity $massEmail */
$massEmail = $this->entityManager->getEntityById(MassEmailEntity::ENTITY_TYPE, $id);
if (!$massEmail) {
throw new NotFound();
}
if (!$this->acl->check($massEmail, Table::ACTION_READ)) {
throw new Forbidden();
}
$this->createTestQueue($massEmail, $targetList);
$this->processTestSending($massEmail);
}
/**
* @param iterable<Entity> $targetList
* @throws Error
*/
private function createTestQueue(MassEmailEntity $massEmail, iterable $targetList): void
{
$this->queueCreator->create($massEmail, true, $targetList);
}
/**
* @throws Error
* @throws NoSmtp
*/
private function processTestSending(MassEmailEntity $massEmail): void
{
$this->sendingProcessor->process($massEmail, true);
}
}

View File

@@ -0,0 +1,332 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\HookManager;
use Espo\Core\Name\Field;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Hasher;
use Espo\Entities\EmailAddress;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignLogRecord;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\Modules\Crm\Tools\Campaign\LogService;
use Espo\Modules\Crm\Tools\MassEmail\Util as MassEmailUtil;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
class UnsubscribeService
{
public function __construct(
private EntityManager $entityManager,
private HookManager $hookManager,
private LogService $service,
private MassEmailUtil $util,
private Hasher $hasher,
) {}
/**
* @throws NotFound
*/
public function unsubscribe(string $queueItemId): void
{
[$queueItem, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address) {
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
}
}
}
$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);
if ($relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => true]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager->process(
TargetList::ENTITY_TYPE,
'afterOptOut',
$targetList,
[],
$hookData
);
}
$this->hookManager->process($target->getEntityType(), 'afterOptOut', $target);
if ($campaign) {
$this->service->logOptedOut($campaign->getId(), $queueItem, $target);
}
}
/**
* @throws NotFound
*/
public function subscribeAgain(string $queueItemId): void
{
[, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($ea) {
$ea->setOptedOut(false);
$this->entityManager->saveEntity($ea);
}
}
}
$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);
if (!$relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => false]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager
->process(TargetList::ENTITY_TYPE, 'afterCancelOptOut', $targetList, [], $hookData);
}
$this->hookManager->process($target->getEntityType(), 'afterCancelOptOut', $target);
if ($campaign) {
$logRecord = $this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItemId,
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
])
->order(Field::CREATED_AT, true)
->findOne();
if ($logRecord) {
$this->entityManager->removeEntity($logRecord);
}
}
}
/**
* @throws NotFound
*/
public function unsubscribeWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if ($address->isOptedOut()) {
return;
}
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function subscribeAgainWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if (!$address->isOptedOut()) {
return;
}
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
$address->setOptedOut(false);
$this->entityManager->saveEntity($address);
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterCancelOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function isSubscribed(string $queueItemId): bool
{
[,, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address && !$address->isOptedOut()) {
return true;
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
foreach ($massEmail->getTargetLists() as $targetList) {
$relation = $this->entityManager->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
return true;
}
}
return false;
}
/**
* @throws NotFound
*/
public function isSubscribedWithHash(string $emailAddress, string $hash): bool
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
return !$address->isOptedOut();
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
/**
* @return array{EmailQueueItem, ?Campaign, MassEmail, Entity}
* @throws NotFound
*/
private function getRecords(string $queueItemId): array
{
$queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId);
if (!$queueItem) {
throw new NotFound("No item.");
}
$massEmail = $queueItem->getMassEmail();
if (!$massEmail) {
throw new NotFound("Mass Email not found or not set.");
}
$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) {
throw new NotFound();
}
return [$queueItem, $campaign, $massEmail, $target];
}
/**
* @throws NotFound
*/
private function getEmailAddressWithHash(string $emailAddress, string $hash): EmailAddress
{
$hash2 = $this->hasher->hash($emailAddress);
if ($hash2 !== $hash) {
throw new NotFound();
}
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if (!$address) {
throw new NotFound();
}
return $address;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* 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\Tools\MassEmail;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
use Espo\Modules\Crm\Entities\TargetList;
use RuntimeException;
class Util
{
/** @var string[] */
private array $targetLinkList;
public function __construct(
private Defs $ormDefs,
private Metadata $metadata
) {
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
}
public function getLinkByEntityType(string $entityType): string
{
foreach ($this->targetLinkList as $link) {
$itemEntityType = $this->ormDefs
->getEntity(TargetList::ENTITY_TYPE)
->getRelation($link)
->getForeignEntityType();
if ($itemEntityType === $entityType) {
return $link;
}
}
throw new RuntimeException("No link for $entityType.");
}
}