Track
4 days ago
GATracking.php
6 months ago
StatisticsBouncesRepository.php
3 years ago
StatisticsClicksRepository.php
2 years ago
StatisticsFormsRepository.php
3 years ago
StatisticsNewslettersRepository.php
1 year ago
StatisticsOpensRepository.php
2 days ago
StatisticsUnsubscribesRepository.php
2 months ago
StatisticsWooCommercePurchasesRepository.php
1 year ago
UnsubscribeReasonTracker.php
2 months ago
UserAgentsRepository.php
1 year ago
index.php
3 years ago
GATracking.php
202 lines
| 1 | <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing |
| 2 | |
| 3 | namespace MailPoet\Statistics; |
| 4 | |
| 5 | if (!defined('ABSPATH')) exit; |
| 6 | |
| 7 | |
| 8 | use MailPoet\Entities\NewsletterEntity; |
| 9 | use MailPoet\Newsletter\Links\Links as NewsletterLinks; |
| 10 | use MailPoet\Settings\TrackingConfig; |
| 11 | use MailPoet\Util\Helpers; |
| 12 | use MailPoet\Util\SecondLevelDomainNames; |
| 13 | use MailPoet\WP\Functions; |
| 14 | |
| 15 | class GATracking { |
| 16 | |
| 17 | /** @var SecondLevelDomainNames */ |
| 18 | private $secondLevelDomainNames; |
| 19 | |
| 20 | /** @var NewsletterLinks */ |
| 21 | private $newsletterLinks; |
| 22 | |
| 23 | /** @var Functions */ |
| 24 | private $wp; |
| 25 | |
| 26 | /** @var TrackingConfig */ |
| 27 | private $tackingConfig; |
| 28 | |
| 29 | public function __construct( |
| 30 | NewsletterLinks $newsletterLinks, |
| 31 | Functions $wp, |
| 32 | TrackingConfig $trackingConfig |
| 33 | ) { |
| 34 | $this->secondLevelDomainNames = new SecondLevelDomainNames(); |
| 35 | $this->newsletterLinks = $newsletterLinks; |
| 36 | $this->wp = $wp; |
| 37 | $this->tackingConfig = $trackingConfig; |
| 38 | } |
| 39 | |
| 40 | public function applyGATracking($renderedNewsletter, NewsletterEntity $newsletter, $internalHost = null) { |
| 41 | if (!$this->tackingConfig->isEmailTrackingEnabled()) { |
| 42 | return $renderedNewsletter; |
| 43 | } |
| 44 | if ($newsletter->getType() == NewsletterEntity::TYPE_NOTIFICATION_HISTORY && $newsletter->getParent() instanceof NewsletterEntity) { |
| 45 | $parentNewsletter = $newsletter->getParent(); |
| 46 | $field = $parentNewsletter->getGaCampaign(); |
| 47 | } else { |
| 48 | $field = $newsletter->getGaCampaign(); |
| 49 | } |
| 50 | |
| 51 | return $this->addGAParamsToLinks($renderedNewsletter, $field, $internalHost); |
| 52 | } |
| 53 | |
| 54 | private function addGAParamsToLinks($renderedNewsletter, $gaCampaign, $internalHost = null) { |
| 55 | // join HTML and TEXT rendered body into a text string |
| 56 | $content = Helpers::joinObject($renderedNewsletter); |
| 57 | $extractedLinks = $this->newsletterLinks->extract($content); |
| 58 | $processedLinks = $this->addParams($extractedLinks, $gaCampaign, $internalHost); |
| 59 | list($content, $links) = $this->newsletterLinks->replace($content, $processedLinks); |
| 60 | // split the processed body with hashed links back to HTML and TEXT |
| 61 | list($renderedNewsletter['html'], $renderedNewsletter['text']) |
| 62 | = Helpers::splitObject($content); |
| 63 | return $renderedNewsletter; |
| 64 | } |
| 65 | |
| 66 | private function addParams($extractedLinks, $gaCampaign, $internalHost = null) { |
| 67 | $processedLinks = []; |
| 68 | $params = [ |
| 69 | 'utm_source' => 'mailpoet', |
| 70 | 'utm_medium' => 'email', |
| 71 | 'utm_source_platform' => 'mailpoet', |
| 72 | ]; |
| 73 | if ($gaCampaign) { |
| 74 | $params['utm_campaign'] = $gaCampaign; |
| 75 | } |
| 76 | $internalHost = $internalHost ?: parse_url(home_url(), PHP_URL_HOST); |
| 77 | $internalHost = $this->secondLevelDomainNames->get($internalHost); |
| 78 | foreach ($extractedLinks as $extractedLink) { |
| 79 | if ($extractedLink['type'] !== NewsletterLinks::LINK_TYPE_URL) { |
| 80 | continue; |
| 81 | } elseif (strpos((string)parse_url($extractedLink['link'], PHP_URL_HOST), $internalHost) === false) { |
| 82 | // Process only internal links (i.e. pointing to current site) |
| 83 | continue; |
| 84 | } |
| 85 | |
| 86 | $link = $extractedLink['link']; |
| 87 | |
| 88 | // Do not overwrite existing query parameters |
| 89 | $parsedUrl = parse_url($link); |
| 90 | $linkParams = $params; |
| 91 | if (isset($parsedUrl['query'])) { |
| 92 | foreach (array_keys($params) as $param) { |
| 93 | if (strpos($parsedUrl['query'], $param . '=') !== false) { |
| 94 | unset($linkParams[$param]); |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // Extract shortcodes from query parameters to preserve them |
| 100 | list($linkWithPlaceholders, $shortcodeMap) = $this->extractShortcodes($link); |
| 101 | |
| 102 | // Add GA parameters to the link with placeholders |
| 103 | $linkWithGAParams = $this->wp->addQueryArg($linkParams, $linkWithPlaceholders); |
| 104 | |
| 105 | // Restore the original shortcodes |
| 106 | $linkWithGAParams = $this->restoreShortcodes($linkWithGAParams, $shortcodeMap); |
| 107 | |
| 108 | $processedLink = $this->wp->applyFilters( |
| 109 | 'mailpoet_ga_tracking_link', |
| 110 | $linkWithGAParams, |
| 111 | $extractedLink['link'], |
| 112 | $linkParams, |
| 113 | $extractedLink['type'] |
| 114 | ); |
| 115 | $processedLinks[$link] = [ |
| 116 | 'type' => $extractedLink['type'], |
| 117 | 'link' => $link, |
| 118 | 'processed_link' => $processedLink, |
| 119 | ]; |
| 120 | } |
| 121 | return $processedLinks; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Extract shortcodes from URL query parameter values and replace them with placeholders. |
| 126 | * Shortcodes use the format [shortcode:value|option:value] and should not be URL-encoded. |
| 127 | * Only shortcodes that are actual parameter values (or part of them) are extracted. |
| 128 | * |
| 129 | * @return array [$urlWithPlaceholders, $shortcodeMap] |
| 130 | */ |
| 131 | private function extractShortcodes(string $url): array { |
| 132 | $parsedUrl = parse_url($url); |
| 133 | if (!isset($parsedUrl['query'])) { |
| 134 | return [$url, []]; |
| 135 | } |
| 136 | |
| 137 | // Parse the query string into parameters to validate shortcodes are in parameter values |
| 138 | parse_str($parsedUrl['query'], $params); |
| 139 | |
| 140 | $shortcodeMap = []; |
| 141 | $urlWithPlaceholders = $url; |
| 142 | $index = 0; |
| 143 | |
| 144 | // Process each parameter value (recursively for arrays) |
| 145 | $this->processParamsForShortcodes($params, $urlWithPlaceholders, $shortcodeMap, $index); |
| 146 | |
| 147 | return [$urlWithPlaceholders, $shortcodeMap]; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Process parameter values recursively to find and replace shortcodes. |
| 152 | * Handles both string values and nested arrays. |
| 153 | * |
| 154 | * @param array $params Parameter values to process |
| 155 | * @param string $urlWithPlaceholders URL being modified (passed by reference) |
| 156 | * @param array $shortcodeMap Map of placeholders to shortcodes (passed by reference) |
| 157 | * @param int $index Current placeholder index (passed by reference) |
| 158 | */ |
| 159 | private function processParamsForShortcodes(array $params, string &$urlWithPlaceholders, array &$shortcodeMap, int &$index): void { |
| 160 | foreach ($params as $value) { |
| 161 | if (is_array($value)) { |
| 162 | // Recursively process array values |
| 163 | $this->processParamsForShortcodes($value, $urlWithPlaceholders, $shortcodeMap, $index); |
| 164 | } elseif (is_string($value)) { |
| 165 | // Find shortcodes in string values |
| 166 | // Pattern matches MailPoet shortcodes in the format [name:value|option:value] |
| 167 | // - \[ matches opening bracket |
| 168 | // - [^\]]{1,400} matches 1-400 characters that are not a closing bracket |
| 169 | // (limit prevents ReDoS attacks from catastrophic backtracking) |
| 170 | // - \] matches closing bracket |
| 171 | // Examples: [subscriber:email], [subscriber:firstname|default:Guest] |
| 172 | $pattern = '/\[[^\]]{1,400}\]/'; |
| 173 | if (preg_match_all($pattern, $value, $matches)) { |
| 174 | foreach ($matches[0] as $shortcode) { |
| 175 | // Create a unique placeholder |
| 176 | $placeholder = 'MPSHORTCODE' . $index . 'MPEND'; |
| 177 | $shortcodeMap[$placeholder] = $shortcode; |
| 178 | // Replace shortcode with placeholder directly in the URL |
| 179 | $urlWithPlaceholders = str_replace($shortcode, $placeholder, $urlWithPlaceholders); |
| 180 | $index++; |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Restore shortcodes in the URL by replacing placeholders with original shortcodes. |
| 189 | */ |
| 190 | private function restoreShortcodes(string $url, array $shortcodeMap): string { |
| 191 | if (empty($shortcodeMap)) { |
| 192 | return $url; |
| 193 | } |
| 194 | |
| 195 | foreach ($shortcodeMap as $placeholder => $shortcode) { |
| 196 | $url = str_replace($placeholder, $shortcode, $url); |
| 197 | } |
| 198 | |
| 199 | return $url; |
| 200 | } |
| 201 | } |
| 202 |