![]() Server : Apache System : Linux server2.corals.io 4.18.0-348.2.1.el8_5.x86_64 #1 SMP Mon Nov 15 09:17:08 EST 2021 x86_64 User : corals ( 1002) PHP Version : 7.4.33 Disable Function : exec,passthru,shell_exec,system Directory : /home/corals/mautic.corals.io/app/bundles/EmailBundle/Helper/ |
<?php 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 protected]> 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); } }