Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.28% |
65 / 72 |
|
20.00% |
1 / 5 |
CRAP | |
0.00% |
0 / 1 |
| MailTrackingService | |
90.14% |
64 / 71 |
|
20.00% |
1 / 5 |
19.35 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| registerTrackingToken | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
3.00 | |||
| recordOpen | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
6.06 | |||
| recordClick | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
6.05 | |||
| injectTracking | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
3.01 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Mail\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Database\FallbackPdoResolver; |
| 12 | use PDO; |
| 13 | |
| 14 | /** |
| 15 | * Mail Tracking Service for Open Pixels and Click-Through URL Analytics. |
| 16 | * |
| 17 | * @package App\Modules\Mail\Application\Service |
| 18 | */ |
| 19 | final class MailTrackingService implements MailTrackingServiceInterface |
| 20 | { |
| 21 | private ?PDO $pdo; |
| 22 | |
| 23 | /** |
| 24 | * MailTrackingService constructor. |
| 25 | * |
| 26 | * @param PDO|null $pdo Optional PDO database connection handle. |
| 27 | */ |
| 28 | public function __construct( |
| 29 | ?PDO $pdo = null |
| 30 | ) { |
| 31 | $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection(); |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * Creates and registers a new tracking token for an outgoing email. |
| 36 | * |
| 37 | * @param int|null $mailQueueId Queue record ID. |
| 38 | * @param string $recipientEmail Recipient email address. |
| 39 | * @return string Generated unique tracking token string. |
| 40 | */ |
| 41 | public function registerTrackingToken(?int $mailQueueId, string $recipientEmail): string |
| 42 | { |
| 43 | $token = bin2hex(random_bytes(20)); |
| 44 | |
| 45 | if ($this->pdo !== null) { |
| 46 | try { |
| 47 | $stmt = $this->pdo->prepare( |
| 48 | 'INSERT INTO `a_mod_mail_tracking_records` (' |
| 49 | . '`tracking_token`, `mail_queue_id`, `recipient_email`' |
| 50 | . ') VALUES (:token, :qid, :email)' |
| 51 | ); |
| 52 | $stmt->execute([ |
| 53 | ':token' => $token, |
| 54 | ':qid' => $mailQueueId, |
| 55 | ':email' => $recipientEmail, |
| 56 | ]); |
| 57 | } catch (\Throwable) { |
| 58 | // Non-blocking in testing environments |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | return $token; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Records email open event in tracking records. |
| 67 | * |
| 68 | * @param string $token Tracking token. |
| 69 | * @param string|null $ipAddress Client IP address. |
| 70 | * @param string|null $userAgent Client User-Agent string. |
| 71 | */ |
| 72 | public function recordOpen(string $token, ?string $ipAddress = null, ?string $userAgent = null): void |
| 73 | { |
| 74 | if ($token === '' || $this->pdo === null) { |
| 75 | return; |
| 76 | } |
| 77 | |
| 78 | try { |
| 79 | $stmt = $this->pdo->prepare( |
| 80 | 'UPDATE `a_mod_mail_tracking_records` SET ' |
| 81 | . '`is_opened` = 1, ' |
| 82 | . '`opened_at` = COALESCE(`opened_at`, NOW(6)), ' |
| 83 | . '`open_count` = `open_count` + 1, ' |
| 84 | . '`ip_address` = :ip, ' |
| 85 | . '`user_agent` = :ua ' |
| 86 | . 'WHERE `tracking_token` = :token' |
| 87 | ); |
| 88 | $stmt->execute([ |
| 89 | ':token' => $token, |
| 90 | ':ip' => $ipAddress !== null ? mb_substr($ipAddress, 0, 45) : null, |
| 91 | ':ua' => $userAgent !== null ? mb_substr($userAgent, 0, 512) : null, |
| 92 | ]); |
| 93 | } catch (\Throwable) { |
| 94 | // Silently handled |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Records link click event and updates click counters. |
| 100 | * |
| 101 | * @param string $token Tracking token. |
| 102 | * @param string $url Target clicked URL. |
| 103 | * @param string|null $ipAddress Client IP address. |
| 104 | * @param string|null $userAgent Client User-Agent string. |
| 105 | */ |
| 106 | public function recordClick( |
| 107 | string $token, |
| 108 | string $url, |
| 109 | ?string $ipAddress = null, |
| 110 | ?string $userAgent = null |
| 111 | ): void { |
| 112 | if ($token === '' || $this->pdo === null) { |
| 113 | return; |
| 114 | } |
| 115 | |
| 116 | try { |
| 117 | $stmt = $this->pdo->prepare( |
| 118 | 'UPDATE `a_mod_mail_tracking_records` SET ' |
| 119 | . '`clicks_count` = `clicks_count` + 1, ' |
| 120 | . '`last_clicked_at` = NOW(6), ' |
| 121 | . '`last_click_url` = :url, ' |
| 122 | . '`ip_address` = :ip, ' |
| 123 | . '`user_agent` = :ua ' |
| 124 | . 'WHERE `tracking_token` = :token' |
| 125 | ); |
| 126 | $stmt->execute([ |
| 127 | ':token' => $token, |
| 128 | ':url' => mb_substr($url, 0, 1024), |
| 129 | ':ip' => $ipAddress !== null ? mb_substr($ipAddress, 0, 45) : null, |
| 130 | ':ua' => $userAgent !== null ? mb_substr($userAgent, 0, 512) : null, |
| 131 | ]); |
| 132 | } catch (\Throwable) { |
| 133 | // Silently handled |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Injects tracking pixel and wraps hyperlinks with click-tracking redirects. |
| 139 | * |
| 140 | * @param string $html Original email HTML body. |
| 141 | * @param string $token Registered tracking token. |
| 142 | * @param string $baseUrl Base application URL. |
| 143 | * @return string Enhanced HTML with pixel and tracked hyperlinks. |
| 144 | */ |
| 145 | public function injectTracking( |
| 146 | string $html, |
| 147 | string $token, |
| 148 | string $baseUrl = 'https://app-admin.ammonly.com' |
| 149 | ): string { |
| 150 | $root = rtrim($baseUrl, '/'); |
| 151 | $pixelUrl = $root . '/mail/track/open/' . urlencode($token); |
| 152 | $pixelImg = sprintf( |
| 153 | '<img src="%s" width="1" height="1" alt="" ' |
| 154 | . 'style="display:none!important;width:1px!important;height:1px!important;border:0!important;" />', |
| 155 | htmlspecialchars($pixelUrl, ENT_QUOTES, 'UTF-8') |
| 156 | ); |
| 157 | |
| 158 | // Wrap hyperlinks |
| 159 | $trackedHtml = (string)preg_replace_callback( |
| 160 | '/<a\s+([^>]*?)href=["\'](https?:\/\/[^"\']+)["\']([^>]*)>/i', |
| 161 | static function (array $m) use ($root, $token): string { |
| 162 | $targetUrl = $m[2]; |
| 163 | if (str_contains($targetUrl, '/mail/track/')) { |
| 164 | return $m[0]; |
| 165 | } |
| 166 | $wrapped = $root . '/mail/track/click/' . urlencode($token) . '?url=' . urlencode($targetUrl); |
| 167 | return sprintf('<a %shref="%s"%s>', $m[1], htmlspecialchars($wrapped, ENT_QUOTES, 'UTF-8'), $m[3]); |
| 168 | }, |
| 169 | $html |
| 170 | ); |
| 171 | |
| 172 | if (str_contains($trackedHtml, '</body>')) { |
| 173 | return str_replace('</body>', $pixelImg . '</body>', $trackedHtml); |
| 174 | } |
| 175 | |
| 176 | return $trackedHtml . $pixelImg; |
| 177 | } |
| 178 | } |