getModel('email'); $stat = $model->getEmailStatus($idHash); if (!empty($stat)) { if ($this->security->isAnonymous()) { $model->hitEmail($stat, $request, true); } $tokens = $stat->getTokens(); if (is_array($tokens)) { // Override tracking_pixel so as to not cause a double hit $tokens['{tracking_pixel}'] = MailHelper::getBlankPixel(); } if ($copy = $stat->getStoredCopy()) { $subject = $copy->getSubject(); $content = $copy->getBody(); // Replace tokens $content = str_ireplace(array_keys($tokens), $tokens, $content); $subject = str_ireplace(array_keys($tokens), $tokens, $subject); } else { $subject = ''; $content = ''; } $content = $analyticsHelper->addCode($content); // Add subject as title if (!empty($subject)) { if (str_contains($content, '')) { $content = str_replace('', "$subject", $content); } elseif (!str_contains($content, '')) { $content = str_replace('<head>', "<head>\n<title>$subject", $content); } } return new Response($content); } return $this->notFound(); } public function trackingImageAction( Request $request, MessageBusInterface $messageBus, LoggerInterface $logger, string $idHash ): Response { try { $messageBus->dispatch(new EmailHitNotification($idHash, $request)); } catch (\Exception $exception) { $logger->error($exception->getMessage(), ['idHash' => $idHash]); $emailModel = $this->getModel('email'); assert($emailModel instanceof EmailModel); $emailModel->hitEmail($idHash, $request); } return TrackingPixelHelper::getResponse($request); } /** * @return Response * * @throws \Exception * @throws \Mautic\CoreBundle\Exception\FileNotFoundException */ public function unsubscribeAction(Request $request, ContactTracker $contactTracker, EmailModel $model, LeadModel $leadModel, FormModel $formModel, PageModel $pageModel, MailHashHelper $mailHash, $idHash, string $urlEmail = null, string $secretHash = null) { $stat = $model->getEmailStatus($idHash); $message = ''; $email = null; $lead = null; $template = null; $session = $request->getSession(); $isOneClickUnsubscribe = $request->isMethod(Request::METHOD_POST) && 'One-Click' === $request->get('List-Unsubscribe'); if (!empty($stat)) { if ($isOneClickUnsubscribe) { // RFC 8058 One-Click unsubscribe $unsubscribeComment = $this->translator->trans('mautic.email.dnc.unsubscribed'); $model->setDoNotContact($stat, $unsubscribeComment, DoNotContact::UNSUBSCRIBED); return new Response($this->translator->trans('mautic.lead.do.not.contact_unsubscribed')); } $email = $stat->getEmail(); } $isCorrectHash = $secretHash && $urlEmail && $mailHash->getEmailHash($urlEmail) === $secretHash; if ($email) { $template = $email->getTemplate(); if ('mautic_code_mode' === $template) { // Use system default $template = null; } /** @var \Mautic\FormBundle\Entity\Form $unsubscribeForm */ $unsubscribeForm = $email->getUnsubscribeForm(); if (null != $unsubscribeForm && $unsubscribeForm->isPublished()) { $formTemplate = $unsubscribeForm->getTemplate(); $formContent = '
'.$formModel->getContent($unsubscribeForm).'
'; } } else { if ($isOneClickUnsubscribe) { return new Response($this->translator->trans('mautic.email.stat_record.not_found'), Response::HTTP_NOT_FOUND); } } if (empty($template) && empty($formTemplate)) { $template = $this->coreParametersHelper->get('theme'); } elseif (!empty($formTemplate)) { $template = $formTemplate; } $theme = $this->factory->getTheme($template); if ($theme->getTheme() != $template) { $template = $theme->getTheme(); } $contentTemplate = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/message.html.twig'); if (!empty($stat) || $isCorrectHash) { $successSessionName = 'mautic.email.prefscenter.success'; if (!empty($stat) && $lead = $stat->getLead()) { // Set the lead as current lead $contactTracker->setTrackedContact($lead); // Set lead lang if ($lead->getPreferredLocale()) { $this->translator->setLocale($lead->getPreferredLocale()); } // Add contact ID to the session name in case more contacts // share the same session/device and the contact is known. $successSessionName .= ".{$lead->getId()}"; } elseif (empty($stat)) { $leadRepo = $leadModel->getRepository(); $contacts = $leadRepo->getContactsByEmail($urlEmail); $lead = null; if (is_array($contacts) && count($contacts) > 0) { $lead = array_pop($contacts); } else { $message = $this->translator->trans('mautic.email.stat_record.not_found'); } } if (!$this->coreParametersHelper->get('show_contact_preferences')) { if (!empty($stat)) { $message = $this->getUnsubscribeMessage($idHash, $model, $stat, $this->translator); } elseif ($lead && $lead instanceof Lead) { $message = $this->getUnsubscribeMessageLead($idHash, $model, $lead, $this->translator, $urlEmail); } } elseif ($lead) { $params = ['idHash' => $idHash, 'urlEmail' => $urlEmail]; if ($urlEmail) { $params['secretHash'] = $mailHash->getEmailHash($urlEmail); } $action = $this->generateUrl('mautic_email_unsubscribe', $params); $viewParameters = [ 'lead' => $lead, 'idHash' => $idHash, 'showContactFrequency' => $this->coreParametersHelper->get('show_contact_frequency'), 'showContactPauseDates' => $this->coreParametersHelper->get('show_contact_pause_dates'), 'showContactPreferredChannels' => $this->coreParametersHelper->get('show_contact_preferred_channels'), 'showContactCategories' => $this->coreParametersHelper->get('show_contact_categories'), 'showContactSegments' => $this->coreParametersHelper->get('show_contact_segments'), ]; if ($session->get($successSessionName)) { $viewParameters['successMessage'] = $this->translator->trans('mautic.email.preferences_center_success_message.text'); } $form = $this->getFrequencyRuleForm($lead, $viewParameters, $data, true, $action, true); if (true === $form) { $session->set($successSessionName, 1); return $this->postActionRedirect( [ 'returnUrl' => $action, 'viewParameters' => $viewParameters, 'contentTemplate' => $contentTemplate, ] ); } else { // success message should not persist on page refresh $session->set($successSessionName, 0); } $formView = $form->createView(); /** @var Page $prefCenter */ if ($email && ($prefCenter = $email->getPreferenceCenter()) && $prefCenter->getIsPreferenceCenter()) { $html = $prefCenter->getCustomHtml(); // check if tokens are present if (str_contains($html, 'data-slot="saveprefsbutton"') || str_contains($html, BuilderSubscriber::saveprefsRegex)) { // set custom tag to inject end form // update show pref center slots by looking for their presence in the html $showParameters = $this->buildSlotShowParametersBasedOnContent($html, $viewParameters); $eventParameters = array_merge( $viewParameters, $showParameters, [ 'form' => $formView, 'startform' => $this->renderView('@MauticCore/Default/form.html.twig', ['form' => $formView]), 'custom_tag' => '', ] ); $event = new PageDisplayEvent($html, $prefCenter, $eventParameters); $this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY); $html = $event->getContent(); if (!$session->has($successSessionName)) { $successMessageDataSlots = [ 'data-slot="successmessage"', 'class="pref-successmessage"', ]; $successMessageDataSlotsHidden = []; foreach ($successMessageDataSlots as $successMessageDataSlot) { $successMessageDataSlotsHidden[] = $successMessageDataSlot.' style=display:none'; } $html = str_replace( $successMessageDataSlots, $successMessageDataSlotsHidden, $html ); } else { $session->remove($successSessionName); } $html = preg_replace( '/'.BuilderSubscriber::identifierToken.'/', $lead->getPrimaryIdentifier(), $html ); $pageModel->hitPage($prefCenter, $request, 200, $lead); } else { unset($html); } } if (empty($html)) { $html = $this->render( '@MauticEmail/Lead/preference_options.html.twig', array_merge( $viewParameters, [ 'form' => $formView, 'currentRoute' => $this->generateUrl( 'mautic_contact_action', [ 'objectAction' => 'contactFrequency', 'objectId' => $lead->getId(), ] ), ] ) )->getContent(); } $message = $html; } } else { $message = $this->translator->trans('mautic.email.stat_record.not_found'); } $config = $theme->getConfig(); $viewParams = [ 'email' => $email, 'lead' => $lead, 'template' => $template, 'message' => $message, ]; if (!empty($formContent)) { $viewParams['content'] = $formContent; if (in_array('form', $config['features'])) { $contentTemplate = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/form.html.twig'); } else { $viewParams['content'] = ''; $viewParams['message'] = $message.$formContent; } } return $this->render($contentTemplate, $viewParams); } /** * @throws \Exception * @throws \Mautic\CoreBundle\Exception\FileNotFoundException */ public function resubscribeAction(ContactTracker $contactTracker, EmailModel $model, LeadModel $leadModel, MailHashHelper $mailHash, $idHash): Response { $stat = $model->getEmailStatus($idHash); if (!empty($stat)) { $email = $stat->getEmail(); $lead = $stat->getLead(); if ($lead) { // Set the lead as current lead $contactTracker->setTrackedContact($lead); if (!$this->translator instanceof LocaleAwareInterface) { throw new \LogicException(sprintf('$this->translator must be an instance of "%s"', LocaleAwareInterface::class)); } // Set lead lang if ($lead->getPreferredLocale()) { $this->translator->setLocale($lead->getPreferredLocale()); } } $model->removeDoNotContact($stat->getEmailAddress()); $message = $this->coreParametersHelper->get('resubscribe_message'); $toEmail = $stat->getEmailAddress(); $unsubscribeHash = $mailHash->getEmailHash($toEmail); if (!$message) { $message = $this->translator->trans( 'mautic.email.resubscribed.success', [ '%unsubscribeUrl%' => '|URL|', '%email%' => '|EMAIL|', ] ); } $message = str_replace( [ '|URL|', '|EMAIL|', ], [ $this->generateUrl('mautic_email_unsubscribe', ['idHash' => $idHash, 'urlEmail' => $toEmail, 'secretHash' => $unsubscribeHash]), $stat->getEmailAddress(), ], $message ); } else { $email = $lead = false; $message = $this->translator->trans('mautic.email.stat_record.not_found'); } $template = (!empty($email) && 'mautic_code_mode' !== $email->getTemplate()) ? $email->getTemplate() : $this->coreParametersHelper->get('theme'); $theme = $this->factory->getTheme($template); if ($theme->getTheme() != $template) { $template = $theme->getTheme(); } // Ensure template still exists $theme = $this->factory->getTheme($template); if (empty($theme) || $theme->getTheme() !== $template) { $template = $this->coreParametersHelper->get('theme'); } $analytics = $this->factory->getHelper('twig.analytics')->getCode(); if (!empty($analytics)) { $this->factory->getHelper('template.assets')->addCustomDeclaration($analytics); } $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/message.html.twig'); return $this->render( $logicalName, [ 'message' => $message, 'type' => 'notice', 'email' => $email, 'lead' => $lead, 'template' => $template, ] ); } /** * Handles mailer transport webhook post. */ public function mailerCallbackAction(Request $request): Response { $event = new TransportWebhookEvent($request); $this->dispatcher->dispatch($event, EmailEvents::ON_TRANSPORT_WEBHOOK); return $event->getResponse() ?? new Response('No email transport that could process this callback was found', Response::HTTP_NOT_FOUND); } /** * Preview email. * * @return Response */ public function previewAction(AnalyticsHelper $analyticsHelper, Request $request, string $objectId, string $objectType = null) { $contactId = (int) $request->query->get('contactId'); /** @var EmailModel $model */ $model = $this->getModel('email'); $emailEntity = $model->getEntity($objectId); if (null === $emailEntity) { return $this->notFound(); } if ( ($this->security->isAnonymous() && (!$emailEntity->getIsPublished() || !$emailEntity->isPublicPreview())) || (!$this->security->isAnonymous() && !$this->security->hasEntityAccess( 'email:emails:viewown', 'email:emails:viewother', $emailEntity->getCreatedBy() )) ) { return $this->accessDenied(); } // bogus ID if ($contactId && ( !$this->security->isAdmin() || !$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother') ) ) { // disallow displaying contact information $contactId = null; } // bogus ID $idHash = 'xxxxxxxxxxxxxx'; $BCcontent = $emailEntity->getContent(); $content = $emailEntity->getCustomHtml(); if (empty($content) && !empty($BCcontent)) { $template = $emailEntity->getTemplate(); $slots = $this->factory->getTheme($template)->getSlots('email'); $assetsHelper = $this->factory->getHelper('template.assets'); $assetsHelper->addCustomDeclaration(''); $this->processSlots($slots, $emailEntity); $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/email.html.twig'); $response = $this->render( $logicalName, [ 'inBrowser' => true, 'slots' => $slots, 'content' => $emailEntity->getContent(), 'email' => $emailEntity, 'lead' => null, 'template' => $template, ] ); // replace tokens $content = $response->getContent(); } // Override tracking_pixel $tokens = ['{tracking_pixel}' => '']; // Prepare contact if ($contactId) { // We have one from request parameter /** @var LeadModel $leadModel */ $leadModel = $this->getModel('lead.lead'); /** @var Lead $contact */ $contact = $leadModel->getEntity($contactId); $contact = $contact->convertToArray(); } else { // Generate faked one /** @var \Mautic\LeadBundle\Model\FieldModel $fieldModel */ $fieldModel = $this->getModel('lead.field'); $contact = $fieldModel->getFieldList(false, false); array_walk( $contact, function (&$field): void { $field = "[$field]"; } ); $contact['id'] = 0; } // Generate and replace tokens $event = new EmailSendEvent( null, [ 'content' => $content, 'email' => $emailEntity, 'idHash' => $idHash, 'tokens' => $tokens, 'internalSend' => true, 'lead' => $contact, ] ); $this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_DISPLAY); $content = $event->getContent(true); if ($this->security->isAnonymous()) { $content = $analyticsHelper->addCode($content); } return new Response($content); } /** * @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); } } /** * @throws \Exception */ private function doTracking(Request $request, IntegrationHelper $integrationHelper, MailHelper $mailer, LoggerInterface $mauticLogger, $integration): void { $logger = $mauticLogger; // if additional data were sent with the tracking pixel $query_string = $request->server->get('QUERY_STRING'); if (!$query_string) { $logger->log('error', $integration.': query string is not available'); return; } if (str_starts_with($query_string, 'r=')) { $query_string = substr($query_string, strpos($query_string, '?') + 1); } // remove route variable parse_str($query_string, $query); // URL attr 'd' is encoded so let's decode it first. if (!isset($query['d'], $query['sig'])) { $logger->log('error', $integration.': query variables are not found'); return; } // get secret from plugin settings $myIntegration = $integrationHelper->getIntegrationObject($integration); if (!$myIntegration) { $logger->log('error', $integration.': integration not found'); return; } $keys = $myIntegration->getDecryptedApiKeys(); // generate signature $salt = $keys['secret']; if (!str_contains($salt, '$1$')) { $salt = '$1$'.$salt; } // add MD5 prefix $cr = crypt(urlencode($query['d']), $salt); $mySig = hash('crc32b', $cr); // this hash type is used in c# // compare signatures if (hash_equals($mySig, $query['sig'])) { // decode and parse query variables $b64 = base64_decode($query['d']); $gz = gzdecode($b64); parse_str($gz, $query); } else { // signatures don't match: stop $logger->log('error', $integration.': signatures don\'t match'); unset($query); } if (empty($query) || !isset($query['email'], $query['subject'], $query['body'])) { $logger->log('error', $integration.': query variables are empty'); return; } if (MAUTIC_ENV === 'dev') { $logger->log('error', $integration.': '.json_encode($query, JSON_PRETTY_PRINT)); } /** @var EmailModel $model */ $model = $this->getModel('email'); // email is a semicolon delimited list of emails $emails = explode(';', $query['email']); $leadModel = $this->getModel('lead'); \assert($leadModel instanceof LeadModel); $repo = $leadModel->getRepository(); foreach ($emails as $email) { $lead = $repo->getLeadByEmail($email); if (null === $lead) { $lead = $this->createLead($email, $repo); if (null === $lead) { continue; } } $idHash = hash('crc32', $email.$query['body']); $idHash = substr($idHash.$idHash, 0, 13); // 13 bytes length $stat = $model->getEmailStatus($idHash); // stat doesn't exist, create one if (null === $stat) { $lead['email'] = $email; // needed for stat $stat = $this->addStat($mailer, $lead, $email, $query, $idHash); } $stat->setSource('email.client'); if ($stat || 'Outlook' !== $integration) { // Outlook requests the tracking gif on send $model->hitEmail($idHash, $request); // add email event } } } /** * @return Response */ public function pluginTrackingGifAction(Request $request, IntegrationHelper $integrationHelper, MailHelper $mailer, LoggerInterface $mauticLogger, $integration) { $this->doTracking($request, $integrationHelper, $mailer, $mauticLogger, $integration); return TrackingPixelHelper::getResponse($request); // send gif } private function addStat(MailHelper $mailer, $lead, $email, $query, $idHash): ?Stat { if (null !== $lead) { // To lead $mailer->addTo($email); // sanitize variables to prevent malicious content $from = filter_var($query['from'], FILTER_SANITIZE_EMAIL); $mailer->setFrom($from, ''); // Set Content $body = filter_var($query['body'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_HIGH); $mailer->setBody($body); $mailer->parsePlainText($body); // Set lead $mailer->setLead($lead); $mailer->setIdHash($idHash); $subject = filter_var($query['subject'], FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_HIGH); $mailer->setSubject($subject); return $mailer->createEmailStat(); } return null; } private function createLead($email, $repo): ?Lead { $model = $this->getModel('lead.lead'); \assert($model instanceof LeadModel); $lead = $model->getEntity(); // set custom field values $data = ['email' => $email]; $model->setFieldValues($lead, $data, true); // create lead $model->saveEntity($lead); // return entity return $repo->getLeadByEmail($email); } public function getUnsubscribeMessage($idHash, $model, $stat, $translator): string { $model->setDoNotContact($stat, $translator->trans('mautic.email.dnc.unsubscribed'), DoNotContact::UNSUBSCRIBED); return $this->getUnsubscribeText($translator, $stat->getEmailAddress(), $idHash); } public function getUnsubscribeMessageLead(string $idHash, EmailModel $model, Lead $lead, TranslatorInterface $translator, string $urlEmail): string { $model->setDoNotContactLead($lead, $translator->trans('mautic.email.dnc.unsubscribed'), DoNotContact::UNSUBSCRIBED); return $this->getUnsubscribeText($translator, $urlEmail, $idHash); } private function getUnsubscribeText(TranslatorInterface $translator, string $email, string $idHash): string { $message = $this->coreParametersHelper->get('unsubscribe_message'); if (!$message) { $message = $translator->trans( 'mautic.email.unsubscribed.success', [ '%resubscribeUrl%' => '|URL|', '%email%' => '|EMAIL|', ] ); } return str_replace( [ '|URL|', '|EMAIL|', ], [ $this->generateUrl('mautic_email_resubscribe', ['idHash' => $idHash]), $email, ], $message ); } /** * The $viewParameters here have already been used to build the $form. * Fields that are set to show based on the app configuration are part * of the form. If the field is not configured to show, but a slot exists * for that field in the content, then we need to keep the configuration * value instead of letting the content determine if it should show. This * is because of what was stated above - fields that are not configured to * to show are not part of the form. Attempting to render them will result * in an error. * * @param mixed[] $viewParameters * * @return mixed[] */ private function buildSlotShowParametersBasedOnContent(string $content, array $viewParameters): array { /* * Since we're going to be merging this with the $viewParameters, filter out `true` values. We do not * want to change a configured value from `false` to `true` because a value of `false` in the $viewParameters * means that the field is not configured to show and therefore is not part of the form. Attempting to * render that field just because a slot for it exists will result in an error. */ $showParamsBasedOnContent = array_filter([ 'showContactFrequency' => str_contains($content, 'data-slot="channelfrequency"') || str_contains($content, BuilderSubscriber::channelfrequency), 'showContactSegments' => str_contains($content, 'data-slot="segmentlist"') || str_contains($content, BuilderSubscriber::segmentListRegex), 'showContactCategories' => str_contains($content, 'data-slot="categorylist"') || str_contains($content, BuilderSubscriber::categoryListRegex), 'showContactPreferredChannels' => str_contains($content, 'data-slot="preferredchannel"') || str_contains($content, BuilderSubscriber::preferredchannel), ], fn (bool $value) =>!$value); $showParamsBasedOnConfiguration = array_filter($viewParameters, fn ($key) => str_starts_with($key, 'show'), ARRAY_FILTER_USE_KEY); return array_merge($showParamsBasedOnConfiguration, $showParamsBasedOnContent); } }