Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.31% |
61 / 64 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| ImapSearchResolver | |
95.24% |
60 / 63 |
|
60.00% |
3 / 5 |
31 | |
0.00% |
0 / 1 |
| buildSearchCommand | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
9.24 | |||
| resolveSearchUids | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
| parseFetchBlocks | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
7 | |||
| sanitizeUids | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| cleanRawMessageLines | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
7 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Mail\Infrastructure\Protocol\Imap\Client; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Mail\Application\Service\MimeDecoderService; |
| 12 | use App\Modules\Mail\Domain\Model\MailMessageSummaryDto; |
| 13 | use 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 | */ |
| 23 | final 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 | } |