Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.31% covered (success)
95.31%
61 / 64
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImapSearchResolver
95.24% covered (success)
95.24%
60 / 63
60.00% covered (warning)
60.00%
3 / 5
31
0.00% covered (danger)
0.00%
0 / 1
 buildSearchCommand
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
9.24
 resolveSearchUids
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 parseFetchBlocks
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
7
 sanitizeUids
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 cleanRawMessageLines
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
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\Infrastructure\Protocol\Imap\Client;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Application\Service\MimeDecoderService;
12use App\Modules\Mail\Domain\Model\MailMessageSummaryDto;
13use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
14
15/**
16 * Enterprise IMAP Search & UID Resolution Engine.
17 *
18 * Translates application search criteria into RFC 3501 SEARCH commands, extracts
19 * sorted numeric UIDs, formats FETCH queries, and sanitizes UID parameters.
20 *
21 * @package App\Modules\Mail\Infrastructure\Protocol\Imap\Client
22 */
23final readonly class ImapSearchResolver
24{
25    /**
26     * Builds IMAP SEARCH command string based on criteria.
27     *
28     * @param MailSearchCriteriaDto $criteria Filter criteria.
29     * @return string Valid RFC 3501 UID SEARCH command.
30     */
31    public function buildSearchCommand(MailSearchCriteriaDto $criteria): string
32    {
33        $searchTerms = [];
34        if ($criteria->isUnreadOnly) {
35            $searchTerms[] = 'UNSEEN';
36        } elseif ($criteria->isFlaggedOnly) {
37            $searchTerms[] = 'FLAGGED';
38        } elseif ($criteria->hasAttachmentsOnly) {
39            $searchTerms[] = 'HEADER Content-Type "multipart/mixed"';
40        }
41
42        if ($criteria->query !== null && $criteria->query !== '') {
43            $searchTerms[] = sprintf('TEXT "%s"', addcslashes($criteria->query, '"\\'));
44        }
45
46        if ($criteria->sinceDate !== null && $criteria->sinceDate !== '') {
47            $searchTerms[] = sprintf('SINCE %s', $criteria->sinceDate);
48        }
49
50        return $searchTerms !== []
51            ? 'UID SEARCH ' . implode(' ', $searchTerms)
52            : 'UID SEARCH ALL';
53    }
54
55    /**
56     * Extracts and sorts UIDs from IMAP search response.
57     *
58     * @param array<string> $searchLines   Response lines.
59     * @param string        $sortDirection 'asc' or 'desc'.
60     * @return array<string> Sorted UIDs.
61     */
62    public function resolveSearchUids(array $searchLines, string $sortDirection): array
63    {
64        $uids = [];
65        foreach ($searchLines as $line) {
66            if (str_starts_with($line, '* SEARCH')) {
67                $parts = explode(' ', trim($line));
68                array_shift($parts); // remove '*'
69                array_shift($parts); // remove 'SEARCH'
70                $uids = array_values(array_filter($parts));
71                break;
72            }
73        }
74
75        if (strtolower($sortDirection) === 'desc') {
76            rsort($uids, SORT_NUMERIC);
77        } else {
78            sort($uids, SORT_NUMERIC);
79        }
80
81        return $uids;
82    }
83
84    /**
85     * Parses FETCH lines into message summary DTOs and restores page UID order.
86     *
87     * @param array<string>           $fetchLines     Response lines.
88     * @param string                  $folderPath     Mailbox folder path.
89     * @param array<string>           $pageUids       Page UIDs in expected order.
90     * @param ImapResponseParser|null $responseParser Optional protocol response parser.
91     * @return array<MailMessageSummaryDto> Parsed and ordered summaries.
92     */
93    public function parseFetchBlocks(
94        array $fetchLines,
95        string $folderPath,
96        array $pageUids,
97        ?ImapResponseParser $responseParser = null
98    ): array {
99        $parser = $responseParser ?? new ImapResponseParser(new MimeDecoderService());
100
101        $messages = [];
102        $currentBlock = '';
103        foreach ($fetchLines as $line) {
104            if (preg_match('/^\*\s+\d+\s+FETCH\b/i', $line)) {
105                if ($currentBlock !== '') {
106                    $summary = $parser->parseFetchSummary($folderPath, $currentBlock);
107                    if ($summary !== null) {
108                        $messages[] = $summary;
109                    }
110                }
111                $currentBlock = $line;
112            } else {
113                $currentBlock .= "\n" . $line;
114            }
115        }
116
117        if ($currentBlock !== '') {
118            $summary = $parser->parseFetchSummary($folderPath, $currentBlock);
119            if ($summary !== null) {
120                $messages[] = $summary;
121            }
122        }
123
124        $uidOrderMap = array_flip($pageUids);
125        usort(
126            $messages,
127            static function (MailMessageSummaryDto $a, MailMessageSummaryDto $b) use ($uidOrderMap): int {
128                $posA = $uidOrderMap[$a->uid] ?? 0;
129                $posB = $uidOrderMap[$b->uid] ?? 0;
130                return $posA <=> $posB;
131            }
132        );
133
134        return $messages;
135    }
136
137    /**
138     * Filters input array to strictly positive numeric UIDs according to RFC 3501.
139     *
140     * @param array<string|int> $uids Raw UIDs.
141     * @return array<string> Validated string UIDs.
142     */
143    public function sanitizeUids(array $uids): array
144    {
145        $clean = [];
146        foreach ($uids as $uid) {
147            $str = (string) $uid;
148            if (ctype_digit($str) && (int) $str > 0) {
149                $clean[] = $str;
150            }
151        }
152        return array_values(array_unique($clean));
153    }
154
155    /**
156     * Cleans protocol wrapper lines from raw message RFC 822 source payload.
157     *
158     * @param array<string> $lines Raw response lines.
159     * @return string Cleaned RFC 822 message payload.
160     */
161    public function cleanRawMessageLines(array $lines): string
162    {
163        if ($lines !== [] && preg_match('/^[A-Z0-9]+\s+OK/i', end($lines))) {
164            array_pop($lines);
165        }
166        if ($lines !== [] && trim(end($lines)) === ')') {
167            array_pop($lines);
168        }
169        if ($lines !== [] && preg_match('/^\*\s+\d+\s+FETCH\b.*\{\d+\}$/i', $lines[0])) {
170            array_shift($lines);
171        }
172        return implode("\n", $lines);
173    }
174}