Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.12% |
156 / 164 |
|
73.91% |
17 / 23 |
CRAP | |
0.00% |
0 / 1 |
| ImapProtocolDriver | |
95.09% |
155 / 163 |
|
73.91% |
17 / 23 |
54 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| __clone | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| connect | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| ping | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| disconnect | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| listFolders | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getFolderStatus | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| createFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renameFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| deleteFolder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fetchMessages | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
3 | |||
| getMessageDetail | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
3 | |||
| getMimePartStream | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| getRawMessageSource | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| setFlags | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| moveMessages | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| copyMessages | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| deleteMessages | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| saveDraft | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
6.24 | |||
| sendMessage | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
| getQuota | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| appendRawMessage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| selectFolder | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| 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; |
| 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\Contract\MailProtocolDriverInterface; |
| 13 | use App\Modules\Mail\Domain\Model\ClientMailbox; |
| 14 | use App\Modules\Mail\Domain\Model\MailFolderDto; |
| 15 | use App\Modules\Mail\Domain\Model\MailFolderStatusDto; |
| 16 | use App\Modules\Mail\Domain\Model\MailMessageDetailDto; |
| 17 | use App\Modules\Mail\Domain\Model\MailMessageSummaryDto; |
| 18 | use App\Modules\Mail\Domain\Model\MailMimePartDto; |
| 19 | use App\Modules\Mail\Domain\Model\MailOutgoingMessageDto; |
| 20 | use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto; |
| 21 | use App\Modules\Mail\Domain\Model\MailSendResultDto; |
| 22 | use App\Modules\Mail\Domain\Model\MailServer; |
| 23 | use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapFolderManager; |
| 24 | use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapMimeParser; |
| 25 | use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapResponseParser; |
| 26 | use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapSearchResolver; |
| 27 | use App\Modules\Mail\Infrastructure\Protocol\Imap\Client\ImapSocketClient; |
| 28 | use 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 | */ |
| 37 | final 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 | } |