Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.28% covered (success)
91.28%
136 / 149
65.00% covered (warning)
65.00%
13 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailMessageService
91.22% covered (success)
91.22%
135 / 148
65.00% covered (warning)
65.00%
13 / 20
47.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fetchMessages
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
6.04
 getAllFolderUids
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getMessageDetail
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
4
 resolveUserTimezone
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
14.11
 getMimePart
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRawMessageSource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setFlags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 toggleKeyword
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 moveMessages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 moveMessagesCrossMailbox
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
10
 deleteMessages
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 assertMailboxAccess
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 connectDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invalidateMailboxMessagesCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeSenderEmail
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractDomainFromEmail
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trustSenderImages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSenderImagesTrusted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forgetTrustedSenderImages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Engine\Domain\Repository\RecordCoOwnerRepositoryInterface;
12use App\Core\Security\Encryption\EncryptionServiceInterface;
13use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManager;
14use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManagerInterface;
15use App\Modules\Mail\Application\Service\Message\WebmailMessageCacheManager;
16use App\Modules\Mail\Application\Service\Message\WebmailTrustedSenderService;
17use App\Modules\Mail\Domain\Contract\MailHtmlSanitizerInterface;
18use App\Modules\Mail\Domain\Contract\MailProtocolDriverInterface;
19use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
20use App\Modules\Mail\Domain\Contract\WebmailMessageServiceInterface;
21use App\Modules\Mail\Domain\Model\ClientMailbox;
22use App\Modules\Mail\Domain\Model\MailMessageDetailDto;
23use App\Modules\Mail\Domain\Model\MailMimePartDto;
24use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
25use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
26use InvalidArgumentException;
27use Throwable;
28use Yiisoft\Cache\CacheInterface;
29
30/**
31 * Webmail Message Operations Application Service.
32 *
33 * Coordinates message listing, server-side filtering, isolated HTML sanitization,
34 * flag toggling, and multi-message batch operations.
35 *
36 * @package App\Modules\Mail\Application\Service
37 */
38final readonly class WebmailMessageService implements WebmailMessageServiceInterface
39{
40    private const string MODULE_NAME = 'client_mailboxes';
41    public const string TRUSTED_IMAGES_CACHE_PREFIX = WebmailTrustedSenderService::TRUSTED_IMAGES_CACHE_PREFIX;
42    public const int TRUSTED_IMAGES_CACHE_TTL = WebmailTrustedSenderService::TRUSTED_IMAGES_CACHE_TTL;
43    public const array PUBLIC_FREEMAIL_DOMAINS = WebmailTrustedSenderService::PUBLIC_FREEMAIL_DOMAINS;
44
45    private WebmailTrustedSenderService $trustedSenderService;
46    private WebmailMessageCacheManager $cacheManager;
47    private WebmailCircuitBreakerService $circuitBreaker;
48    private WebmailConnectionManagerInterface $connectionManager;
49
50    /**
51     * WebmailMessageService constructor.
52     *
53     * @param MailRepositoryInterface                 $mailRepo          Mail repository.
54     * @param RecordCoOwnerRepositoryInterface        $coOwnerRepo       Relational co-owner repository.
55     * @param EncryptionServiceInterface              $encryption        AES-256 encryption service.
56     * @param MailProtocolDriverRegistryInterface     $driverRegistry    Protocol driver registry.
57     * @param MailHtmlSanitizerInterface              $sanitizer         HTML body sanitizer.
58     * @param MailDateFormatService|null              $dateFormatService Optional date formatting service.
59     * @param \App\Modules\User\Infrastructure\Repository\SqlUserRepository|null $userRepository User repository.
60     * @param CacheInterface|null                     $cache             Optional cache implementation.
61     * @param WebmailTrustedSenderService|null        $trustedSenderService Optional trusted sender service.
62     * @param WebmailMessageCacheManager|null         $cacheManager      Optional message cache manager.
63     * @param WebmailCircuitBreakerService|null       $circuitBreaker    Optional circuit breaker service.
64     * @param WebmailConnectionManagerInterface|null  $connManager       Optional connection manager.
65     */
66    public function __construct(
67        private MailRepositoryInterface $mailRepo,
68        private RecordCoOwnerRepositoryInterface $coOwnerRepo,
69        private EncryptionServiceInterface $encryption,
70        private MailProtocolDriverRegistryInterface $driverRegistry,
71        private MailHtmlSanitizerInterface $sanitizer,
72        private ?MailDateFormatService $dateFormatService = null,
73        private ?\App\Modules\User\Infrastructure\Repository\SqlUserRepository $userRepository = null,
74        private ?CacheInterface $cache = null,
75        ?WebmailTrustedSenderService $trustedSenderService = null,
76        ?WebmailMessageCacheManager $cacheManager = null,
77        ?WebmailCircuitBreakerService $circuitBreaker = null,
78        ?WebmailConnectionManagerInterface $connManager = null
79    ) {
80        $this->trustedSenderService = $trustedSenderService ?? new WebmailTrustedSenderService($this->cache);
81        $this->cacheManager         = $cacheManager ?? new WebmailMessageCacheManager($this->cache);
82        $this->circuitBreaker       = $circuitBreaker ?? new WebmailCircuitBreakerService($this->cache);
83        $this->connectionManager    = $connManager ?? new WebmailConnectionManager(
84            $this->mailRepo,
85            $this->encryption,
86            $this->driverRegistry
87        );
88    }
89
90    /**
91     * Fetches paginated message list for a specific mailbox folder.
92     *
93     * @param int                   $mailboxId Mailbox primary key.
94     * @param int                   $userId    Current user ID.
95     * @param MailSearchCriteriaDto $criteria  Search and sort parameters.
96     * @param int                   $page      Page index (1-based).
97     * @param int                   $perPage   Items per page.
98     * @return array{messages: array<\App\Modules\Mail\Domain\Model\MailMessageSummaryDto>, total: int}
99     */
100    public function fetchMessages(
101        int $mailboxId,
102        int $userId,
103        MailSearchCriteriaDto $criteria,
104        int $page = 1,
105        int $perPage = 25
106    ): array {
107        $cacheKey = $this->cacheManager->buildMessagesCacheKey($mailboxId, $criteria, $page, $perPage);
108        $cached = $this->cacheManager->getCachedMessages($cacheKey);
109
110        if ($cached !== null && !$this->circuitBreaker->isAvailable($mailboxId)) {
111            $cached['is_cached'] = true;
112            return $cached;
113        }
114
115        try {
116            $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
117            $driver  = $this->connectDriver($mailbox);
118
119            try {
120                $result = $driver->fetchMessages($criteria->folder, $criteria, $page, $perPage);
121                $timezone = $this->resolveUserTimezone($userId);
122                $dateFormat = $this->dateFormatService ?? new MailDateFormatService();
123
124                $enriched = [];
125                foreach ($result['messages'] as $msg) {
126                    $enriched[] = $dateFormat->enrichSummary($msg, $timezone);
127                }
128
129                $payload = [
130                    'messages'  => $enriched,
131                    'total'     => $result['total'],
132                    'is_cached' => false,
133                ];
134
135                $this->cacheManager->saveCachedMessages($cacheKey, $payload);
136                $this->circuitBreaker->recordSuccess($mailboxId);
137
138                return $payload;
139            } finally {
140                $driver->disconnect();
141            }
142        } catch (Throwable $e) {
143            $this->circuitBreaker->recordFailure($mailboxId, $e->getMessage());
144            if ($cached !== null) {
145                $cached['is_cached'] = true;
146                return $cached;
147            }
148
149            throw $e;
150        }
151    }
152
153    /**
154     * Retrieves all message UIDs for a mailbox folder matching optional criteria.
155     *
156     * @param int                        $mailboxId Mailbox primary key.
157     * @param int                        $userId    Current user ID.
158     * @param string                     $folder    Mailbox folder path.
159     * @param MailSearchCriteriaDto|null $criteria  Search parameters.
160     * @return array<string> List of all matching message UIDs.
161     */
162    public function getAllFolderUids(
163        int $mailboxId,
164        int $userId,
165        string $folder,
166        ?MailSearchCriteriaDto $criteria = null
167    ): array {
168        $criteria ??= new MailSearchCriteriaDto(folder: $folder);
169        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
170        $driver  = $this->connectDriver($mailbox);
171
172        try {
173            $result = $driver->fetchMessages($folder, $criteria, 1, 10000);
174            return array_map(static fn ($msg): string => $msg->uid, $result['messages']);
175        } finally {
176            $driver->disconnect();
177        }
178    }
179
180    /**
181     * Fetches complete message payload with OWASP ASVS 5.0 HTML sanitization.
182     *
183     * @param int    $mailboxId          Mailbox primary key.
184     * @param int    $userId             Current user ID.
185     * @param string $folder             Mailbox folder path.
186     * @param string $messageUid         Message UID.
187     * @param bool   $markAsSeen         Whether to set \Seen flag.
188     * @param bool   $allowRemoteImages  Whether to allow remote external images.
189     * @return MailMessageDetailDto Sanitized message detail.
190     */
191    public function getMessageDetail(
192        int $mailboxId,
193        int $userId,
194        string $folder,
195        string $messageUid,
196        bool $markAsSeen = true,
197        bool $allowRemoteImages = false
198    ): MailMessageDetailDto {
199        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
200        $driver  = $this->connectDriver($mailbox);
201
202        try {
203            $rawDetail = $driver->getMessageDetail($folder, $messageUid, $markAsSeen);
204            $cleanFromEmail = $this->trustedSenderService->normalizeSenderEmail($rawDetail->summary->fromEmail);
205
206            if ($allowRemoteImages) {
207                $this->trustedSenderService->trustSenderImages($userId, $cleanFromEmail);
208            } elseif ($cleanFromEmail !== ''
209                && $this->trustedSenderService->isSenderImagesTrusted($userId, $cleanFromEmail)) {
210                $allowRemoteImages = true;
211            }
212
213            $sanitized = $this->sanitizer->sanitize($rawDetail->htmlBody, $allowRemoteImages);
214
215            $timezone = $this->resolveUserTimezone($userId);
216            $dateFormat = $this->dateFormatService ?? new MailDateFormatService();
217            $enrichedSummary = $dateFormat->enrichSummary($rawDetail->summary, $timezone);
218
219            return new MailMessageDetailDto(
220                summary: $enrichedSummary,
221                htmlBody: $sanitized['html'],
222                textBody: $rawDetail->textBody,
223                attachments: $rawDetail->attachments,
224                headers: $rawDetail->headers,
225                replyTo: $rawDetail->replyTo,
226                bcc: $rawDetail->bcc,
227                spfStatus: $rawDetail->spfStatus,
228                dkimStatus: $rawDetail->dkimStatus,
229                dmarcStatus: $rawDetail->dmarcStatus,
230                hasBlockedImages: $sanitized['hasBlockedImages']
231            );
232        } finally {
233            $driver->disconnect();
234        }
235    }
236
237    /**
238     * Resolves user timezone or falls back to Europe/Warsaw.
239     *
240     * @param int $userId Target user ID.
241     * @return string Valid IANA timezone string.
242     */
243    private function resolveUserTimezone(int $userId): string
244    {
245        if ($this->userRepository !== null && $userId > 0) {
246            try {
247                $profile = $this->userRepository->getUserProfile($userId);
248                $tz = trim((string) ($profile['timezone'] ?? ''));
249                if ($tz !== '') {
250                    return $tz;
251                }
252            } catch (\Throwable) {
253                // Ignore and fallback
254            }
255        }
256
257        return 'Europe/Warsaw';
258    }
259
260    /**
261     * Streams binary payload of a single MIME attachment part.
262     */
263    public function getMimePart(
264        int $mailboxId,
265        int $userId,
266        string $folder,
267        string $messageUid,
268        string $partId
269    ): MailMimePartDto {
270        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
271        $driver  = $this->connectDriver($mailbox);
272
273        try {
274            return $driver->getMimePartStream($folder, $messageUid, $partId);
275        } finally {
276            $driver->disconnect();
277        }
278    }
279
280    /**
281     * Retrieves full raw RFC 822 / EML message source string.
282     */
283    public function getRawMessageSource(int $mailboxId, int $userId, string $folder, string $messageUid): string
284    {
285        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
286        $driver  = $this->connectDriver($mailbox);
287
288        try {
289            return $driver->getRawMessageSource($folder, $messageUid);
290        } finally {
291            $driver->disconnect();
292        }
293    }
294
295    /**
296     * Toggles flags (\Seen, \Flagged) for given messages.
297     *
298     * @param int           $mailboxId Mailbox primary key.
299     * @param int           $userId    Current user ID.
300     * @param string        $folder    Mailbox folder path.
301     * @param array<string> $uids      Target message UIDs.
302     * @param array<string> $flags     Flag names.
303     * @param bool          $add       True to set, false to unset.
304     */
305    public function setFlags(
306        int $mailboxId,
307        int $userId,
308        string $folder,
309        array $uids,
310        array $flags,
311        bool $add = true
312    ): void {
313        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
314        $driver  = $this->connectDriver($mailbox);
315
316        try {
317            $driver->setFlags($folder, $uids, $flags, $add);
318            $this->cacheManager->invalidateMailboxMessagesCache($mailboxId);
319        } finally {
320            $driver->disconnect();
321        }
322    }
323
324    /**
325     * Toggles an RFC 5788 keyword/label on specified messages.
326     *
327     * @param int           $mailboxId Mailbox ID.
328     * @param int           $userId    User ID.
329     * @param string        $folder    Folder path.
330     * @param array<string> $uids      Message UIDs.
331     * @param string        $keyword   Keyword flag (e.g. $Important, $Work).
332     * @param bool          $add       True to add, false to remove.
333     */
334    public function toggleKeyword(
335        int $mailboxId,
336        int $userId,
337        string $folder,
338        array $uids,
339        string $keyword,
340        bool $add = true
341    ): void {
342        $this->setFlags($mailboxId, $userId, $folder, $uids, [$keyword], $add);
343    }
344
345    /**
346     * Moves messages from source folder to target folder.
347     */
348    public function moveMessages(
349        int $mailboxId,
350        int $userId,
351        string $sourceFolder,
352        string $targetFolder,
353        array $uids
354    ): void {
355        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
356        $driver  = $this->connectDriver($mailbox);
357
358        try {
359            $driver->moveMessages($sourceFolder, $targetFolder, $uids);
360            $this->cacheManager->invalidateMailboxMessagesCache($mailboxId);
361        } finally {
362            $driver->disconnect();
363        }
364    }
365
366    /**
367     * Moves messages across two different mailboxes for the authorized user.
368     *
369     * @param int           $sourceMailboxId Source mailbox ID.
370     * @param int           $targetMailboxId Destination mailbox ID.
371     * @param int           $userId          Current authenticated user ID.
372     * @param string        $sourceFolder    Source folder path.
373     * @param string        $targetFolder    Destination folder path.
374     * @param array<string> $uids            Message UIDs to transfer.
375     */
376    public function moveMessagesCrossMailbox(
377        int $sourceMailboxId,
378        int $targetMailboxId,
379        int $userId,
380        string $sourceFolder,
381        string $targetFolder,
382        array $uids
383    ): void {
384        if ($sourceMailboxId === $targetMailboxId) {
385            $this->moveMessages($sourceMailboxId, $userId, $sourceFolder, $targetFolder, $uids);
386            return;
387        }
388
389        $sourceMailbox = $this->assertMailboxAccess($sourceMailboxId, $userId);
390        $targetMailbox = $this->assertMailboxAccess($targetMailboxId, $userId);
391
392        $sourceDriver = $this->connectDriver($sourceMailbox);
393        $targetDriver = $this->connectDriver($targetMailbox);
394
395        try {
396            foreach ($uids as $uid) {
397                $uidStr = (string) $uid;
398                if ($uidStr === '') {
399                    continue;
400                }
401
402                $flags = [];
403                try {
404                    $detail = $sourceDriver->getMessageDetail($sourceFolder, $uidStr, false);
405                    if ($detail->summary->isSeen) {
406                        $flags[] = '\\Seen';
407                    }
408                    if ($detail->summary->isFlagged) {
409                        $flags[] = '\\Flagged';
410                    }
411                } catch (\Throwable) {
412                    $flags = ['\\Seen'];
413                }
414
415                $rawSource = $sourceDriver->getRawMessageSource($sourceFolder, $uidStr);
416                if (trim($rawSource) === '') {
417                    continue;
418                }
419
420                $appendedUid = $targetDriver->appendRawMessage($targetFolder, $rawSource, $flags);
421
422                if ($appendedUid !== null && $appendedUid !== '') {
423                    $sourceDriver->deleteMessages($sourceFolder, [$uidStr], true);
424                }
425            }
426        } finally {
427            $sourceDriver->disconnect();
428            $targetDriver->disconnect();
429        }
430
431        $this->cacheManager->invalidateMailboxMessagesCache($sourceMailboxId);
432        $this->cacheManager->invalidateMailboxMessagesCache($targetMailboxId);
433    }
434
435    /**
436     * Deletes messages (moves to Trash if configured, or permanently deletes).
437     */
438    public function deleteMessages(int $mailboxId, int $userId, string $folder, array $uids): void
439    {
440        $mailbox = $this->assertMailboxAccess($mailboxId, $userId);
441        $trashFolder = $mailbox->folderTrash ?? 'Trash';
442
443        if (strtolower($folder) !== strtolower($trashFolder)) {
444            $this->moveMessages($mailboxId, $userId, $folder, $trashFolder, $uids);
445            return;
446        }
447
448        $driver = $this->connectDriver($mailbox);
449        try {
450            $driver->deleteMessages($folder, $uids, true);
451            $this->cacheManager->invalidateMailboxMessagesCache($mailboxId);
452        } finally {
453            $driver->disconnect();
454        }
455    }
456
457    /**
458     * Asserts that the current user has access to the mailbox.
459     */
460    private function assertMailboxAccess(int $mailboxId, int $userId): ClientMailbox
461    {
462        $mailbox = $this->mailRepo->findClientMailboxById($mailboxId);
463        if ($mailbox === null) {
464            throw new InvalidArgumentException(sprintf('Mailbox %d not found.', $mailboxId));
465        }
466
467        $isOwner   = $mailbox->owner === $userId;
468        $isCoOwner = $this->coOwnerRepo->isCoOwner(self::MODULE_NAME, $mailboxId, $userId);
469
470        if (!$isOwner && !$isCoOwner && !$mailbox->isShared) {
471            throw new \App\Modules\Mail\Domain\Exception\WebmailMessageException(
472                'Access denied to requested mailbox.'
473            );
474        }
475
476        return $mailbox;
477    }
478
479    /**
480     * Connects driver instance with decrypted password.
481     */
482    private function connectDriver(ClientMailbox $mailbox): MailProtocolDriverInterface
483    {
484        return $this->connectionManager->connectDriver($mailbox);
485    }
486
487    /**
488     * Invalidates all cached message lists for a mailbox.
489     */
490    public function invalidateMailboxMessagesCache(int $mailboxId): void
491    {
492        $this->cacheManager->invalidateMailboxMessagesCache($mailboxId);
493    }
494
495    /**
496     * Normalizes an email address or address header string into a clean lowercase email.
497     */
498    public function normalizeSenderEmail(string $rawEmail): string
499    {
500        return $this->trustedSenderService->normalizeSenderEmail($rawEmail);
501    }
502
503    /**
504     * Extracts lowercase domain from a normalized email address.
505     */
506    public function extractDomainFromEmail(string $email): string
507    {
508        return $this->trustedSenderService->extractDomainFromEmail($email);
509    }
510
511    /**
512     * Records a sender email and organization domain into time-based trusted remote images cache.
513     */
514    public function trustSenderImages(
515        int $userId,
516        string $cleanEmail,
517        int $ttl = self::TRUSTED_IMAGES_CACHE_TTL
518    ): void {
519        $this->trustedSenderService->trustSenderImages($userId, $cleanEmail, $ttl);
520    }
521
522    /**
523     * Checks whether a sender email or organization server domain is present in trusted images cache.
524     */
525    public function isSenderImagesTrusted(int $userId, string $cleanEmail): bool
526    {
527        return $this->trustedSenderService->isSenderImagesTrusted($userId, $cleanEmail);
528    }
529
530    /**
531     * Revokes trusted sender status from remote images cache.
532     */
533    public function forgetTrustedSenderImages(int $userId, string $cleanEmail): void
534    {
535        $this->trustedSenderService->forgetTrustedSenderImages($userId, $cleanEmail);
536    }
537}