Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.76% covered (success)
96.76%
209 / 216
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImapMimeParser
96.74% covered (success)
96.74%
208 / 215
70.59% covered (warning)
70.59%
12 / 17
80
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
 parseMessage
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
10
 splitHeaderAndBody
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 parseHeaders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 parseMultipart
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
8
 tryParseNestedMultipart
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 extractPartFilename
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isAttachmentPart
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 processAttachmentPart
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 processTextPart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 resolveSpfStatus
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveDkimStatus
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveDmarcStatus
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractCharset
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 enrichSummary
92.86% covered (success)
92.86%
39 / 42
0.00% covered (danger)
0.00%
0 / 1
15.08
 parseAddressHeader
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 parseAddressList
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
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\MailAttachmentDto;
13use App\Modules\Mail\Domain\Model\MailMessageDetailDto;
14use App\Modules\Mail\Domain\Model\MailMessageSummaryDto;
15
16/**
17 * Universal RFC 822 MIME Parser.
18 *
19 * Recursively inspects multipart trees, extracts HTML and plain-text bodies,
20 * parses attachments and inline assets, and analyzes SPF, DKIM, and DMARC verification headers.
21 *
22 * @package App\Modules\Mail\Infrastructure\Protocol\Imap\Client
23 */
24final readonly class ImapMimeParser
25{
26    /**
27     * ImapMimeParser constructor.
28     *
29     * @param MimeDecoderInterface $decoder MIME decoder service.
30     */
31    public function __construct(private MimeDecoderInterface $decoder)
32    {
33    }
34
35    /**
36     * Parses raw RFC 822 EML message source into MailMessageDetailDto.
37     *
38     * @param string                 $rawSource Complete RFC 822 message text.
39     * @param MailMessageSummaryDto  $summary   Associated message summary.
40     * @return MailMessageDetailDto Populated detail DTO.
41     */
42    public function parseMessage(string $rawSource, MailMessageSummaryDto $summary): MailMessageDetailDto
43    {
44        [$headerBlock, $bodyBlock] = $this->splitHeaderAndBody($rawSource);
45        $headers = $this->parseHeaders($headerBlock);
46
47        $spfStatus   = $this->resolveSpfStatus($headers);
48        $dkimStatus  = $this->resolveDkimStatus($headers);
49        $dmarcStatus = $this->resolveDmarcStatus($headers);
50
51        $contentType = (string) ($headers['content-type'] ?? 'text/plain; charset=utf-8');
52        $encoding    = (string) ($headers['content-transfer-encoding'] ?? '7bit');
53
54        $htmlBody    = '';
55        $textBody    = '';
56        $attachments = [];
57        $cidMap      = [];
58        $isMultipart = (bool) preg_match('/multipart\/[a-z]+/i', $contentType);
59        if ($isMultipart && preg_match('/boundary="?([^";\r\n]+)"?/i', $contentType, $bM)) {
60            $boundary = $bM[1];
61            $this->parseMultipart($bodyBlock, $boundary, $htmlBody, $textBody, $attachments, $cidMap, '');
62        } else {
63            $decodedBody = $this->decoder->decodeTransferEncoding($bodyBlock, $encoding);
64            $charset     = $this->extractCharset($contentType);
65            $utf8Body    = $this->decoder->convertToUtf8($decodedBody, $charset);
66
67            if (str_contains(strtolower($contentType), 'text/html')) {
68                $htmlBody = $utf8Body;
69            } else {
70                $textBody = $utf8Body;
71            }
72        }
73
74        // Replace embedded CID references in HTML body with base64 data URIs
75        foreach ($cidMap as $cid => $dataUri) {
76            $htmlBody = str_ireplace('cid:' . $cid, $dataUri, $htmlBody);
77        }
78
79        // Only genuine, non-inline attachments qualify as message attachments
80        $hasDownloadableAttachments = false;
81        foreach ($attachments as $att) {
82            if (!$att->isInline) {
83                $hasDownloadableAttachments = true;
84                break;
85            }
86        }
87
88        $rawBcc = (string) ($headers['bcc'] ?? '');
89        $parsedBcc = $rawBcc !== '' ? $this->parseAddressList($rawBcc) : [];
90
91        $enrichedSummary = $this->enrichSummary($headers, $summary, $hasDownloadableAttachments);
92
93        return new MailMessageDetailDto(
94            summary: $enrichedSummary,
95            htmlBody: $htmlBody !== '' ? $htmlBody : nl2br(htmlspecialchars($textBody)),
96            textBody: $textBody,
97            attachments: $attachments,
98            headers: $headers,
99            replyTo: isset($headers['reply-to']) ? [$this->decoder->decodeHeader((string) $headers['reply-to'])] : [],
100            bcc: $parsedBcc,
101            spfStatus: $spfStatus,
102            dkimStatus: $dkimStatus,
103            dmarcStatus: $dmarcStatus
104        );
105    }
106
107    /**
108     * Splits message text into header block and body block.
109     *
110     * @param string $source Full message.
111     * @return array{0: string, 1: string} Header and body.
112     */
113    private function splitHeaderAndBody(string $source): array
114    {
115        $parts = preg_split("/\r?\n\r?\n/", $source, 2);
116        return [
117            $parts[0] ?? '',
118            $parts[1] ?? '',
119        ];
120    }
121
122    /**
123     * Parses multiline headers into normalized lowercase key-value array.
124     *
125     * @param string $headerBlock Raw header lines.
126     * @return array<string, string> Normalized headers.
127     */
128    private function parseHeaders(string $headerBlock): array
129    {
130        $headers = [];
131        $unfolded = (string) preg_replace("/\r?\n[ \t]+/", ' ', $headerBlock);
132        $lines = explode("\n", str_replace("\r", '', $unfolded));
133
134        foreach ($lines as $line) {
135            $colonPos = strpos($line, ':');
136            if ($colonPos !== false) {
137                $key = strtolower(trim(substr($line, 0, $colonPos)));
138                $val = trim(substr($line, $colonPos + 1));
139                $headers[$key] = $val;
140            }
141        }
142
143        return $headers;
144    }
145
146    /**
147     * Recursively parses multipart MIME parts.
148     *
149     * @param string                   $body        Raw MIME body.
150     * @param string                   $boundary    MIME boundary marker.
151     * @param string                   $htmlBody    HTML body output buffer.
152     * @param string                   $textBody    Plaintext body output buffer.
153     * @param array<MailAttachmentDto> &$attachments Parsed attachments list.
154     * @param-out array<MailAttachmentDto> $attachments
155     * @param array<string, string>    &$cidMap      CID-to-data-URI replacement map.
156     * @param-out array<string, string> $cidMap
157     * @param string                   $partPrefix   MIME part prefix.
158     */
159    private function parseMultipart(
160        string $body,
161        string $boundary,
162        string &$htmlBody,
163        string &$textBody,
164        array &$attachments,
165        array &$cidMap,
166        string $partPrefix
167    ): void {
168        $delimiter = '--' . $boundary;
169        $sections  = explode($delimiter, $body);
170
171        $partIndex = 1;
172        foreach ($sections as $section) {
173            $trimmed = trim($section);
174            if ($trimmed === '' || $trimmed === '--') {
175                continue;
176            }
177
178            [$partHeadersRaw, $partBodyRaw] = $this->splitHeaderAndBody($trimmed);
179            $partHeaders = $this->parseHeaders($partHeadersRaw);
180            $partId = $partPrefix === '' ? (string) $partIndex : sprintf('%s.%d', $partPrefix, $partIndex);
181            $partIndex++;
182
183            $contentType = (string) ($partHeaders['content-type'] ?? 'text/plain');
184            $encoding    = (string) ($partHeaders['content-transfer-encoding'] ?? '7bit');
185            $disposition = (string) ($partHeaders['content-disposition'] ?? '');
186
187            if ($this->tryParseNestedMultipart(
188                $contentType,
189                $partBodyRaw,
190                $partId,
191                $htmlBody,
192                $textBody,
193                $attachments,
194                $cidMap
195            )) {
196                continue;
197            }
198
199            $filename = $this->extractPartFilename($disposition, $contentType);
200            $contentId = isset($partHeaders['content-id']) ? trim($partHeaders['content-id'], '<>') : null;
201
202            if ($this->isAttachmentPart($disposition, $filename, $contentId)) {
203                $meta = [
204                    'partId'      => $partId,
205                    'encoding'    => $encoding,
206                    'contentType' => $contentType,
207                    'disposition' => $disposition,
208                    'filename'    => $filename,
209                    'contentId'   => $contentId,
210                ];
211                $this->processAttachmentPart($meta, $partBodyRaw, $attachments, $cidMap);
212            } else {
213                $this->processTextPart($partBodyRaw, $encoding, $contentType, $htmlBody, $textBody);
214            }
215        }
216    }
217
218    /**
219     * Attempts to parse nested multipart body if boundary is detected.
220     *
221     * @param array<MailAttachmentDto> &$attachments
222     * @param-out array<MailAttachmentDto> $attachments
223     * @param array<string, string>    &$cidMap
224     * @param-out array<string, string> $cidMap
225     */
226    private function tryParseNestedMultipart(
227        string $contentType,
228        string $partBodyRaw,
229        string $partId,
230        string &$htmlBody,
231        string &$textBody,
232        array &$attachments,
233        array &$cidMap
234    ): bool {
235        $isNestedMultipart = (bool) preg_match('/multipart\/[a-z]+/i', $contentType);
236        if ($isNestedMultipart && preg_match('/boundary="?([^";\r\n]+)"?/i', $contentType, $bM)) {
237            $this->parseMultipart($partBodyRaw, $bM[1], $htmlBody, $textBody, $attachments, $cidMap, $partId);
238            return true;
239        }
240        return false;
241    }
242
243    /**
244     * Extracts filename parameter from Content-Disposition or Content-Type headers.
245     */
246    private function extractPartFilename(string $disposition, string $contentType): ?string
247    {
248        if (preg_match('/filename="?([^";\r\n]+)"?/i', $disposition, $fnM)
249            || preg_match('/name="?([^";\r\n]+)"?/i', $contentType, $fnM)) {
250            return $this->decoder->decodeHeader($fnM[1]);
251        }
252        return null;
253    }
254
255    /**
256     * Determines whether MIME part should be treated as an attachment.
257     */
258    private function isAttachmentPart(string $disposition, ?string $filename, ?string $contentId): bool
259    {
260        $hasFilename = $filename !== null && $filename !== '';
261        $isExplicitAttachment = str_contains(strtolower($disposition), 'attachment');
262        return $isExplicitAttachment || $hasFilename || ($contentId !== null);
263    }
264
265    /**
266     * Processes attachment part and records DTO and CID data URL mapping.
267     *
268     * @param array<string, string|null> $meta Metadata including partId, encoding, contentType, etc.
269     * @param string                     $partBodyRaw Raw MIME body content.
270     * @param array<MailAttachmentDto>   $attachments List of attachments to append to.
271     * @param array<string, string>      $cidMap Content-ID to data URL map.
272     */
273    private function processAttachmentPart(
274        array $meta,
275        string $partBodyRaw,
276        array &$attachments,
277        array &$cidMap
278    ): void {
279        $partId = (string) ($meta['partId'] ?? '');
280        $encoding = (string) ($meta['encoding'] ?? '7bit');
281        $contentType = (string) ($meta['contentType'] ?? 'application/octet-stream');
282        $disposition = (string) ($meta['disposition'] ?? '');
283        $filename = isset($meta['filename']) ? (string) $meta['filename'] : null;
284        $contentId = isset($meta['contentId']) ? (string) $meta['contentId'] : null;
285
286        $content = $this->decoder->decodeTransferEncoding($partBodyRaw, $encoding);
287        $mimeOnly = explode(';', $contentType)[0] ?? 'application/octet-stream';
288        $isExplicitAttachment = str_contains(strtolower($disposition), 'attachment');
289        $isInline = ($contentId !== null)
290            || (str_contains(strtolower($disposition), 'inline') && !$isExplicitAttachment);
291
292        $attachments[] = new MailAttachmentDto(
293            partId: $partId,
294            filename: $filename ?? 'attachment_' . $partId,
295            mimeType: $mimeOnly,
296            size: strlen($content),
297            contentId: $contentId,
298            isInline: $isInline,
299            disposition: $disposition
300        );
301
302        if ($contentId !== null && str_starts_with(strtolower($contentType), 'image/')) {
303            $cidMap[$contentId] = sprintf('data:%s;base64,%s', $mimeOnly, base64_encode($content));
304        }
305    }
306
307    /**
308     * Processes body text or HTML payload and stores into appropriate buffer.
309     */
310    private function processTextPart(
311        string $partBodyRaw,
312        string $encoding,
313        string $contentType,
314        string &$htmlBody,
315        string &$textBody
316    ): void {
317        $decoded = $this->decoder->decodeTransferEncoding($partBodyRaw, $encoding);
318        $charset = $this->extractCharset($contentType);
319        $utf8    = $this->decoder->convertToUtf8($decoded, $charset);
320
321        if (str_contains(strtolower($contentType), 'text/html') && $htmlBody === '') {
322            $htmlBody = $utf8;
323        } elseif (str_contains(strtolower($contentType), 'text/plain') && $textBody === '') {
324            $textBody = $utf8;
325        }
326    }
327
328    /**
329     * Resolves SPF check status from Authentication-Results or Received-SPF headers.
330     */
331    private function resolveSpfStatus(array $headers): string
332    {
333        $authRes = (string) ($headers['authentication-results'] ?? '');
334        $recSpf  = (string) ($headers['received-spf'] ?? '');
335
336        if (preg_match('/spf=(pass|fail|softfail|neutral)/i', $authRes, $m)
337            || preg_match('/^(pass|fail|softfail|neutral)/i', $recSpf, $m)) {
338            return strtolower($m[1]);
339        }
340        return 'none';
341    }
342
343    /**
344     * Resolves DKIM signature check status.
345     */
346    private function resolveDkimStatus(array $headers): string
347    {
348        $authRes = (string) ($headers['authentication-results'] ?? '');
349        if (preg_match('/dkim=(pass|fail)/i', $authRes, $m)) {
350            return strtolower($m[1]);
351        }
352        return isset($headers['dkim-signature']) ? 'present' : 'none';
353    }
354
355    /**
356     * Resolves DMARC policy validation status.
357     */
358    private function resolveDmarcStatus(array $headers): string
359    {
360        $authRes = (string) ($headers['authentication-results'] ?? '');
361        if (preg_match('/dmarc=(pass|fail)/i', $authRes, $m)) {
362            return strtolower($m[1]);
363        }
364        return 'none';
365    }
366
367    /**
368     * Extracts charset attribute from Content-Type string.
369     */
370    private function extractCharset(string $contentType): string
371    {
372        if (preg_match('/charset="?([^";\r\n]+)"?/i', $contentType, $m)) {
373            return trim($m[1]);
374        }
375        return 'UTF-8';
376    }
377
378    /**
379     * Enriches message summary DTO with accurate values parsed from RFC 822 headers.
380     *
381     * @param array<string, string> $headers        Parsed MIME headers.
382     * @param MailMessageSummaryDto $summary        Initial summary.
383     * @param bool                  $hasAttachments Whether attachments were found.
384     * @return MailMessageSummaryDto Enriched summary DTO.
385     */
386    private function enrichSummary(
387        array $headers,
388        MailMessageSummaryDto $summary,
389        bool $hasAttachments
390    ): MailMessageSummaryDto {
391        $rawSubject = (string) ($headers['subject'] ?? '');
392        $subject = $rawSubject !== '' ? $this->decoder->decodeHeader($rawSubject) : $summary->subject;
393        if ($subject === '') {
394            $subject = '(No Subject)';
395        }
396
397        $rawFrom = (string) ($headers['from'] ?? '');
398        [$parsedFromName, $parsedFromEmail] = $this->parseAddressHeader($rawFrom);
399        $fromEmail = $parsedFromEmail !== '' ? $parsedFromEmail : $summary->fromEmail;
400        $fromName = $fromEmail;
401        if ($parsedFromName !== '') {
402            $fromName = $parsedFromName;
403        } elseif ($summary->fromName !== '') {
404            $fromName = $summary->fromName;
405        }
406
407        $rawTo = (string) ($headers['to'] ?? '');
408        $parsedTo = $rawTo !== '' ? $this->parseAddressList($rawTo) : [];
409        $to = $parsedTo !== [] ? $parsedTo : $summary->to;
410
411        $rawCc = (string) ($headers['cc'] ?? '');
412        $parsedCc = $rawCc !== '' ? $this->parseAddressList($rawCc) : [];
413        $cc = $parsedCc !== [] ? $parsedCc : $summary->cc;
414
415        $rawDate = (string) ($headers['date'] ?? '');
416        $dateStr = $rawDate !== '' ? $rawDate : $summary->date;
417        $timestamp = $rawDate !== '' ? (int) strtotime($rawDate) : $summary->dateTimestamp;
418
419        $rawMsgId = isset($headers['message-id']) ? (string) $headers['message-id'] : null;
420        $messageId = $rawMsgId !== null ? trim($rawMsgId) : $summary->messageId;
421
422        return new MailMessageSummaryDto(
423            uid: $summary->uid,
424            messageId: $messageId,
425            subject: $subject,
426            fromName: $fromName,
427            fromEmail: $fromEmail,
428            to: $to,
429            cc: $cc,
430            date: $dateStr,
431            dateTimestamp: $timestamp,
432            size: $summary->size,
433            isSeen: $summary->isSeen,
434            isFlagged: $summary->isFlagged,
435            isAnswered: $summary->isAnswered,
436            isDraft: $summary->isDraft,
437            hasAttachments: $hasAttachments || $summary->hasAttachments,
438            folder: $summary->folder,
439            keywords: $summary->keywords
440        );
441
442    }
443
444    /**
445     * Parses RFC 2822 From / Sender address string into display name and email address.
446     *
447     * @param string $header Raw address header.
448     * @return array{0: string, 1: string} Name and email.
449     */
450    private function parseAddressHeader(string $header): array
451    {
452        $header = trim($header);
453        if ($header === '') {
454            return ['', ''];
455        }
456
457        if (preg_match('/^(.*?)\s*<([^>]+)>$/', $header, $m)) {
458            $rawName = trim($m[1], " \t\n\r\0\x0B\"'");
459            $name = $rawName !== '' ? $this->decoder->decodeHeader($rawName) : '';
460            $email = trim($m[2]);
461            return [$name !== '' ? $name : $email, $email];
462        }
463
464        $isEmail = filter_var($header, FILTER_VALIDATE_EMAIL) !== false;
465        return [$header, $isEmail ? $header : ''];
466    }
467
468    /**
469     * Parses comma-separated list of RFC 2822 recipient addresses.
470     *
471     * @param string $header Raw To / Cc header.
472     * @return array<string> Formatted address strings.
473     */
474    private function parseAddressList(string $header): array
475    {
476        $parts = preg_split('/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/', $header);
477        if ($parts === false) {
478            return [];
479        }
480
481        $result = [];
482        foreach ($parts as $part) {
483            $trimmed = trim($part);
484            if ($trimmed !== '') {
485                $result[] = $this->decoder->decodeHeader($trimmed);
486            }
487        }
488
489        return $result;
490    }
491}