Spaces:
No application file
No application file
| namespace Mautic\EmailBundle\Helper; | |
| use Doctrine\ORM\ORMException; | |
| use Mautic\AssetBundle\Entity\Asset; | |
| use Mautic\CoreBundle\Factory\MauticFactory; | |
| use Mautic\CoreBundle\Helper\CoreParametersHelper; | |
| use Mautic\CoreBundle\Helper\InputHelper; | |
| use Mautic\EmailBundle\EmailEvents; | |
| use Mautic\EmailBundle\Entity\Copy; | |
| use Mautic\EmailBundle\Entity\Email; | |
| use Mautic\EmailBundle\Entity\Stat; | |
| use Mautic\EmailBundle\Event\EmailSendEvent; | |
| use Mautic\EmailBundle\Exception\InvalidEmailException; | |
| use Mautic\EmailBundle\Form\Type\ConfigType; | |
| use Mautic\EmailBundle\Helper\DTO\AddressDTO; | |
| use Mautic\EmailBundle\Helper\Exception\OwnerNotFoundException; | |
| use Mautic\EmailBundle\Mailer\Exception\BatchQueueMaxException; | |
| use Mautic\EmailBundle\Mailer\Message\MauticMessage; | |
| use Mautic\EmailBundle\Mailer\Transport\TokenTransportInterface; | |
| use Mautic\EmailBundle\MonitoredEmail\Mailbox; | |
| use Mautic\LeadBundle\Entity\Lead; | |
| use Psr\Log\LoggerInterface; | |
| use Symfony\Component\EventDispatcher\EventDispatcherInterface; | |
| use Symfony\Component\Mailer\Exception\TransportExceptionInterface; | |
| use Symfony\Component\Mailer\MailerInterface; | |
| use Symfony\Component\Mailer\Transport\TransportInterface; | |
| use Symfony\Component\Mime\Address; | |
| use Symfony\Component\Mime\Exception\RfcComplianceException; | |
| use Symfony\Component\Mime\Header\HeaderInterface; | |
| use Symfony\Component\Mime\Header\UnstructuredHeader; | |
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |
| use Symfony\Component\Routing\RouterInterface; | |
| use Twig\Environment; | |
| class MailHelper | |
| { | |
| public const QUEUE_RESET_TO = 'RESET_TO'; | |
| public const QUEUE_FULL_RESET = 'FULL_RESET'; | |
| public const QUEUE_DO_NOTHING = 'DO_NOTHING'; | |
| public const QUEUE_NOTHING_IF_FAILED = 'IF_FAILED'; | |
| public const QUEUE_RETURN_ERRORS = 'RETURN_ERRORS'; | |
| public const EMAIL_TYPE_TRANSACTIONAL = 'transactional'; | |
| public const EMAIL_TYPE_MARKETING = 'marketing'; | |
| /** | |
| * @var TransportInterface | |
| */ | |
| protected $transport; | |
| /** | |
| * @var Environment | |
| */ | |
| protected $twig; | |
| protected ?EventDispatcherInterface $dispatcher = null; | |
| /** | |
| * @var bool|MauticMessage | |
| */ | |
| public $message; | |
| protected ?AddressDTO $from = null; | |
| protected ?AddressDTO $systemFrom = null; | |
| protected ?string $replyTo = null; | |
| protected ?string $systemReplyTo = null; | |
| /** | |
| * @var string | |
| */ | |
| protected $returnPath; | |
| /** | |
| * @var array | |
| */ | |
| protected $errors = []; | |
| /** | |
| * @var array|Lead | |
| */ | |
| protected $lead; | |
| /** | |
| * @var bool | |
| */ | |
| protected $internalSend = false; | |
| /** | |
| * @var null | |
| */ | |
| protected $idHash; | |
| /** | |
| * @var bool | |
| */ | |
| protected $idHashState = true; | |
| /** | |
| * @var bool | |
| */ | |
| protected $appendTrackingPixel = false; | |
| /** | |
| * @var array | |
| */ | |
| protected $source = []; | |
| /** | |
| * @var Email|null | |
| */ | |
| protected $email; | |
| protected ?string $emailType = null; | |
| /** | |
| * @var array | |
| */ | |
| protected $globalTokens = []; | |
| /** | |
| * @var array | |
| */ | |
| protected $eventTokens = []; | |
| /** | |
| * Tells the helper that the transport supports tokenized emails (likely HTTP API). | |
| * | |
| * @var bool | |
| */ | |
| protected $tokenizationEnabled = false; | |
| /** | |
| * Use queue mode when sending email through this mailer; this requires a transport that supports tokenization and the use of queue/flushQueue. | |
| * | |
| * @var bool | |
| */ | |
| protected $queueEnabled = false; | |
| /** | |
| * @var array | |
| */ | |
| protected $queuedRecipients = []; | |
| /** | |
| * @var array | |
| */ | |
| public $metadata = []; | |
| /** | |
| * @var string | |
| */ | |
| protected $subject = ''; | |
| /** | |
| * @var string | |
| */ | |
| protected $plainText = ''; | |
| /** | |
| * @var bool | |
| */ | |
| protected $plainTextSet = false; | |
| /** | |
| * @var array | |
| */ | |
| protected $assets = []; | |
| /** | |
| * @var array | |
| */ | |
| protected $attachedAssets = []; | |
| /** | |
| * @var array | |
| */ | |
| protected $assetStats = []; | |
| /** | |
| * @var array | |
| */ | |
| protected $headers = []; | |
| /** | |
| * @var array | |
| */ | |
| protected $body = [ | |
| 'content' => '', | |
| 'contentType' => 'text/html', | |
| 'charset' => null, | |
| ]; | |
| /** | |
| * Cache for lead owners. | |
| * | |
| * @var array | |
| */ | |
| protected static $leadOwners = []; | |
| /** | |
| * @var bool | |
| */ | |
| protected $fatal = false; | |
| protected bool $skip = false; | |
| /** | |
| * Simply a md5 of the content so that event listeners can easily determine if the content has been changed. | |
| */ | |
| private ?string $contentHash = null; | |
| private array $copies = []; | |
| private array $embedImagesReplaces = []; | |
| public function __construct( | |
| private MauticFactory $factory, | |
| private MailerInterface $mailer, | |
| private FromEmailHelper $fromEmailHelper, | |
| private CoreParametersHelper $coreParametersHelper, | |
| private Mailbox $mailbox, | |
| private LoggerInterface $logger, | |
| private MailHashHelper $mailHashHelper, | |
| private RouterInterface $router | |
| ) { | |
| $this->transport = $this->getTransport(); | |
| $this->returnPath = $coreParametersHelper->get('mailer_return_path'); | |
| $systemFromEmail = (string) $coreParametersHelper->get('mailer_from_email'); | |
| $systemReplyToEmail = $coreParametersHelper->get('mailer_reply_to_email'); | |
| $systemFromName = $this->cleanName( | |
| $coreParametersHelper->get('mailer_from_name') | |
| ); | |
| $this->setDefaultFrom(false, new AddressDTO($systemFromEmail, $systemFromName)); | |
| $this->setDefaultReplyTo($systemReplyToEmail, $this->from); | |
| // Check if batching is supported by the transport | |
| if ($this->transport instanceof TokenTransportInterface) { | |
| $this->tokenizationEnabled = true; | |
| } | |
| $this->message = $this->getMessageInstance(); | |
| } | |
| /** | |
| * Mirrors previous MauticFactory functionality. | |
| * | |
| * @param bool $cleanSlate | |
| * | |
| * @return $this | |
| */ | |
| public function getMailer($cleanSlate = true) | |
| { | |
| $this->reset($cleanSlate); | |
| return $this; | |
| } | |
| /** | |
| * Mirrors previous MauticFactory functionality. | |
| * | |
| * @param bool $cleanSlate | |
| * | |
| * @return $this | |
| */ | |
| public function getSampleMailer($cleanSlate = true) | |
| { | |
| return $this->getMailer($cleanSlate); | |
| } | |
| /** | |
| * Send the message. | |
| * | |
| * @param bool $dispatchSendEvent | |
| * @param bool $isQueueFlush (a tokenized/batch send via API such as Mandrill) | |
| * | |
| * @return bool | |
| */ | |
| public function send($dispatchSendEvent = false, $isQueueFlush = false) | |
| { | |
| if ($this->tokenizationEnabled && !empty($this->queuedRecipients) && !$isQueueFlush) { | |
| // This transport uses tokenization and queue()/flushQueue() was not used therefore use them in order | |
| // properly populate metadata for this transport | |
| if ($result = $this->queue($dispatchSendEvent)) { | |
| $result = $this->flushQueue(['To', 'Cc', 'Bcc']); | |
| } | |
| return $result; | |
| } | |
| // Set from email | |
| if (!$isQueueFlush) { | |
| $this->setFromForSingleMessage(); | |
| $this->setReplyToForSingleMessage($this->email); | |
| } // from is set in flushQueue | |
| if (empty($this->message->getReplyTo()) && !empty($this->getReplyTo())) { | |
| $this->setMessageReplyTo($this->getReplyTo()); | |
| } | |
| // Set system return path if applicable | |
| if (!$isQueueFlush && ($bounceEmail = $this->generateBounceEmail())) { | |
| $this->message->returnPath($bounceEmail); | |
| } elseif (!empty($this->returnPath)) { | |
| $this->message->returnPath($this->returnPath); | |
| } | |
| $this->dispatchPreSendEvent(); | |
| if (empty($this->fatal)) { | |
| if (!$isQueueFlush) { | |
| // Search/replace tokens if this is not a queue flush | |
| // Generate tokens from listeners | |
| if ($dispatchSendEvent) { | |
| $this->dispatchSendEvent(); | |
| } | |
| // Queue an asset stat if applicable | |
| $this->queueAssetDownloadEntry(); | |
| } | |
| $this->message->subject($this->subject); | |
| // Only set body if not empty or if plain text is empty - this ensures an empty HTML body does not show for | |
| // messages only with plain text | |
| if (!empty($this->body['content']) || empty($this->plainText)) { | |
| $this->message->html($this->body['content'], $this->body['charset'] ?? 'utf-8'); | |
| } | |
| $this->setMessagePlainText(); | |
| $this->setMessageHeaders(); | |
| if (!$isQueueFlush) { | |
| // Replace token content | |
| $tokens = $this->getTokens(); | |
| if ($ownerSignature = $this->fromEmailHelper->getSignature()) { | |
| $tokens['{signature}'] = $ownerSignature; | |
| } | |
| // Set metadata if applicable | |
| foreach ($this->queuedRecipients as $email => $name) { | |
| $this->message->addMetadata($email, $this->buildMetadata($name, $tokens)); | |
| } | |
| // Replace tokens | |
| $search = array_keys($tokens); | |
| $replace = $tokens; | |
| self::searchReplaceTokens($search, $replace, $this->message); | |
| } | |
| if (true === $this->coreParametersHelper->get('mailer_convert_embed_images')) { | |
| $this->convertEmbedImages(); | |
| } | |
| // Attach assets | |
| /** @var Asset $asset */ | |
| foreach ($this->assets as $asset) { | |
| if (!in_array($asset->getId(), $this->attachedAssets)) { | |
| $this->attachedAssets[] = $asset->getId(); | |
| $this->attachFile( | |
| $asset->getFilePath(), | |
| $asset->getOriginalFileName(), | |
| $asset->getMime() | |
| ); | |
| } | |
| } | |
| try { | |
| if (!$this->skip) { | |
| $this->mailer->send($this->message); | |
| } | |
| $this->skip = false; | |
| } catch (TransportExceptionInterface $exception) { | |
| /* | |
| The nature of symfony/mailer is working with transactional emails only | |
| if a message fails to send, all the contacts on that message will be considered failed | |
| */ | |
| $failures = $this->tokenizationEnabled ? array_keys($this->message->getMetadata()) : []; | |
| // Exception encountered when sending so all recipients are considered failures | |
| $this->errors['failures'] = array_unique( | |
| array_merge( | |
| $failures, | |
| array_keys((array) $this->message->getTo()), | |
| array_keys((array) $this->message->getCc()), | |
| array_keys((array) $this->message->getBcc()) | |
| ) | |
| ); | |
| $this->logError($exception->getMessage()); | |
| } | |
| } | |
| $error = empty($this->errors); | |
| if (!$isQueueFlush) { | |
| $this->createAssetDownloadEntries(); | |
| } // else handled in flushQueue | |
| return $error; | |
| } | |
| /** | |
| * If batching is supported and enabled, the message will be queued and will on be sent upon flushQueue(). | |
| * Otherwise, the message will be sent to the transport immediately. | |
| * | |
| * @param bool $dispatchSendEvent | |
| * @param string $returnMode What should happen post send/queue to $this->message after the email send is attempted. | |
| * Options are: | |
| * RESET_TO resets the to recipients and resets errors | |
| * FULL_RESET creates a new MauticMessage instance and resets errors | |
| * DO_NOTHING leaves the current errors array and MauticMessage instance intact | |
| * NOTHING_IF_FAILED leaves the current errors array MauticMessage instance intact if it fails, otherwise reset_to | |
| * RETURN_ERROR return an array of [success, $errors]; only one applicable if message is queued | |
| * | |
| * @return bool|array | |
| */ | |
| public function queue($dispatchSendEvent = false, $returnMode = self::QUEUE_RESET_TO) | |
| { | |
| if ($this->tokenizationEnabled) { | |
| // Dispatch event to get custom tokens from listeners | |
| if ($dispatchSendEvent) { | |
| $this->dispatchSendEvent(); | |
| } | |
| // Metadata has to be set for each recipient | |
| foreach ($this->queuedRecipients as $email => $name) { | |
| $from = $this->fromEmailHelper->getFromAddressConsideringOwner($this->getFrom(), $this->lead, $this->email); | |
| $fromAddress = $from->getEmail(); | |
| $tokens = $this->getTokens(); | |
| $tokens['{signature}'] = $this->fromEmailHelper->getSignature(); | |
| if (!isset($this->metadata[$fromAddress])) { | |
| $this->metadata[$fromAddress] = [ | |
| 'from' => $from, | |
| 'contacts' => [], | |
| ]; | |
| } | |
| $this->metadata[$fromAddress]['contacts'][$email] = $this->buildMetadata($name, $tokens); | |
| } | |
| // Reset recipients | |
| $this->queuedRecipients = []; | |
| // Assume success | |
| return (self::QUEUE_RETURN_ERRORS) ? [true, []] : true; | |
| } else { | |
| $success = $this->send($dispatchSendEvent); | |
| // Reset the message for the next | |
| $this->queuedRecipients = []; | |
| // Reset message | |
| switch (strtoupper($returnMode)) { | |
| case self::QUEUE_RESET_TO: | |
| $this->message->to(); | |
| $this->clearErrors(); | |
| break; | |
| case self::QUEUE_NOTHING_IF_FAILED: | |
| if ($success) { | |
| $this->message->to(); | |
| $this->clearErrors(); | |
| } | |
| break; | |
| case self::QUEUE_FULL_RESET: | |
| $this->message = $this->getMessageInstance(); | |
| $this->attachedAssets = []; | |
| $this->clearErrors(); | |
| break; | |
| case self::QUEUE_RETURN_ERRORS: | |
| $this->message->to(); | |
| $errors = $this->getErrors(); | |
| $this->clearErrors(); | |
| return [$success, $errors]; | |
| case self::QUEUE_DO_NOTHING: | |
| default: | |
| // Nada | |
| break; | |
| } | |
| return $success; | |
| } | |
| } | |
| /** | |
| * Send batched mail to mailer. | |
| * | |
| * @param array $resetEmailTypes Array of email types to clear after flusing the queue | |
| * | |
| * @return bool | |
| */ | |
| public function flushQueue($resetEmailTypes = ['To', 'Cc', 'Bcc']) | |
| { | |
| // Assume true unless there was a fatal error configuring the mailer because if tokenizationEnabled is false, the send happened in queue() | |
| $flushed = empty($this->fatal); | |
| if ($this->tokenizationEnabled && count($this->metadata) && $flushed) { | |
| $errors = $this->errors; | |
| $errors['failures'] = []; | |
| $flushed = false; | |
| foreach ($this->metadata as $metadatum) { | |
| // Whatever is in the message "to" should be ignored as we will send to the contacts grouped by from addresses | |
| // This prevents mailers such as sparkpost from sending duplicates to contacts | |
| $this->message->to(); | |
| $this->errors = []; | |
| $email = $this->getEmail(); | |
| if ($email && $email->getUseOwnerAsMailer()) { | |
| $this->setFrom($metadatum['from']->getEmail(), $metadatum['from']->getName()); | |
| $this->setMessageFrom(new AddressDTO($metadatum['from']->getEmail(), $metadatum['from']->getName())); | |
| } else { | |
| $this->setMessageFrom($this->getFrom()); | |
| } | |
| foreach ($metadatum['contacts'] as $email => $contact) { | |
| $this->message->addMetadata($email, $contact); | |
| // Add asset stats if applicable | |
| if (!empty($contact['leadId'])) { | |
| $this->queueAssetDownloadEntry($email, $contact); | |
| } | |
| $this->message->to(new Address($email, $contact['name'] ?? '')); | |
| } | |
| $flushed = $this->send(false, true); | |
| // Merge errors | |
| if (isset($this->errors['failures'])) { | |
| $errors['failures'] = array_merge($errors['failures'], $this->errors['failures']); | |
| unset($this->errors['failures']); | |
| } | |
| if (!empty($this->errors)) { | |
| $errors = array_merge($errors, $this->errors); | |
| } | |
| // Clear metadata for the previous recipients | |
| $this->message->clearMetadata(); | |
| } | |
| $this->errors = $errors; | |
| // Clear queued to recipients | |
| $this->queuedRecipients = []; | |
| $this->metadata = []; | |
| } | |
| foreach ($resetEmailTypes as $type) { | |
| $this->message->{$type}(); | |
| } | |
| return $flushed; | |
| } | |
| /** | |
| * Resets the mailer. | |
| * | |
| * @param bool $cleanSlate | |
| */ | |
| public function reset($cleanSlate = true): void | |
| { | |
| $this->eventTokens = []; | |
| $this->queuedRecipients = []; | |
| $this->errors = []; | |
| $this->lead = null; | |
| $this->idHash = null; | |
| $this->contentHash = null; | |
| $this->internalSend = false; | |
| $this->fatal = false; | |
| $this->idHashState = true; | |
| if ($cleanSlate) { | |
| $this->appendTrackingPixel = false; | |
| $this->queueEnabled = false; | |
| $this->from = $this->getSystemFrom(); | |
| $this->replyTo = $this->getSystemReplyTo(); | |
| $this->headers = []; | |
| $this->source = []; | |
| $this->assets = []; | |
| $this->globalTokens = []; | |
| $this->attachedAssets = []; | |
| $this->email = null; | |
| $this->copies = []; | |
| $this->message = $this->getMessageInstance(); | |
| $this->subject = ''; | |
| $this->plainText = ''; | |
| $this->plainTextSet = false; | |
| $this->body = [ | |
| 'content' => '', | |
| 'contentType' => 'text/html', | |
| 'charset' => null, | |
| ]; | |
| } | |
| } | |
| /** | |
| * Search and replace tokens | |
| * Adapted from \Swift_Plugins_DecoratorPlugin. | |
| * | |
| * @param array $search | |
| * @param array $replace | |
| */ | |
| public static function searchReplaceTokens($search, $replace, MauticMessage &$message): void | |
| { | |
| // Body | |
| $body = $message->getHtmlBody(); | |
| $bodyReplaced = str_ireplace($search, $replace, (string) $body, $updated); | |
| if ($updated) { | |
| $message->html($bodyReplaced); | |
| } | |
| unset($body, $bodyReplaced); | |
| // Subject | |
| $subject = $message->getSubject(); | |
| $bodyReplaced = str_ireplace($search, $replace, $subject, $updated); | |
| if ($updated) { | |
| $message->subject($bodyReplaced); | |
| } | |
| unset($subject, $bodyReplaced); | |
| // Headers | |
| /** @var HeaderInterface $header */ | |
| foreach ($message->getHeaders()->all() as $header) { | |
| // It only makes sense to tokenize headers that can be interpreted as text. | |
| if (!$header instanceof UnstructuredHeader) { | |
| continue; | |
| } | |
| $headerBody = $header->getBody(); | |
| $bodyReplaced = str_ireplace($search, $replace, $headerBody); | |
| $header->setBody($bodyReplaced); | |
| } | |
| // Parts (plaintext) | |
| $textBody = $message->getTextBody() ?? ''; | |
| $bodyReplaced = str_ireplace($search, $replace, $textBody); | |
| if ($textBody != $bodyReplaced) { | |
| $textBody = strip_tags($bodyReplaced); | |
| $message->text($textBody); | |
| } | |
| } | |
| public static function getBlankPixel(): string | |
| { | |
| return 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; | |
| } | |
| /** | |
| * Add an attachment to email. | |
| * | |
| * @param string $filePath | |
| * @param string $fileName | |
| * @param string $contentType | |
| * @param bool $inline | |
| */ | |
| public function attachFile($filePath, $fileName = null, $contentType = null, $inline = false): void | |
| { | |
| if (true === $inline) { | |
| $this->message->embedFromPath($filePath, $fileName, $contentType); | |
| return; | |
| } | |
| $this->message->attachFromPath($filePath, $fileName, $contentType); | |
| } | |
| /** | |
| * @param int|Asset $asset | |
| */ | |
| public function attachAsset($asset): void | |
| { | |
| $model = $this->factory->getModel('asset'); | |
| if (!$asset instanceof Asset) { | |
| $asset = $model->getEntity($asset); | |
| if (null == $asset) { | |
| return; | |
| } | |
| } | |
| if ($asset->isPublished()) { | |
| $asset->setUploadDir($this->factory->getParameter('upload_dir')); | |
| $this->assets[$asset->getId()] = $asset; | |
| } | |
| } | |
| /** | |
| * Use a template as the body. | |
| * | |
| * @param string $template | |
| * @param array $vars | |
| * @param bool $returnContent | |
| * | |
| * @return void|string | |
| */ | |
| public function setTemplate($template, $vars = [], $returnContent = false, $charset = null) | |
| { | |
| if (null == $this->twig) { | |
| $this->twig = $this->factory->getTwig(); | |
| } | |
| $content = $this->twig->render($template, $vars); | |
| unset($vars); | |
| if ($returnContent) { | |
| return $content; | |
| } | |
| $this->setBody($content, 'text/html', $charset); | |
| unset($content); | |
| } | |
| public function setSubject($subject): void | |
| { | |
| $this->subject = $subject; | |
| } | |
| /** | |
| * @return string | |
| */ | |
| public function getSubject() | |
| { | |
| return $this->subject; | |
| } | |
| /** | |
| * Set a plain text part. | |
| */ | |
| public function setPlainText($content): void | |
| { | |
| $this->plainText = $content; | |
| // Update the identifier for the content | |
| $this->contentHash = md5($this->body['content'].$this->plainText); | |
| } | |
| /** | |
| * @return string | |
| */ | |
| public function getPlainText() | |
| { | |
| return $this->plainText; | |
| } | |
| /** | |
| * Set plain text for $this->message, replacing if necessary. | |
| */ | |
| protected function setMessagePlainText() | |
| { | |
| if ($this->tokenizationEnabled && $this->plainTextSet) { | |
| // No need to find and replace since tokenization happens at the transport level | |
| return; | |
| } | |
| $this->message->text($this->plainText); | |
| $this->plainTextSet = true; | |
| } | |
| /** | |
| * @param string $contentType | |
| * @param bool $ignoreTrackingPixel | |
| */ | |
| public function setBody($content, $contentType = 'text/html', $charset = null, $ignoreTrackingPixel = false): void | |
| { | |
| if (!$ignoreTrackingPixel && $this->coreParametersHelper->get('mailer_append_tracking_pixel')) { | |
| // Append tracking pixel | |
| $trackingImg = '<img height="1" width="1" src="{tracking_pixel}" alt="" />'; | |
| if (str_contains((string) $content, '</body>')) { | |
| $content = str_replace('</body>', $trackingImg.'</body>', $content); | |
| } else { | |
| $content .= $trackingImg; | |
| } | |
| } | |
| // Update the identifier for the content | |
| $this->contentHash = md5($content.$this->plainText); | |
| $this->body = [ | |
| 'content' => $content, | |
| 'contentType' => $contentType, | |
| 'charset' => $charset, | |
| ]; | |
| } | |
| private function convertEmbedImages(): void | |
| { | |
| $content = $this->message->getHtmlBody(); | |
| $matches = []; | |
| $content = strtr($content, $this->embedImagesReplaces); | |
| $tokens = $this->getTokens(); | |
| if (preg_match_all('/<img.+?src=[\"\'](.+?)[\"\'].*?>/i', $content, $matches) > 0) { | |
| foreach ($matches[1] as $match) { | |
| // skip items that already embedded, or have token {tracking_pixel} | |
| if (str_contains($match, 'cid:') || str_contains($match, '{tracking_pixel}') || array_key_exists($match, $this->embedImagesReplaces)) { | |
| continue; | |
| } | |
| // skip images with tracking pixel that are already replaced. | |
| if (isset($tokens['{tracking_pixel}']) && $match === $tokens['{tracking_pixel}']) { | |
| continue; | |
| } | |
| $path = $match; | |
| // if the path contains the site url, make it an absolute path, so it can be fetched. | |
| if (str_starts_with($match, $this->coreParametersHelper->get('site_url'))) { | |
| $path = str_replace($this->coreParametersHelper->get('site_url'), '', $match); | |
| $path = $this->factory->getSystemPath('root', true).$path; | |
| } | |
| if ($imageContent = file_get_contents($path)) { | |
| $this->message->embed($imageContent, md5($match)); | |
| $this->embedImagesReplaces[$match] = 'cid:'.md5($match); | |
| } | |
| } | |
| $content = strtr($content, $this->embedImagesReplaces); | |
| } | |
| $this->message->html($content); | |
| } | |
| /** | |
| * Get a copy of the raw body. | |
| * | |
| * @return mixed | |
| */ | |
| public function getBody() | |
| { | |
| return $this->body['content']; | |
| } | |
| /** | |
| * Return the content identifier. | |
| * | |
| * @return string | |
| */ | |
| public function getContentHash() | |
| { | |
| return $this->contentHash; | |
| } | |
| /** | |
| * Set to address(es). | |
| * | |
| * @return bool | |
| */ | |
| public function setTo($addresses, $name = null) | |
| { | |
| $name = $this->cleanName($name); | |
| if (!is_array($addresses)) { | |
| $addresses = [$addresses => $name]; | |
| } elseif (0 === array_keys($addresses)[0]) { | |
| // We need an array of $email => $name pairs | |
| $addresses = array_reduce($addresses, function ($address, $item) use ($name) { | |
| $address[$item] = $name; | |
| return $address; | |
| }, []); | |
| } | |
| $this->checkBatchMaxRecipients(count($addresses)); | |
| // Convert to array of Address objects | |
| $toAddresses = array_map(fn (string $address, ?string $name): Address => new Address($address, $name ?? ''), array_keys($addresses), $addresses); | |
| try { | |
| $this->message->to(...$toAddresses); | |
| $this->queuedRecipients = array_merge($this->queuedRecipients, $addresses); | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'to'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Add to address. | |
| * | |
| * @param string $address | |
| * @param string|null $name | |
| * | |
| * @return bool | |
| */ | |
| public function addTo($address, $name = null) | |
| { | |
| $this->checkBatchMaxRecipients(); | |
| try { | |
| $this->message->addTo((new AddressDTO($address, $name))->toMailerAddress()); | |
| $this->queuedRecipients[$address] = $name; | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'to'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Set CC address(es). | |
| * | |
| * @param array<string,?string> $addresses | |
| * @param ?string $name | |
| * | |
| * //TODO: there is a bug here, the name is not passed in CC nor in the array of addresses, we do not handle names for CC | |
| * | |
| * @return bool | |
| */ | |
| public function setCc($addresses, $name = null) | |
| { | |
| $this->checkBatchMaxRecipients(count($addresses), 'cc'); | |
| try { | |
| $ccAddresses = []; | |
| // The email addresses are stored in the array keys not the values | |
| // The name of the CC is passed in the function and not in the array | |
| foreach ($addresses as $address => $noName) { | |
| $ccAddresses[] = (new AddressDTO($address, $name))->toMailerAddress(); | |
| } | |
| $this->message->cc(...$ccAddresses); | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'cc'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Add cc address. | |
| * | |
| * @param string $address | |
| * @param ?string $name | |
| * | |
| * @return bool | |
| */ | |
| public function addCc($address, $name = null) | |
| { | |
| $this->checkBatchMaxRecipients(1, 'cc'); | |
| try { | |
| $this->message->addCc((new AddressDTO($address, $name ?? ''))->toMailerAddress()); | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'cc'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Set BCC address(es). | |
| * | |
| * @param array<string,?string> $addresses | |
| * @param ?string $name | |
| * | |
| * //TODO: same bug for the name as the one we have in setCc | |
| * | |
| * @return bool | |
| */ | |
| public function setBcc($addresses, $name = null) | |
| { | |
| $this->checkBatchMaxRecipients(count($addresses), 'bcc'); | |
| try { | |
| $bccAddresses = []; | |
| // The email addresses are stored in the array keys not the values | |
| // The name of the Bcc is passed in the function and not in the array | |
| foreach ($addresses as $address => $noName) { | |
| $bccAddresses[] = (new AddressDTO($address, $name))->toMailerAddress(); | |
| } | |
| $this->message->bcc(...$bccAddresses); | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'bcc'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Add bcc address. | |
| * | |
| * @param string $address | |
| * @param ?string $name | |
| * | |
| * @return bool | |
| */ | |
| public function addBcc($address, $name = null) | |
| { | |
| $this->checkBatchMaxRecipients(1, 'bcc'); | |
| try { | |
| $this->message->addBcc((new AddressDTO($address, $name))->toMailerAddress()); | |
| return true; | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'bcc'); | |
| return false; | |
| } | |
| } | |
| /** | |
| * @param int $toBeAdded | |
| * @param string $type | |
| * | |
| * @throws BatchQueueMaxException | |
| */ | |
| protected function checkBatchMaxRecipients($toBeAdded = 1, $type = 'to') | |
| { | |
| if ($this->queueEnabled && $this->transport instanceof TokenTransportInterface) { | |
| // Check if max batching has been hit | |
| $maxAllowed = $this->transport->getMaxBatchLimit(); | |
| if ($maxAllowed > 0) { | |
| $currentCount = $this->transport->getBatchRecipientCount($this->message, $toBeAdded, $type); | |
| if ($currentCount > $maxAllowed) { | |
| throw new BatchQueueMaxException(); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Set reply to address(es) for this mailer instance. | |
| * | |
| * @param array<string>|string $addresses | |
| * @param string $name | |
| */ | |
| public function setReplyTo($addresses, $name = null): void | |
| { | |
| $this->replyTo = $addresses; | |
| } | |
| /** | |
| * Set Reply to for the current message we are sending. Can be in the middle of the sending loop. | |
| */ | |
| private function setMessageReplyTo(string $addresses, string $name = null): void | |
| { | |
| if (str_contains($addresses, ',')) { | |
| $addresses = explode(',', $addresses); | |
| } | |
| try { | |
| foreach ((array) $addresses as $address) { | |
| $this->message->replyTo((new AddressDTO($address, $name))->toMailerAddress()); | |
| } | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'reply to'); | |
| } | |
| } | |
| /** | |
| * Set a custom return path. | |
| * | |
| * @param string $address | |
| */ | |
| public function setReturnPath($address): void | |
| { | |
| try { | |
| $this->message->returnPath($address); | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'return path'); | |
| } | |
| } | |
| /** | |
| * Sets FROM for the mailer which can overwrite the system default. | |
| * | |
| * @param string|array $fromEmail | |
| * @param string $fromName | |
| */ | |
| public function setFrom($fromEmail, $fromName = null): void | |
| { | |
| if (is_array($fromEmail)) { | |
| $this->from = AddressDTO::fromAddressArray($fromEmail); | |
| $this->from->setName($fromName); | |
| } else { | |
| $this->from = new AddressDTO($fromEmail, $fromName); | |
| } | |
| } | |
| /** | |
| * Sets FROM for the concreste message that we are currently sending. Can be in the middle of the loop of sending. | |
| */ | |
| private function setMessageFrom(AddressDTO $from): void | |
| { | |
| try { | |
| $this->message->from($from->toMailerAddress()); | |
| } catch (\Exception $e) { | |
| $this->logError($e, 'from'); | |
| } | |
| } | |
| /** | |
| * @return string|null | |
| */ | |
| public function getIdHash() | |
| { | |
| return $this->idHash; | |
| } | |
| /** | |
| * @param string|null $idHash | |
| * @param bool $statToBeGenerated Pass false if a stat entry is not to be created | |
| */ | |
| public function setIdHash($idHash = null, $statToBeGenerated = true): void | |
| { | |
| if (null === $idHash) { | |
| $idHash = str_replace('.', '', uniqid('', true)); | |
| } | |
| $this->idHash = $idHash; | |
| $this->idHashState = $statToBeGenerated; | |
| // Append pixel to body before send | |
| $this->appendTrackingPixel = true; | |
| // Add the trackingID to the $message object in order to update the stats if the email failed to send | |
| $this->message->updateLeadIdHash($idHash); | |
| } | |
| /** | |
| * @return array|Lead | |
| */ | |
| public function getLead() | |
| { | |
| return $this->lead; | |
| } | |
| /** | |
| * @param array|Lead $lead | |
| */ | |
| public function setLead($lead, $interalSend = false): void | |
| { | |
| $this->lead = $lead; | |
| $this->internalSend = $interalSend; | |
| } | |
| /** | |
| * Check if this is not being send directly to the lead. | |
| * | |
| * @return bool | |
| */ | |
| public function isInternalSend() | |
| { | |
| return $this->internalSend; | |
| } | |
| /** | |
| * @return array | |
| */ | |
| public function getSource() | |
| { | |
| return $this->source; | |
| } | |
| /** | |
| * @param array $source | |
| */ | |
| public function setSource($source): void | |
| { | |
| $this->source = $source; | |
| } | |
| public function getEmailType(): ?string | |
| { | |
| return $this->emailType; | |
| } | |
| public function setEmailType(?string $emailType): void | |
| { | |
| $this->emailType = $emailType; | |
| } | |
| /** | |
| * @return Email|null | |
| */ | |
| public function getEmail() | |
| { | |
| return $this->email; | |
| } | |
| /** | |
| * @param bool $allowBcc Honor BCC if set in email | |
| * @param array $slots Slots configured in theme | |
| * @param array $assetAttachments Assets to send | |
| * @param bool $ignoreTrackingPixel Do not append tracking pixel HTML | |
| * | |
| * @return bool Returns false if there were errors with the email configuration | |
| */ | |
| public function setEmail(Email $email, $allowBcc = true, $slots = [], $assetAttachments = [], $ignoreTrackingPixel = false): bool | |
| { | |
| if ($this->coreParametersHelper->get(ConfigType::MINIFY_EMAIL_HTML)) { | |
| $email->setCustomHtml(InputHelper::minifyHTML($email->getCustomHtml())); | |
| } | |
| $this->email = $email; | |
| $subject = $email->getSubject(); | |
| // Set message settings from the email | |
| $this->setSubject($subject); | |
| if ($allowBcc) { | |
| $bccAddress = $email->getBccAddress(); | |
| if (!empty($bccAddress)) { | |
| $addresses = array_fill_keys(array_map('trim', explode(',', $bccAddress)), null); | |
| foreach ($addresses as $bccAddress => $name) { | |
| $this->addBcc($bccAddress, $name); | |
| } | |
| } | |
| } | |
| if ($plainText = $email->getPlainText()) { | |
| $this->setPlainText($plainText); | |
| } | |
| $template = $email->getTemplate(); | |
| $customHtml = $email->getCustomHtml(); | |
| // Process emails created by Mautic v1 | |
| if (empty($customHtml) && $template) { | |
| if (empty($slots)) { | |
| $slots = $this->factory->getTheme($template)->getSlots('email'); | |
| } | |
| if (isset($slots[$template])) { | |
| $slots = $slots[$template]; | |
| } | |
| $this->processSlots($slots, $email); | |
| $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/email.html.twig'); | |
| $customHtml = $this->setTemplate($logicalName, [ | |
| 'slots' => $slots, | |
| 'content' => $email->getContent(), | |
| 'email' => $email, | |
| 'template' => $template, | |
| ], true); | |
| } | |
| $this->setBody($customHtml, 'text/html', null, $ignoreTrackingPixel); | |
| // Reset attachments | |
| $this->assets = $this->attachedAssets = []; | |
| if (empty($assetAttachments)) { | |
| if ($assets = $email->getAssetAttachments()) { | |
| foreach ($assets as $asset) { | |
| $this->attachAsset($asset); | |
| } | |
| } | |
| } else { | |
| foreach ($assetAttachments as $asset) { | |
| $this->attachAsset($asset); | |
| } | |
| } | |
| // Set custom headers | |
| if ($headers = $email->getHeaders()) { | |
| // HTML decode headers | |
| $headers = array_map('html_entity_decode', $headers); | |
| foreach ($headers as $name => $value) { | |
| $this->addCustomHeader($name, $value); | |
| } | |
| } | |
| return empty($this->errors); | |
| } | |
| /** | |
| * Set custom headers. | |
| * | |
| * @param bool $merge | |
| */ | |
| public function setCustomHeaders(array $headers, $merge = true): void | |
| { | |
| if ($merge) { | |
| $this->headers = array_merge($this->headers, $headers); | |
| return; | |
| } | |
| $this->headers = $headers; | |
| } | |
| public function addCustomHeader($name, $value): void | |
| { | |
| $this->headers[$name] = $value; | |
| } | |
| public function getCustomHeaders(): array | |
| { | |
| $headers = array_merge($this->headers, $this->getSystemHeaders()); | |
| // Personal and transactional emails do not contain unsubscribe header | |
| $email = $this->getEmail(); | |
| if (empty($email) || self::EMAIL_TYPE_TRANSACTIONAL === $this->getEmailType()) { | |
| return $headers; | |
| } | |
| $listUnsubscribeHeader = $this->getUnsubscribeHeader(); | |
| if ($listUnsubscribeHeader) { | |
| if (!empty($headers['List-Unsubscribe'])) { | |
| if (!str_contains($headers['List-Unsubscribe'], $listUnsubscribeHeader)) { | |
| // Ensure Mautic's is always part of this header | |
| $headers['List-Unsubscribe'] = $listUnsubscribeHeader.','.$headers['List-Unsubscribe']; | |
| } | |
| } else { | |
| $headers['List-Unsubscribe'] = $listUnsubscribeHeader; | |
| } | |
| $headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; | |
| } | |
| return $headers; | |
| } | |
| /** | |
| * @return bool|string | |
| */ | |
| private function getUnsubscribeHeader() | |
| { | |
| if ($this->idHash) { | |
| $lead = $this->getLead(); | |
| $toEmail = null; | |
| if (is_array($lead) && array_key_exists('email', $lead) && is_string($lead['email'])) { | |
| $toEmail = $lead['email']; | |
| } elseif ($lead instanceof Lead && is_string($lead->getEmail())) { | |
| $toEmail = $lead->getEmail(); | |
| } | |
| if ($toEmail) { | |
| $unsubscribeHash = $this->mailHashHelper->getEmailHash($toEmail); | |
| $url = $this->router->generate('mautic_email_unsubscribe', | |
| ['idHash' => $this->idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash], | |
| UrlGeneratorInterface::ABSOLUTE_URL | |
| ); | |
| } else { | |
| $url = $this->router->generate('mautic_email_unsubscribe', | |
| ['idHash' => $this->idHash], | |
| UrlGeneratorInterface::ABSOLUTE_URL | |
| ); | |
| } | |
| return "<$url>"; | |
| } | |
| if (!empty($this->queuedRecipients) || !empty($this->lead)) { | |
| return '<{unsubscribe_url}>'; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Append tokens. | |
| */ | |
| public function addTokens(array $tokens): void | |
| { | |
| $this->globalTokens = array_merge($this->globalTokens, $tokens); | |
| } | |
| public function setTokens(array $tokens): void | |
| { | |
| $this->globalTokens = $tokens; | |
| } | |
| /** | |
| * @return mixed[] | |
| */ | |
| public function getTokens(): array | |
| { | |
| $tokens = array_merge($this->globalTokens, $this->eventTokens); | |
| // Include the tracking pixel token as it's auto appended to the body | |
| if ($this->appendTrackingPixel) { | |
| $tokens['{tracking_pixel}'] = $this->router->generate( | |
| 'mautic_email_tracker', | |
| [ | |
| 'idHash' => $this->idHash, | |
| ], | |
| UrlGeneratorInterface::ABSOLUTE_URL | |
| ); | |
| } else { | |
| $tokens['{tracking_pixel}'] = self::getBlankPixel(); | |
| } | |
| return $tokens; | |
| } | |
| /** | |
| * @return array | |
| */ | |
| public function getGlobalTokens() | |
| { | |
| return $this->globalTokens; | |
| } | |
| /** | |
| * Parses html into basic plaintext. | |
| * | |
| * @param string $content | |
| */ | |
| public function parsePlainText($content = null): void | |
| { | |
| if (null == $content) { | |
| if (!$content = $this->message->getHtmlBody()) { | |
| $content = $this->body['content']; | |
| } | |
| } | |
| $request = $this->factory->getRequest(); | |
| $parser = new PlainTextHelper([ | |
| 'base_url' => $request->getSchemeAndHttpHost().$request->getBasePath(), | |
| ]); | |
| $this->plainText = $parser->setHtml($content)->getText(); | |
| } | |
| /** | |
| * Enables queue mode if the transport supports tokenization. | |
| * | |
| * @param bool $enabled | |
| */ | |
| public function enableQueue($enabled = true): void | |
| { | |
| if ($this->tokenizationEnabled) { | |
| $this->queueEnabled = $enabled; | |
| } | |
| } | |
| /** | |
| * Dispatch send event to generate tokens. | |
| */ | |
| public function dispatchSendEvent(): void | |
| { | |
| if (null == $this->dispatcher) { | |
| $this->dispatcher = $this->factory->getDispatcher(); | |
| } | |
| $event = new EmailSendEvent($this); | |
| $this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_SEND); | |
| $this->eventTokens = array_merge($this->eventTokens, $event->getTokens(false)); | |
| unset($event); | |
| } | |
| /** | |
| * Log exception. | |
| */ | |
| protected function logError($error, $context = null) | |
| { | |
| if ($error instanceof \Exception) { | |
| $exceptionContext = ['exception' => $error]; | |
| $errorMessage = $error->getMessage(); | |
| $error = ('dev' === MAUTIC_ENV) ? (string) $error : $errorMessage; | |
| // Clean up the error message | |
| $errorMessage = trim(preg_replace('/(.*?)Log data:(.*)$/is', '$1', $errorMessage)); | |
| $this->fatal = true; | |
| } else { | |
| $exceptionContext = []; | |
| $errorMessage = trim($error); | |
| } | |
| if ($context) { | |
| $error .= " ($context)"; | |
| if ('send' === $context) { | |
| $error .= '; '.implode(', ', $this->errors['failures']); | |
| } | |
| } | |
| $this->errors[] = $errorMessage; | |
| $this->logger->log('error', '[MAIL ERROR] '.$error, $exceptionContext); | |
| } | |
| /** | |
| * Get list of errors. | |
| * | |
| * @param bool $reset Resets the error array in preparation for the next mail send or else it'll fail | |
| * | |
| * @return array | |
| */ | |
| public function getErrors($reset = true) | |
| { | |
| $errors = $this->errors; | |
| if ($reset) { | |
| $this->clearErrors(); | |
| } | |
| return $errors; | |
| } | |
| /** | |
| * Clears the errors from a previous send. | |
| */ | |
| public function clearErrors(): void | |
| { | |
| $this->errors = []; | |
| $this->fatal = false; | |
| } | |
| /** | |
| * @return TransportInterface | |
| */ | |
| public function getTransport() | |
| { | |
| $reflectedMailer = new \ReflectionClass($this->mailer); | |
| $reflectedTransports = $reflectedMailer->getProperty('transport'); | |
| $reflectedTransports->setAccessible(true); | |
| $allTransports = $reflectedTransports->getValue($this->mailer); | |
| $reflectedTransports = new \ReflectionClass($allTransports); | |
| $reflectedTransport = $reflectedTransports->getProperty('transports'); | |
| $reflectedTransport->setAccessible(true); | |
| $currentTransport = $reflectedTransport->getValue($allTransports); | |
| return $currentTransport['main']; | |
| } | |
| /** | |
| * Creates a download stat for the asset. | |
| */ | |
| protected function createAssetDownloadEntries() | |
| { | |
| // Nothing was sent out so bail | |
| if ($this->fatal || empty($this->assetStats)) { | |
| return; | |
| } | |
| if (isset($this->errors['failures'])) { | |
| // Remove the failures from the asset queue | |
| foreach ($this->errors['failures'] as $failed) { | |
| unset($this->assetStats[$failed]); | |
| } | |
| } | |
| // Create a download entry if there is an Asset attachment | |
| if (!empty($this->assetStats)) { | |
| /** @var \Mautic\AssetBundle\Model\AssetModel $assetModel */ | |
| $assetModel = $this->factory->getModel('asset'); | |
| foreach ($this->assets as $asset) { | |
| foreach ($this->assetStats as $stat) { | |
| $assetModel->trackDownload( | |
| $asset, | |
| null, | |
| 200, | |
| $stat | |
| ); | |
| } | |
| $assetModel->upDownloadCount($asset, count($this->assetStats), true); | |
| } | |
| } | |
| // Reset the stat | |
| $this->assetStats = []; | |
| } | |
| /** | |
| * Queues the details to note if a lead received an asset if no errors are generated. | |
| */ | |
| protected function queueAssetDownloadEntry($contactEmail = null, array $metadata = null) | |
| { | |
| if ($this->internalSend || empty($this->assets)) { | |
| return; | |
| } | |
| if (null === $contactEmail) { | |
| if (!$this->lead) { | |
| return; | |
| } | |
| $contactEmail = $this->lead['email']; | |
| $contactId = $this->lead['id']; | |
| $emailId = $this->email->getId(); | |
| $idHash = $this->idHash; | |
| } else { | |
| $contactId = $metadata['leadId']; | |
| $emailId = $metadata['emailId']; | |
| $idHash = $metadata['hashId']; | |
| } | |
| $this->assetStats[$contactEmail] = [ | |
| 'lead' => $contactId, | |
| 'email' => $emailId, | |
| 'source' => ['email', $emailId], | |
| 'tracking_id' => $idHash, | |
| ]; | |
| } | |
| /** | |
| * Returns if the mailer supports and is in tokenization mode. | |
| * | |
| * @return bool | |
| */ | |
| public function inTokenizationMode() | |
| { | |
| return $this->tokenizationEnabled; | |
| } | |
| /** | |
| * @return \Mautic\PageBundle\Entity\Redirect|object|null | |
| */ | |
| public function getTrackableLink($url) | |
| { | |
| // Ensure a valid URL and that it has not already been found | |
| if (!str_starts_with($url, 'http') && !str_starts_with($url, 'ftp')) { | |
| return null; | |
| } | |
| if ($this->email) { | |
| // Get a Trackable which is channel aware | |
| /** @var \Mautic\PageBundle\Model\TrackableModel $trackableModel */ | |
| $trackableModel = $this->factory->getModel('page.trackable'); | |
| $trackable = $trackableModel->getTrackableByUrl($url, 'email', $this->email->getId()); | |
| return $trackable->getRedirect(); | |
| } | |
| /** @var \Mautic\PageBundle\Model\RedirectModel $redirectModel */ | |
| $redirectModel = $this->factory->getModel('page.redirect'); | |
| return $redirectModel->getRedirectByUrl($url); | |
| } | |
| /** | |
| * Create an email stat. | |
| * | |
| * @param bool|true $persist | |
| * @param string|null $emailAddress | |
| */ | |
| public function createEmailStat($persist = true, $emailAddress = null, $listId = null): Stat | |
| { | |
| $stat = new Stat(); | |
| $stat->setDateSent(new \DateTime()); | |
| $emailExists = $this->email && $this->email->getId(); | |
| if ($emailExists) { | |
| $stat->setEmail($this->email); | |
| } | |
| // Note if a lead | |
| if (null !== $this->lead) { | |
| try { | |
| $stat->setLead($this->factory->getEntityManager()->getReference(Lead::class, $this->lead['id'])); | |
| } catch (ORMException) { | |
| // keep IDE happy | |
| } | |
| $emailAddress = $this->lead['email']; | |
| } | |
| // Find email if applicable | |
| if (null === $emailAddress) { | |
| // Use the last address set | |
| $emailAddresses = $this->message->getTo(); | |
| if (count($emailAddresses)) { | |
| $emailAddress = array_key_last($emailAddresses); | |
| } | |
| } | |
| $stat->setEmailAddress($emailAddress); | |
| // Note if sent from a lead list | |
| if (null !== $listId) { | |
| try { | |
| $stat->setList($this->factory->getEntityManager()->getReference(\Mautic\LeadBundle\Entity\LeadList::class, $listId)); | |
| } catch (ORMException) { | |
| // keep IDE happy | |
| } | |
| } | |
| $stat->setTrackingHash($this->idHash); | |
| if (!empty($this->source)) { | |
| $stat->setSource($this->source[0]); | |
| $stat->setSourceId($this->source[1]); | |
| } | |
| $stat->setTokens($this->getTokens()); | |
| /** @var \Mautic\EmailBundle\Model\EmailModel $emailModel */ | |
| $emailModel = $this->factory->getModel('email'); | |
| // Save a copy of the email - use email ID if available simply to prevent from having to rehash over and over | |
| $id = $emailExists ? $this->email->getId() : md5($this->subject.$this->body['content']); | |
| if (!isset($this->copies[$id])) { | |
| $hash = (32 !== strlen($id)) ? md5($this->subject.$this->body['content']) : $id; | |
| $copy = $emailModel->getCopyRepository()->findByHash($hash); | |
| $copyCreated = false; | |
| if (null === $copy) { | |
| $contentToPersist = strtr($this->body['content'], array_flip($this->embedImagesReplaces)); | |
| if (!$emailModel->getCopyRepository()->saveCopy($hash, $this->subject, $contentToPersist, $this->plainText)) { | |
| // Try one more time to find the ID in case there was overlap when creating | |
| $copy = $emailModel->getCopyRepository()->findByHash($hash); | |
| } else { | |
| $copyCreated = true; | |
| } | |
| } | |
| if ($copy || $copyCreated) { | |
| $this->copies[$id] = $hash; | |
| } | |
| } | |
| if (isset($this->copies[$id])) { | |
| try { | |
| $stat->setStoredCopy($this->factory->getEntityManager()->getReference(Copy::class, $this->copies[$id])); | |
| } catch (ORMException) { | |
| // keep IDE happy | |
| } | |
| } | |
| if ($persist) { | |
| $emailModel->saveEmailStat($stat); | |
| } | |
| return $stat; | |
| } | |
| /** | |
| * Check to see if a monitored email box is enabled and configured. | |
| * | |
| * @return bool|array | |
| */ | |
| public function isMontoringEnabled($bundleKey, $folderKey) | |
| { | |
| if ($this->mailbox->isConfigured($bundleKey, $folderKey)) { | |
| return $this->mailbox->getMailboxSettings(); | |
| } | |
| return false; | |
| } | |
| /** | |
| * Generate bounce email for the lead. | |
| * | |
| * @return bool|string | |
| */ | |
| public function generateBounceEmail($idHash = null) | |
| { | |
| $monitoredEmail = false; | |
| if ($settings = $this->isMontoringEnabled('EmailBundle', 'bounces')) { | |
| // Append the bounce notation | |
| [$email, $domain] = explode('@', $settings['address']); | |
| $email .= '+bounce'; | |
| if ($idHash || $this->idHash) { | |
| $email .= '_'.($idHash ?: $this->idHash); | |
| } | |
| $monitoredEmail = $email.'@'.$domain; | |
| } | |
| return $monitoredEmail; | |
| } | |
| /** | |
| * Generate an unsubscribe email for the lead. | |
| * | |
| * @return bool|string | |
| */ | |
| public function generateUnsubscribeEmail($idHash = null) | |
| { | |
| $monitoredEmail = false; | |
| if ($settings = $this->isMontoringEnabled('EmailBundle', 'unsubscribes')) { | |
| // Append the bounce notation | |
| [$email, $domain] = explode('@', $settings['address']); | |
| $email .= '+unsubscribe'; | |
| if ($idHash || $this->idHash) { | |
| $email .= '_'.($idHash ?: $this->idHash); | |
| } | |
| $monitoredEmail = $email.'@'.$domain; | |
| } | |
| return $monitoredEmail; | |
| } | |
| /** | |
| * @param Email $entity | |
| */ | |
| public function processSlots($slots, $entity): void | |
| { | |
| /** @var \Mautic\CoreBundle\Twig\Helper\SlotsHelper $slotsHelper */ | |
| $slotsHelper = $this->factory->getHelper('template.slots'); | |
| $content = $entity->getContent(); | |
| foreach ($slots as $slot => $slotConfig) { | |
| if (is_numeric($slot)) { | |
| $slot = $slotConfig; | |
| $slotConfig = []; | |
| } | |
| $value = $content[$slot] ?? ''; | |
| $slotsHelper->set($slot, $value); | |
| } | |
| } | |
| /** | |
| * Clean the name - if empty, set as null to ensure pretty headers. | |
| * | |
| * @return string|null | |
| */ | |
| protected function cleanName($name) | |
| { | |
| if (null === $name) { | |
| return $name; | |
| } | |
| $name = trim(html_entity_decode($name, ENT_QUOTES)); | |
| // If empty, replace with null so that email clients do not show empty name because of To: '' <email@domain.com> | |
| if (empty($name)) { | |
| $name = null; | |
| } | |
| return $name; | |
| } | |
| /** | |
| * @return array<string,string> | |
| */ | |
| private function getSystemHeaders(): array | |
| { | |
| /** | |
| * This section is stopped, because it is preventing global headers from being merged | |
| * if ($this->email) { | |
| * // We are purposively ignoring system headers if using an Email entity | |
| * return []; | |
| * }. | |
| */ | |
| if (!$systemHeaders = $this->coreParametersHelper->get('mailer_custom_headers', [])) { | |
| return []; | |
| } | |
| // HTML decode headers | |
| $systemHeaders = array_map('html_entity_decode', $systemHeaders); | |
| return $systemHeaders; | |
| } | |
| /** | |
| * Merge system headers into custom headers if applicable. | |
| */ | |
| private function setMessageHeaders(): void | |
| { | |
| $headers = $this->getCustomHeaders(); | |
| // Set custom headers | |
| if (!empty($headers)) { | |
| $tokens = $this->getTokens(); | |
| // Replace tokens | |
| $messageHeaders = $this->message->getHeaders(); | |
| foreach ($headers as $headerKey => $headerValue) { | |
| $headerValue = str_ireplace(array_keys($tokens), $tokens, $headerValue); | |
| if (!$headerValue) { | |
| $messageHeaders->remove($headerKey); | |
| continue; | |
| } | |
| try { | |
| if (in_array(strtolower($headerKey), ['from', 'to', 'cc', 'bcc', 'reply-to'])) { | |
| // Handling headers that require MailboxListHeader | |
| $headerValue = array_map(fn ($address): Address => new Address($address), | |
| explode(',', $headerValue)); | |
| } | |
| if ($messageHeaders->has($headerKey)) { | |
| $header = $messageHeaders->get($headerKey); | |
| $header->setBody($headerValue); | |
| } else { | |
| $messageHeaders->addHeader($headerKey, $headerValue); | |
| } | |
| } catch (RfcComplianceException) { | |
| $messageHeaders->remove($headerKey); | |
| } | |
| } | |
| } | |
| if (array_key_exists('List-Unsubscribe', $headers)) { | |
| unset($headers['List-Unsubscribe']); | |
| $this->setCustomHeaders($headers, false); | |
| } | |
| } | |
| private function buildMetadata($name, array $tokens): array | |
| { | |
| return [ | |
| 'name' => $name, | |
| 'leadId' => (!empty($this->lead)) ? $this->lead['id'] : null, | |
| 'emailId' => (!empty($this->email)) ? $this->email->getId() : null, | |
| 'emailName' => (!empty($this->email)) ? $this->email->getName() : null, | |
| 'hashId' => $this->idHash, | |
| 'hashIdState' => $this->idHashState, | |
| 'source' => $this->source, | |
| 'tokens' => $tokens, | |
| 'utmTags' => (!empty($this->email)) ? $this->email->getUtmTags() : [], | |
| ]; | |
| } | |
| /** | |
| * Validates a given address to ensure RFC 2822, 3.6.2 specs. | |
| * | |
| * @deprecated 2.11.0 to be removed in 3.0; use Mautic\EmailBundle\Helper\EmailValidator | |
| * | |
| * @throws InvalidEmailException | |
| */ | |
| public static function validateEmail($address): void | |
| { | |
| $invalidChar = strpbrk($address, '\'^&*%'); | |
| if (false !== $invalidChar) { | |
| throw new InvalidEmailException('Email address ['.$address.'] contains this invalid character: '.substr($invalidChar, 0, 1)); | |
| } | |
| if (!filter_var($address, FILTER_VALIDATE_EMAIL)) { | |
| throw new InvalidEmailException('Email address ['.$address.'] is invalid'); | |
| } | |
| } | |
| private function setDefaultFrom($overrideFrom, AddressDTO $systemFrom): void | |
| { | |
| if (is_array($overrideFrom)) { | |
| $fromEmail = key($overrideFrom); | |
| $fromName = $this->cleanName($overrideFrom[$fromEmail]); | |
| $overrideFrom = [$fromEmail => $fromName]; | |
| } elseif (!empty($overrideFrom)) { | |
| $overrideFrom = [$overrideFrom => null]; | |
| } | |
| $this->systemFrom = $overrideFrom ?: $systemFrom; | |
| $this->from = $this->systemFrom; | |
| } | |
| private function setDefaultReplyTo($systemReplyToEmail = null, AddressDTO $systemFromEmail = null): void | |
| { | |
| $fromEmail = null; | |
| if ($systemFromEmail) { | |
| $fromEmail = $systemFromEmail->getEmail(); | |
| } | |
| $this->systemReplyTo = $systemReplyToEmail ?: $fromEmail; | |
| $this->replyTo = $this->systemReplyTo; | |
| } | |
| private function setFromForSingleMessage(): void | |
| { | |
| $email = $this->getEmail(); | |
| if ($this->lead && $email && $email->getUseOwnerAsMailer()) { | |
| if (!isset($this->lead['owner_id'])) { | |
| $this->lead['owner_id'] = 0; | |
| } | |
| $from = $this->fromEmailHelper->getFromAddressConsideringOwner($this->getFrom(), $this->lead, $email); | |
| $this->setMessageFrom($from); | |
| return; | |
| } | |
| if ($email) { | |
| $fromEmail = $email->getFromAddress(); | |
| $fromName = $email->getFromName(); | |
| if (!empty($fromEmail) || !empty($fromName)) { | |
| if (empty($fromName)) { | |
| $fromName = $this->getFrom()->getName(); | |
| } elseif (empty($fromEmail)) { | |
| $fromEmail = $this->getFrom()->getEmail(); | |
| } | |
| $this->from = new AddressDTO($fromEmail, $fromName); | |
| } | |
| } | |
| $from = $this->fromEmailHelper->getFromAddressDto($this->getFrom(), $this->lead, $email); | |
| $this->setMessageFrom($from); | |
| } | |
| private function setReplyToForSingleMessage(?Email $emailToSend): void | |
| { | |
| // 1. Set the reply to address from the email "reply-to" setting if set. | |
| if ($emailToSend && null !== $emailToSend->getReplyToAddress()) { | |
| $this->setMessageReplyTo($emailToSend->getReplyToAddress()); | |
| return; | |
| } | |
| // 2. Set the reply to address from the lead owner if set. | |
| if (!empty($this->lead['owner_id'])) { | |
| try { | |
| $owner = $this->fromEmailHelper->getContactOwner((int) $this->lead['owner_id'], $emailToSend); | |
| $this->setMessageReplyTo($owner['email']); | |
| } catch (OwnerNotFoundException) { | |
| $this->setMessageReplyTo($this->getSystemReplyTo()); | |
| } | |
| return; | |
| } | |
| // 3. Set the reply to address from the email "from" setting if set. | |
| if ($emailToSend && null !== $emailToSend->getFromAddress() && empty($this->coreParametersHelper->get('mailer_reply_to_email'))) { | |
| $this->setMessageReplyTo($emailToSend->getFromAddress()); | |
| return; | |
| } | |
| // 4. Set the reply to address from the global config if nothing from above is set. | |
| $this->setMessageReplyTo($this->getReplyTo()); | |
| } | |
| /** | |
| * @return bool|array | |
| * | |
| * @deprecated | |
| */ | |
| protected function getContactOwner(&$contact) | |
| { | |
| if (!is_array($contact)) { | |
| return false; | |
| } | |
| if (!isset($contact['id'])) { | |
| return false; | |
| } | |
| if (!isset($contact['owner_id'])) { | |
| $contact['owner_id'] = 0; | |
| return false; | |
| } | |
| try { | |
| return $this->fromEmailHelper->getContactOwner($contact['owner_id']); | |
| } catch (OwnerNotFoundException) { | |
| return false; | |
| } | |
| } | |
| /** | |
| * @deprecated; use FromEmailHelper::getUserSignature | |
| */ | |
| protected function getContactOwnerSignature($owner): string | |
| { | |
| if (empty($owner['id'])) { | |
| return ''; | |
| } | |
| try { | |
| $this->fromEmailHelper->getContactOwner($owner['id']); | |
| } catch (OwnerNotFoundException) { | |
| return ''; | |
| } | |
| return $this->fromEmailHelper->getSignature(); | |
| } | |
| private function getMessageInstance(): MauticMessage | |
| { | |
| return new MauticMessage(); | |
| } | |
| private function getReplyTo(): string | |
| { | |
| return $this->replyTo ?? $this->getSystemReplyTo(); | |
| } | |
| private function getSystemReplyTo(): string | |
| { | |
| if (!$this->systemReplyTo) { | |
| $fromEmailAddress = $this->from ? $this->from->getEmail() : null; | |
| $this->systemReplyTo = $this->coreParametersHelper->get('mailer_reply_to_email') ?? $fromEmailAddress ?? $this->getSystemFrom()->getEmail(); | |
| } | |
| return $this->systemReplyTo; | |
| } | |
| private function getFrom(): AddressDTO | |
| { | |
| return $this->from ?? $this->getSystemFrom(); | |
| } | |
| private function getSystemFrom(): AddressDTO | |
| { | |
| if (!$this->systemFrom || $this->systemFrom->isEmpty()) { | |
| $this->systemFrom = new AddressDTO($this->coreParametersHelper->get('mailer_from_email'), $this->coreParametersHelper->get('mailer_from_name')); | |
| $this->fromEmailHelper->setDefaultFrom($this->systemFrom); | |
| } | |
| return $this->systemFrom; | |
| } | |
| public function dispatchPreSendEvent(): void | |
| { | |
| if (null === $this->dispatcher) { | |
| $this->dispatcher = $this->factory->getDispatcher(); | |
| } | |
| if (empty($this->dispatcher)) { | |
| return; | |
| } | |
| $event = new EmailSendEvent($this); | |
| $this->dispatcher->dispatch($event, EmailEvents::EMAIL_PRE_SEND); | |
| $this->skip = $event->isSkip(); | |
| $this->fatal = $event->isFatal(); | |
| $errors = $event->getErrors(); | |
| if (!empty($errors)) { | |
| $currentErrors = []; | |
| if (isset($this->errors['failures']) && is_array($this->errors['failures'])) { | |
| $currentErrors = $this->errors['failures']; | |
| } | |
| $this->errors['failures'] = array_merge($errors, $currentErrors); | |
| } | |
| unset($event); | |
| } | |
| } | |