2364 lines
85 KiB
PHP
Executable File
2364 lines
85 KiB
PHP
Executable File
<?php
|
|
|
|
namespace Mautic\EmailBundle\Model;
|
|
|
|
use Doctrine\DBAL\Query\QueryBuilder;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\OptimisticLockException;
|
|
use Doctrine\ORM\Tools\Pagination\Paginator;
|
|
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\GlobalSearchInterface;
|
|
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\EmailRepository;
|
|
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\BotRatioHelper;
|
|
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, GlobalSearchInterface
|
|
{
|
|
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,
|
|
private BotRatioHelper $botRatioHelper,
|
|
) {
|
|
$this->connection = $em->getConnection(); // Necessary for FilterTrait
|
|
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
|
|
}
|
|
|
|
public function getRepository(): EmailRepository
|
|
{
|
|
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>
|
|
*/
|
|
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());
|
|
$this->setCachedCount($entity);
|
|
}
|
|
}
|
|
|
|
return $entity;
|
|
}
|
|
|
|
/**
|
|
* Return a list of entities.
|
|
*
|
|
* @param array $args [start, limit, filter, orderBy, orderByDir]
|
|
*
|
|
* @return Paginator|array<string, int|object>
|
|
*/
|
|
public function getEntities(array $args = [])
|
|
{
|
|
$entities = parent::getEntities($args);
|
|
|
|
foreach ($entities as $entity) {
|
|
$this->setCachedCount($entity);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
$ipAddress = $this->ipLookupHelper->getIpAddress();
|
|
if (!$ipAddress->isTrackable()) {
|
|
return;
|
|
}
|
|
|
|
$readDateTime = new DateTimeHelper($hitDateTime);
|
|
$userAgent = $request->server->get('HTTP_USER_AGENT');
|
|
if ($this->botRatioHelper->isHitByBot($stat, $readDateTime->getDateTime(), $ipAddress, (string) $userAgent)) {
|
|
return;
|
|
}
|
|
|
|
$email = $stat->getEmail();
|
|
|
|
if ((int) $stat->isRead()) {
|
|
if ($viaBrowser && !$stat->getViewedInBrowser()) {
|
|
// opened via browser so note it
|
|
$stat->setViewedInBrowser($viaBrowser);
|
|
}
|
|
}
|
|
|
|
$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' => $userAgent,
|
|
'inBrowser' => $viaBrowser,
|
|
]
|
|
);
|
|
|
|
$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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
$this->emailStatModel->saveEntity($stat);
|
|
|
|
// 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,
|
|
$userAgent
|
|
);
|
|
|
|
// 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.unique_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.unique_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,
|
|
$email->isSegmentEmail() && !$email->getContinueSending() ? $email->getPublishUp() : null,
|
|
);
|
|
|
|
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()])) {
|
|
// 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(),
|
|
'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()) {
|
|
$variantSettings = $child->getVariantSettings();
|
|
$emailSettings[$child->getId()] = [
|
|
'template' => $child->getTemplate(),
|
|
'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 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 {
|
|
if ('list' === $email->getEmailType()
|
|
&& $this->getStatRepository()->checkContactSentEmail(
|
|
$contact['id'],
|
|
$email->getId(),
|
|
)) {
|
|
// This segment email is already sent to this contact
|
|
continue;
|
|
}
|
|
|
|
$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, $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 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->executeQuery()->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
|
|
*/
|
|
public function getEmailStatList($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $options = []): array
|
|
{
|
|
$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->executeQuery()->fetchAllAssociative();
|
|
}
|
|
|
|
/**
|
|
* Get a list of emails in a date range.
|
|
*
|
|
* @param int $limit
|
|
* @param array $filters
|
|
* @param array $options
|
|
*/
|
|
public function getEmailList($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $options = []): array
|
|
{
|
|
$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->executeQuery()->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;
|
|
}
|
|
|
|
/**
|
|
* @param string|array<string> $filter
|
|
* @param array<string,mixed> $options
|
|
*
|
|
* @return array<string,array<string,string>>
|
|
*/
|
|
public function getLookupResultsWithIdName(
|
|
string $type, string|array $filter = '', int $limit = 10, int $start = 0, array $options = [],
|
|
): array {
|
|
$results = $this->getLookupResults($type, $filter, $limit, $start, $options);
|
|
$newResults = [];
|
|
|
|
foreach ($results as $language => $emails) {
|
|
if (!isset($options['name_is_key']) || empty($options['name_is_key'])) {
|
|
foreach ($emails as $name => $id) {
|
|
$newResults[$language][$name] = sprintf('%s (%s)', $id, $name);
|
|
}
|
|
} else {
|
|
foreach ($emails as $id => $name) {
|
|
$newResults[$language][$id] = sprintf('%s (%s)', $name, $id);
|
|
}
|
|
}
|
|
}
|
|
// sort by language
|
|
ksort($newResults);
|
|
|
|
return $newResults;
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, int|string|array<int|string, mixed>|null> $contact
|
|
*
|
|
* @return array<int|string, int|string|array<int|string, mixed>|null>
|
|
*/
|
|
public function enrichedContactWithCompanies(array $contact): array
|
|
{
|
|
if (!isset($contact['id']) || isset($contact['companies'])) {
|
|
return $contact;
|
|
}
|
|
|
|
$companies = $this->companyModel
|
|
->getRepository()
|
|
->getCompaniesForContacts([$contact['id']]);
|
|
|
|
$contact['companies'] = $companies[$contact['id']] ?? [];
|
|
|
|
return $contact;
|
|
}
|
|
|
|
private function getContactCompanies(array &$sendTo): void
|
|
{
|
|
foreach ($sendTo as $key => $contact) {
|
|
$sendTo[$key] = $this->enrichedContactWithCompanies($contact);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Generate and replace tokens
|
|
$event = new EmailSendEvent(
|
|
null,
|
|
[
|
|
'content' => $email->getCustomHtml(),
|
|
'email' => $email,
|
|
'idHash' => 'xxxxxxxxxxxxxx', // bogus ID
|
|
'tokens' => ['{tracking_pixel}' => ''], // Override tracking_pixel
|
|
'internalSend' => true,
|
|
'lead' => $leadFields,
|
|
]
|
|
);
|
|
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_DISPLAY);
|
|
|
|
$mailer = $this->mailHelper->getMailer(true);
|
|
$mailer->setLead($leadFields, true);
|
|
$mailer->setTokens($tokens);
|
|
$mailer->setEmail($email, false, $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;
|
|
}
|
|
|
|
/**
|
|
* @throws \Psr\Cache\InvalidArgumentException
|
|
*/
|
|
protected function setCachedCount(mixed $entity): void
|
|
{
|
|
$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);
|
|
}
|
|
}
|
|
}
|