Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
23 / 24
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailTrackingWebController
95.65% covered (success)
95.65%
22 / 23
66.67% covered (warning)
66.67%
2 / 3
4
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
 trackOpen
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 trackClick
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
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\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Application\Service\MailTrackingServiceInterface;
12use Psr\Http\Message\ResponseFactoryInterface;
13use Psr\Http\Message\ResponseInterface;
14use Psr\Http\Message\ServerRequestInterface;
15
16/**
17 * Public Web Controller for Email Tracking Pixels and Click Redirects.
18 *
19 * @package App\Modules\Mail\Presentation\Web
20 */
21final readonly class MailTrackingWebController
22{
23    /**
24     * Transparent 1x1 GIF binary string (43 bytes).
25     */
26    private const string TRANSPARENT_GIF =
27        "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00"
28        . "\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3b";
29
30    /**
31     * MailTrackingWebController constructor.
32     *
33     * @param ResponseFactoryInterface     $responseFactory PSR-17 response factory.
34     * @param MailTrackingServiceInterface $trackingService Mail tracking engine.
35     */
36    public function __construct(
37        private ResponseFactoryInterface $responseFactory,
38        private MailTrackingServiceInterface $trackingService
39    ) {
40    }
41
42    /**
43     * Handles open tracking pixel requests: GET /mail/track/open/{token}
44     *
45     * @param ServerRequestInterface $request Incoming HTTP request.
46     * @param string                 $token   Cryptographic tracking token.
47     * @return ResponseInterface Binary transparent 1x1 GIF response.
48     */
49    public function trackOpen(ServerRequestInterface $request, string $token): ResponseInterface
50    {
51        $serverParams = $request->getServerParams();
52        $ip = (string)($serverParams['REMOTE_ADDR'] ?? '');
53        $ua = (string)($request->getHeaderLine('User-Agent'));
54
55        $this->trackingService->recordOpen($token, $ip, $ua);
56
57        $response = $this->responseFactory->createResponse(200)
58            ->withHeader('Content-Type', 'image/gif')
59            ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0')
60            ->withHeader('Pragma', 'no-cache')
61            ->withHeader('Expires', 'Thu, 01 Jan 1970 00:00:00 GMT');
62
63        $response->getBody()->write(self::TRANSPARENT_GIF);
64
65        return $response;
66    }
67
68    /**
69     * Handles hyperlink click-through redirect requests: GET /mail/track/click/{token}?url={targetUrl}
70     *
71     * @param ServerRequestInterface $request Incoming HTTP request.
72     * @param string                 $token   Cryptographic tracking token.
73     * @return ResponseInterface HTTP 302 Found redirect response.
74     */
75    public function trackClick(ServerRequestInterface $request, string $token): ResponseInterface
76    {
77        $queryParams = $request->getQueryParams();
78        $targetUrl = (string)($queryParams['url'] ?? '/');
79        if (trim($targetUrl) === '') {
80            $targetUrl = '/';
81        }
82
83        $serverParams = $request->getServerParams();
84        $ip = (string)($serverParams['REMOTE_ADDR'] ?? '');
85        $ua = (string)($request->getHeaderLine('User-Agent'));
86
87        $this->trackingService->recordClick($token, $targetUrl, $ip, $ua);
88
89        return $this->responseFactory->createResponse(302)
90            ->withHeader('Location', $targetUrl)
91            ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
92    }
93}