Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.50% covered (success)
98.50%
197 / 200
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImapResponseParser
98.49% covered (success)
98.49%
196 / 199
76.92% covered (warning)
76.92%
10 / 13
55
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
 parseListResponse
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
7
 parseStatusResponse
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 parseFetchSummary
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
5
 parseFlagsAndKeywords
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 parseDateAndTimestamp
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 detectHasAttachments
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 detectSpecialUse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 detectSpecialUseFromPath
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 normalizeLiterals
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 extractEnvelope
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 parseEnvelopeFromAddress
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 extractEnvelopeRecipients
100.00% covered (success)
100.00%
10 / 10
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\Domain\Contract\MimeDecoderInterface;
12use App\Modules\Mail\Domain\Model\MailFolderDto;
13use App\Modules\Mail\Domain\Model\MailFolderStatusDto;
14use App\Modules\Mail\Domain\Model\MailMessageSummaryDto;
15
16/**
17 * IMAP Protocol Response and S-Expression Parser.
18 *
19 * Parses RFC 3501 LIST responses, STATUS snapshots, FLAGS, and ENVELOPE structures into domain DTOs.
20 *
21 * @package App\Modules\Mail\Infrastructure\Protocol\Imap\Client
22 */
23final readonly class ImapResponseParser
24{
25    /**
26     * ImapResponseParser constructor.
27     *
28     * @param MimeDecoderInterface $decoder MIME header decoder.
29     */
30    public function __construct(private MimeDecoderInterface $decoder)
31    {
32    }
33
34    /**
35     * Parses RFC 3501 LIST response lines into list of MailFolderDto.
36     *
37     * @param array<string> $lines Raw response lines.
38     * @return array<MailFolderDto> Parsed folders.
39     */
40    public function parseListResponse(array $lines): array
41    {
42        $folders = [];
43        $pattern = '/^\*\s+LIST\s+\(([^)]*)\)\s+("[^"]*"|NIL)\s+("([^"]*)"|(\S+))/i';
44
45        foreach ($lines as $line) {
46            if (preg_match($pattern, trim($line), $m)) {
47                $rawAttrs  = explode(' ', trim($m[1]));
48                $attrs     = array_values(array_filter(array_map('trim', $rawAttrs)));
49                $delimiter = trim($m[2], '"');
50                if ($delimiter === 'NIL' || $delimiter === '') {
51                    $delimiter = '/';
52                }
53
54                $rawPath = isset($m[4]) && $m[4] !== '' ? $m[4] : ($m[5] ?? '');
55                $path    = trim($rawPath, '"');
56
57                $parts = explode($delimiter, $path);
58                $name  = end($parts);
59                $name  = $this->decoder->decodeHeader($name);
60
61                $specialUse = $this->detectSpecialUse($attrs, $path);
62
63                $folders[] = new MailFolderDto(
64                    id: base64_encode($path),
65                    name: $name,
66                    path: $path,
67                    delimiter: $delimiter,
68                    attributes: $attrs,
69                    specialUse: $specialUse
70                );
71            }
72        }
73
74        return $folders;
75    }
76
77    /**
78     * Parses RFC 3501 STATUS response into MailFolderStatusDto.
79     *
80     * @param string $folderPath Folder path.
81     * @param string $line       Status line (e.g. * STATUS "INBOX" (MESSAGES 12 UNSEEN 2 UIDNEXT 40)).
82     * @return MailFolderStatusDto Populated status.
83     */
84    public function parseStatusResponse(string $folderPath, string $line): MailFolderStatusDto
85    {
86        $total    = 0;
87        $unseen   = 0;
88        $uidNext  = null;
89        $validity = null;
90        $modseq   = null;
91
92        if (preg_match('/MESSAGES\s+(\d+)/i', $line, $m)) {
93            $total = (int) $m[1];
94        }
95        if (preg_match('/UNSEEN\s+(\d+)/i', $line, $m)) {
96            $unseen = (int) $m[1];
97        }
98        if (preg_match('/UIDNEXT\s+(\d+)/i', $line, $m)) {
99            $uidNext = (int) $m[1];
100        }
101        if (preg_match('/UIDVALIDITY\s+(\d+)/i', $line, $m)) {
102            $validity = (int) $m[1];
103        }
104        if (preg_match('/HIGHESTMODSEQ\s+(\d+)/i', $line, $m)) {
105            $modseq = (int) $m[1];
106        }
107
108        return new MailFolderStatusDto(
109            folderPath: $folderPath,
110            totalMessages: $total,
111            unseenMessages: $unseen,
112            uidNext: $uidNext,
113            uidValidity: $validity,
114            highestModseq: $modseq
115        );
116    }
117
118    /**
119     * Parses UID FETCH response items into MailMessageSummaryDto.
120     *
121     * @param string $folder Mailbox folder path.
122     * @param string $block  FETCH data block.
123     * @return MailMessageSummaryDto|null Populated summary or null if invalid.
124     */
125    public function parseFetchSummary(string $folder, string $block): ?MailMessageSummaryDto
126    {
127        if (!preg_match('/\bUID\s+(\d+)/i', $block, $uidM)) {
128            return null;
129        }
130        $uid = $uidM[1];
131
132        $size = 0;
133        if (preg_match('/RFC822\.SIZE\s+(\d+)/i', $block, $sizeM)) {
134            $size = (int) $sizeM[1];
135        }
136
137        [$flags, $keywords] = $this->parseFlagsAndKeywords($block);
138        $isSeen     = in_array('\\seen', $flags, true);
139        $isFlagged  = in_array('\\flagged', $flags, true);
140        $isAnswered = in_array('\\answered', $flags, true);
141        $isDraft    = in_array('\\draft', $flags, true);
142
143        [$dateStr, $timestamp] = $this->parseDateAndTimestamp($block);
144
145        $subject   = '(No Subject)';
146        $fromName  = '';
147        $fromEmail = '';
148        $to        = [];
149        $messageId = null;
150
151        if (preg_match('/ENVELOPE\s*\(/i', $block)) {
152            $envData   = $this->extractEnvelope($block);
153            $subject   = $this->decoder->decodeHeader($envData['subject'] ?? '(No Subject)');
154            $fromName  = $this->decoder->decodeHeader($envData['from_name'] ?? '');
155            $fromEmail = $envData['from_email'] ?? '';
156            $to        = $envData['to'] ?? [];
157            $messageId = $envData['message_id'] ?? null;
158        }
159
160        $hasAttachments = $this->detectHasAttachments($block);
161
162        return new MailMessageSummaryDto(
163            uid: $uid,
164            messageId: $messageId,
165            subject: $subject,
166            fromName: $fromName !== '' ? $fromName : $fromEmail,
167            fromEmail: $fromEmail,
168            to: $to,
169            date: $dateStr,
170            dateTimestamp: $timestamp,
171            size: $size,
172            isSeen: $isSeen,
173            isFlagged: $isFlagged,
174            isAnswered: $isAnswered,
175            isDraft: $isDraft,
176            hasAttachments: $hasAttachments,
177            folder: $folder,
178            keywords: $keywords
179        );
180
181    }
182
183    /**
184     * Parses flags and user keywords from FETCH block.
185     *
186     * @param string $block FETCH block.
187     * @return array{0: array<string>, 1: array<string>} System flags and user keywords.
188     */
189    private function parseFlagsAndKeywords(string $block): array
190    {
191        $flags = [];
192        $keywords = [];
193        if (preg_match('/FLAGS\s+\(([^)]*)\)/i', $block, $flagsM)) {
194            $rawFlags = explode(' ', trim($flagsM[1]));
195            foreach ($rawFlags as $f) {
196                $trimmed = trim($f);
197                if ($trimmed === '') {
198                    continue;
199                }
200                if (str_starts_with($trimmed, '\\')) {
201                    $flags[] = strtolower($trimmed);
202                } else {
203                    $keywords[] = $trimmed;
204                }
205            }
206        }
207        return [$flags, $keywords];
208    }
209
210    /**
211     * Parses INTERNALDATE string and returns date string and timestamp.
212     *
213     * @param string $block FETCH block.
214     * @return array{0: string, 1: int} Date string and timestamp.
215     */
216    private function parseDateAndTimestamp(string $block): array
217    {
218        if (preg_match('/INTERNALDATE\s+"([^"]+)"/i', $block, $dateM)) {
219            return [$dateM[1], (int) strtotime($dateM[1])];
220        }
221        return ['', 0];
222    }
223
224    /**
225     * Checks if FETCH block indicates attachments in BODYSTRUCTURE.
226     *
227     * @param string $block FETCH block.
228     * @return bool True if message has attachments.
229     */
230    private function detectHasAttachments(string $block): bool
231    {
232        if (preg_match('/BODYSTRUCTURE\s*\((.+)\)/is', $block, $bsM)) {
233            return (bool) (preg_match('/\(["\']attachment["\']/i', $bsM[1])
234                || preg_match('/\)\s*["\']mixed["\']/i', $bsM[1]));
235        }
236        return (bool) preg_match('/\(["\']attachment["\']/i', $block);
237    }
238
239    /**
240     * Detects special use category from IMAP attributes or folder name.
241     */
242    private function detectSpecialUse(array $attrs, string $path): ?string
243    {
244        $attrMap = [
245            '\\sent'    => 'sent',
246            '\\drafts'  => 'drafts',
247            '\\trash'   => 'trash',
248            '\\junk'    => 'spam',
249            '\\archive' => 'archive',
250        ];
251
252        $normalizedAttrs = array_map('strtolower', $attrs);
253        foreach ($attrMap as $attr => $specialUse) {
254            if (in_array($attr, $normalizedAttrs, true)) {
255                return $specialUse;
256            }
257        }
258
259        return $this->detectSpecialUseFromPath($path);
260    }
261
262    /**
263     * Detects special use category from folder path name.
264     */
265    private function detectSpecialUseFromPath(string $path): ?string
266    {
267        $cleanPath = preg_replace('/^inbox[\/\.]/i', '', $path) ?? $path;
268        if (str_contains($cleanPath, '/') || str_contains($cleanPath, '.')) {
269            return null;
270        }
271
272        $lowerPath = strtolower($cleanPath);
273        $aliasToRole = [
274            'inbox'         => 'inbox',
275            ''              => 'inbox',
276            'sent'          => 'sent',
277            'sent items'    => 'sent',
278            'sent messages' => 'sent',
279            'drafts'        => 'drafts',
280            'draft'         => 'drafts',
281            'trash'         => 'trash',
282            'deleted'       => 'trash',
283            'deleted items' => 'trash',
284            'bin'           => 'trash',
285            'spam'          => 'spam',
286            'junk'          => 'spam',
287            'junk email'    => 'spam',
288            'junk mail'     => 'spam',
289            'archive'       => 'archive',
290            'archives'      => 'archive',
291        ];
292
293        return $aliasToRole[$lowerPath] ?? null;
294    }
295
296    /**
297     * Normalizes IMAP literal strings {n}\r\n<content> to standard quoted strings.
298     *
299     * @param string $str Raw IMAP response string.
300     * @return string String with literal sequences converted to double-quoted strings.
301     */
302    private function normalizeLiterals(string $str): string
303    {
304        $offset = 0;
305        $result = '';
306        while (preg_match('/\{(\d+)\}\r?\n/', $str, $matches, PREG_OFFSET_CAPTURE, $offset)) {
307            $fullMatch   = $matches[0][0];
308            $matchPos    = $matches[0][1];
309            $length      = (int) $matches[1][0];
310
311            $result     .= substr($str, $offset, $matchPos - $offset);
312            $dataStart   = $matchPos + strlen($fullMatch);
313            $literalData = substr($str, $dataStart, $length);
314
315            $result     .= '"' . addcslashes($literalData, '"\\') . '"';
316            $offset      = $dataStart + $length;
317        }
318        $result .= substr($str, $offset);
319
320        return $result;
321    }
322
323    /**
324     * Extracts fields from ENVELOPE S-Expression string.
325     *
326     * @param string $block FETCH block.
327     * @return array{
328     *     subject: ?string,
329     *     from_name: ?string,
330     *     from_email: ?string,
331     *     to: array<string>,
332     *     message_id: ?string
333     * }
334     */
335    private function extractEnvelope(string $block): array
336    {
337        $subject   = null;
338        $fromName  = null;
339        $fromEmail = null;
340
341        $normalized = $this->normalizeLiterals($block);
342
343        $envelopePattern = '/ENVELOPE\s*\(\s*("(?:[^"\\\\]|\\\\.)*"|NIL)\s+'
344            . '("(?:[^"\\\\]|\\\\.)*"|NIL)\s+(\(\([^\)]*\)\)|NIL)/i';
345
346        if (preg_match($envelopePattern, $normalized, $m)) {
347            if ($m[2] !== 'NIL') {
348                $subject = stripslashes(trim($m[2], '"'));
349            }
350            [$fromName, $fromEmail] = $this->parseEnvelopeFromAddress(trim($m[3]));
351        }
352
353        $to = $this->extractEnvelopeRecipients($normalized, $fromEmail);
354        $messageId = null;
355        if (preg_match('/<([^>]+@[^>]+)>/', $normalized, $idM)) {
356            $messageId = sprintf('<%s>', $idM[1]);
357        }
358
359        return [
360            'subject'    => $subject,
361            'from_name'  => $fromName,
362            'from_email' => $fromEmail,
363            'to'         => $to,
364            'message_id' => $messageId,
365        ];
366    }
367
368    /**
369     * Parses From address block within envelope.
370     *
371     * @param string $fromBlock From block string.
372     * @return array{0: ?string, 1: ?string} Name and email.
373     */
374    private function parseEnvelopeFromAddress(string $fromBlock): array
375    {
376        $addrPattern = '/("(?:[^"\\\\]|\\\\.)*"|NIL)\s+("(?:[^"\\\\]|\\\\.)*"|NIL)\s+'
377            . '("(?:[^"\\\\]|\\\\.)*"|NIL)\s+("(?:[^"\\\\]|\\\\.)*"|NIL)/';
378
379        if (preg_match($addrPattern, $fromBlock, $addrM)) {
380            $fromName = $addrM[1] !== 'NIL' ? stripslashes(trim($addrM[1], '"')) : null;
381            $user = $addrM[3] !== 'NIL' ? stripslashes(trim($addrM[3], '"')) : '';
382            $host = $addrM[4] !== 'NIL' ? stripslashes(trim($addrM[4], '"')) : '';
383            $fromEmail = ($user !== '' && $host !== '') ? sprintf('%s@%s', $user, $host) : null;
384            return [$fromName, $fromEmail];
385        }
386
387        return [null, null];
388    }
389
390    /**
391     * Extracts recipient addresses from normalized envelope string.
392     *
393     * @param string $normalized Normalized envelope text.
394     * @param string|null $fromEmail Sender email to exclude.
395     * @return array<string> List of recipient emails.
396     */
397    private function extractEnvelopeRecipients(string $normalized, ?string $fromEmail): array
398    {
399        $to = [];
400        if (preg_match_all('/"([^"]+)"\s+"([^"]+)"\s*\)\s*\)/i', $normalized, $toMatches, PREG_SET_ORDER)) {
401            foreach ($toMatches as $match) {
402                $user = trim($match[1]);
403                $host = trim($match[2]);
404                if (str_contains($host, '.') && !str_contains($user, ' ')) {
405                    $candidate = sprintf('%s@%s', $user, $host);
406                    if ($fromEmail === null || !str_contains(strtolower($candidate), strtolower($fromEmail))) {
407                        $to[] = $candidate;
408                    }
409                }
410            }
411        }
412        return array_values(array_unique($to));
413    }
414}
415
416