Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.67% covered (warning)
86.67%
39 / 45
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailTrustedSenderService
86.36% covered (warning)
86.36%
38 / 44
50.00% covered (danger)
50.00%
3 / 6
28.85
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
 normalizeSenderEmail
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractDomainFromEmail
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 trustSenderImages
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 isSenderImagesTrusted
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
9.19
 forgetTrustedSenderImages
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
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\Message;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Throwable;
12use Yiisoft\Cache\CacheInterface;
13
14/**
15 * Webmail Trusted Sender Service.
16 *
17 * Manages trusted sender email and domain records for remote external images display in webmail client.
18 *
19 * @package App\Modules\Mail\Application\Service\Message
20 */
21final readonly class WebmailTrustedSenderService
22{
23    public const string TRUSTED_IMAGES_CACHE_PREFIX = 'webmail_trusted_img_';
24    public const int TRUSTED_IMAGES_CACHE_TTL = 604800; // 7 days in seconds
25
26    /**
27     * Common public freemail domains where domain-wide trust is disabled to prevent spoofing.
28     */
29    public const array PUBLIC_FREEMAIL_DOMAINS = [
30        'gmail.com',
31        'googlemail.com',
32        'yahoo.com',
33        'ymail.com',
34        'outlook.com',
35        'hotmail.com',
36        'live.com',
37        'msn.com',
38        'icloud.com',
39        'me.com',
40        'mac.com',
41        'wp.pl',
42        'onet.pl',
43        'interia.pl',
44        'o2.pl',
45        'gazeta.pl',
46        'proton.me',
47        'protonmail.com',
48        'zoho.com',
49        'aol.com',
50    ];
51
52    /**
53     * WebmailTrustedSenderService constructor.
54     *
55     * @param CacheInterface|null $cache Cache implementation.
56     */
57    public function __construct(
58        private ?CacheInterface $cache = null,
59    ) {
60    }
61
62    /**
63     * Normalizes an email address or address header string into a clean lowercase email.
64     *
65     * @param string $rawEmail Raw email address or formatted name & email.
66     * @return string Clean lowercase email address.
67     */
68    public function normalizeSenderEmail(string $rawEmail): string
69    {
70        $clean = trim($rawEmail);
71        if (preg_match('/<([^>]+)>/', $clean, $matches)) {
72            $clean = trim($matches[1]);
73        }
74        return strtolower($clean);
75    }
76
77    /**
78     * Extracts lowercase domain from a normalized email address.
79     *
80     * @param string $email Normalized email address.
81     * @return string Domain or empty string.
82     */
83    public function extractDomainFromEmail(string $email): string
84    {
85        $atPos = strrpos($email, '@');
86        if ($atPos === false) {
87            return '';
88        }
89        return strtolower(substr($email, $atPos + 1));
90    }
91
92    /**
93     * Records a sender email and organization domain into time-based trusted remote images cache.
94     *
95     * @param int    $userId     Owner user ID.
96     * @param string $cleanEmail Normalized lowercase email address.
97     * @param int    $ttl        Cache TTL in seconds (defaults to 7 days).
98     */
99    public function trustSenderImages(
100        int $userId,
101        string $cleanEmail,
102        int $ttl = self::TRUSTED_IMAGES_CACHE_TTL,
103    ): void {
104        if ($this->cache === null || $userId <= 0 || $cleanEmail === '') {
105            return;
106        }
107
108        try {
109            $psr = $this->cache->psr();
110            $emailKey = self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_email_' . hash('sha256', $cleanEmail);
111            $psr->set($emailKey, true, $ttl);
112
113            $domain = $this->extractDomainFromEmail($cleanEmail);
114            if ($domain !== '' && !in_array($domain, self::PUBLIC_FREEMAIL_DOMAINS, true)) {
115                $domainKey = self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_domain_' . hash('sha256', $domain);
116                $psr->set($domainKey, true, $ttl);
117            }
118        } catch (Throwable) {
119            // Cache write failure must not disrupt user flow
120        }
121    }
122
123    /**
124     * Checks whether a sender email or organization server domain is present in trusted images cache.
125     *
126     * @param int    $userId     Owner user ID.
127     * @param string $cleanEmail Normalized lowercase email address.
128     * @return bool True if remote images are allowed by cache.
129     */
130    public function isSenderImagesTrusted(int $userId, string $cleanEmail): bool
131    {
132        if ($this->cache === null || $userId <= 0 || $cleanEmail === '') {
133            return false;
134        }
135
136        $trusted = false;
137        try {
138            $psr = $this->cache->psr();
139            $emailKey = self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_email_' . hash('sha256', $cleanEmail);
140            $domain = $this->extractDomainFromEmail($cleanEmail);
141            $isFreemail = in_array($domain, self::PUBLIC_FREEMAIL_DOMAINS, true);
142            $domainKey = $domain !== '' && !$isFreemail
143                ? self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_domain_' . hash('sha256', $domain)
144                : null;
145
146            $trusted = (bool) $psr->get($emailKey, false)
147                || ($domainKey !== null && (bool) $psr->get($domainKey, false));
148        } catch (Throwable) {
149            $trusted = false;
150        }
151
152        return $trusted;
153    }
154
155    /**
156     * Revokes trusted sender status from remote images cache.
157     *
158     * @param int    $userId     Owner user ID.
159     * @param string $cleanEmail Normalized lowercase email address.
160     */
161    public function forgetTrustedSenderImages(int $userId, string $cleanEmail): void
162    {
163        if ($this->cache === null || $userId <= 0 || $cleanEmail === '') {
164            return;
165        }
166
167        try {
168            $psr = $this->cache->psr();
169            $emailKey = self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_email_' . hash('sha256', $cleanEmail);
170            $psr->delete($emailKey);
171
172            $domain = $this->extractDomainFromEmail($cleanEmail);
173            if ($domain !== '') {
174                $domainKey = self::TRUSTED_IMAGES_CACHE_PREFIX . $userId . '_domain_' . hash('sha256', $domain);
175                $psr->delete($domainKey);
176            }
177        } catch (Throwable) {
178            // Ignore cache deletion errors
179        }
180    }
181}