Spaces:
No application file
No application file
namespace Mautic\EmailBundle\Model; | |
use Doctrine\DBAL\Query\QueryBuilder; | |
use Doctrine\ORM\EntityManagerInterface; | |
use Doctrine\ORM\OptimisticLockException; | |
use Exception; | |
use Mautic\ChannelBundle\Entity\MessageQueue; | |
use Mautic\ChannelBundle\Model\MessageQueueModel; | |
use Mautic\CoreBundle\Helper\ArrayHelper; | |
use Mautic\CoreBundle\Helper\CacheStorageHelper; | |
use Mautic\CoreBundle\Helper\Chart\BarChart; | |
use Mautic\CoreBundle\Helper\Chart\ChartQuery; | |
use Mautic\CoreBundle\Helper\Chart\LineChart; | |
use Mautic\CoreBundle\Helper\Chart\PieChart; | |
use Mautic\CoreBundle\Helper\CoreParametersHelper; | |
use Mautic\CoreBundle\Helper\DateTimeHelper; | |
use Mautic\CoreBundle\Helper\IpLookupHelper; | |
use Mautic\CoreBundle\Helper\ThemeHelperInterface; | |
use Mautic\CoreBundle\Helper\UserHelper; | |
use Mautic\CoreBundle\Model\AjaxLookupModelInterface; | |
use Mautic\CoreBundle\Model\BuilderModelTrait; | |
use Mautic\CoreBundle\Model\FormModel; | |
use Mautic\CoreBundle\Model\TranslationModelTrait; | |
use Mautic\CoreBundle\Model\VariantModelTrait; | |
use Mautic\CoreBundle\Security\Permissions\CorePermissions; | |
use Mautic\CoreBundle\Translation\Translator; | |
use Mautic\EmailBundle\EmailEvents; | |
use Mautic\EmailBundle\Entity\Email; | |
use Mautic\EmailBundle\Entity\Stat; | |
use Mautic\EmailBundle\Entity\StatDevice; | |
use Mautic\EmailBundle\Entity\StatRepository; | |
use Mautic\EmailBundle\Event\EmailBuilderEvent; | |
use Mautic\EmailBundle\Event\EmailEvent; | |
use Mautic\EmailBundle\Event\EmailOpenEvent; | |
use Mautic\EmailBundle\Event\EmailSendEvent; | |
use Mautic\EmailBundle\Exception\EmailCouldNotBeSentException; | |
use Mautic\EmailBundle\Exception\FailedToSendToContactException; | |
use Mautic\EmailBundle\Form\Type\EmailType; | |
use Mautic\EmailBundle\Helper\MailHelper; | |
use Mautic\EmailBundle\Helper\StatsCollectionHelper; | |
use Mautic\EmailBundle\MonitoredEmail\Mailbox; | |
use Mautic\EmailBundle\Stats\FetchOptions\EmailStatOptions; | |
use Mautic\EmailBundle\Stats\Helper\FilterTrait; | |
use Mautic\LeadBundle\Entity\DoNotContact; | |
use Mautic\LeadBundle\Entity\Lead; | |
use Mautic\LeadBundle\Entity\LeadDevice; | |
use Mautic\LeadBundle\Model\CompanyModel; | |
use Mautic\LeadBundle\Model\DoNotContact as DNC; | |
use Mautic\LeadBundle\Model\LeadModel; | |
use Mautic\LeadBundle\Tracker\ContactTracker; | |
use Mautic\LeadBundle\Tracker\DeviceTracker; | |
use Mautic\PageBundle\Entity\RedirectRepository; | |
use Mautic\PageBundle\Entity\Trackable; | |
use Mautic\PageBundle\Entity\TrackableRepository; | |
use Mautic\PageBundle\Model\TrackableModel; | |
use Mautic\UserBundle\Model\UserModel; | |
use Psr\Log\LoggerInterface; | |
use Symfony\Component\Console\Helper\ProgressBar; | |
use Symfony\Component\Console\Output\OutputInterface; | |
use Symfony\Component\EventDispatcher\EventDispatcherInterface; | |
use Symfony\Component\Form\FormFactoryInterface; | |
use Symfony\Component\Form\FormInterface; | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; | |
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |
use Symfony\Contracts\EventDispatcher\Event; | |
/** | |
* @extends FormModel<Email> | |
* | |
* @implements AjaxLookupModelInterface<Email> | |
*/ | |
class EmailModel extends FormModel implements AjaxLookupModelInterface | |
{ | |
use VariantModelTrait; | |
use TranslationModelTrait; | |
use BuilderModelTrait; | |
use FilterTrait; | |
/** | |
* @var bool | |
*/ | |
protected $updatingTranslationChildren = false; | |
/** | |
* @var array | |
*/ | |
protected $emailSettings = []; | |
public function __construct( | |
protected IpLookupHelper $ipLookupHelper, | |
protected ThemeHelperInterface $themeHelper, | |
protected Mailbox $mailboxHelper, | |
protected MailHelper $mailHelper, | |
protected LeadModel $leadModel, | |
protected CompanyModel $companyModel, | |
protected TrackableModel $pageTrackableModel, | |
protected UserModel $userModel, | |
protected MessageQueueModel $messageQueueModel, | |
protected SendEmailToContact $sendModel, | |
private DeviceTracker $deviceTracker, | |
private RedirectRepository $redirectRepository, | |
private CacheStorageHelper $cacheStorageHelper, | |
private ContactTracker $contactTracker, | |
private DNC $doNotContact, | |
private StatsCollectionHelper $statsCollectionHelper, | |
CorePermissions $security, | |
EntityManagerInterface $em, | |
EventDispatcherInterface $dispatcher, | |
UrlGeneratorInterface $router, | |
Translator $translator, | |
UserHelper $userHelper, | |
LoggerInterface $mauticLogger, | |
CoreParametersHelper $coreParametersHelper, | |
private EmailStatModel $emailStatModel | |
) { | |
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper); | |
} | |
/** | |
* @return \Mautic\EmailBundle\Entity\EmailRepository | |
*/ | |
public function getRepository() | |
{ | |
return $this->em->getRepository(Email::class); | |
} | |
public function getStatRepository(): StatRepository | |
{ | |
return $this->emailStatModel->getRepository(); | |
} | |
/** | |
* @return \Mautic\EmailBundle\Entity\CopyRepository | |
*/ | |
public function getCopyRepository() | |
{ | |
return $this->em->getRepository(\Mautic\EmailBundle\Entity\Copy::class); | |
} | |
/** | |
* @return \Mautic\EmailBundle\Entity\StatDeviceRepository | |
*/ | |
public function getStatDeviceRepository() | |
{ | |
return $this->em->getRepository(StatDevice::class); | |
} | |
public function getPermissionBase(): string | |
{ | |
return 'email:emails'; | |
} | |
/** | |
* @param Email $entity | |
*/ | |
public function saveEntity($entity, $unlock = true): void | |
{ | |
$type = $entity->getEmailType(); | |
if (empty($type)) { | |
// Just in case JS failed | |
$entity->setEmailType('template'); | |
} | |
// Ensure that list emails are published | |
if ('list' == $entity->getEmailType()) { | |
// Ensure that this email has the same lists assigned as the translated parent if applicable | |
if ($translationParent = $entity->getTranslationParent()) { | |
\assert($translationParent instanceof Email); | |
$parentLists = $translationParent->getLists()->toArray(); | |
$entity->setLists($parentLists); | |
} | |
} else { | |
// Ensure that all lists are been removed in case of a clone | |
$entity->setLists([]); | |
} | |
if (!$this->updatingTranslationChildren) { | |
if (!$entity->isNew()) { | |
// increase the revision | |
$revision = $entity->getRevision(); | |
++$revision; | |
$entity->setRevision($revision); | |
} | |
// Reset a/b test if applicable | |
if ($isVariant = $entity->isVariant()) { | |
$variantStartDate = new \DateTime(); | |
$resetVariants = $this->preVariantSaveEntity($entity, ['setVariantSentCount', 'setVariantReadCount'], $variantStartDate); | |
} | |
parent::saveEntity($entity, $unlock); | |
if ($isVariant) { | |
$emailIds = $entity->getRelatedEntityIds(); | |
$this->postVariantSaveEntity($entity, $resetVariants, $emailIds, $variantStartDate); | |
} | |
$this->postTranslationEntitySave($entity); | |
// Force translations for this entity to use the same segments | |
if ('list' == $entity->getEmailType() && $entity->hasTranslations()) { | |
$translations = $entity->getTranslationChildren()->toArray(); | |
$this->updatingTranslationChildren = true; | |
foreach ($translations as $translation) { | |
$this->saveEntity($translation); | |
} | |
$this->updatingTranslationChildren = false; | |
} | |
} else { | |
parent::saveEntity($entity, false); | |
} | |
} | |
/** | |
* Save an array of entities. | |
*/ | |
public function saveEntities($entities, $unlock = true): void | |
{ | |
// iterate over the results so the events are dispatched on each delete | |
$batchSize = 20; | |
$i = 0; | |
foreach ($entities as $entity) { | |
$isNew = ($entity->getId()) ? false : true; | |
// set some defaults | |
$this->setTimestamps($entity, $isNew, $unlock); | |
if ($dispatchEvent = $entity instanceof Email) { | |
$event = $this->dispatchEvent('pre_save', $entity, $isNew); | |
} | |
$this->getRepository()->saveEntity($entity, false); | |
if ($dispatchEvent) { | |
$this->dispatchEvent('post_save', $entity, $isNew, $event); | |
} | |
if (0 === ++$i % $batchSize) { | |
$this->em->flush(); | |
} | |
} | |
$this->em->flush(); | |
} | |
/** | |
* @param Email $entity | |
*/ | |
public function deleteEntity($entity): void | |
{ | |
if ($entity->isVariant() && $entity->getIsPublished()) { | |
$this->resetVariants($entity); | |
} | |
parent::deleteEntity($entity); | |
} | |
/** | |
* @param string|null $action | |
* @param array $options | |
* | |
* @return FormInterface<Email> | |
* | |
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException | |
*/ | |
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): FormInterface | |
{ | |
if (!$entity instanceof Email) { | |
throw new MethodNotAllowedHttpException(['Email']); | |
} | |
if (!empty($action)) { | |
$options['action'] = $action; | |
} | |
return $formFactory->create(EmailType::class, $entity, $options); | |
} | |
/** | |
* Get a specific entity or generate a new one if id is empty. | |
*/ | |
public function getEntity($id = null): ?Email | |
{ | |
if (null === $id) { | |
$entity = new Email(); | |
$entity->setSessionId('new_'.hash('sha1', uniqid(mt_rand()))); | |
} else { | |
$entity = parent::getEntity($id); | |
if (null !== $entity) { | |
$entity->setSessionId($entity->getId()); | |
} | |
} | |
return $entity; | |
} | |
/** | |
* Return a list of entities. | |
* | |
* @param array $args [start, limit, filter, orderBy, orderByDir] | |
* | |
* @return \Doctrine\ORM\Tools\Pagination\Paginator|array | |
*/ | |
public function getEntities(array $args = []) | |
{ | |
$entities = parent::getEntities($args); | |
foreach ($entities as $entity) { | |
$queued = $this->cacheStorageHelper->get(sprintf('%s|%s|%s', 'email', $entity->getId(), 'queued')); | |
$pending = $this->cacheStorageHelper->get(sprintf('%s|%s|%s', 'email', $entity->getId(), 'pending')); | |
if (false !== $queued) { | |
$entity->setQueuedCount($queued); | |
} | |
if (false !== $pending) { | |
$entity->setPendingCount($pending); | |
} | |
} | |
return $entities; | |
} | |
/** | |
* @throws MethodNotAllowedHttpException | |
*/ | |
protected function dispatchEvent($action, &$entity, $isNew = false, Event $event = null): ?Event | |
{ | |
if (!$entity instanceof Email) { | |
throw new MethodNotAllowedHttpException(['Email']); | |
} | |
switch ($action) { | |
case 'pre_save': | |
$name = EmailEvents::EMAIL_PRE_SAVE; | |
break; | |
case 'post_save': | |
$name = EmailEvents::EMAIL_POST_SAVE; | |
break; | |
case 'pre_delete': | |
$name = EmailEvents::EMAIL_PRE_DELETE; | |
break; | |
case 'post_delete': | |
$name = EmailEvents::EMAIL_POST_DELETE; | |
break; | |
default: | |
return null; | |
} | |
if ($this->dispatcher->hasListeners($name)) { | |
if (empty($event)) { | |
$event = new EmailEvent($entity, $isNew); | |
$event->setEntityManager($this->em); | |
} | |
$this->dispatcher->dispatch($event, $name); | |
return $event; | |
} else { | |
return null; | |
} | |
} | |
/** | |
* @param Stat|string|null $stat The null is just for BC reasons, should be Stat|string | |
* @param bool $throwDoctrineExceptions in asynchronous processing; we do not wish to ignore the error, rather let the messenger do the handling | |
* | |
* @throws OptimisticLockException|\Exception | |
*/ | |
public function hitEmail( | |
$stat, | |
Request $request, | |
bool $viaBrowser = false, | |
bool $activeRequest = true, | |
\DateTimeInterface $hitDateTime = null, | |
bool $throwDoctrineExceptions = false | |
): void { | |
if (!$stat instanceof Stat) { | |
$stat = $this->getEmailStatus($stat); | |
} | |
if (!$stat) { | |
trigger_deprecation('mautic/mautic', '5.0', 'Calls to hitEmail without a stat are deprecated'); | |
return; | |
} | |
$email = $stat->getEmail(); | |
if ((int) $stat->isRead()) { | |
if ($viaBrowser && !$stat->getViewedInBrowser()) { | |
// opened via browser so note it | |
$stat->setViewedInBrowser($viaBrowser); | |
} | |
} | |
$readDateTime = new DateTimeHelper($hitDateTime ?? ''); | |
$stat->setLastOpened($readDateTime->getDateTime()); | |
$lead = $stat->getLead(); | |
if (null !== $lead) { | |
// Set the lead as current lead | |
if ($activeRequest) { | |
$this->contactTracker->setTrackedContact($lead); | |
} else { | |
$this->contactTracker->setSystemContact($lead); | |
} | |
} | |
$firstTime = false; | |
if (!$stat->getIsRead()) { | |
$firstTime = true; | |
$stat->setIsRead(true); | |
$stat->setDateRead($readDateTime->getDateTime()); | |
} | |
if ($viaBrowser) { | |
$stat->setViewedInBrowser($viaBrowser); | |
} | |
$stat->addOpenDetails( | |
[ | |
'datetime' => $readDateTime->toUtcString(), | |
'useragent' => $request->server->get('HTTP_USER_AGENT'), | |
'inBrowser' => $viaBrowser, | |
] | |
); | |
// check for existing IP | |
$ipAddress = $this->ipLookupHelper->getIpAddress(); | |
$stat->setIpAddress($ipAddress); | |
if ($this->dispatcher->hasListeners(EmailEvents::EMAIL_ON_OPEN)) { | |
$event = new EmailOpenEvent($stat, $request, $firstTime); | |
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_OPEN); | |
} | |
$this->emailStatModel->saveEntity($stat); | |
// Only up counts if associated with both an email and lead | |
if ($firstTime && $email && $lead) { | |
try { | |
$this->getRepository()->incrementRead($email->getId(), $stat->getId(), $email->isVariant()); | |
} catch (\Exception $exception) { | |
error_log($exception); | |
} | |
} | |
if ($email) { | |
$this->em->persist($email); | |
} | |
// Flush the email stat entity in different transactions than the device stat entity to avoid deadlocks. | |
if ($throwDoctrineExceptions) { | |
$this->em->flush(); | |
} else { | |
$this->flushAndCatch(); | |
} | |
if ($lead) { | |
$trackedDevice = $this->deviceTracker->createDeviceFromUserAgent( | |
$lead, | |
$request->server->get('HTTP_USER_AGENT') | |
); | |
// As the entity might be cached, present in EM, but not attached, we need to reload it | |
if ($trackedDevice->getId()) { | |
$trackedDevice = $this->em->getRepository(LeadDevice::class)->find($trackedDevice->getId()); | |
} | |
$emailOpenStat = new StatDevice(); | |
$emailOpenStat->setIpAddress($ipAddress); | |
$emailOpenStat->setDevice($trackedDevice); | |
$emailOpenStat->setDateOpened($readDateTime->toUtcString()); | |
$emailOpenStat->setStat($stat); | |
$this->em->persist($emailOpenStat); | |
if ($throwDoctrineExceptions) { | |
$this->em->flush(); | |
} else { | |
$this->flushAndCatch(); | |
} | |
if (null !== $hitDateTime && $lead->getLastActive() < $hitDateTime) { // We need to perform the update after all is saved | |
$this->leadModel->getRepository()->updateLastActive($lead->getId(), $hitDateTime); | |
} | |
} | |
} | |
public function saveEmailStat(Stat $stat): void | |
{ | |
$this->emailStatModel->saveEntity($stat); | |
} | |
/** | |
* Get array of page builder tokens from bundles subscribed PageEvents::PAGE_ON_BUILD. | |
* | |
* @param array|string $requestedComponents all | tokens | abTestWinnerCriteria | |
* | |
* @return array | |
*/ | |
public function getBuilderComponents(Email $email = null, $requestedComponents = 'all', string $tokenFilter = '') | |
{ | |
$event = new EmailBuilderEvent($this->translator, $email, $requestedComponents, $tokenFilter); | |
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_BUILD); | |
return $this->getCommonBuilderComponents($requestedComponents, $event); | |
} | |
/** | |
* @param array $options | |
* @param int|null $companyId | |
* @param int|null $campaignId | |
* @param int|null $segmentId | |
*/ | |
public function getSentEmailToContactData($limit, \DateTime $dateFrom, \DateTime $dateTo, $options = [], $companyId = null, $campaignId = null, $segmentId = null): array | |
{ | |
$createdByUserId = null; | |
$canViewOthers = empty($options['canViewOthers']) ? false : $options['canViewOthers']; | |
if (!$canViewOthers) { | |
$createdByUserId = $this->userHelper->getUser()->getId(); | |
} | |
$stats = $this->getStatRepository()->getSentEmailToContactData($limit, $dateFrom, $dateTo, $createdByUserId, $companyId, $campaignId, $segmentId); | |
$data = []; | |
foreach ($stats as $stat) { | |
$statId = $stat['id']; | |
if (empty($stat['segment_id']) && !empty($stat['campaign_id'])) { | |
// Let's fetch the segment based on current campaign/segment membership | |
$segmentMembership = $this->em->getRepository(\Mautic\CampaignBundle\Entity\Campaign::class) | |
->getContactSingleSegmentByCampaign($stat['lead_id'], $stat['campaign_id']); | |
if ($segmentMembership) { | |
$stat['segment_id'] = $segmentMembership['id']; | |
$stat['segment_name'] = $segmentMembership['name']; | |
} | |
} | |
$item = [ | |
'contact_id' => $stat['lead_id'], | |
'contact_email' => $stat['email_address'], | |
'open' => $stat['is_read'], | |
'click' => $stat['link_hits'] ?? 0, | |
'links_clicked' => [], | |
'email_id' => (string) $stat['email_id'], | |
'email_name' => (string) $stat['email_name'], | |
'segment_id' => (string) $stat['segment_id'], | |
'segment_name' => (string) $stat['segment_name'], | |
'company_id' => (string) $stat['company_id'], | |
'company_name' => (string) $stat['company_name'], | |
'campaign_id' => (string) $stat['campaign_id'], | |
'campaign_name' => (string) $stat['campaign_name'], | |
'date_sent' => $stat['date_sent'], | |
'date_read' => $stat['date_read'], | |
]; | |
if ($item['click'] && $item['email_id'] && $item['contact_id']) { | |
$item['links_clicked'] = $this->getStatRepository()->getUniqueClickedLinksPerContactAndEmail($item['contact_id'], $item['email_id']); | |
} | |
$data[$statId] = $item; | |
} | |
return $data; | |
} | |
/** | |
* @param int $limit | |
* @param array $options | |
* @param int|null $companyId | |
* @param int|null $campaignId | |
* @param int|null $segmentId | |
*/ | |
public function getMostHitEmailRedirects($limit, \DateTime $dateFrom, \DateTime $dateTo, $options = [], $companyId = null, $campaignId = null, $segmentId = null): array | |
{ | |
$createdByUserId = null; | |
$canViewOthers = empty($options['canViewOthers']) ? false : $options['canViewOthers']; | |
if (!$canViewOthers) { | |
$createdByUserId = $this->userHelper->getUser()->getId(); | |
} | |
$redirects = $this->redirectRepository->getMostHitEmailRedirects($limit, $dateFrom, $dateTo, $createdByUserId, $companyId, $campaignId, $segmentId); | |
$data = []; | |
foreach ($redirects as $redirect) { | |
$data[] = [ | |
'url' => (string) $redirect['url'], | |
'unique_hits' => (string) $redirect['unique_hits'], | |
'hits' => (string) $redirect['hits'], | |
'email_id' => (string) $redirect['email_id'], | |
'email_name' => (string) $redirect['email_name'], | |
]; | |
} | |
return $data; | |
} | |
/** | |
* @return Stat|null | |
*/ | |
public function getEmailStatus($idHash) | |
{ | |
return $this->getStatRepository()->getEmailStatus($idHash); | |
} | |
/** | |
* Search for an email stat by email and lead IDs. | |
* | |
* @return array | |
*/ | |
public function getEmailStati($emailId, $leadId) | |
{ | |
return $this->getStatRepository()->findBy( | |
[ | |
'email' => (int) $emailId, | |
'lead' => (int) $leadId, | |
], | |
['dateSent' => 'DESC'] | |
); | |
} | |
/** | |
* @return array<string, array<int, array<string, int|string>>> | |
* | |
* @throws \Doctrine\DBAL\Exception | |
*/ | |
public function getCountryStats(Email $entity, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, bool $includeVariants = false): array | |
{ | |
$emailIds = ($includeVariants && ($entity->isVariant() || $entity->isTranslation())) ? $entity->getRelatedEntityIds() : [$entity->getId()]; | |
$emailStats = $this->getStatRepository()->getStatsSummaryByCountry($dateFrom, $dateTo, $emailIds); | |
$results['read_count'] = $results['clicked_through_count'] = []; | |
foreach ($emailStats as $e) { | |
$results['read_count'][] = array_intersect_key($e, array_flip(['country', 'read_count'])); | |
$results['clicked_through_count'][] = array_intersect_key($e, array_flip(['country', 'clicked_through_count'])); | |
} | |
return $results; | |
} | |
/** | |
* Get a stats for email by list. | |
* | |
* @param bool $includeVariants | |
*/ | |
public function getEmailListStats($email, $includeVariants = false, \DateTime $dateFrom = null, \DateTime $dateTo = null): array | |
{ | |
if (!$email instanceof Email) { | |
$email = $this->getEntity($email); | |
} | |
$emailIds = ($includeVariants && ($email->isVariant() || $email->isTranslation())) ? $email->getRelatedEntityIds() : [$email->getId()]; | |
$lists = $email->getLists(); | |
$listCount = count($lists); | |
$chart = new BarChart( | |
[ | |
$this->translator->trans('mautic.email.sent'), | |
$this->translator->trans('mautic.email.read'), | |
$this->translator->trans('mautic.email.failed'), | |
$this->translator->trans('mautic.email.clicked'), | |
$this->translator->trans('mautic.email.unsubscribed'), | |
$this->translator->trans('mautic.email.bounced'), | |
] | |
); | |
$statRepo = $this->getStatRepository(); | |
/** @var \Mautic\LeadBundle\Entity\DoNotContactRepository $dncRepo */ | |
$dncRepo = $this->em->getRepository(DoNotContact::class); | |
/** @var TrackableRepository $trackableRepo */ | |
$trackableRepo = $this->em->getRepository(Trackable::class); | |
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$key = ($listCount > 1) ? 1 : 0; | |
if ($listCount > 1) { | |
$sentCounts = $statRepo->getSentCount($emailIds, $lists->getKeys(), $query); | |
$readCounts = $statRepo->getReadCount($emailIds, $lists->getKeys(), $query); | |
$failedCounts = $statRepo->getFailedCount($emailIds, $lists->getKeys(), $query); | |
$clickCounts = $trackableRepo->getCount('email', $emailIds, $lists->getKeys(), $query, false, 'DISTINCT ph.lead_id'); | |
$unsubscribedCounts = $dncRepo->getCount('email', $emailIds, DoNotContact::UNSUBSCRIBED, $lists->getKeys(), $query); | |
$bouncedCounts = $dncRepo->getCount('email', $emailIds, DoNotContact::BOUNCED, $lists->getKeys(), $query); | |
foreach ($lists as $l) { | |
$sentCount = $sentCounts[$l->getId()] ?? 0; | |
$readCount = $readCounts[$l->getId()] ?? 0; | |
$failedCount = $failedCounts[$l->getId()] ?? 0; | |
$clickCount = $clickCounts[$l->getId()] ?? 0; | |
$unsubscribedCount = $unsubscribedCounts[$l->getId()] ?? 0; | |
$bouncedCount = $bouncedCounts[$l->getId()] ?? 0; | |
$chart->setDataset( | |
$l->getName(), | |
[ | |
$sentCount, | |
$readCount, | |
$failedCount, | |
$clickCount, | |
$unsubscribedCount, | |
$bouncedCount, | |
], | |
$key | |
); | |
++$key; | |
} | |
} | |
if ($listCount) { | |
$combined = [ | |
$statRepo->getSentCount($emailIds, null, $query), | |
$statRepo->getReadCount($emailIds, null, $query), | |
$statRepo->getFailedCount($emailIds, null, $query), | |
$trackableRepo->getCount('email', $emailIds, null, $query, true, 'DISTINCT ph.lead_id'), | |
$dncRepo->getCount('email', $emailIds, DoNotContact::UNSUBSCRIBED, null, $query), | |
$dncRepo->getCount('email', $emailIds, DoNotContact::BOUNCED, null, $query), | |
]; | |
if ($listCount > 1) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.lists.combined'), | |
$combined, | |
0 | |
); | |
} else { | |
$chart->setDataset( | |
$lists->first()->getName(), | |
$combined, | |
0 | |
); | |
} | |
} | |
return $chart->render(); | |
} | |
/** | |
* Get a stats for email by list. | |
* | |
* @param Email|int $email | |
* @param bool $includeVariants | |
*/ | |
public function getEmailDeviceStats($email, $includeVariants = false, $dateFrom = null, $dateTo = null): array | |
{ | |
if (!$email instanceof Email) { | |
$email = $this->getEntity($email); | |
} | |
$emailIds = ($includeVariants) ? $email->getRelatedEntityIds() : [$email->getId()]; | |
$templateEmail = 'template' === $email->getEmailType(); | |
$results = $this->getStatDeviceRepository()->getDeviceStats($emailIds, $dateFrom, $dateTo); | |
// Organize by list_id (if a segment email) and/or device | |
$stats = []; | |
$devices = []; | |
foreach ($results as $result) { | |
if (empty($result['device'])) { | |
$result['device'] = $this->translator->trans('mautic.core.unknown'); | |
} else { | |
$result['device'] = mb_substr($result['device'], 0, 12); | |
} | |
$devices[$result['device']] = $result['device']; | |
if ($templateEmail) { | |
// List doesn't matter | |
$stats[$result['device']] = $result['count']; | |
} elseif (null !== $result['list_id']) { | |
if (!isset($stats[$result['list_id']])) { | |
$stats[$result['list_id']] = []; | |
} | |
if (!isset($stats[$result['list_id']][$result['device']])) { | |
$stats[$result['list_id']][$result['device']] = (int) $result['count']; | |
} else { | |
$stats[$result['list_id']][$result['device']] += (int) $result['count']; | |
} | |
} | |
} | |
$listCount = 0; | |
if (!$templateEmail) { | |
$lists = $email->getLists(); | |
$listNames = []; | |
foreach ($lists as $l) { | |
$listNames[$l->getId()] = $l->getName(); | |
} | |
$listCount = count($listNames); | |
} | |
natcasesort($devices); | |
$chart = new BarChart(array_values($devices)); | |
if ($templateEmail) { | |
// Populate the data | |
$chart->setDataset( | |
null, | |
array_values($stats), | |
0 | |
); | |
} else { | |
$combined = []; | |
$key = ($listCount > 1) ? 1 : 0; | |
foreach ($listNames as $id => $name) { | |
// Fill in missing devices | |
$listStats = []; | |
foreach ($devices as $device) { | |
$listStat = (!isset($stats[$id][$device])) ? 0 : $stats[$id][$device]; | |
$listStats[] = $listStat; | |
if (!isset($combined[$device])) { | |
$combined[$device] = 0; | |
} | |
$combined[$device] += $listStat; | |
} | |
// Populate the data | |
$chart->setDataset( | |
$name, | |
$listStats, | |
$key | |
); | |
++$key; | |
} | |
if ($listCount > 1) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.lists.combined'), | |
array_values($combined), | |
0 | |
); | |
} | |
} | |
return $chart->render(); | |
} | |
/** | |
* @param bool $includeVariants | |
* | |
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException | |
*/ | |
public function getEmailGeneralStats($email, $includeVariants, $unit, \DateTime $dateFrom, \DateTime $dateTo): array | |
{ | |
if (!$email instanceof Email) { | |
$email = $this->getEntity($email); | |
} | |
$ids = ($includeVariants) ? $email->getRelatedEntityIds() : [$email->getId()]; | |
$chart = new LineChart($unit, $dateFrom, $dateTo); | |
$fetchOptions = new EmailStatOptions(); | |
$fetchOptions->setEmailIds($ids); | |
$fetchOptions->setCanViewOthers($this->security->isGranted('email:emails:viewother')); | |
$fetchOptions->setUnit($chart->getUnit()); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.sent.emails'), | |
$this->statsCollectionHelper->fetchSentStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.read.emails'), | |
$this->statsCollectionHelper->fetchOpenedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.failed.emails'), | |
$this->statsCollectionHelper->fetchFailedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.clicked'), | |
$this->statsCollectionHelper->fetchClickedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.unsubscribed'), | |
$this->statsCollectionHelper->fetchUnsubscribedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.bounced'), | |
$this->statsCollectionHelper->fetchBouncedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
return $chart->render(); | |
} | |
/** | |
* Get an array of tracked links. | |
*/ | |
public function getEmailClickStats($emailId): array | |
{ | |
return $this->pageTrackableModel->getTrackableList('email', $emailId); | |
} | |
/** | |
* Get the number of leads this email will be sent to. | |
* | |
* @param mixed $listId Leads for a specific lead list | |
* @param bool $countOnly If true, return count otherwise array of leads | |
* @param int $limit Max number of leads to retrieve | |
* @param bool $includeVariants If false, emails sent to a variant will not be included | |
* @param int $minContactId Filter by min contact ID | |
* @param int $maxContactId Filter by max contact ID | |
* @param bool $countWithMaxMin Add min_id and max_id info to the count result | |
* @param bool $storeToCache Whether to store the result to the cache | |
* | |
* @return int|array | |
*/ | |
public function getPendingLeads( | |
Email $email, | |
$listId = null, | |
$countOnly = false, | |
$limit = null, | |
$includeVariants = true, | |
$minContactId = null, | |
$maxContactId = null, | |
$countWithMaxMin = false, | |
$storeToCache = true, | |
int $maxThreads = null, | |
int $threadId = null | |
) { | |
$variantIds = ($includeVariants) ? $email->getRelatedEntityIds() : null; | |
$total = $this->getRepository()->getEmailPendingLeads( | |
$email->getId(), | |
$variantIds, | |
$listId, | |
$countOnly, | |
$limit, | |
$minContactId, | |
$maxContactId, | |
$countWithMaxMin, | |
$maxThreads, | |
$threadId | |
); | |
if ($storeToCache) { | |
if ($countOnly && $countWithMaxMin) { | |
$toStore = $total['count']; | |
} elseif ($countOnly) { | |
$toStore = $total; | |
} else { | |
$toStore = count($total); | |
} | |
$this->cacheStorageHelper->set(sprintf('%s|%s|%s', 'email', $email->getId(), 'pending'), $toStore); | |
} | |
return $total; | |
} | |
/** | |
* @param bool $includeVariants | |
*/ | |
public function getQueuedCounts(Email $email, $includeVariants = true): int | |
{ | |
$ids = ($includeVariants) ? $email->getRelatedEntityIds() : null; | |
if (!in_array($email->getId(), $ids)) { | |
$ids[] = $email->getId(); | |
} | |
$queued = (int) $this->messageQueueModel->getQueuedChannelCount('email', $ids); | |
$this->cacheStorageHelper->set(sprintf('%s|%s|%s', 'email', $email->getId(), 'queued'), $queued); | |
return $queued; | |
} | |
public function getDeliveredCount(Email $email, bool $includeVariants = false): int | |
{ | |
$emailIds = ($includeVariants && ($email->isVariant() || $email->isTranslation())) ? $email->getRelatedEntityIds() : [$email->getId()]; | |
$statRepo = $this->getStatRepository(); | |
/** @var \Mautic\LeadBundle\Entity\DoNotContactRepository $dncRepo */ | |
$dncRepo = $this->em->getRepository(DoNotContact::class); | |
$failedCount = (int) $statRepo->getFailedCount($emailIds); | |
$bouncedCount = (int) $dncRepo->getCount('email', $emailIds, DoNotContact::BOUNCED); | |
$sentCount = (int) $email->getSentCount($includeVariants); | |
$deliveredCount = $sentCount - $failedCount - $bouncedCount; | |
// we never want to display a negative number of delivered emails | |
return max($deliveredCount, 0); | |
} | |
/** | |
* Send an email to lead lists. | |
* | |
* @param array $lists | |
* @param int|null $limit | |
* @param int|null $batch True to process and batch all pending leads | |
* @param int $minContactId | |
* @param int $maxContactId | |
* | |
* @return array array(int $sentCount, int $failedCount, array $failedRecipientsByList) | |
*/ | |
public function sendEmailToLists( | |
Email $email, | |
$lists = null, | |
$limit = null, | |
$batch = null, | |
OutputInterface $output = null, | |
$minContactId = null, | |
$maxContactId = null, | |
int $maxThreads = null, | |
int $threadId = null | |
): array { | |
// get the leads | |
if (empty($lists)) { | |
$lists = $email->getLists(); | |
} | |
// Safety check | |
if ('list' !== $email->getEmailType()) { | |
return [0, 0, []]; | |
} | |
// Doesn't make sense to send unpublished emails. Probably a user error. | |
// @todo throw an exception in Mautic 3 here. | |
if (!$email->isPublished()) { | |
return [0, 0, []]; | |
} | |
$options = [ | |
'source' => ['email', $email->getId()], | |
'allowResends' => false, | |
'customHeaders' => [ | |
'Precedence' => 'Bulk', | |
'X-EMAIL-ID' => $email->getId(), | |
], | |
]; | |
$failedRecipientsByList = []; | |
$sentCount = 0; | |
$failedCount = 0; | |
$progress = false; | |
if ($batch && $output) { | |
$progressCounter = 0; | |
$totalLeadCount = $this->getPendingLeads($email, null, true, null, true, $minContactId, $maxContactId, false, false, $maxThreads, $threadId); | |
if (!$totalLeadCount) { | |
return [0, 0, []]; | |
} | |
// Broadcast send through CLI | |
$output->writeln("\n<info>".$email->getName().'</info>'); | |
$progress = new ProgressBar($output, $totalLeadCount); | |
} | |
foreach ($lists as $list) { | |
if (!$batch && null !== $limit && $limit <= 0) { | |
// Hit the max for this batch | |
break; | |
} | |
$options['listId'] = $list->getId(); | |
$leads = $this->getPendingLeads($email, $list->getId(), false, $batch ?: $limit, true, $minContactId, $maxContactId, false, false, $maxThreads, $threadId); | |
$leadCount = count($leads); | |
while ($leadCount) { | |
if (null != $limit) { | |
// Only retrieve the difference between what has already been sent and the limit | |
$limit -= $leadCount; | |
// recalculate | |
if ($limit < 0) { | |
$leads = array_slice($leads, 0, $limit); | |
$leadCount = count($leads); | |
$limit = 0; | |
} | |
} | |
$sentCount += $leadCount; | |
$listErrors = $this->sendEmail($email, $leads, $options); | |
if (!empty($listErrors)) { | |
$listFailedCount = count($listErrors); | |
$sentCount -= $listFailedCount; | |
$failedCount += $listFailedCount; | |
$failedRecipientsByList[$options['listId']] = $listErrors; | |
} | |
if (null !== $limit && 0 == $limit) { | |
break; | |
} | |
if ($batch) { | |
if ($progress) { | |
$progressCounter += $leadCount; | |
$progress->setProgress($progressCounter); | |
} | |
// Get the next batch of leads | |
$leads = $this->getPendingLeads($email, $list->getId(), false, $batch, true, $minContactId, $maxContactId, false, false, $maxThreads, $threadId); | |
$leadCount = count($leads); | |
} else { | |
$leadCount = 0; | |
} | |
} | |
} | |
if ($progress) { | |
$progress->finish(); | |
} | |
return [$sentCount, $failedCount, $failedRecipientsByList]; | |
} | |
/** | |
* Gets template, stats, weights, etc for an email in preparation to be sent. | |
* | |
* @param bool $includeVariants | |
* | |
* @return array | |
*/ | |
public function &getEmailSettings(Email $email, $includeVariants = true) | |
{ | |
if (empty($this->emailSettings[$email->getId()])) { | |
// used to house slots so they don't have to be fetched over and over for same template | |
// BC for Mautic v1 templates | |
$slots = []; | |
if ($template = $email->getTemplate()) { | |
$slots[$template] = $this->themeHelper->getTheme($template)->getSlots('email'); | |
} | |
// store the settings of all the variants in order to properly disperse the emails | |
// set the parent's settings | |
$emailSettings = [ | |
$email->getId() => [ | |
'template' => $email->getTemplate(), | |
'slots' => $slots, | |
'sentCount' => $email->getSentCount(), | |
'variantCount' => $email->getVariantSentCount(), | |
'isVariant' => null !== $email->getVariantStartDate(), | |
'entity' => $email, | |
'translations' => $email->getTranslations(true), | |
'languages' => ['default' => $email->getId()], | |
], | |
]; | |
if ($emailSettings[$email->getId()]['translations']) { | |
// Add in the sent counts for translations of this email | |
/** @var Email $translation */ | |
foreach ($emailSettings[$email->getId()]['translations'] as $translation) { | |
if ($translation->isPublished()) { | |
$emailSettings[$email->getId()]['sentCount'] += $translation->getSentCount(); | |
$emailSettings[$email->getId()]['variantCount'] += $translation->getVariantSentCount(); | |
// Prevent empty key due to misconfiguration - pretty much ignored | |
if (!$language = $translation->getLanguage()) { | |
$language = 'unknown'; | |
} | |
$core = $this->getTranslationLocaleCore($language); | |
if (!isset($emailSettings[$email->getId()]['languages'][$core])) { | |
$emailSettings[$email->getId()]['languages'][$core] = []; | |
} | |
$emailSettings[$email->getId()]['languages'][$core][$language] = $translation->getId(); | |
} | |
} | |
} | |
if ($includeVariants && $email->isVariant()) { | |
// get a list of variants for A/B testing | |
$childrenVariant = $email->getVariantChildren(); | |
if (count($childrenVariant)) { | |
$variantWeight = 0; | |
$totalSent = $emailSettings[$email->getId()]['variantCount']; | |
foreach ($childrenVariant as $child) { | |
if ($child->isPublished()) { | |
$useSlots = []; | |
if ($template = $child->getTemplate()) { | |
if (isset($slots[$template])) { | |
$useSlots = $slots[$template]; | |
} else { | |
$slots[$template] = $this->themeHelper->getTheme($template)->getSlots('email'); | |
$useSlots = $slots[$template]; | |
} | |
} | |
$variantSettings = $child->getVariantSettings(); | |
$emailSettings[$child->getId()] = [ | |
'template' => $child->getTemplate(), | |
'slots' => $useSlots, | |
'sentCount' => $child->getSentCount(), | |
'variantCount' => $child->getVariantSentCount(), | |
'isVariant' => null !== $email->getVariantStartDate(), | |
'weight' => ($variantSettings['weight'] / 100), | |
'entity' => $child, | |
'translations' => $child->getTranslations(true), | |
'languages' => ['default' => $child->getId()], | |
]; | |
$variantWeight += $variantSettings['weight']; | |
if ($emailSettings[$child->getId()]['translations']) { | |
// Add in the sent counts for translations of this email | |
/** @var Email $translation */ | |
foreach ($emailSettings[$child->getId()]['translations'] as $translation) { | |
if ($translation->isPublished()) { | |
$emailSettings[$child->getId()]['sentCount'] += $translation->getSentCount(); | |
$emailSettings[$child->getId()]['variantCount'] += $translation->getVariantSentCount(); | |
// Prevent empty key due to misconfiguration - pretty much ignored | |
if (!$language = $translation->getLanguage()) { | |
$language = 'unknown'; | |
} | |
$core = $this->getTranslationLocaleCore($language); | |
if (!isset($emailSettings[$child->getId()]['languages'][$core])) { | |
$emailSettings[$child->getId()]['languages'][$core] = []; | |
} | |
$emailSettings[$child->getId()]['languages'][$core][$language] = $translation->getId(); | |
} | |
} | |
} | |
$totalSent += $emailSettings[$child->getId()]['variantCount']; | |
} | |
} | |
// set parent weight | |
$emailSettings[$email->getId()]['weight'] = ((100 - $variantWeight) / 100); | |
} else { | |
$emailSettings[$email->getId()]['weight'] = 1; | |
} | |
} | |
$this->emailSettings[$email->getId()] = $emailSettings; | |
} | |
if ($includeVariants && $email->isVariant()) { | |
// now find what percentage of current leads should receive the variants | |
if (!isset($totalSent)) { | |
$totalSent = 0; | |
foreach ($this->emailSettings[$email->getId()] as $details) { | |
$totalSent += $details['variantCount']; | |
} | |
} | |
foreach ($this->emailSettings[$email->getId()] as &$details) { | |
// Determine the deficit for email ordering | |
if ($totalSent) { | |
$details['weight_deficit'] = $details['weight'] - ($details['variantCount'] / $totalSent); | |
$details['send_weight'] = ($details['weight'] - ($details['variantCount'] / $totalSent)) + $details['weight']; | |
} else { | |
$details['weight_deficit'] = $details['weight']; | |
$details['send_weight'] = $details['weight']; | |
} | |
} | |
// Reorder according to send_weight so that campaigns which currently send one at a time alternate | |
uasort($this->emailSettings[$email->getId()], function ($a, $b): int { | |
if ($a['weight_deficit'] === $b['weight_deficit']) { | |
if ($a['variantCount'] === $b['variantCount']) { | |
return 0; | |
} | |
// if weight is the same - sort by least number sent | |
return ($a['variantCount'] < $b['variantCount']) ? -1 : 1; | |
} | |
// sort by the one with the greatest deficit first | |
return ($a['weight_deficit'] > $b['weight_deficit']) ? -1 : 1; | |
}); | |
} | |
return $this->emailSettings[$email->getId()]; | |
} | |
/** | |
* Send an email to lead(s). | |
* | |
* @param $options = array() | |
* array source array('model', 'id') | |
* array emailSettings | |
* int listId | |
* bool allowResends If false, exact emails (by id) already sent to the lead will not be resent | |
* bool ignoreDNC If true, emails listed in the do not contact table will still get the email | |
* array assetAttachments Array of optional Asset IDs to attach | |
* | |
* @return string[]|bool|string|null | |
*/ | |
public function sendEmail(Email $email, $leads, $options = []) | |
{ | |
$listId = ArrayHelper::getValue('listId', $options); | |
$ignoreDNC = ArrayHelper::getValue('ignoreDNC', $options, false); | |
$tokens = ArrayHelper::getValue('tokens', $options, []); | |
$assetAttachments = ArrayHelper::getValue('assetAttachments', $options, []); | |
$customHeaders = ArrayHelper::getValue('customHeaders', $options, []); | |
$emailType = ArrayHelper::getValue('email_type', $options, ''); | |
$isMarketing = (in_array($emailType, [MailHelper::EMAIL_TYPE_MARKETING]) || !empty($listId)); | |
$emailAttempts = ArrayHelper::getValue('email_attempts', $options, 3); | |
$emailPriority = ArrayHelper::getValue('email_priority', $options, MessageQueue::PRIORITY_NORMAL); | |
$messageQueue = ArrayHelper::getValue('resend_message_queue', $options); | |
$returnErrorMessages = ArrayHelper::getValue('return_errors', $options, false); | |
$channel = ArrayHelper::getValue('channel', $options); | |
$dncAsError = ArrayHelper::getValue('dnc_as_error', $options, false); | |
$errors = []; | |
if (empty($channel)) { | |
$channel = $options['source'] ?? []; | |
} | |
if (!$email->getId()) { | |
return false; | |
} | |
// Ensure $sendTo is indexed by lead ID | |
$leadIds = []; | |
$singleEmail = false; | |
if (isset($leads['id'])) { | |
$singleEmail = $leads['id']; | |
$leadIds[$leads['id']] = $leads['id']; | |
$leads = [$leads['id'] => $leads]; | |
$sendTo = $leads; | |
} else { | |
$sendTo = []; | |
foreach ($leads as $lead) { | |
$sendTo[$lead['id']] = $lead; | |
$leadIds[$lead['id']] = $lead['id']; | |
} | |
} | |
/** @var \Mautic\EmailBundle\Entity\EmailRepository $emailRepo */ | |
$emailRepo = $this->getRepository(); | |
// get email settings such as templates, weights, etc | |
$emailSettings = &$this->getEmailSettings($email); | |
if (!$ignoreDNC) { | |
$dnc = $emailRepo->getDoNotEmailList($leadIds); | |
foreach ($dnc as $removeMeId => $removeMeEmail) { | |
if ($dncAsError) { | |
$errors[$removeMeId] = $this->translator->trans('mautic.email.dnc'); | |
} | |
unset($sendTo[$removeMeId]); | |
unset($leadIds[$removeMeId]); | |
} | |
} | |
// Process frequency rules for email | |
if ($isMarketing && count($sendTo)) { | |
$campaignEventId = (is_array($channel) && !empty($channel) && 'campaign.event' === $channel[0] && !empty($channel[1])) ? $channel[1] | |
: null; | |
$this->messageQueueModel->processFrequencyRules( | |
$sendTo, | |
'email', | |
$email->getId(), | |
$campaignEventId, | |
$emailAttempts, | |
$emailPriority, | |
$messageQueue | |
); | |
} | |
// get a count of leads | |
$count = count($sendTo); | |
// no one to send to so bail or if marketing email from a campaign has been put in a queue | |
if (empty($count)) { | |
if ($returnErrorMessages) { | |
return $singleEmail && isset($errors[$singleEmail]) ? $errors[$singleEmail] : $errors; | |
} | |
return $singleEmail ? true : $errors; | |
} | |
// Hydrate contacts with company profile fields | |
$this->getContactCompanies($sendTo); | |
foreach ($emailSettings as $eid => $details) { | |
if (isset($details['send_weight'])) { | |
$emailSettings[$eid]['limit'] = ceil($count * $details['send_weight']); | |
} else { | |
$emailSettings[$eid]['limit'] = $count; | |
} | |
} | |
// Randomize the contacts for statistic purposes | |
shuffle($sendTo); | |
// Organize the contacts according to the variant and translation they are to receive | |
$groupedContactsByEmail = []; | |
$offset = 0; | |
foreach ($emailSettings as $eid => $details) { | |
if (empty($details['limit'])) { | |
continue; | |
} | |
$groupedContactsByEmail[$eid] = []; | |
if ($details['limit']) { | |
// Take a chunk of contacts based on variant weights | |
if ($batchContacts = array_slice($sendTo, $offset, $details['limit'])) { | |
$offset += $details['limit']; | |
// Group contacts by preferred locale | |
foreach ($batchContacts as $key => $contact) { | |
if (!empty($contact['preferred_locale'])) { | |
$locale = $contact['preferred_locale']; | |
$localeCore = $this->getTranslationLocaleCore($locale); | |
if (isset($details['languages'][$localeCore])) { | |
if (isset($details['languages'][$localeCore][$locale])) { | |
// Exact match | |
$translatedId = $details['languages'][$localeCore][$locale]; | |
$groupedContactsByEmail[$eid][$translatedId][] = $contact; | |
} else { | |
// Grab the closest match | |
$bestMatch = array_keys($details['languages'][$localeCore])[0]; | |
$translatedId = $details['languages'][$localeCore][$bestMatch]; | |
$groupedContactsByEmail[$eid][$translatedId][] = $contact; | |
} | |
unset($batchContacts[$key]); | |
} | |
} | |
} | |
// If there are any contacts left over, assign them to the default | |
if (count($batchContacts)) { | |
$translatedId = $details['languages']['default']; | |
$groupedContactsByEmail[$eid][$translatedId] = $batchContacts; | |
} | |
} | |
} | |
} | |
foreach ($groupedContactsByEmail as $parentId => $translatedEmails) { | |
$useSettings = $emailSettings[$parentId]; | |
foreach ($translatedEmails as $translatedId => $contacts) { | |
$emailEntity = ($translatedId === $parentId) ? $useSettings['entity'] : $useSettings['translations'][$translatedId]; | |
$this->sendModel->setEmail($emailEntity, $channel, $customHeaders, $assetAttachments, $emailType) | |
->setListId($listId); | |
foreach ($contacts as $contact) { | |
try { | |
$this->sendModel->setContact($contact, $tokens) | |
->send(); | |
// Update $emailSetting so campaign a/b tests are handled correctly | |
++$emailSettings[$parentId]['sentCount']; | |
if (!empty($emailSettings[$parentId]['isVariant'])) { | |
++$emailSettings[$parentId]['variantCount']; | |
} | |
} catch (FailedToSendToContactException) { | |
// move along to the next contact | |
} | |
} | |
} | |
} | |
// Flush the queue and store pending email stats | |
$this->sendModel->finalFlush(); | |
// Get the errors to return | |
// Don't use array_merge or it will reset contact ID based keys | |
$errorMessages = $errors + $this->sendModel->getErrors(); | |
$failedContacts = $this->sendModel->getFailedContacts(); | |
// Get sent counts to update email stats | |
$sentCounts = $this->sendModel->getSentCounts(); | |
// Reset the model for the next send | |
$this->sendModel->reset(); | |
// Update sent counts | |
foreach ($sentCounts as $emailId => $count) { | |
// Retry a few times in case of deadlock errors | |
$strikes = 3; | |
while ($strikes >= 0) { | |
try { | |
$this->getRepository()->upCountSent($emailId, (int) $count, (bool) $emailSettings[$emailId]['isVariant']); | |
break; | |
} catch (\Exception $exception) { | |
error_log($exception); | |
} | |
--$strikes; | |
} | |
} | |
unset($emailSettings, $options, $sendTo); | |
$success = empty($failedContacts); | |
if (!$success && $returnErrorMessages) { | |
return $singleEmail ? $errorMessages[$singleEmail] : $errorMessages; | |
} | |
return $singleEmail ? $success : $failedContacts; | |
} | |
/** | |
* Send an email to lead(s). | |
* | |
* @param array|int $users | |
* @param bool $saveStat | |
* | |
* @return bool|string[] | |
* | |
* @throws \Doctrine\ORM\ORMException | |
*/ | |
public function sendEmailToUser( | |
Email $email, | |
$users, | |
array $lead = null, | |
array $tokens = [], | |
array $assetAttachments = [], | |
$saveStat = false, | |
array $to = [], | |
array $cc = [], | |
array $bcc = [] | |
) { | |
if (!$emailId = $email->getId()) { | |
return false; | |
} | |
// In case only user ID was provided | |
if (!is_array($users)) { | |
$users = [['id' => $users]]; | |
} | |
// Get email settings | |
$emailSettings = &$this->getEmailSettings($email, false); | |
// No one to send to so bail | |
if (empty($users) && empty($to)) { | |
return false; | |
} | |
$mailer = $this->mailHelper->getMailer(); | |
if (!isset($lead['companies'])) { | |
$lead['companies'] = $this->companyModel->getRepository()->getCompaniesByLeadId($lead['id']); | |
} | |
$mailer->setLead($lead, true); | |
$mailer->setTokens($tokens); | |
$mailer->setEmail($email, false, $emailSettings[$emailId]['slots'], $assetAttachments, !$saveStat); | |
$mailer->setCc($cc); | |
$mailer->setBcc($bcc); | |
$errors = []; | |
$firstMail = true; | |
foreach ($to as $toAddress) { | |
$idHash = uniqid(); | |
$mailer->setIdHash($idHash, $saveStat); | |
if (!$mailer->addTo($toAddress)) { | |
$errors[] = "{$toAddress}: ".$this->translator->trans('mautic.email.bounce.reason.bad_email'); | |
continue; | |
} | |
if (!$mailer->queue(true)) { | |
$errorArray = $mailer->getErrors(); | |
unset($errorArray['failures']); | |
$errors[] = "{$toAddress}: ".implode('; ', $errorArray); | |
} | |
if ($saveStat) { | |
$saveEntities[] = $mailer->createEmailStat(false, $toAddress); | |
} | |
// If this is the first message, flush the queue. This process clears the cc and bcc. | |
if (true === $firstMail) { | |
try { | |
$this->flushQueue($mailer); | |
} catch (EmailCouldNotBeSentException $e) { | |
$errors[] = $e->getMessage(); | |
} | |
$firstMail = false; | |
} | |
} | |
foreach ($users as $user) { | |
$idHash = uniqid(); | |
$mailer->setIdHash($idHash, $saveStat); | |
if (!is_array($user)) { | |
$id = $user; | |
$user = ['id' => $id]; | |
} else { | |
$id = $user['id']; | |
} | |
if (!isset($user['email'])) { | |
$userEntity = $this->userModel->getEntity($id); | |
if (null === $userEntity) { | |
continue; | |
} | |
$user['email'] = $userEntity->getEmail(); | |
$user['firstname'] = $userEntity->getFirstName(); | |
$user['lastname'] = $userEntity->getLastName(); | |
} | |
if (!$mailer->setTo($user['email'], $user['firstname'].' '.$user['lastname'])) { | |
$errors[] = "{$user['email']}: ".$this->translator->trans('mautic.email.bounce.reason.bad_email'); | |
continue; | |
} | |
if (!$mailer->queue(true)) { | |
$errorArray = $mailer->getErrors(); | |
unset($errorArray['failures']); | |
$errors[] = "{$user['email']}: ".implode('; ', $errorArray); | |
} | |
if ($saveStat) { | |
$saveEntities[] = $mailer->createEmailStat(false, $user['email']); | |
} | |
// If this is the first message, flush the queue. This process clears the cc and bcc. | |
if (true === $firstMail) { | |
try { | |
$this->flushQueue($mailer); | |
} catch (EmailCouldNotBeSentException $e) { | |
$errors[] = $e->getMessage(); | |
} | |
$firstMail = false; | |
} | |
} | |
try { | |
$this->flushQueue($mailer); | |
} catch (EmailCouldNotBeSentException $e) { | |
$errors[] = $e->getMessage(); | |
} | |
if (isset($saveEntities)) { | |
$this->emailStatModel->saveEntities($saveEntities); | |
} | |
// save some memory | |
unset($mailer); | |
return $errors; | |
} | |
/** | |
* @throws EmailCouldNotBeSentException | |
*/ | |
private function flushQueue(MailHelper $mailer): void | |
{ | |
if (!$mailer->flushQueue()) { | |
$errorArray = $mailer->getErrors(); | |
unset($errorArray['failures']); | |
throw new EmailCouldNotBeSentException(implode('; ', $errorArray)); | |
} | |
} | |
/** | |
* Dispatches EmailSendEvent so you could get tokens form it or tokenized content. | |
* | |
* @param string $idHash | |
*/ | |
public function dispatchEmailSendEvent(Email $email, array $leadFields = [], $idHash = null, array $tokens = []): EmailSendEvent | |
{ | |
$event = new EmailSendEvent( | |
null, | |
[ | |
'content' => $email->getCustomHtml(), | |
'email' => $email, | |
'idHash' => $idHash, | |
'tokens' => $tokens, | |
'internalSend' => true, | |
'lead' => $leadFields, | |
] | |
); | |
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_DISPLAY); | |
return $event; | |
} | |
/** | |
* @param int $reason | |
* @param bool $flush | |
* | |
* @return bool|DoNotContact | |
*/ | |
public function setDoNotContact(Stat $stat, $comments, $reason = DoNotContact::BOUNCED, $flush = true) | |
{ | |
$lead = $stat->getLead(); | |
if ($lead instanceof Lead) { | |
$email = $stat->getEmail(); | |
$channel = ($email) ? ['email' => $email->getId()] : 'email'; | |
return $this->doNotContact->addDncForContact($lead->getId(), $channel, $reason, $comments, $flush); | |
} | |
return false; | |
} | |
public function setDoNotContactLead(Lead $lead, string $comments, int $reason = DoNotContact::BOUNCED, bool $flush = true): false|DoNotContact | |
{ | |
return $this->doNotContact->addDncForContact($lead->getId(), 'email', $reason, $comments, $flush); | |
} | |
/** | |
* Remove a Lead's EMAIL DNC entry. | |
* | |
* @param string $email | |
*/ | |
public function removeDoNotContact($email): void | |
{ | |
/** @var \Mautic\LeadBundle\Entity\LeadRepository $leadRepo */ | |
$leadRepo = $this->em->getRepository(Lead::class); | |
$leadId = (array) $leadRepo->getLeadByEmail($email, true); | |
/** @var \Mautic\LeadBundle\Entity\Lead[] $leads */ | |
$leads = []; | |
foreach ($leadId as $lead) { | |
$leads[] = $leadRepo->getEntity($lead['id']); | |
} | |
foreach ($leads as $lead) { | |
$this->doNotContact->removeDncForContact($lead->getId(), 'email'); | |
} | |
} | |
/** | |
* @param int $reason | |
* @param string $comments | |
* @param bool $flush | |
*/ | |
public function setEmailDoNotContact($email, $reason = DoNotContact::BOUNCED, $comments = '', $flush = true, $leadId = null): array | |
{ | |
/** @var \Mautic\LeadBundle\Entity\LeadRepository $leadRepo */ | |
$leadRepo = $this->em->getRepository(Lead::class); | |
if (null === $leadId) { | |
$leadId = (array) $leadRepo->getLeadByEmail($email, true); | |
} elseif (!is_array($leadId)) { | |
$leadId = [$leadId]; | |
} | |
$dnc = []; | |
foreach ($leadId as $lead) { | |
$dnc[] = $this->doNotContact->addDncForContact( | |
$this->em->getReference(Lead::class, $lead), | |
'email', | |
$reason, | |
$comments, | |
$flush | |
); | |
} | |
return $dnc; | |
} | |
/** | |
* Get the settings for a monitored mailbox or false if not enabled. | |
* | |
* @return bool|array | |
*/ | |
public function getMonitoredMailbox($bundleKey, $folderKey) | |
{ | |
if ($this->mailboxHelper->isConfigured($bundleKey, $folderKey)) { | |
return $this->mailboxHelper->getMailboxSettings(); | |
} | |
return false; | |
} | |
/** | |
* Joins the email table and limits created_by to currently logged in user. | |
*/ | |
public function limitQueryToCreator(QueryBuilder &$q): void | |
{ | |
$q->join('t', MAUTIC_TABLE_PREFIX.'emails', 'e', 'e.id = t.email_id') | |
->andWhere('e.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
/** | |
* @param string $column | |
* @param bool $canViewOthers | |
*/ | |
public function getBestHours( | |
$column, | |
\DateTime $dateFrom, | |
\DateTime $dateTo, | |
array $filter = [], | |
$canViewOthers = true, | |
$timeFormat = 24 | |
): array { | |
$companyId = ArrayHelper::pickValue('companyId', $filter); | |
$campaignId = ArrayHelper::pickValue('campaignId', $filter); | |
$segmentId = ArrayHelper::pickValue('segmentId', $filter); | |
$format = '%H:00'; | |
if (12 == $timeFormat) { | |
$format = '%h %p'; | |
} | |
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$q = $query->prepareTimeDataQuery('email_stats', $column, $filter); | |
$columnWithTimezone = 't.'.$column; | |
$defaultTimezoneOffset = (new DateTimeHelper())->getLocalTimezoneOffset(); | |
$columnName = "CONVERT_TZ($columnWithTimezone, '+00:00', '{$defaultTimezoneOffset}')"; | |
$q->select('CONCAT(TIME_FORMAT('.$columnName.', \''.$format.'\'),\'-\',TIME_FORMAT('.$columnName.' + INTERVAL 1 HOUR, \''.$format.'\'),\'\') as hour, COUNT(t.id) AS count') | |
->groupBy('hour') | |
->orderBy('count', 'DESC') | |
->setMaxResults(24); | |
if (!$canViewOthers) { | |
$this->limitQueryToCreator($q); | |
} | |
$this->addCompanyFilter($q, $companyId); | |
$this->addCampaignFilter($q, $campaignId); | |
$this->addSegmentFilter($q, $segmentId); | |
$result = $q->execute()->fetchAllAssociative(); | |
$chart = new BarChart(array_column($result, 'hour')); | |
$counts = array_column($result, 'count'); | |
$total = array_sum($counts); | |
array_walk($counts, function (&$percentage) use ($total): void { | |
$percentage = round(($percentage / $total) * 100, 1); | |
}); | |
$chart->setDataset($this->translator->trans('mautic.widget.emails.best.hours.reads_total', ['%reads%'=>$total]), $counts); | |
return $chart->render(); | |
} | |
/** | |
* Get line chart data of emails sent and read. | |
* | |
* @param string|null $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters} | |
* @param string|null $dateFormat | |
* @param bool $canViewOthers | |
* | |
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException | |
*/ | |
public function getEmailsLineChartData( | |
$unit, | |
\DateTime $dateFrom, | |
\DateTime $dateTo, | |
$dateFormat = null, | |
array $filter = [], | |
$canViewOthers = true | |
): array { | |
$fetchOptions = new EmailStatOptions(); | |
$fetchOptions->setCanViewOthers($canViewOthers); | |
$flag = ArrayHelper::pickValue('flag', $filter, false); | |
$dataset = ArrayHelper::pickValue('dataset', $filter, []); | |
if (!is_null($companyId = ArrayHelper::pickValue('companyId', $filter, null))) { | |
$fetchOptions->setCompanyId((int) $companyId); | |
} | |
if (!is_null($campaignId = ArrayHelper::pickValue('campaignId', $filter, null))) { | |
$fetchOptions->setCampaignId((int) $campaignId); | |
} | |
if (!is_null($segmentId = ArrayHelper::pickValue('segmentId', $filter, null))) { | |
$fetchOptions->setSegmentId((int) $segmentId); | |
} | |
if (!is_null($emailId = ArrayHelper::pickValue('email_id', $filter, null))) { | |
$fetchOptions->setEmailIds([(int) $emailId]); | |
} | |
// Set anything left over to be passed to prepareTimeDataQuery | |
$fetchOptions->setFilters($filter); | |
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat); | |
if (in_array($flag, ['all', 'sent_and_opened_and_failed', 'sent_and_opened']) || !$flag || in_array('sent', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.sent.emails'), | |
$this->statsCollectionHelper->fetchSentStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
if (in_array($flag, ['all', 'sent_and_opened_and_failed', 'sent_and_opened', 'opened']) || in_array('opened', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.read.emails'), | |
$this->statsCollectionHelper->fetchOpenedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
if (in_array($flag, ['all', 'sent_and_opened_and_failed', 'failed']) || in_array('failed', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.failed.emails'), | |
$this->statsCollectionHelper->fetchFailedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
if (in_array($flag, ['all', 'clicked']) || in_array('clicked', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.clicked'), | |
$this->statsCollectionHelper->fetchClickedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
if (in_array($flag, ['all', 'unsubscribed']) || in_array('unsubscribed', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.unsubscribed'), | |
$this->statsCollectionHelper->fetchUnsubscribedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
if (in_array($flag, ['all', 'bounced']) || in_array('bounced', $dataset)) { | |
$chart->setDataset( | |
$this->translator->trans('mautic.email.bounced'), | |
$this->statsCollectionHelper->fetchBouncedStats($dateFrom, $dateTo, $fetchOptions) | |
); | |
} | |
return $chart->render(); | |
} | |
/** | |
* Get pie chart data of ignored vs opened emails. | |
* | |
* @param string $dateFrom | |
* @param string $dateTo | |
* @param array $filters | |
* @param bool $canViewOthers | |
*/ | |
public function getIgnoredVsReadPieChartData($dateFrom, $dateTo, $filters = [], $canViewOthers = true): array | |
{ | |
$chart = new PieChart(); | |
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$readFilters = $filters; | |
$readFilters['is_read'] = true; | |
$failedFilters = $filters; | |
$failedFilters['is_failed'] = true; | |
$sentQ = $query->getCountQuery('email_stats', 'id', 'date_sent', $filters); | |
$readQ = $query->getCountQuery('email_stats', 'id', 'date_sent', $readFilters); | |
$failedQ = $query->getCountQuery('email_stats', 'id', 'date_sent', $failedFilters); | |
if (!$canViewOthers) { | |
$this->limitQueryToCreator($sentQ); | |
$this->limitQueryToCreator($readQ); | |
$this->limitQueryToCreator($failedQ); | |
} | |
$sent = $query->fetchCount($sentQ); | |
$read = $query->fetchCount($readQ); | |
$failed = $query->fetchCount($failedQ); | |
$chart->setDataset($this->translator->trans('mautic.email.graph.pie.ignored.read.failed.ignored'), $sent - $read - $failed); | |
$chart->setDataset($this->translator->trans('mautic.email.graph.pie.ignored.read.failed.read'), $read); | |
$chart->setDataset($this->translator->trans('mautic.email.graph.pie.ignored.read.failed.failed'), $failed); | |
return $chart->render(); | |
} | |
/** | |
* Get pie chart data of ignored vs opened emails. | |
*/ | |
public function getDeviceGranularityPieChartData($dateFrom, $dateTo): array | |
{ | |
$chart = new PieChart(); | |
$deviceStats = $this->getStatDeviceRepository()->getDeviceStats( | |
null, | |
$dateFrom, | |
$dateTo | |
); | |
if (empty($deviceStats)) { | |
$deviceStats[] = [ | |
'count' => 0, | |
'device' => $this->translator->trans('mautic.report.report.noresults'), | |
'list_id' => 0, | |
]; | |
} | |
foreach ($deviceStats as $device) { | |
$chart->setDataset( | |
$device['device'] ?: $this->translator->trans('mautic.core.unknown'), | |
$device['count'] | |
); | |
} | |
return $chart->render(); | |
} | |
/** | |
* Get a list of emails in a date range, grouped by a stat date count. | |
* | |
* @param int $limit | |
* @param array $filters | |
* @param array $options | |
* | |
* @return array | |
*/ | |
public function getEmailStatList($limit = 10, \DateTime $dateFrom = null, \DateTime $dateTo = null, $filters = [], $options = []) | |
{ | |
$canViewOthers = empty($options['canViewOthers']) ? false : $options['canViewOthers']; | |
$q = $this->em->getConnection()->createQueryBuilder(); | |
$q->select('COUNT(DISTINCT t.id) AS count, e.id, e.name') | |
->from(MAUTIC_TABLE_PREFIX.'email_stats', 't') | |
->join('t', MAUTIC_TABLE_PREFIX.'emails', 'e', 'e.id = t.email_id') | |
->orderBy('count', 'DESC') | |
->groupBy('e.id') | |
->setMaxResults($limit); | |
if (!$canViewOthers) { | |
$q->andWhere('e.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$chartQuery->applyFilters($q, $filters); | |
if (isset($options['groupBy']) && 'sends' == $options['groupBy']) { | |
$chartQuery->applyDateFilters($q, 'date_sent'); | |
} | |
if (isset($options['groupBy']) && 'reads' == $options['groupBy']) { | |
$chartQuery->applyDateFilters($q, 'date_read'); | |
} | |
return $q->execute()->fetchAllAssociative(); | |
} | |
/** | |
* Get a list of emails in a date range. | |
* | |
* @param int $limit | |
* @param array $filters | |
* @param array $options | |
* | |
* @return array | |
*/ | |
public function getEmailList($limit = 10, \DateTime $dateFrom = null, \DateTime $dateTo = null, $filters = [], $options = []) | |
{ | |
$canViewOthers = empty($options['canViewOthers']) ? false : $options['canViewOthers']; | |
$q = $this->em->getConnection()->createQueryBuilder(); | |
$q->select('t.id, t.name, t.date_added, t.date_modified') | |
->from(MAUTIC_TABLE_PREFIX.'emails', 't') | |
->setMaxResults($limit); | |
if (!$canViewOthers) { | |
$q->andWhere('t.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$chartQuery->applyFilters($q, $filters); | |
$chartQuery->applyDateFilters($q, 'date_added'); | |
return $q->execute()->fetchAllAssociative(); | |
} | |
/** | |
* Get a list of upcoming emails. | |
* | |
* @param int $limit | |
* @param bool $canViewOthers | |
*/ | |
public function getUpcomingEmails($limit = 10, $canViewOthers = true): array | |
{ | |
/** @var \Mautic\CampaignBundle\Entity\LeadEventLogRepository $leadEventLogRepository */ | |
$leadEventLogRepository = $this->em->getRepository(\Mautic\CampaignBundle\Entity\LeadEventLog::class); | |
$leadEventLogRepository->setCurrentUser($this->userHelper->getUser()); | |
return $leadEventLogRepository->getUpcomingEvents( | |
[ | |
'type' => 'email.send', | |
'limit' => $limit, | |
'canViewOthers' => $canViewOthers, | |
] | |
); | |
} | |
/** | |
* @param string $type | |
* @param string $filter | |
* @param int $limit | |
* @param int $start | |
* @param array $options | |
*/ | |
public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, $options = []): array | |
{ | |
$results = []; | |
switch ($type) { | |
case 'email': | |
$emailRepo = $this->getRepository(); | |
$emailRepo->setCurrentUser($this->userHelper->getUser()); | |
$emails = $emailRepo->getEmailList( | |
$filter, | |
$limit, | |
$start, | |
$this->security->isGranted('email:emails:viewother'), | |
$options['top_level'] ?? false, | |
$options['email_type'] ?? null, | |
$options['ignore_ids'] ?? [], | |
$options['variant_parent'] ?? null | |
); | |
foreach ($emails as $email) { | |
if (empty($options['name_is_key'])) { | |
$results[$email['language']][$email['id']] = $email['name']; | |
} else { | |
$results[$email['language']][$email['name']] = $email['id']; | |
} | |
} | |
// sort by language | |
ksort($results); | |
break; | |
} | |
return $results; | |
} | |
private function getContactCompanies(array &$sendTo): void | |
{ | |
$fetchCompanies = []; | |
foreach ($sendTo as $key => $contact) { | |
if (!isset($contact['companies'])) { | |
$fetchCompanies[$contact['id']] = $key; | |
$sendTo[$key]['companies'] = []; | |
} | |
} | |
if (!empty($fetchCompanies)) { | |
// Simple dbal query that fetches lead_id IN $fetchCompanies and returns as array | |
$companies = $this->companyModel->getRepository()->getCompaniesForContacts(array_keys($fetchCompanies)); | |
foreach ($companies as $contactId => $contactCompanies) { | |
$key = $fetchCompanies[$contactId]; | |
$sendTo[$key]['companies'] = $contactCompanies; | |
} | |
} | |
} | |
/** | |
* Send an email to lead(s). | |
* | |
* @param array $tokens | |
* @param array $assetAttachments | |
* @param array<string>|Lead|null $leadFields | |
* @param bool $saveStat | |
* | |
* @return bool|string[] | |
* | |
* @throws \Doctrine\ORM\ORMException | |
*/ | |
public function sendSampleEmailToUser($email, $users, $leadFields = null, $tokens = [], $assetAttachments = [], $saveStat = true) | |
{ | |
if (!$emailId = $email->getId()) { | |
return false; | |
} | |
if (!is_array($users)) { | |
$user = ['id' => $users]; | |
$users = [$user]; | |
} | |
// get email settings | |
$emailSettings = &$this->getEmailSettings($email, false); | |
// noone to send to so bail | |
if (empty($users)) { | |
return false; | |
} | |
$mailer = $this->mailHelper->getSampleMailer(); | |
$mailer->setLead($leadFields, true); | |
$mailer->setTokens($tokens); | |
$mailer->setEmail($email, false, $emailSettings[$emailId]['slots'], $assetAttachments, !$saveStat); | |
$errors = []; | |
foreach ($users as $user) { | |
$idHash = uniqid(); | |
$mailer->setIdHash($idHash, $saveStat); | |
if (!is_array($user)) { | |
$id = $user; | |
$user = ['id' => $id]; | |
} else { | |
$id = $user['id']; | |
} | |
if (!isset($user['email'])) { | |
$userEntity = $this->userModel->getEntity($id); | |
$user['email'] = $userEntity->getEmail(); | |
$user['firstname'] = $userEntity->getFirstName(); | |
$user['lastname'] = $userEntity->getLastName(); | |
} | |
if (!$mailer->setTo($user['email'], $user['firstname'].' '.$user['lastname'])) { | |
$errors[] = "{$user['email']}: ".$this->translator->trans('mautic.email.bounce.reason.bad_email'); | |
} else { | |
if (!$mailer->queue(true)) { | |
$errorArray = $mailer->getErrors(); | |
unset($errorArray['failures']); | |
$errors[] = "{$user['email']}: ".implode('; ', $errorArray); | |
} | |
if ($saveStat) { | |
$saveEntities[] = $mailer->createEmailStat(false, $user['email']); | |
} | |
} | |
} | |
// flush the message | |
if (!$mailer->flushQueue()) { | |
$errorArray = $mailer->getErrors(); | |
unset($errorArray['failures']); | |
$errors[] = implode('; ', $errorArray); | |
} | |
if (isset($saveEntities)) { | |
$this->emailStatModel->saveEntities($saveEntities); | |
} | |
// save some memory | |
unset($mailer); | |
return $errors; | |
} | |
public function getEmailsIdsWithDependenciesOnSegment($segmentId): array | |
{ | |
$entities = $this->getEntities( | |
[ | |
'filter' => [ | |
'force' => [ | |
[ | |
'column' => 'l.id', | |
'expr' => 'eq', | |
'value' => $segmentId, | |
], | |
], | |
], | |
] | |
); | |
$ids = []; | |
foreach ($entities as $entity) { | |
$ids[] = $entity->getId(); | |
} | |
return $ids; | |
} | |
public function isUpdatingTranslationChildren(): bool | |
{ | |
return $this->updatingTranslationChildren; | |
} | |
/** | |
* @param string $route | |
* @param array<string, string>|array<string, int> $routeParams | |
* @param bool $absolute | |
* @param array<array<string>> $clickthrough | |
* | |
* @return string | |
*/ | |
public function buildUrl($route, $routeParams = [], $absolute = true, $clickthrough = []) | |
{ | |
$parts = parse_url($this->coreParametersHelper->get('site_url') ?: ''); | |
$context = $this->router->getContext(); | |
$original_host = $context->getHost(); | |
$original_scheme = $context->getScheme(); | |
if (!empty($parts['host'])) { | |
$this->router->getContext()->setHost($parts['host']); | |
} | |
if (!empty($parts['scheme'])) { | |
$this->router->getContext()->setScheme($parts['scheme']); | |
} | |
$url = parent::buildUrl($route, $routeParams, $absolute, $clickthrough); | |
$context->setHost($original_host); | |
$context->setScheme($original_scheme); | |
return $url; | |
} | |
} | |