Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.12% covered (success)
95.12%
156 / 164
73.91% covered (warning)
73.91%
17 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImapProtocolDriver
95.09% covered (success)
95.09%
155 / 163
73.91% covered (warning)
73.91%
17 / 23
54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __clone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 ping
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 disconnect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 listFolders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFolderStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renameFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFolder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchMessages
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 getMessageDetail
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 getMimePartStream
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRawMessageSource
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setFlags
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 moveMessages
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 copyMessages
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 deleteMessages
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 saveDraft
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
6.24
 sendMessage
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 getQuota
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 appendRawMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 selectFolder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
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;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Application\Service\MimeDecoderService;
12use App\Modules\Mail\Domain\Contract\MailProtocolDriverInterface;
13use App\Modules\Mail\Domain\Model\ClientMailbox;
14use App\Modules\Mail\Domain\Model\MailFolderDto;
15use App\Modules\Mail\Domain\Model\MailFolderStatusDto;
16use App\Modules\Mail\Domain\Model\MailMessageDetailDto;
17use App\Modules\Mail\Domain\Model\MailMessageSummaryDto;
18use App\Modules\Mail\Domain\Model\MailMimePartDto;
19use App\Modules\Mail\Domain\Model\MailOutgoingMessageDto;
20use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
21use App\Modules\Mail\Domain\Model\MailSendResultDto;
22use App\Modules\Mail\Domain\Model\MailServer;
23use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapFolderManager;
24use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapMimeParser;
25use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapResponseParser;
26use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapSearchResolver;
27use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapSocketClient;
28use Throwable;
29
30/**
31 * IMAP Protocol Driver Implementation.
32 *
33 * Implements RFC 3501 and RFC 9051 IMAP operations over secure socket streams.
34 *
35 * @package App\Modules\Mail\Infrastructure\Protocol\Imap
36 */
37final class ImapProtocolDriver implements MailProtocolDriverInterface
38{
39    private const string REGEX_APPEND_UID = '/\[APPENDUID\s+\d+\s+(\d+)\]/i';
40    private const string MIME_HTML_TEMPLATE =
41        "From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s";
42
43    private ImapSocketClient $client;
44    private ImapFolderManager $folderManager;
45    private ImapSearchResolver $searchResolver;
46    private ?ClientMailbox $activeMailbox = null;
47    private ?string $selectedFolder = null;
48
49    /**
50     * ImapProtocolDriver constructor.
51     *
52     * @param ImapSocketClient|null   $client         Socket stream client.
53     * @param ImapResponseParser|null $responseParser Protocol response parser.
54     * @param ImapMimeParser|null     $mimeParser     MIME payload parser.
55     * @param ImapFolderManager|null  $folderManager  Mailbox folder manager helper.
56     * @param ImapSearchResolver|null $searchResolver Search and UID resolution helper.
57     */
58    public function __construct(
59        ?ImapSocketClient $client = null,
60        private readonly ?ImapResponseParser $responseParser = null,
61        private readonly ?ImapMimeParser $mimeParser = null,
62        ?ImapFolderManager $folderManager = null,
63        ?ImapSearchResolver $searchResolver = null
64    ) {
65        $this->client = $client ?? new ImapSocketClient();
66        $this->folderManager = $folderManager ?? new ImapFolderManager();
67        $this->searchResolver = $searchResolver ?? new ImapSearchResolver();
68    }
69
70    /**
71     * Resets socket client and session state on clone.
72     */
73    public function __clone(): void
74    {
75        $this->client = new ImapSocketClient();
76        $this->activeMailbox = null;
77        $this->selectedFolder = null;
78    }
79
80    /**
81     * {@inheritdoc}
82     */
83    public function connect(ClientMailbox $mailbox, ?MailServer $server = null): void
84    {
85        $this->activeMailbox = $mailbox;
86        $host = $server !== null ? $server->imapHost : 'localhost';
87        $port = $server !== null ? $server->imapPort : 993;
88        $enc  = $server !== null ? $server->imapEncryption : 'ssl';
89        $selfSigned = $server !== null ? $server->allowSelfSigned : false;
90
91        $this->client->connect($host, $port, $enc, $selfSigned);
92
93        $loginUser = addcslashes($mailbox->username, '"\\');
94        $loginPass = addcslashes($mailbox->password, '"\\');
95        $this->client->executeCommand(sprintf('LOGIN "%s" "%s"', $loginUser, $loginPass));
96    }
97
98    /**
99     * {@inheritdoc}
100     */
101    public function ping(): bool
102    {
103        try {
104            $this->client->executeCommand('NOOP');
105            return true;
106        } catch (Throwable) {
107            return false;
108        }
109    }
110
111    /**
112     * {@inheritdoc}
113     */
114    public function disconnect(): void
115    {
116        $this->client->disconnect();
117        $this->activeMailbox  = null;
118        $this->selectedFolder = null;
119    }
120
121    /**
122     * {@inheritdoc}
123     */
124    public function listFolders(): array
125    {
126        return $this->folderManager->listFolders($this->client, $this->responseParser);
127    }
128
129    /**
130     * {@inheritdoc}
131     */
132    public function getFolderStatus(string $folderPath): MailFolderStatusDto
133    {
134        return $this->folderManager->getFolderStatus($this->client, $folderPath, $this->responseParser);
135    }
136
137    /**
138     * {@inheritdoc}
139     */
140    public function createFolder(string $folderName, ?string $parentFolder = null): MailFolderDto
141    {
142        return $this->folderManager->createFolder($this->client, $folderName, $parentFolder);
143    }
144
145    /**
146     * {@inheritdoc}
147     */
148    public function renameFolder(string $oldPath, string $newPath): void
149    {
150        $this->folderManager->renameFolder($this->client, $oldPath, $newPath);
151    }
152
153    /**
154     * {@inheritdoc}
155     */
156    public function deleteFolder(string $folderPath): void
157    {
158        $this->folderManager->deleteFolder($this->client, $folderPath);
159    }
160
161    /**
162     * {@inheritdoc}
163     */
164    public function fetchMessages(
165        string $folderPath,
166        MailSearchCriteriaDto $criteria,
167        int $page,
168        int $perPage
169    ): array {
170        $this->selectFolder($folderPath);
171
172        $searchCmd = $this->searchResolver->buildSearchCommand($criteria);
173        $searchLines = $this->client->executeCommand($searchCmd);
174        $uids = $this->searchResolver->resolveSearchUids($searchLines, $criteria->sortDirection);
175
176        $total = count($uids);
177        if ($total === 0) {
178            return ['messages' => [], 'total' => 0];
179        }
180
181        $offset = max(0, ($page - 1) * $perPage);
182        $pageUids = array_slice($uids, $offset, $perPage);
183        if ($pageUids === []) {
184            return ['messages' => [], 'total' => $total];
185        }
186
187        $fetchCmd = sprintf(
188            'UID FETCH %s (UID RFC822.SIZE FLAGS INTERNALDATE ENVELOPE BODYSTRUCTURE)',
189            implode(',', $pageUids)
190        );
191        $fetchLines = $this->client->executeCommand($fetchCmd);
192        $messages = $this->searchResolver->parseFetchBlocks(
193            $fetchLines,
194            $folderPath,
195            $pageUids,
196            $this->responseParser
197        );
198
199        return [
200            'messages' => $messages,
201            'total'    => $total,
202        ];
203    }
204
205    /**
206     * {@inheritdoc}
207     */
208    public function getMessageDetail(
209        string $folderPath,
210        string $messageUid,
211        bool $markAsSeen = true
212    ): MailMessageDetailDto {
213        $this->selectFolder($folderPath);
214
215        if ($markAsSeen) {
216            $this->setFlags($folderPath, [$messageUid], ['\\Seen'], true);
217        }
218
219        $parser = $this->responseParser ?? new ImapResponseParser(new MimeDecoderService());
220
221        $summary = null;
222        try {
223            $fetchCmd = sprintf('UID FETCH %s (UID RFC822.SIZE FLAGS INTERNALDATE ENVELOPE)', $messageUid);
224            $fetchLines = $this->client->executeCommand($fetchCmd);
225            $summary = $parser->parseFetchSummary($folderPath, implode("\n", $fetchLines));
226        } catch (Throwable) {
227            // Fallback if server rejects ENVELOPE
228        }
229
230        $rawSource = $this->getRawMessageSource($folderPath, $messageUid);
231        $summary ??= new MailMessageSummaryDto(
232            uid: $messageUid,
233            messageId: null,
234            subject: '(No Subject)',
235            fromName: '',
236            fromEmail: '',
237            folder: $folderPath
238        );
239
240        $mimeParser = $this->mimeParser ?? new ImapMimeParser(new MimeDecoderService());
241
242        return $mimeParser->parseMessage($rawSource, $summary);
243    }
244
245    /**
246     * {@inheritdoc}
247     */
248    public function getMimePartStream(string $folderPath, string $messageUid, string $partId): MailMimePartDto
249    {
250        $this->selectFolder($folderPath);
251        $lines = $this->client->executeCommand(sprintf('UID FETCH %s (BODY.PEEK[%s])', $messageUid, $partId));
252        $content = implode("\n", $lines);
253
254        return new MailMimePartDto($partId, 'application/octet-stream', $content, strlen($content));
255    }
256
257    /**
258     * {@inheritdoc}
259     */
260    public function getRawMessageSource(string $folderPath, string $messageUid): string
261    {
262        $this->selectFolder($folderPath);
263        $lines = $this->client->executeCommand(sprintf('UID FETCH %s (BODY.PEEK[])', $messageUid));
264        return $this->searchResolver->cleanRawMessageLines($lines);
265    }
266
267    /**
268     * {@inheritdoc}
269     */
270    public function setFlags(string $folderPath, array $messageUids, array $flags, bool $add = true): void
271    {
272        $validUids = $this->searchResolver->sanitizeUids($messageUids);
273        if ($validUids === [] || $flags === []) {
274            return;
275        }
276
277        $this->selectFolder($folderPath);
278        $op = $add ? '+FLAGS' : '-FLAGS';
279        $cmd = sprintf(
280            'UID STORE %s %s (%s)',
281            implode(',', $validUids),
282            $op,
283            implode(' ', $flags)
284        );
285        $this->client->executeCommand($cmd);
286    }
287
288    /**
289     * {@inheritdoc}
290     */
291    public function moveMessages(string $sourceFolder, string $targetFolder, array $messageUids): void
292    {
293        $validUids = $this->searchResolver->sanitizeUids($messageUids);
294        if ($validUids === []) {
295            return;
296        }
297
298        $this->selectFolder($sourceFolder);
299        try {
300            $escapedTarget = addcslashes($targetFolder, '"\\');
301            $cmd = sprintf('UID MOVE %s "%s"', implode(',', $validUids), $escapedTarget);
302            $this->client->executeCommand($cmd);
303        } catch (Throwable) {
304            // Fallback for servers without RFC 6851 MOVE extension
305            $this->copyMessages($sourceFolder, $targetFolder, $validUids);
306            $this->deleteMessages($sourceFolder, $validUids, true);
307        }
308    }
309
310    /**
311     * {@inheritdoc}
312     */
313    public function copyMessages(string $sourceFolder, string $targetFolder, array $messageUids): void
314    {
315        $validUids = $this->searchResolver->sanitizeUids($messageUids);
316        if ($validUids === []) {
317            return;
318        }
319
320        $this->selectFolder($sourceFolder);
321        $cmd = sprintf('UID COPY %s "%s"', implode(',', $validUids), addcslashes($targetFolder, '"\\'));
322        $this->client->executeCommand($cmd);
323    }
324
325    /**
326     * {@inheritdoc}
327     */
328    public function deleteMessages(string $folderPath, array $messageUids, bool $expunge = true): void
329    {
330        $validUids = $this->searchResolver->sanitizeUids($messageUids);
331        if ($validUids === []) {
332            return;
333        }
334
335        $this->setFlags($folderPath, $validUids, ['\\Deleted'], true);
336        if ($expunge) {
337            $this->client->executeCommand('EXPUNGE');
338        }
339    }
340
341    /**
342     * {@inheritdoc}
343     */
344    public function saveDraft(MailOutgoingMessageDto $message, ?string $existingDraftUid = null): string
345    {
346        $draftFolder = $this->activeMailbox !== null
347            ? ($this->activeMailbox->folderDrafts ?? 'Drafts')
348            : 'Drafts';
349
350        if ($existingDraftUid !== null) {
351            try {
352                $this->deleteMessages($draftFolder, [$existingDraftUid], true);
353            } catch (Throwable) {
354                // Ignore delete errors for previous draft
355            }
356        }
357
358        $payload = sprintf(
359            self::MIME_HTML_TEMPLATE,
360            $message->fromName,
361            $message->fromEmail,
362            implode(', ', $message->to),
363            $message->subject,
364            $message->htmlBody !== '' ? $message->htmlBody : $message->textBody
365        );
366
367        $res = $this->client->appendMessage($draftFolder, '\\Draft \\Seen', $payload);
368        return preg_match(self::REGEX_APPEND_UID, $res, $m) ? $m[1] : (string) time();
369    }
370
371    /** {@inheritdoc} */
372    public function sendMessage(MailOutgoingMessageDto $message): MailSendResultDto
373    {
374        $sentFolder = $this->activeMailbox !== null
375            ? ($this->activeMailbox->folderSent ?? 'Sent')
376            : 'Sent';
377
378        $payload = sprintf(
379            self::MIME_HTML_TEMPLATE,
380            $message->fromName,
381            $message->fromEmail,
382            implode(', ', $message->to),
383            $message->subject,
384            $message->htmlBody !== '' ? $message->htmlBody : $message->textBody
385        );
386
387        $res = $this->client->appendMessage($sentFolder, '\\Seen', $payload);
388        $sentUid = preg_match(self::REGEX_APPEND_UID, $res, $m) ? $m[1] : null;
389
390        return MailSendResultDto::success(sprintf('<%s@ammonly>', bin2hex(random_bytes(16))), $sentUid);
391    }
392
393    /** {@inheritdoc} */
394    public function getQuota(): ?array
395    {
396        try {
397            $lines = $this->client->executeCommand('GETQUOTAROOT INBOX');
398            foreach ($lines as $line) {
399                if (preg_match('/\* QUOTA\s+\S+\s+\(STORAGE\s+(\d+)\s+(\d+)\)/i', $line, $matches)) {
400                    $usedKb  = (int) $matches[1];
401                    $totalKb = (int) $matches[2];
402                    $percent = $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 2) : 0.0;
403                    return [
404                        'used_kb'  => $usedKb,
405                        'total_kb' => $totalKb,
406                        'percent'  => $percent,
407                    ];
408                }
409            }
410        } catch (Throwable) {
411            // Graceful fallback if QUOTA is not supported
412        }
413
414        return null;
415    }
416
417    /** {@inheritdoc} */
418    public function appendRawMessage(string $folderPath, string $rawPayload, array $flags = []): string
419    {
420        $flagStr = implode(' ', $flags);
421        $res = $this->client->appendMessage($folderPath, $flagStr, $rawPayload);
422        return preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $res, $m) ? $m[1] : (string) time();
423    }
424
425    /**
426     * Selects mailbox folder if not currently selected.
427     */
428    private function selectFolder(string $folderPath): void
429    {
430        if ($this->selectedFolder !== $folderPath) {
431            $this->client->executeCommand(sprintf('SELECT "%s"', addcslashes($folderPath, '"\\')));
432            $this->selectedFolder = $folderPath;
433        }
434    }
435}