chrisbryan17's picture
Upload folder using huggingface_hub
d2897cd verified
<?php
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;
}
}