Files
espocrm/custom/Espo/Modules/Advanced/Tools/Workflow/SendEmailService.php
bsiggel f95246f99f updated advanced pack to 3.12.0:
Reports:

    Non-aggregated columns in Grid report export.
    Normalized table mode for 2-dimensional Grid reports.
    Ability to create internal reports via the UI.
    Ability to show/hide and resize columns in the list report result view.
2026-02-07 16:09:20 +01:00

668 lines
21 KiB
PHP

<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Sender;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Tools\EmailTemplate\Result;
use Laminas\Mail\Message;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory;
use Espo\Core\InjectableFactory;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Hasher;
use Espo\Core\Utils\Language;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailTemplate;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Modules\Advanced\Core\Workflow\Helper;
use Espo\Modules\Advanced\Entities\BpmnProcess as BpmnProcessEntity;
use Espo\Modules\Advanced\Entities\Workflow as WorkflowEntity;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\EmailTemplate\Processor as EmailTemplateProcessor;
use Espo\Tools\EmailTemplate\Data as EmailTemplateData;
use Espo\Tools\EmailTemplate\Params as EmailTemplateParams;
use RuntimeException;
use Exception;
use stdClass;
class SendEmailService
{
public function __construct(
private EntityManager $entityManager,
private ServiceContainer $recordServiceContainer,
private Config $config,
private Helper $workflowHelper,
private EmailSender $emailSender,
private Hasher $hasher,
private Language $defaultLanguage,
private EmailTemplateProcessor $emailTemplateProcessor,
private InjectableFactory $injectableFactory
) {}
/**
* Send email for a workflow.
* @return bool|string
* @throws Error
* @throws NoSmtp
* @todo Introduce SendEmailData class.
*/
public function send(stdClass $data)
{
$workflowId = $data->workflowId;
if (!$this->validateSendEmailData($data)) {
throw new Error("Workflow[$workflowId][sendEmail]: Email data is invalid.");
}
$data->doNotStore ??= false;
$data->returnEmailId ??= false;
$data->from ??= (object) [];
$data->to ??= (object) [];
$data->cc ??= null;
$data->replyTo ??= null;
$data->attachmentIds ??= [];
/**
* @var object{
* variables?: stdClass,
* optOutLink?: bool,
* attachmentIds: string[],
* entityType?: string|null,
* entityId?: string|null,
* from: stdClass,
* to: stdClass,
* cc: stdClass|null,
* replyTo: stdClass|null,
* doNotStore: bool,
* returnEmailId: bool,
* } & stdClass $data
*/
if ($workflowId) {
$workflow = $this->entityManager->getRDBRepositoryByClass(WorkflowEntity::class)->getById($workflowId);
if (!$workflow || !$workflow->isActive()) {
return false;
}
}
$entity = null;
if (!empty($data->entityType) && !empty($data->entityId)) {
$entity = $this->entityManager->getEntityById($data->entityType, $data->entityId);
}
if (!$entity) {
throw new Error("Workflow[$workflowId][sendEmail]: Target Entity is not found.");
}
$this->recordServiceContainer->get($entity->getEntityType())
->loadAdditionalFields($entity);
$fromAddress = $this->getEmailAddress($data->from);
$toAddress = $this->getEmailAddress($data->to);
$replyToAddress = !empty($data->replyTo) ? $this->getEmailAddress($data->replyTo) : null;
$ccAddress = !empty($data->cc) ? $this->getEmailAddress($data->cc) : null;
if (!$fromAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: From email address is empty or could not be obtained.");
}
if (!$toAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: To email address is empty.");
}
/** @var array<string, Entity> $entityHash */
$entityHash = [$data->entityType => $entity];
if (
isset($data->to->entityType) &&
isset($data->to->entityId) &&
$data->to->entityType !== $data->entityType
) {
/** @var string $toEntityType */
$toEntityType = $data->to->entityType;
$toEntity = $this->entityManager->getEntityById($toEntityType, $data->to->entityId);
if ($toEntity) {
$entityHash[$toEntityType] = $toEntity;
}
}
$fromName = null;
if (
isset($data->from->entityType) &&
isset($data->from->entityId) &&
$data->from->entityType === User::ENTITY_TYPE
) {
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($data->from->entityId);
if ($user) {
$entityHash[User::ENTITY_TYPE] = $user;
$fromName = $user->getName();
}
}
$sender = $this->emailSender->create();
$templateResult = $this->getTemplateResult(
data: $data,
entityHash: $entityHash,
toEmailAddress: $toAddress,
entity: $entity,
);
[$subject, $body] = $this->prepareSubjectBody(
templateResult: $templateResult,
data: $data,
toEmailAddress: $toAddress,
sender: $sender,
);
$emailData = [
'from' => $fromAddress,
'to' => $toAddress,
'cc' => $ccAddress,
'replyTo' => $replyToAddress,
'subject' => $subject,
'body' => $body,
'isHtml' => $templateResult->isHtml(),
'parentId' => $entity->getId(),
'parentType' => $entity->getEntityType(),
];
if ($fromName !== null) {
$emailData['fromName'] = $fromName;
}
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->setMultiple($emailData);
$attachmentList = $this->getAttachmentList($templateResult, $data->attachmentIds);
if (!$data->doNotStore) {
// Additional attachments not added intentionally?
$email->set('attachmentsIds', $templateResult->getAttachmentIdList());
}
$smtpParams = $this->prepareSmtpParams($data, $fromAddress);
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
$sender->withAttachments($attachmentList);
if ($replyToAddress) {
$senderParams = SenderParams::create()->withReplyToAddress($replyToAddress);
$sender->withParams($senderParams);
}
try {
$sender->send($email);
} catch (Exception $e) {
$sendExceptionMessage = $e->getMessage();
throw new Error("Workflow[$workflowId][sendEmail]: $sendExceptionMessage.", 0, $e);
}
if ($data->doNotStore) {
return true;
}
$this->storeEmail($email, $data);
if ($data->returnEmailId) {
return $email->getId();
}
return true;
}
private function validateSendEmailData(stdClass $data): bool
{
if (
!isset($data->entityId) ||
!(isset($data->entityType)) ||
!isset($data->emailTemplateId) ||
!isset($data->from) ||
!isset($data->to)
) {
return false;
}
return true;
}
private function getEmailAddress(stdClass $data): ?string
{
if (isset($data->email)) {
return $data->email;
}
$entityType = $data->entityType ?? $data->entityName ?? null;
$entity = null;
if (isset($entityType) && isset($data->entityId)) {
$entity = $this->entityManager->getEntityById($entityType, $data->entityId);
}
$workflowHelper = $this->workflowHelper;
if (isset($data->type)) {
switch ($data->type) {
case 'specifiedTeams':
$userIds = $workflowHelper->getUserIdsByTeamIds($data->entityIds);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'teamUsers':
if (!$entity instanceof CoreEntity) {
return null;
}
$entity->loadLinkMultipleField('teams');
$userIds = $workflowHelper->getUserIdsByTeamIds($entity->get('teamsIds'));
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followers':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIds($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followersExcludingAssignedUser':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIdsExcludingAssignedUser($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'system':
return $this->config->get('outboundEmailFromAddress');
case 'specifiedUsers':
return implode('; ', $workflowHelper->getUsersEmailAddress($data->entityIds));
case 'specifiedContacts':
return implode('; ', $workflowHelper->getEmailAddressesForEntity('Contact', $data->entityIds));
}
}
if ($entity instanceof Entity && $entity->hasAttribute('emailAddress')) {
return $entity->get('emailAddress');
}
if (
isset($data->type) &&
isset($entityType) &&
isset($data->entityIds) &&
is_array($data->entityIds)
) {
return implode('; ', $workflowHelper->getEmailAddressesForEntity($entityType, $data->entityIds));
}
return null;
}
private function applyTrackingUrlsToEmailBody(string $body, string $toEmailAddress): string
{
$siteUrl = $this->getSiteUrl();
if (!str_contains($body, '{trackingUrl:')) {
return $body;
}
$hash = $this->hasher->hash($toEmailAddress);
preg_match_all('/\{trackingUrl:(.*?)}/', $body, $matches);
/** @phpstan-ignore-next-line */
if (!$matches || !count($matches)) {
return $body;
}
foreach ($matches[0] as $item) {
$id = explode(':', trim($item, '{}'), 2)[1] ?? null;
if (!$id) {
continue;
}
if (strpos($id, '.')) {
[$id, $uid] = explode('.', $id);
$uidHash = $this->hasher->hash($uid);
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&uid=$uid&hash=$uidHash";
} else {
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&emailAddress=$toEmailAddress&hash=$hash";
}
$body = str_replace($item, $url, $body);
}
return $body;
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getUserSmtpParams(string $emailAddress, string $userId): ?SmtpParams
{
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user || !$user->isActive()) {
return null;
}
$emailAccount = $this->entityManager
->getRDBRepositoryByClass(EmailAccount::class)
->where([
'emailAddress' => $emailAddress,
'assignedUserId' => $userId,
'useSmtp' => true,
'status' => EmailAccount::STATUS_ACTIVE,
])
->findOne();
if (!$emailAccount) {
return null;
}
$factory = $this->injectableFactory->create(PersonalAccountFactory::class);
$params = $factory->create($emailAccount->getId())
->getSmtpParams();
if (!$params) {
return null;
}
return $params->withFromName($user->getName());
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getGroupSmtpParams(string $emailAddress): ?SmtpParams
{
$inboundEmail = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpHost!=' => null,
'emailAddress' => $emailAddress,
])
->findOne();
if (!$inboundEmail) {
return null;
}
return $this->injectableFactory
->create(GroupAccountFactory::class)
->create($inboundEmail->getId())
->getSmtpParams();
}
/**
* @param Result $templateResult
* @param string[] $attachmentIds
* @return Attachment[]
*/
private function getAttachmentList(Result $templateResult, array $attachmentIds): array
{
$attachmentList = [];
foreach (array_merge($templateResult->getAttachmentIdList(), $attachmentIds) as $attachmentId) {
$attachment = $this->entityManager
->getRDBRepositoryByClass(Attachment::class)
->getById($attachmentId);
if ($attachment) {
$attachmentList[] = $attachment;
}
}
return $attachmentList;
}
private function storeEmail(Email $email, stdClass $data): void
{
$processId = $data->processId ?? null;
$emailTemplateId = $data->emailTemplateId ?? null;
$teamsIds = [];
if ($processId) {
$process = $this->entityManager
->getRDBRepositoryByClass(BpmnProcessEntity::class)
->getById($processId);
if ($process) {
$teamsIds = $process->getLinkMultipleIdList('teams');
}
} else if ($emailTemplateId) {
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if ($emailTemplate) {
$teamsIds = $emailTemplate->getLinkMultipleIdList('teams');
}
}
if (count($teamsIds)) {
$email->set('teamsIds', $teamsIds);
}
$this->entityManager->saveEntity($email, ['createdById' => 'system']);
}
/**
* @throws Error
* @throws NoSmtp
*/
private function prepareSmtpParams(stdClass $data, string $fromEmailAddress): ?SmtpParams
{
if (
isset($data->from->entityType) &&
$data->from->entityType === User::ENTITY_TYPE &&
isset($data->from->entityId)
) {
return $this->getUserSmtpParams($fromEmailAddress, $data->from->entityId);
}
if (isset($data->from->email)) {
return $this->getGroupSmtpParams($fromEmailAddress);
}
return null;
}
private function getEmailTemplate(stdClass $data): EmailTemplate
{
$emailTemplateId = $data->emailTemplateId ?? null;
if (!$emailTemplateId) {
throw new RuntimeException("No email template.");
}
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if (!$emailTemplate) {
throw new RuntimeException("Email template $emailTemplateId not found.");
}
return $emailTemplate;
}
/**
* @param array<string, Entity> $entityHash
* @return Result
*/
private function getTemplateResult(
stdClass $data,
array $entityHash,
string $toEmailAddress,
Entity $entity
): Result {
$emailTemplate = $this->getEmailTemplate($data);
$emailTemplateData = EmailTemplateData::create()
->withEntityHash($entityHash)
->withEmailAddress($toEmailAddress)
->withParentId($entity->getId())
->withParentType($entity->getEntityType());
if (
$entity->hasAttribute('parentId') &&
$entity->hasAttribute('parentType')
) {
$emailTemplateData = $emailTemplateData
->withRelatedId($entity->get('parentId'))
->withRelatedType($entity->get('parentType'));
}
return $this->emailTemplateProcessor->process(
$emailTemplate,
EmailTemplateParams::create()->withCopyAttachments(),
$emailTemplateData
);
}
private function applyOptOutLink(
string $toEmailAddress,
string $body,
Result $templateResult,
Sender $sender,
): string {
$siteUrl = $this->getSiteUrl();
$hash = $this->hasher->hash($toEmailAddress);
$optOutUrl = "$siteUrl?entryPoint=unsubscribe&emailAddress=$toEmailAddress&hash=$hash";
$optOutLink = "<a href=\"$optOutUrl\">" .
"{$this->defaultLanguage->translateLabel('Unsubscribe', 'labels', 'Campaign')}</a>";
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
if (stripos($body, '?entryPoint=unsubscribe') === false) {
if ($templateResult->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
}
if (method_exists($sender, 'withAddedHeader')) { /** @phpstan-ignore-line */
$sender->withAddedHeader('List-Unsubscribe', '<' . $optOutUrl . '>');
} else {
$message = new Message();
$message->getHeaders()->addHeaderLine('List-Unsubscribe', '<' . $optOutUrl . '>');
$sender->withMessage($message);
}
return $body;
}
/**
* @param Result $templateResult
* @param object{variables?: stdClass, optOutLink?: bool}&stdClass $data
* @return array{?string, ?string}
*/
private function prepareSubjectBody(
Result $templateResult,
stdClass $data,
string $toEmailAddress,
Sender $sender
): array {
$subject = $templateResult->getSubject();
$body = $templateResult->getBody();
if (isset($data->variables)) {
foreach (get_object_vars($data->variables) as $key => $value) {
if (!is_string($value) && !is_int($value) && !is_float($value)) {
continue;
}
if (is_int($value) || is_float($value)) {
$value = strval($value);
} else if (!$value) {
continue;
}
$subject = str_replace('{$$' . $key . '}', $value, $subject);
$body = str_replace('{$$' . $key . '}', $value, $body);
}
}
$body = $this->applyTrackingUrlsToEmailBody($body, $toEmailAddress);
if ($data->optOutLink ?? false) {
$body = $this->applyOptOutLink($toEmailAddress, $body, $templateResult, $sender);
}
return [$subject, $body];
}
private function getSiteUrl(): ?string
{
return $this->config->get('workflowEmailSiteUrl') ?? $this->config->get('siteUrl');
}
}