Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.64% covered (warning)
82.64%
100 / 121
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImapSocketClient
82.50% covered (warning)
82.50%
99 / 120
41.67% covered (danger)
41.67%
5 / 12
63.40
0.00% covered (danger)
0.00%
0 / 1
 setTimeout
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 connect
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
7
 executeCommand
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 assertSocketActiveAndCommandValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 collectCommandResponses
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 readLiteralData
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
25.72
 isTaggedLine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertTaggedResponseOk
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 appendMessage
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 disconnect
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 readLine
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 negotiateStarttls
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
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\Exception\WebmailProtocolException;
12use 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 */
22class 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}