Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.28% covered (success)
90.28%
65 / 72
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailTrackingService
90.14% covered (success)
90.14%
64 / 71
20.00% covered (danger)
20.00%
1 / 5
19.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerTrackingToken
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 recordOpen
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
6.06
 recordClick
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 injectTracking
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Mail\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use PDO;
13
14/**
15 * Mail Tracking Service for Open Pixels and Click-Through URL Analytics.
16 *
17 * @package App\Modules\Mail\Application\Service
18 */
19final 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}