Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
82.64% |
100 / 121 |
|
41.67% |
5 / 12 |
CRAP | |
0.00% |
0 / 1 |
| ImapSocketClient | |
82.50% |
99 / 120 |
|
41.67% |
5 / 12 |
63.40 | |
0.00% |
0 / 1 |
| setTimeout | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| connect | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
7 | |||
| executeCommand | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| assertSocketActiveAndCommandValid | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| collectCommandResponses | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
4.10 | |||
| readLiteralData | |
18.18% |
2 / 11 |
|
0.00% |
0 / 1 |
25.72 | |||
| isTaggedLine | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| assertTaggedResponseOk | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| appendMessage | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
12 | |||
| disconnect | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| readLine | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
4.32 | |||
| negotiateStarttls | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| 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\Domain\Exception\WebmailProtocolException; |
| 12 | use InvalidArgumentException; |
| 13 | |
| 14 | /** |
| 15 | * High-Performance Pure PHP Stream Socket IMAP Client. |
| 16 | * |
| 17 | * Implements RFC 3501 socket communication with TLS 1.3/1.2, STARTTLS negotiation, |
| 18 | * tag sequencing, literal payload framing, and connection state management. |
| 19 | * |
| 20 | * @package App\Modules\Mail\Infrastructure\Protocol\Imap\Client |
| 21 | */ |
| 22 | class ImapSocketClient |
| 23 | { |
| 24 | /** @var resource|null Active stream socket resource. */ |
| 25 | private $socket = null; |
| 26 | |
| 27 | private int $tagSequence = 1; |
| 28 | private int $connectTimeoutSeconds = 5; |
| 29 | private int $streamTimeoutSeconds = 20; |
| 30 | |
| 31 | /** |
| 32 | * Configures socket connect and stream read/write timeout in seconds. |
| 33 | * |
| 34 | * @param int $seconds Timeout in seconds. |
| 35 | */ |
| 36 | public function setTimeout(int $seconds): void |
| 37 | { |
| 38 | $this->connectTimeoutSeconds = max(1, min($seconds, 10)); |
| 39 | $this->streamTimeoutSeconds = max(10, $seconds * 4); |
| 40 | if (is_resource($this->socket)) { |
| 41 | stream_set_timeout($this->socket, $this->streamTimeoutSeconds); |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Connects to IMAP server over SSL/TLS or plain socket with STARTTLS. |
| 47 | * |
| 48 | * @param string $host Hostname. |
| 49 | * @param int $port Port (993 for SSL, 143 for STARTTLS/plain). |
| 50 | * @param string $encryption Encryption mode ('ssl', 'tls', 'starttls', 'none'). |
| 51 | * @param bool $allowSelfSigned Whether to allow self-signed certificates. |
| 52 | */ |
| 53 | public function connect( |
| 54 | string $host, |
| 55 | int $port = 993, |
| 56 | string $encryption = 'ssl', |
| 57 | bool $allowSelfSigned = false |
| 58 | ): void { |
| 59 | $this->disconnect(); |
| 60 | |
| 61 | $isDirectSsl = strtolower($encryption) === 'ssl' || $port === 993; |
| 62 | $remoteUri = sprintf('%s://%s:%d', $isDirectSsl ? 'ssl' : 'tcp', $host, $port); |
| 63 | |
| 64 | $context = stream_context_create([ |
| 65 | 'ssl' => [ |
| 66 | 'verify_peer' => !$allowSelfSigned, |
| 67 | 'verify_peer_name' => !$allowSelfSigned, |
| 68 | 'allow_self_signed' => $allowSelfSigned, |
| 69 | 'SNI_enabled' => true, |
| 70 | 'peer_name' => $host, |
| 71 | ], |
| 72 | ]); |
| 73 | |
| 74 | $errno = 0; |
| 75 | $errstr = ''; |
| 76 | $socket = @stream_socket_client( |
| 77 | $remoteUri, |
| 78 | $errno, |
| 79 | $errstr, |
| 80 | $this->connectTimeoutSeconds, |
| 81 | STREAM_CLIENT_CONNECT, |
| 82 | $context |
| 83 | ); |
| 84 | |
| 85 | if (!is_resource($socket)) { |
| 86 | $msg = sprintf('Failed to connect to IMAP server "%s": %s', $remoteUri, $errstr); |
| 87 | throw new WebmailProtocolException($msg); |
| 88 | } |
| 89 | |
| 90 | stream_set_timeout($socket, $this->streamTimeoutSeconds); |
| 91 | $this->socket = $socket; |
| 92 | |
| 93 | // Read server greeting |
| 94 | $greeting = $this->readLine(); |
| 95 | if (!str_starts_with($greeting, '* OK')) { |
| 96 | throw new WebmailProtocolException('Invalid IMAP greeting response: ' . $greeting); |
| 97 | } |
| 98 | |
| 99 | // Handle STARTTLS if configured |
| 100 | if (!$isDirectSsl && strtolower($encryption) === 'starttls') { |
| 101 | $this->negotiateStarttls(); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Executes IMAP command and collects response lines until matching tag status. |
| 107 | * |
| 108 | * @param string $command IMAP command (e.g. 'CAPABILITY' or 'LOGIN user pass'). |
| 109 | * @return array<string> Response lines. |
| 110 | */ |
| 111 | public function executeCommand(string $command): array |
| 112 | { |
| 113 | $this->assertSocketActiveAndCommandValid($command); |
| 114 | |
| 115 | $tag = sprintf('A%04d', $this->tagSequence++); |
| 116 | $line = sprintf('%s %s' . "\r\n", $tag, $command); |
| 117 | |
| 118 | if (@fwrite($this->socket, $line) === false) { |
| 119 | throw new WebmailProtocolException('Failed to write command to IMAP stream socket.'); |
| 120 | } |
| 121 | |
| 122 | return $this->collectCommandResponses($tag); |
| 123 | } |
| 124 | |
| 125 | private function assertSocketActiveAndCommandValid(string $command): void |
| 126 | { |
| 127 | if (!is_resource($this->socket)) { |
| 128 | throw new WebmailProtocolException('IMAP socket connection is not active.'); |
| 129 | } |
| 130 | |
| 131 | if (str_contains($command, "\r") || str_contains($command, "\n")) { |
| 132 | throw new InvalidArgumentException('IMAP command contains forbidden newline characters.'); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * @return array<string> |
| 138 | */ |
| 139 | private function collectCommandResponses(string $tag): array |
| 140 | { |
| 141 | $responseLines = []; |
| 142 | while (!feof($this->socket)) { |
| 143 | $respLine = $this->readLine(); |
| 144 | $responseLines[] = $respLine; |
| 145 | |
| 146 | $literalData = $this->readLiteralData($respLine); |
| 147 | if ($literalData !== null) { |
| 148 | $responseLines[] = $literalData; |
| 149 | } |
| 150 | |
| 151 | if ($this->isTaggedLine($respLine, $tag)) { |
| 152 | $this->assertTaggedResponseOk($respLine, $tag); |
| 153 | |
| 154 | return $responseLines; |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | throw new WebmailProtocolException('IMAP server unexpectedly terminated connection.'); |
| 159 | } |
| 160 | |
| 161 | private function readLiteralData(string $respLine): ?string |
| 162 | { |
| 163 | if (!preg_match('/\{(\d+)\}\s*$/', $respLine, $matches)) { |
| 164 | return null; |
| 165 | } |
| 166 | |
| 167 | $literalLength = (int) $matches[1]; |
| 168 | $literalData = ''; |
| 169 | $remaining = $literalLength; |
| 170 | while ($remaining > 0 && !feof($this->socket)) { |
| 171 | $chunk = @fread($this->socket, min($remaining, 8192)); |
| 172 | if ($chunk === false || $chunk === '') { |
| 173 | break; |
| 174 | } |
| 175 | $literalData .= $chunk; |
| 176 | $remaining -= strlen($chunk); |
| 177 | } |
| 178 | |
| 179 | return $literalData; |
| 180 | } |
| 181 | |
| 182 | private function isTaggedLine(string $respLine, string $tag): bool |
| 183 | { |
| 184 | return str_starts_with($respLine, $tag . ' '); |
| 185 | } |
| 186 | |
| 187 | private function assertTaggedResponseOk(string $respLine, string $tag): void |
| 188 | { |
| 189 | $status = substr($respLine, strlen($tag) + 1); |
| 190 | if (!str_starts_with(strtoupper($status), 'OK')) { |
| 191 | throw new WebmailProtocolException('IMAP command failed: ' . $respLine); |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Appends raw message payload using IMAP APPEND command with literal framing. |
| 197 | * |
| 198 | * @param string $folder Target folder. |
| 199 | * @param string $flags Flags to set (e.g. '\Seen'). |
| 200 | * @param string $payload Full RFC 822 message payload. |
| 201 | * @return string Final response status. |
| 202 | */ |
| 203 | public function appendMessage(string $folder, string $flags, string $payload): string |
| 204 | { |
| 205 | if (!is_resource($this->socket)) { |
| 206 | throw new WebmailProtocolException('IMAP socket connection is not active.'); |
| 207 | } |
| 208 | |
| 209 | if (str_contains($folder, "\r") || str_contains($folder, "\n") |
| 210 | || str_contains($flags, "\r") || str_contains($flags, "\n")) { |
| 211 | throw new InvalidArgumentException('IMAP APPEND arguments cannot contain newlines.'); |
| 212 | } |
| 213 | |
| 214 | $tag = sprintf('A%04d', $this->tagSequence++); |
| 215 | $length = strlen($payload); |
| 216 | $escapedFolder = addcslashes($folder, '"\\'); |
| 217 | $cmd = sprintf('%s APPEND "%s" (%s) {%d}' . "\r\n", $tag, $escapedFolder, $flags, $length); |
| 218 | |
| 219 | $written = @fwrite($this->socket, $cmd); |
| 220 | if ($written === false) { |
| 221 | throw new WebmailProtocolException('Failed to write APPEND command to IMAP socket.'); |
| 222 | } |
| 223 | |
| 224 | $continuation = $this->readLine(); |
| 225 | if (!str_starts_with($continuation, '+')) { |
| 226 | throw new WebmailProtocolException('IMAP server rejected literal payload: ' . $continuation); |
| 227 | } |
| 228 | |
| 229 | $writtenPayload = @fwrite($this->socket, $payload . "\r\n"); |
| 230 | if ($writtenPayload === false) { |
| 231 | throw new WebmailProtocolException('Failed to write message payload to IMAP socket.'); |
| 232 | } |
| 233 | |
| 234 | while (!feof($this->socket)) { |
| 235 | $resp = $this->readLine(); |
| 236 | if (str_starts_with($resp, $tag . ' ')) { |
| 237 | if (str_starts_with(strtoupper(substr($resp, strlen($tag) + 1)), 'OK')) { |
| 238 | return $resp; |
| 239 | } |
| 240 | throw new WebmailProtocolException('IMAP APPEND failed: ' . $resp); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | throw new WebmailProtocolException('Connection lost during IMAP APPEND.'); |
| 245 | } |
| 246 | |
| 247 | /** |
| 248 | * Disconnects and closes socket. |
| 249 | */ |
| 250 | public function disconnect(): void |
| 251 | { |
| 252 | if (is_resource($this->socket)) { |
| 253 | try { |
| 254 | @fwrite($this->socket, sprintf('A%04d LOGOUT' . "\r\n", $this->tagSequence++)); |
| 255 | } catch (\Throwable) { |
| 256 | // Ignore logout write errors on tear down |
| 257 | } |
| 258 | @fclose($this->socket); |
| 259 | $this->socket = null; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * Reads single CRLF terminated line from socket. |
| 265 | */ |
| 266 | private function readLine(): string |
| 267 | { |
| 268 | if (!is_resource($this->socket)) { |
| 269 | throw new WebmailProtocolException('Cannot read line: socket is closed.'); |
| 270 | } |
| 271 | |
| 272 | $line = fgets($this->socket, 8192); |
| 273 | if ($line === false) { |
| 274 | $meta = stream_get_meta_data($this->socket); |
| 275 | if (!empty($meta['timed_out'])) { |
| 276 | throw new WebmailProtocolException( |
| 277 | sprintf('IMAP socket read timed out (%ds limit).', $this->streamTimeoutSeconds) |
| 278 | ); |
| 279 | } |
| 280 | throw new WebmailProtocolException('Socket read error or EOF reached.'); |
| 281 | } |
| 282 | |
| 283 | return rtrim($line, "\r\n"); |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * Negotiates STARTTLS command on plain connection. |
| 288 | */ |
| 289 | private function negotiateStarttls(): void |
| 290 | { |
| 291 | $this->executeCommand('STARTTLS'); |
| 292 | $cryptoMethod = defined('STREAM_CRYPTO_METHOD_TLS_CLIENT') |
| 293 | ? STREAM_CRYPTO_METHOD_TLS_CLIENT |
| 294 | : (STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT); |
| 295 | $success = @stream_socket_enable_crypto($this->socket, true, $cryptoMethod); |
| 296 | |
| 297 | if ($success !== true) { |
| 298 | throw new WebmailProtocolException('STARTTLS cryptographic handshake failed.'); |
| 299 | } |
| 300 | } |
| 301 | } |