8889841cNewsletterStatistics.php000064400000004011150514210520011453 0ustar00clickCount = $clickCount; $this->openCount = $openCount; $this->unsubscribeCount = $unsubscribeCount; $this->bounceCount = $bounceCount; $this->totalSentCount = $totalSentCount; $this->wooCommerceRevenue = $wooCommerceRevenue; } public function getClickCount(): int { return $this->clickCount; } public function getOpenCount(): int { return $this->openCount; } public function getUnsubscribeCount(): int { return $this->unsubscribeCount; } public function getBounceCount(): int { return $this->bounceCount; } public function getTotalSentCount(): int { return $this->totalSentCount; } public function getWooCommerceRevenue(): ?WooCommerceRevenue { return $this->wooCommerceRevenue; } public function setMachineOpenCount(int $machineOpenCount): void { $this->machineOpenCount = $machineOpenCount; } public function getMachineOpenCount(): int { return $this->machineOpenCount; } public function asArray(): array { return [ 'clicked' => $this->clickCount, 'opened' => $this->openCount, 'machineOpened' => $this->machineOpenCount, 'unsubscribed' => $this->unsubscribeCount, 'bounced' => $this->bounceCount, 'revenue' => empty($this->wooCommerceRevenue) ? null : $this->wooCommerceRevenue->asArray(), ]; } } WooCommerceRevenue.php000064400000003272150514210520011025 0ustar00currency = $currency; $this->value = $value; $this->ordersCount = $ordersCount; $this->wooCommerceHelper = $wooCommerceHelper; } /** @return string */ public function getCurrency() { return $this->currency; } /** @return int */ public function getOrdersCount() { return $this->ordersCount; } /** @return float */ public function getValue() { return $this->value; } /** @return string */ public function getFormattedValue() { return $this->wooCommerceHelper->getRawPrice($this->value, ['currency' => $this->currency]); } /** @return string */ public function getFormattedAverageValue(): string { $average = 0; if ($this->ordersCount > 0) { $average = $this->value / $this->ordersCount; } return $this->wooCommerceHelper->getRawPrice($average, ['currency' => $this->currency]); } /** * @return array */ public function asArray() { return [ 'currency' => $this->currency, 'value' => (float)$this->value, 'count' => (int)$this->ordersCount, 'formatted' => $this->getFormattedValue(), 'formatted_average' => $this->getFormattedAverageValue(), ]; } } index.php000064400000000006150514210520006353 0ustar00 */ class NewsletterStatisticsRepository extends Repository { /** @var WCHelper */ private $wcHelper; public function __construct( EntityManager $entityManager, WCHelper $wcHelper ) { parent::__construct($entityManager); $this->wcHelper = $wcHelper; } protected function getEntityClassName() { return NewsletterEntity::class; } public function getStatistics(NewsletterEntity $newsletter): NewsletterStatistics { $stats = new NewsletterStatistics( $this->getStatisticsClickCount($newsletter), $this->getStatisticsOpenCount($newsletter), $this->getStatisticsUnsubscribeCount($newsletter), $this->getStatisticsBounceCount($newsletter), $this->getTotalSentCount($newsletter), $this->getWooCommerceRevenue($newsletter) ); $stats->setMachineOpenCount($this->getStatisticsMachineOpenCount($newsletter)); return $stats; } /** * @param NewsletterEntity[] $newsletters * @return NewsletterStatistics[] */ public function getBatchStatistics(array $newsletters): array { $totalSentCounts = $this->getTotalSentCounts($newsletters); $clickCounts = $this->getStatisticCounts(StatisticsClickEntity::class, $newsletters); $openCounts = $this->getStatisticCounts(StatisticsOpenEntity::class, $newsletters); $unsubscribeCounts = $this->getStatisticCounts(StatisticsUnsubscribeEntity::class, $newsletters); $bounceCounts = $this->getStatisticCounts(StatisticsBounceEntity::class, $newsletters); $wooCommerceRevenues = $this->getWooCommerceRevenues($newsletters); $statistics = []; foreach ($newsletters as $newsletter) { $id = $newsletter->getId(); $statistics[$id] = new NewsletterStatistics( $clickCounts[$id] ?? 0, $openCounts[$id] ?? 0, $unsubscribeCounts[$id] ?? 0, $bounceCounts[$id] ?? 0, $totalSentCounts[$id] ?? 0, $wooCommerceRevenues[$id] ?? null ); } return $statistics; } public function getTotalSentCount(NewsletterEntity $newsletter): int { $counts = $this->getTotalSentCounts([$newsletter]); return $counts[$newsletter->getId()] ?? 0; } public function getStatisticsClickCount(NewsletterEntity $newsletter): int { $counts = $this->getStatisticCounts(StatisticsClickEntity::class, [$newsletter]); return $counts[$newsletter->getId()] ?? 0; } public function getStatisticsOpenCount(NewsletterEntity $newsletter): int { $counts = $this->getStatisticCounts(StatisticsOpenEntity::class, [$newsletter]); return $counts[$newsletter->getId()] ?? 0; } public function getStatisticsMachineOpenCount(NewsletterEntity $newsletter): int { $qb = $this->getStatisticsQuery(StatisticsOpenEntity::class, [$newsletter]); $result = $qb->andWhere('(stats.userAgentType = :userAgentType)') ->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_MACHINE) ->getQuery() ->getOneOrNullResult(); if (empty($result)) return 0; return $result['cnt'] ?? 0; } /** * @param SubscriberEntity $subscriber * @param int|null $limit * @param int|null $offset * @return array(newsletter_id: string, newsletter_rendered_subject: string, opened_at: string|null, sent_at: string) */ public function getAllForSubscriber( SubscriberEntity $subscriber, int $limit = null, int $offset = null ): array { return $this->entityManager->createQueryBuilder() ->select('IDENTITY(statistics.newsletter) AS newsletter_id') ->addSelect('opens.createdAt AS opened_at') ->addSelect('queue.newsletterRenderedSubject AS newsletter_rendered_subject') ->addSelect('statistics.sentAt AS sent_at') ->from(StatisticsNewsletterEntity::class, 'statistics') ->join(SendingQueueEntity::class, 'queue', Join::WITH, 'statistics.queue = queue' ) ->leftJoin( StatisticsOpenEntity::class, 'opens', Join::WITH, 'statistics.newsletter = opens.newsletter AND statistics.subscriber = opens.subscriber' ) ->where('statistics.subscriber = :subscriber') ->setParameter('subscriber', $subscriber) ->addOrderBy('newsletter_id') ->setMaxResults($limit) ->setFirstResult($offset) ->getQuery() ->getResult(); } public function getStatisticsUnsubscribeCount(NewsletterEntity $newsletter): int { $counts = $this->getStatisticCounts(StatisticsUnsubscribeEntity::class, [$newsletter]); return $counts[$newsletter->getId()] ?? 0; } public function getStatisticsBounceCount(NewsletterEntity $newsletter): int { $counts = $this->getStatisticCounts(StatisticsBounceEntity::class, [$newsletter]); return $counts[$newsletter->getId()] ?? 0; } public function getWooCommerceRevenue(NewsletterEntity $newsletter) { $revenues = $this->getWooCommerceRevenues([$newsletter]); return $revenues[$newsletter->getId()] ?? null; } /** * @param NewsletterEntity $newsletter * @return int */ public function getChildrenCount(NewsletterEntity $newsletter) { try { return (int)$this->entityManager ->createQueryBuilder() ->select('COUNT(n.id) as cnt') ->from(NewsletterEntity::class, 'n') ->where('n.parent = :newsletter') ->setParameter('newsletter', $newsletter) ->getQuery() ->getSingleScalarResult(); } catch (UnexpectedResultException $e) { return 0; } } private function getTotalSentCounts(array $newsletters): array { $results = $this->doctrineRepository ->createQueryBuilder('n') ->select('n.id, SUM(q.countProcessed) AS cnt') ->join('n.queues', 'q') ->join('q.task', 't') ->where('t.status = :status') ->setParameter('status', ScheduledTaskEntity::STATUS_COMPLETED) ->andWhere('q.newsletter IN (:newsletters)') ->setParameter('newsletters', $newsletters) ->groupBy('n.id') ->getQuery() ->getResult(); $counts = []; foreach ($results ?: [] as $result) { $counts[(int)$result['id']] = (int)$result['cnt']; } return $counts; } private function getStatisticCounts(string $statisticsEntityName, array $newsletters): array { $qb = $this->getStatisticsQuery($statisticsEntityName, $newsletters); if (in_array($statisticsEntityName, [StatisticsOpenEntity::class, StatisticsClickEntity::class], true)) { $qb->andWhere('(stats.userAgentType = :userAgentType) OR (stats.userAgentType IS NULL)') ->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_HUMAN); } $results = $qb ->getQuery() ->getResult(); $counts = []; foreach ($results ?: [] as $result) { $counts[(int)$result['id']] = (int)$result['cnt']; } return $counts; } private function getStatisticsQuery(string $statisticsEntityName, array $newsletters): QueryBuilder { return $this->entityManager->createQueryBuilder() ->select('IDENTITY(stats.newsletter) AS id, COUNT(DISTINCT stats.subscriber) as cnt') ->from($statisticsEntityName, 'stats') ->where('stats.newsletter IN (:newsletters)') ->groupBy('stats.newsletter') ->setParameter('newsletters', $newsletters); } private function getWooCommerceRevenues(array $newsletters) { if (!$this->wcHelper->isWooCommerceActive()) { return null; } $currency = $this->wcHelper->getWoocommerceCurrency(); $results = $this->entityManager ->createQueryBuilder() ->select('IDENTITY(stats.newsletter) AS id, SUM(stats.orderPriceTotal) AS total, COUNT(stats.id) AS cnt') ->from(StatisticsWooCommercePurchaseEntity::class, 'stats') ->where('stats.newsletter IN (:newsletters)') ->andWhere('stats.orderCurrency = :currency') ->setParameter('newsletters', $newsletters) ->setParameter('currency', $currency) ->groupBy('stats.newsletter') ->getQuery() ->getResult(); $revenues = []; foreach ($results ?: [] as $result) { $revenues[(int)$result['id']] = new WooCommerceRevenue( $currency, (float)$result['total'], (int)$result['cnt'], $this->wcHelper ); } return $revenues; } } StatisticsUnsubscribesRepository.php000064400000002571150514535530014112 0ustar00 */ class StatisticsUnsubscribesRepository extends Repository { protected function getEntityClassName() { return StatisticsUnsubscribeEntity::class; } public function getTotalForMonths(int $forMonths): int { $from = (new Carbon())->subMonths($forMonths); $count = $this->entityManager->createQueryBuilder() ->select('count(stats.id)') ->from(StatisticsUnsubscribeEntity::class, 'stats') ->andWhere('stats.createdAt >= :dateTime') ->setParameter('dateTime', $from) ->getQuery() ->getSingleScalarResult(); return intval($count); } public function getCountPerMethodForMonths(int $forMonths): array { $from = (new Carbon())->subMonths($forMonths); return $this->entityManager->createQueryBuilder() ->select('count(stats.id) as count, stats.method as method') ->from(StatisticsUnsubscribeEntity::class, 'stats') ->andWhere('stats.createdAt >= :dateTime') ->groupBy('stats.method') ->setParameter('dateTime', $from) ->getQuery() ->getResult(); } } StatisticsWooCommercePurchasesRepository.php000064400000006630150514535530015540 0ustar00 */ class StatisticsWooCommercePurchasesRepository extends Repository { protected function getEntityClassName() { return StatisticsWooCommercePurchaseEntity::class; } public function createOrUpdateByClickDataAndOrder(StatisticsClickEntity $click, \WC_Order $order) { // search by subscriber and newsletter IDs (instead of click itself) to avoid duplicities // when a new click from the subscriber appeared since last tracking for given newsletter // (this will keep the originally tracked click - likely the click that led to the order) $statistics = $this->findOneBy([ 'orderId' => $order->get_id(), 'subscriber' => $click->getSubscriber(), 'newsletter' => $click->getNewsletter(), ]); if (!$statistics instanceof StatisticsWooCommercePurchaseEntity) { $newsletter = $click->getNewsletter(); $queue = $click->getQueue(); if ((!$newsletter instanceof NewsletterEntity) || (!$queue instanceof SendingQueueEntity)) return; $statistics = new StatisticsWooCommercePurchaseEntity( $newsletter, $queue, $click, $order->get_id(), $order->get_currency(), $order->get_total() ); $this->persist($statistics); } else { $statistics->setOrderCurrency($order->get_currency()); $statistics->setOrderPriceTotal($order->get_total()); } $statistics->setSubscriber($click->getSubscriber()); $this->flush(); } public function getRevenuesByCampaigns(string $currency): array { $revenueStatsTable = $this->entityManager->getClassMetadata(StatisticsWooCommercePurchaseEntity::class)->getTableName(); $newsletterTable = $this->entityManager->getClassMetadata(NewsletterEntity::class)->getTableName(); // The "SELECT MIN(click_id)..." sub-query is used to count each purchase only once. // In the data we track a purchase to multiple newsletters if clicks from multiple newsletters occurred. $data = $this->entityManager->getConnection()->executeQuery(' SELECT SUM(swp.order_price_total) AS revenue, COALESCE(n.parent_id, n.id) AS campaign_id, (CASE WHEN n.type = :notification_history_type THEN :notification_type ELSE n.type END) AS campaign_type, COUNT(order_id) as orders_count FROM ' . $revenueStatsTable . ' swp JOIN ' . $newsletterTable . ' n ON n.id = swp.newsletter_id AND swp.click_id IN (SELECT MIN(click_id) FROM ' . $revenueStatsTable . ' ss GROUP BY order_id) WHERE swp.order_currency = :currency GROUP BY campaign_id, n.type; ', [ 'notification_history_type' => NewsletterEntity::TYPE_NOTIFICATION_HISTORY, 'notification_type' => NewsletterEntity::TYPE_NOTIFICATION, 'currency' => $currency, ])->fetchAllAssociative(); $data = array_map(function($row) { $row['revenue'] = round(floatval($row['revenue']), 2); $row['orders_count'] = intval($row['orders_count']); return $row; }, $data); return $data; } } GATracking.php000064400000006417150514535530007245 0ustar00secondLevelDomainNames = new SecondLevelDomainNames(); $this->newsletterLinks = $newsletterLinks; } public function applyGATracking($renderedNewsletter, NewsletterEntity $newsletter, $internalHost = null) { if ($newsletter->getType() == NewsletterEntity::TYPE_NOTIFICATION_HISTORY && $newsletter->getParent() instanceof NewsletterEntity) { $parentNewsletter = $newsletter->getParent(); $field = $parentNewsletter->getGaCampaign(); } else { $field = $newsletter->getGaCampaign(); } if (!empty($field)) { $renderedNewsletter = $this->addGAParamsToLinks($renderedNewsletter, $field, $internalHost); } return $renderedNewsletter; } private function addGAParamsToLinks($renderedNewsletter, $gaCampaign, $internalHost = null) { // join HTML and TEXT rendered body into a text string $content = Helpers::joinObject($renderedNewsletter); $extractedLinks = $this->newsletterLinks->extract($content); $processedLinks = $this->addParams($extractedLinks, $gaCampaign, $internalHost); list($content, $links) = $this->newsletterLinks->replace($content, $processedLinks); // split the processed body with hashed links back to HTML and TEXT list($renderedNewsletter['html'], $renderedNewsletter['text']) = Helpers::splitObject($content); return $renderedNewsletter; } private function addParams($extractedLinks, $gaCampaign, $internalHost = null) { $processedLinks = []; $params = 'utm_source=mailpoet&utm_medium=email&utm_campaign=' . urlencode($gaCampaign); $internalHost = $internalHost ?: parse_url(home_url(), PHP_URL_HOST); $internalHost = $this->secondLevelDomainNames->get($internalHost); foreach ($extractedLinks as $extractedLink) { if ($extractedLink['type'] !== NewsletterLinks::LINK_TYPE_URL) { continue; } elseif (strpos((string)parse_url($extractedLink['link'], PHP_URL_HOST), $internalHost) === false) { // Process only internal links (i.e. pointing to current site) continue; } list($path, $search, $hash) = $this->splitLink($extractedLink['link']); $search = empty($search) ? $params : $search . '&' . $params; $processedLink = $path . '?' . $search . ($hash ? '#' . $hash : ''); $link = $extractedLink['link']; $processedLinks[$link] = [ 'type' => $extractedLink['type'], 'link' => $link, 'processed_link' => $processedLink, ]; } return $processedLinks; } private function splitLink($link) { $parts = explode('#', $link); $hash = implode('#', array_slice($parts, 1)); $parts = explode('?', $parts[0]); $path = $parts[0]; $search = implode('?', array_slice($parts, 1)); return [$path, $search, $hash]; } } StatisticsFormsRepository.php000064400000002241150514535530012523 0ustar00 */ class StatisticsFormsRepository extends Repository { protected function getEntityClassName() { return StatisticsFormEntity::class; } public function getTotalSignups(int $formId): int { return $this->countBy(['form' => $formId]); } public function record(FormEntity $form, SubscriberEntity $subscriber): ?StatisticsFormEntity { if ($form->getId() > 0 && $subscriber->getId() > 0) { // check if we already have a record for today $statisticsForm = $this->findOneBy(['form' => $form, 'subscriber' => $subscriber]); if (!$statisticsForm) { // create a new entry $statisticsForm = new StatisticsFormEntity($form, $subscriber); $this->persist($statisticsForm); $this->flush(); } return $statisticsForm; } return null; } } StatisticsClicksRepository.php000064400000006301150514535530012646 0ustar00 */ class StatisticsClicksRepository extends Repository { protected function getEntityClassName(): string { return StatisticsClickEntity::class; } public function createOrUpdateClickCount( NewsletterLinkEntity $link, SubscriberEntity $subscriber, NewsletterEntity $newsletter, SendingQueueEntity $queue, ?UserAgentEntity $userAgent ): StatisticsClickEntity { $statistics = $this->findOneBy([ 'link' => $link, 'newsletter' => $newsletter, 'subscriber' => $subscriber, 'queue' => $queue, ]); if (!$statistics instanceof StatisticsClickEntity) { $statistics = new StatisticsClickEntity($newsletter, $queue, $subscriber, $link, 1); if ($userAgent) { $statistics->setUserAgent($userAgent); $statistics->setUserAgentType($userAgent->getUserAgentType()); } $this->persist($statistics); } else { $statistics->setCount($statistics->getCount() + 1); } return $statistics; } public function getAllForSubscriber(SubscriberEntity $subscriber): QueryBuilder { return $this->entityManager->createQueryBuilder() ->select('clicks.id id, queue.newsletterRenderedSubject, clicks.createdAt, link.url, userAgent.userAgent') ->from(StatisticsClickEntity::class, 'clicks') ->join('clicks.queue', 'queue') ->join('clicks.link', 'link') ->leftJoin('clicks.userAgent', 'userAgent') ->where('clicks.subscriber = :subscriber') ->orderBy('link.url') ->setParameter('subscriber', $subscriber->getId()); } /** * @param SubscriberEntity $subscriber * @param \DateTimeInterface $from * @param \DateTimeInterface $to * @return StatisticsClickEntity[] */ public function findLatestPerNewsletterBySubscriber(SubscriberEntity $subscriber, \DateTimeInterface $from, \DateTimeInterface $to): array { // subquery to find latest click IDs for each newsletter $latestClickIdsPerNewsletterQuery = $this->entityManager->createQueryBuilder() ->select('MAX(clicks.id)') ->from(StatisticsClickEntity::class, 'clicks') ->where('clicks.subscriber = :subscriber') ->andWhere('clicks.updatedAt > :from') ->andWhere('clicks.updatedAt < :to') ->groupBy('clicks.newsletter'); $expr = $this->entityManager->getExpressionBuilder(); return $this->entityManager->createQueryBuilder() ->select('c') ->from(StatisticsClickEntity::class, 'c') ->where( $expr->in( 'c.id', $latestClickIdsPerNewsletterQuery->getDQL() ) ) ->setParameter('subscriber', $subscriber) ->setParameter('from', $from->format('Y-m-d H:i:s')) ->setParameter('to', $to->format('Y-m-d H:i:s')) ->getQuery() ->getResult(); } } StatisticsOpensRepository.php000064400000007721150514535530012531 0ustar00 */ class StatisticsOpensRepository extends Repository { protected function getEntityClassName(): string { return StatisticsOpenEntity::class; } public function recalculateSubscriberScore(SubscriberEntity $subscriber): void { $subscriber->setEngagementScoreUpdatedAt(new \DateTimeImmutable()); $dateTime = (new Carbon())->subYear(); $newslettersSentCount = $this ->entityManager ->createQueryBuilder() ->select('count(DISTINCT statisticsNewsletter.newsletter)') ->from(StatisticsNewsletterEntity::class, 'statisticsNewsletter') ->where('statisticsNewsletter.subscriber = :subscriber') ->andWhere('statisticsNewsletter.sentAt > :dateTime') ->setParameter('subscriber', $subscriber) ->setParameter('dateTime', $dateTime) ->getQuery() ->getSingleScalarResult(); if ($newslettersSentCount < 3) { $this->entityManager->flush(); return; } $opens = $this ->entityManager ->createQueryBuilder() ->select('count(DISTINCT opens.newsletter)') ->from(StatisticsOpenEntity::class, 'opens') ->join('opens.newsletter', 'newsletter') ->where('opens.subscriber = :subscriberId') ->andWhere('(newsletter.sentAt > :dateTime OR newsletter.sentAt IS NULL)') ->andWhere('opens.createdAt > :dateTime') ->setParameter('subscriberId', $subscriber) ->setParameter('dateTime', $dateTime) ->getQuery() ->getSingleScalarResult(); $score = ($opens / $newslettersSentCount) * 100; $subscriber->setEngagementScore($score); $this->entityManager->flush(); } public function resetSubscribersScoreCalculation() { $this->entityManager->createQueryBuilder()->update(SubscriberEntity::class, 's') ->set('s.engagementScoreUpdatedAt', ':updatedAt') ->setParameter('updatedAt', null) ->getQuery()->execute(); } public function recalculateSegmentScore(SegmentEntity $segment): void { $segment->setAverageEngagementScoreUpdatedAt(new \DateTimeImmutable()); $avgScore = $this ->entityManager ->createQueryBuilder() ->select('avg(subscriber.engagementScore)') ->from(SubscriberEntity::class, 'subscriber') ->join('subscriber.subscriberSegments', 'subscriberSegments') ->where('subscriberSegments.segment = :segment') ->andWhere('subscriber.status = :subscribed') ->andWhere('subscriber.deletedAt IS NULL') ->andWhere('subscriberSegments.status = :subscribed') ->setParameter('segment', $segment) ->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED) ->getQuery() ->getSingleScalarResult(); $segment->setAverageEngagementScore($avgScore === null ? $avgScore : (float)$avgScore); $this->entityManager->flush(); } public function resetSegmentsScoreCalculation(): void { $this->entityManager->createQueryBuilder()->update(SegmentEntity::class, 's') ->set('s.averageEngagementScoreUpdatedAt', ':updatedAt') ->setParameter('updatedAt', null) ->getQuery()->execute(); } public function getAllForSubscriber(SubscriberEntity $subscriber): QueryBuilder { return $this->entityManager->createQueryBuilder() ->select('opens.id id, queue.newsletterRenderedSubject, opens.createdAt, userAgent.userAgent') ->from(StatisticsOpenEntity::class, 'opens') ->join('opens.queue', 'queue') ->leftJoin('opens.userAgent', 'userAgent') ->where('opens.subscriber = :subscriber') ->orderBy('queue.newsletterRenderedSubject') ->setParameter('subscriber', $subscriber->getId()); } } Track/SubscriberActivityTracker.php000064400000007645150514535530013477 0ustar00pageViewCookie = $pageViewCookie; $this->subscriberCookie = $subscriberCookie; $this->subscribersRepository = $subscribersRepository; $this->wp = $wp; $this->wooCommerceHelper = $wooCommerceHelper; $this->trackingConfig = $trackingConfig; } public function trackActivity(): bool { // Don't track in admin interface if ($this->wp->isAdmin()) { return false; } $subscriber = null; $latestTimestamp = $this->getLatestTimestampFromCookie(); // If cookie tracking is not allowed try use last activity from subscriber data if ($latestTimestamp === null) { $subscriber = $this->getSubscriber(); if (!$subscriber) { return false; // Can't determine timestamp } $latestTimestamp = $this->getLatestTimestampFromSubscriber($subscriber); } if ($latestTimestamp + self::TRACK_INTERVAL > $this->wp->currentTime('timestamp')) { return false; } if ($subscriber === null) { $subscriber = $this->getSubscriber(); } if (!$subscriber) { return false; } $this->processTracking($subscriber); return true; } public function registerCallback(string $slug, callable $callback): void { $this->callbacks[$slug] = $callback; } public function unregisterCallback(string $slug): void { unset($this->callbacks[$slug]); } private function processTracking(SubscriberEntity $subscriber): void { $this->subscribersRepository->maybeUpdateLastEngagement($subscriber); $this->pageViewCookie->setPageViewTimestamp($this->wp->currentTime('timestamp')); foreach ($this->callbacks as $callback) { $callback($subscriber); } } private function getLatestTimestampFromCookie(): ?int { if ($this->trackingConfig->isCookieTrackingEnabled()) { return $this->pageViewCookie->getPageViewTimestamp() ?? 0; } return null; } private function getLatestTimestampFromSubscriber(SubscriberEntity $subscriber): int { return $subscriber->getLastEngagementAt() ? $subscriber->getLastEngagementAt()->getTimestamp() : 0; } private function getSubscriber(): ?SubscriberEntity { $wpUser = $this->wp->wpGetCurrentUser(); if ($wpUser->exists()) { return $this->subscribersRepository->findOneBy(['wpUserId' => $wpUser->ID]); } $subscriberId = $this->subscriberCookie->getSubscriberId(); if ($subscriberId) { return $this->subscribersRepository->findOneById($subscriberId); } if (!$this->wooCommerceHelper->isWooCommerceActive()) { return null; } $wooCommerce = $this->wooCommerceHelper->WC(); if (!$wooCommerce || !$wooCommerce->session) { return null; } $customer = $wooCommerce->session->get('customer'); if (!is_array($customer) || empty($customer['email'])) { return null; } return $this->subscribersRepository->findOneBy(['email' => $customer['email']]); } } Track/SubscriberHandler.php000064400000003346150514535530011736 0ustar00subscriberCookie = $subscriberCookie; $this->subscribersRepository = $subscribersRepository; $this->trackingConfig = $trackingConfig; $this->wp = $wp; } public function identifyByLogin(string $login): void { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return; } $wpUser = $this->wp->getUserBy('login', $login); if ($wpUser) { $this->identifyByEmail($wpUser->user_email); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps } } public function identifyByEmail(string $email): void { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return; } $subscriber = $this->subscribersRepository->findOneBy(['email' => $email]); if ($subscriber) { $this->setCookieBySubscriber($subscriber); } } private function setCookieBySubscriber(SubscriberEntity $subscriber): void { $subscriberId = $subscriber->getId(); if ($subscriberId) { $this->subscriberCookie->setSubscriberId($subscriberId); } } } Track/Unsubscribes.php000064400000005013150514535530010775 0ustar00sendingQueuesRepository = $sendingQueuesRepository; $this->statisticsUnsubscribesRepository = $statisticsUnsubscribesRepository; $this->subscribersRepository = $subscribersRepository; } public function track( int $subscriberId, string $source, int $queueId = null, string $meta = null, string $method = StatisticsUnsubscribeEntity::METHOD_UNKNOWN ) { $queue = null; $statistics = null; if ($queueId) { $queue = $this->sendingQueuesRepository->findOneById($queueId); } $subscriber = $this->subscribersRepository->findOneById($subscriberId); if (!$subscriber instanceof SubscriberEntity) { return; } if (($queue instanceof SendingQueueEntity)) { $newsletter = $queue->getNewsletter(); if ($newsletter instanceof NewsletterEntity) { $statistics = $this->statisticsUnsubscribesRepository->findOneBy( [ 'queue' => $queue, 'newsletter' => $newsletter, 'subscriber' => $subscriber, ] ); if (!$statistics) { $statistics = new StatisticsUnsubscribeEntity($newsletter, $queue, $subscriber); } } } if ($statistics === null) { $statistics = new StatisticsUnsubscribeEntity(null, null, $subscriber); } if ($meta !== null) { $statistics->setMeta($meta); } $statistics->setSource($source); $statistics->setMethod($method); $this->statisticsUnsubscribesRepository->persist($statistics); $this->statisticsUnsubscribesRepository->flush(); } } Track/Clicks.php000064400000013401150514535530007536 0ustar00cookies = $cookies; $this->subscriberCookie = $subscriberCookie; $this->shortcodes = $shortcodes; $this->linkShortcodeCategory = $linkShortcodeCategory; $this->opens = $opens; $this->statisticsClicksRepository = $statisticsClicksRepository; $this->userAgentsRepository = $userAgentsRepository; $this->subscribersRepository = $subscribersRepository; $this->trackingConfig = $trackingConfig; } /** * @param \stdClass|null $data */ public function track($data) { if (!$data || empty($data->link)) { return $this->abort(); } /** @var SubscriberEntity $subscriber */ $subscriber = $data->subscriber; /** @var SendingQueueEntity $queue */ $queue = $data->queue; /** @var NewsletterEntity $newsletter */ $newsletter = $data->newsletter; /** @var NewsletterLinkEntity $link */ $link = $data->link; $wpUserPreview = ($data->preview && ($subscriber->isWPUser())); // log statistics only if the action did not come from // a WP user previewing the newsletter if (!$wpUserPreview) { $userAgent = !empty($data->userAgent) ? $this->userAgentsRepository->findOrCreate($data->userAgent) : null; $statisticsClicks = $this->statisticsClicksRepository->createOrUpdateClickCount( $link, $subscriber, $newsletter, $queue, $userAgent ); if ( $userAgent instanceof UserAgentEntity && ($userAgent->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_HUMAN || $statisticsClicks->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_MACHINE) ) { $statisticsClicks->setUserAgent($userAgent); $statisticsClicks->setUserAgentType($userAgent->getUserAgentType()); } $this->statisticsClicksRepository->flush(); $this->sendRevenueCookie($statisticsClicks); $subscriberId = $subscriber->getId(); if ($subscriberId) { $this->subscriberCookie->setSubscriberId($subscriberId); } // track open event $this->opens->track($data, $displayImage = false); // Update engagement date $this->subscribersRepository->maybeUpdateLastEngagement($subscriber); } $url = $this->processUrl($link->getUrl(), $newsletter, $subscriber, $queue, $wpUserPreview); $this->redirectToUrl($url); } private function sendRevenueCookie(StatisticsClickEntity $clicks) { if ($this->trackingConfig->isCookieTrackingEnabled()) { $this->cookies->set( self::REVENUE_TRACKING_COOKIE_NAME, [ 'statistics_clicks' => $clicks->getId(), 'created_at' => time(), ], [ 'expires' => time() + self::REVENUE_TRACKING_COOKIE_EXPIRY, 'path' => '/', ] ); } } public function processUrl( string $url, NewsletterEntity $newsletter, SubscriberEntity $subscriber, SendingQueueEntity $queue, bool $wpUserPreview ) { if (preg_match('/\[link:(?P.*?)\]/', $url, $shortcode)) { if (!$shortcode['action']) $this->abort(); $url = $this->linkShortcodeCategory->processShortcodeAction( $shortcode['action'], $newsletter, $subscriber, $queue, $wpUserPreview ); } else { $this->shortcodes->setQueue($queue); $this->shortcodes->setNewsletter($newsletter); $this->shortcodes->setSubscriber($subscriber); $this->shortcodes->setWpUserPreview($wpUserPreview); $url = $this->shortcodes->replace($url); } return $url; } public function abort() { global $wp_query;// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps WPFunctions::get()->statusHeader(404); $wp_query->set_404();// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps WPFunctions::get()->getTemplatePart((string)404); exit; } public function redirectToUrl($url) { header('Location: ' . $url, true, 302); exit; } } Track/Opens.php000064400000007300150514535530007413 0ustar00statisticsOpensRepository = $statisticsOpensRepository; $this->userAgentsRepository = $userAgentsRepository; $this->subscribersRepository = $subscribersRepository; } public function track($data, $displayImage = true) { if (!$data) { return $this->returnResponse($displayImage); } /** @var SubscriberEntity $subscriber */ $subscriber = $data->subscriber; /** @var SendingQueueEntity $queue */ $queue = $data->queue; /** @var NewsletterEntity $newsletter */ $newsletter = $data->newsletter; $wpUserPreview = ($data->preview && ($subscriber->isWPUser())); // log statistics only if the action did not come from // a WP user previewing the newsletter if (!$wpUserPreview) { $oldStatistics = $this->statisticsOpensRepository->findOneBy([ 'subscriber' => $subscriber->getId(), 'newsletter' => $newsletter->getId(), 'queue' => $queue->getId(), ]); // Open was already tracked if ($oldStatistics) { if (!empty($data->userAgent)) { $userAgent = $this->userAgentsRepository->findOrCreate($data->userAgent); if ( $userAgent->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_HUMAN || $oldStatistics->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_MACHINE ) { $oldStatistics->setUserAgent($userAgent); $oldStatistics->setUserAgentType($userAgent->getUserAgentType()); $this->statisticsOpensRepository->flush(); } } $this->subscribersRepository->maybeUpdateLastEngagement($subscriber); return $this->returnResponse($displayImage); } $statistics = new StatisticsOpenEntity($newsletter, $queue, $subscriber); if (!empty($data->userAgent)) { $userAgent = $this->userAgentsRepository->findOrCreate($data->userAgent); $statistics->setUserAgent($userAgent); $statistics->setUserAgentType($userAgent->getUserAgentType()); } $this->statisticsOpensRepository->persist($statistics); $this->statisticsOpensRepository->flush(); $this->subscribersRepository->maybeUpdateLastEngagement($subscriber); $this->statisticsOpensRepository->recalculateSubscriberScore($subscriber); } return $this->returnResponse($displayImage); } public function returnResponse($displayImage) { if (!$displayImage) return; // return 1x1 pixel transparent gif image header('Content-Type: image/gif'); // Output of base64_decode is predetermined and safe in this case // phpcs:ignore WordPressDotOrg.sniffs.OutputEscaping.UnescapedOutputParameter, WordPress.Security.EscapeOutput.OutputNotEscaped echo base64_decode('R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw=='); exit; } } Track/SubscriberCookie.php000064400000003427150514535530011572 0ustar00cookies = $cookies; $this->trackingConfig = $trackingConfig; } public function getSubscriberId(): ?int { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return null; } $subscriberId = $this->getSubscriberIdFromCookie(self::COOKIE_NAME); if ($subscriberId) { return $subscriberId; } $subscriberId = $this->getSubscriberIdFromCookie(self::COOKIE_NAME_LEGACY); if ($subscriberId) { $this->setSubscriberId($subscriberId); $this->cookies->delete(self::COOKIE_NAME_LEGACY); return $subscriberId; } return null; } public function setSubscriberId(int $subscriberId): void { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return; } $this->cookies->set( self::COOKIE_NAME, ['subscriber_id' => $subscriberId], [ 'expires' => time() + self::COOKIE_EXPIRY, 'path' => '/', ] ); } private function getSubscriberIdFromCookie(string $cookieName): ?int { $data = $this->cookies->get($cookieName); return is_array($data) && $data['subscriber_id'] ? (int)$data['subscriber_id'] : null; } } Track/index.php000064400000000006150514535530007432 0ustar00cookies = $cookies; $this->trackingConfig = $trackingConfig; } public function getPageViewTimestamp(): ?int { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return null; } return $this->getTimestampCookie(self::COOKIE_NAME); } public function setPageViewTimestamp(int $timestamp): void { if (!$this->trackingConfig->isCookieTrackingEnabled()) { return; } $this->cookies->set( self::COOKIE_NAME, ['timestamp' => $timestamp], [ 'expires' => time() + self::COOKIE_EXPIRY, 'path' => '/', ] ); } private function getTimestampCookie(string $cookieName): ?int { $data = $this->cookies->get($cookieName); return is_array($data) && $data['timestamp'] ? (int)$data['timestamp'] : null; } } Track/WooCommercePurchases.php000064400000011163150514535530012426 0ustar00woocommerceHelper = $woocommerceHelper; $this->cookies = $cookies; $this->statisticsWooCommercePurchasesRepository = $statisticsWooCommercePurchasesRepository; $this->statisticsClicksRepository = $statisticsClicksRepository; $this->subscribersRepository = $subscribersRepository; $this->subscriberHandler = $subscriberHandler; } public function trackPurchase($id, $useCookies = true) { $order = $this->woocommerceHelper->wcGetOrder($id); if (!$order instanceof WC_Order) { return; } // limit clicks to 'USE_CLICKS_SINCE_DAYS_AGO' range before order has been created $fromDate = $order->get_date_created(); if (is_null($fromDate)) { return; } $from = clone $fromDate; $from->modify(-self::USE_CLICKS_SINCE_DAYS_AGO . ' days'); $to = $order->get_date_created(); if (is_null($to)) { return; } // track purchases from all clicks matched by order email $processedNewsletterIdsMap = []; $orderEmailClicks = $this->getClicks($order->get_billing_email(), $from, $to); foreach ($orderEmailClicks as $click) { $this->statisticsWooCommercePurchasesRepository->createOrUpdateByClickDataAndOrder($click, $order); $newsletter = $click->getNewsletter(); if (!$newsletter instanceof NewsletterEntity) continue; $processedNewsletterIdsMap[$newsletter->getId()] = true; } // try to find a subscriber by order email and start tracking $this->subscriberHandler->identifyByEmail($order->get_billing_email()); if (!$useCookies) { return; } // track purchases from clicks matched by cookie email (only for newsletters not tracked by order) $cookieEmailClicks = $this->getClicks($this->getSubscriberEmailFromCookie(), $from, $to); foreach ($cookieEmailClicks as $click) { $newsletter = $click->getNewsletter(); if (!$newsletter instanceof NewsletterEntity) continue; if (isset($processedNewsletterIdsMap[$newsletter->getId()])) { continue; // do not track click for newsletters that were already tracked by order email } $this->statisticsWooCommercePurchasesRepository->createOrUpdateByClickDataAndOrder($click, $order); } } /** * @param ?string $email * @param \DateTimeInterface $from * @param \DateTimeInterface $to * @return StatisticsClickEntity[] */ private function getClicks(?string $email, \DateTimeInterface $from, \DateTimeInterface $to): array { if (!$email) return []; $subscriber = $this->subscribersRepository->findOneBy(['email' => $email]); if (!$subscriber instanceof SubscriberEntity) { return []; } return $this->statisticsClicksRepository->findLatestPerNewsletterBySubscriber($subscriber, $from, $to); } private function getSubscriberEmailFromCookie(): ?string { $cookieData = $this->cookies->get(Clicks::REVENUE_TRACKING_COOKIE_NAME); if (!$cookieData) { return null; } try { $click = $this->statisticsClicksRepository->findOneById($cookieData['statistics_clicks']); } catch (\Exception $e) { return null; } if (!$click instanceof StatisticsClickEntity) { return null; } $subscriber = $click->getSubscriber(); if ($subscriber instanceof SubscriberEntity) { return $subscriber->getEmail(); } return null; } } StatisticsBouncesRepository.php000064400000000606150514535530013036 0ustar00 */ class StatisticsBouncesRepository extends Repository { protected function getEntityClassName(): string { return StatisticsBounceEntity::class; } } UserAgentsRepository.php000064400000001275150514535530011450 0ustar00 */ class UserAgentsRepository extends Repository { protected function getEntityClassName() { return UserAgentEntity::class; } public function findOrCreate(string $userAgent): UserAgentEntity { $hash = (string)crc32($userAgent); $userAgentEntity = $this->findOneBy(['hash' => $hash]); if ($userAgentEntity) return $userAgentEntity; $userAgentEntity = new UserAgentEntity($userAgent); $this->persist($userAgentEntity); return $userAgentEntity; } }