Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 253
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Smtp
0.00% covered (danger)
0.00%
0 / 253
0.00% covered (danger)
0.00%
0 / 26
15006
0.00% covered (danger)
0.00%
0 / 1
 connect
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getSMTPConnection
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 startTLS
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 authenticate
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
650
 hmac
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 isConnected
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 close
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 data
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
156
 hello
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 sendHello
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 parseHelloFields
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 mail
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 quit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 recipient
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 reset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sendCommand
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 sendAndMail
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 verify
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 noop
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clientSend
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getServerExtList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getServerExt
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getLastReply
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLines
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
210
 recordLastTransactionId
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getLastTransactionId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Message\Mail
8 * @license   GLGPL 2.1 License
9 * @version   1.0.0
10 * @link      https://jingga.app
11 *
12 * Extended based on:
13 * GLGPL 2.1 License
14 * (c) 2012 - 2015 Marcus Bointon, 2010 - 2012 Jim Jagielski, 2004 - 2009 Andy Prevost
15 * (c) PHPMailer
16 */
17declare(strict_types=1);
18
19namespace phpOMS\Message\Mail;
20
21/**
22 * Smtp mail class.
23 *
24 * @package phpOMS\Message\Mail
25 * @license GLGPL 2.1 License
26 * @link    https://jingga.app
27 * @since   1.0.0
28 */
29class Smtp
30{
31    /**
32     * The maximum line length allowed
33     *
34     * @var int
35     * @since 1.0.0
36     */
37    public const MAX_REPLY_LENGTH = 512;
38
39    /**
40     * SMTP RFC standard line ending
41     *
42     * @var string
43     * @since 1.0.0
44     */
45    protected static string $LE = "\r\n";
46
47    /**
48     * Whether to use VERP.
49     *
50     * @var bool
51     * @since 1.0.0
52     */
53    public bool $doVerp = false;
54
55    /**
56     * The timeout value for connection, in seconds.
57     *
58     * @var int
59     * @since 1.0.0
60     */
61    public int $timeout = 300;
62
63    /**
64     * How long to wait for commands to complete, in seconds.
65     *
66     * @var int
67     * @since 1.0.0
68     */
69    public int $timeLimit = 300;
70
71    /**
72     * The last transaction ID issued in response to a DATA command,
73     * if one was detected.
74     *
75     * @var string
76     * @since 1.0.0
77     */
78    protected string $lastSmtpTransactionId = '';
79
80    /**
81     * The socket for the server connection.
82     *
83     * @var ?resource
84     * @since 1.0.0
85     */
86    protected $con = null;
87
88    /**
89     * The reply the server sent to us for HELO.
90     * If empty no HELO string has yet been received.
91     *
92     * @var string
93     * @since 1.0.0
94     */
95    protected string $heloRply = '';
96
97    /**
98     * The set of SMTP extensions sent in reply to EHLO command.
99     *
100     * @var array
101     * @since 1.0.0
102     */
103    protected array $serverCaps = [];
104
105    /**
106     * The most recent reply received from the server.
107     *
108     * @var string
109     * @since 1.0.0
110     */
111    protected string $lastReply = '';
112
113    /**
114     * Connect to an SMTP server.
115     *
116     * @param string $host    SMTP server IP or host name
117     * @param int    $port    The port number to connect to
118     * @param int    $timeout How long to wait for the connection to open
119     * @param array  $options An array of options for stream_context_create()
120     *
121     * @return bool
122     *
123     * @since 1.0.0
124     */
125    public function connect(string $host, int $port = 25, int $timeout = 30, array $options = []) : bool
126    {
127        if ($this->isConnected()) {
128            return false;
129        }
130
131        $this->con = $this->getSMTPConnection($host, $port, $timeout, $options);
132        if ($this->con === null) {
133            return false;
134        }
135
136        $this->lastReply = $this->getLines();
137        $responseCode    = (int) \substr($this->lastReply, 0, 3);
138        if ($responseCode === 220) {
139            return true;
140        }
141
142        if ($responseCode === 554) {
143            $this->quit();
144        }
145
146        $this->close();
147
148        return false;
149    }
150
151    /**
152     * Create connection to the SMTP server.
153     *
154     * @param string $host    SMTP server IP or host name
155     * @param int    $port    The port number to connect to
156     * @param int    $timeout How long to wait for the connection to open
157     * @param array  $options An array of options for stream_context_create()
158     *
159     * @return null|resource
160     *
161     * @since 1.0.0
162     */
163    protected function getSMTPConnection(string $host, int $port = 25, int $timeout = 30, array $options = []) : mixed
164    {
165        static $streamok;
166        if ($streamok === null) {
167            $streamok = \function_exists('stream_socket_client');
168        }
169
170        $errno  = 0;
171        $errstr = '';
172
173        if ($streamok) {
174            $socketContext = \stream_context_create($options);
175            $connection    = @\stream_socket_client($host . ':' . $port, $errno, $errstr, $timeout, \STREAM_CLIENT_CONNECT, $socketContext);
176        } else {
177            //Fall back to fsockopen which should work in more places, but is missing some features
178            $connection = \fsockopen($host, $port, $errno, $errstr, $timeout);
179        }
180
181        if (!\is_resource($connection)) {
182            return null;
183        }
184
185        // SMTP server can take longer to respond, give longer timeout for first read
186        // Windows does not have support for this timeout function
187        if (!\str_starts_with(\PHP_OS, 'WIN')) {
188            $max = (int) \ini_get('max_execution_time');
189            if ($max !== 0 && $timeout > $max && \strpos(\ini_get('disable_functions'), 'set_time_limit') === false) {
190                \set_time_limit($timeout);
191            }
192
193            \stream_set_timeout($connection, $timeout, 0);
194        }
195
196        return $connection === false ? null : $connection;
197    }
198
199    /**
200     * Initiate a TLS (encrypted) session.
201     *
202     * @return bool
203     *
204     * @since 1.0.0
205     */
206    public function startTLS() : bool
207    {
208        if (!$this->sendCommand('STARTTLS', 'STARTTLS', [220])) {
209            return false;
210        }
211
212        $crypto_method = \STREAM_CRYPTO_METHOD_TLS_CLIENT;
213        if (\defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) {
214            $crypto_method |= \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
215            $crypto_method |= \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
216        }
217
218        // This may throw "Peer certificate CN=`...` did not match expected CN=`...`"
219        // The solution is to replace the invalid ssl certificate with a correct one
220        return (bool) \stream_socket_enable_crypto($this->con, true, $crypto_method);
221    }
222
223    /**
224     * Perform SMTP authentication.
225     * Must be run after hello().
226     *
227     * @param string $username The user name
228     * @param string $password The password
229     * @param string $authtype The auth type (CRAM-MD5, PLAIN, LOGIN, XOAUTH2)
230     * @param OAuth  $oauth    An optional OAuth instance for XOAUTH2 authentication
231     *
232     * @return bool
233     *
234     * @since 1.0.0
235     */
236    public function authenticate(
237        string $username,
238        string $password,
239        string $authtype = '',
240        mixed $oauth = null
241    ) : bool
242    {
243        if (empty($this->serverCaps)) {
244            return false;
245        }
246
247        if (isset($this->serverCaps['EHLO'])) {
248            // SMTP extensions are available; try to find a proper authentication method
249            if (!isset($this->serverCaps['AUTH'])) {
250                // 'at this stage' means that auth may be allowed after the stage changes
251                // e.g. after STARTTLS
252                return false;
253            }
254
255            //If we have requested a specific auth type, check the server supports it before trying others
256            if ($authtype !== '' && !\in_array($authtype, $this->serverCaps['AUTH'], true)) {
257                $authtype = '';
258            }
259
260            if ($authtype === '') {
261                //If no auth mechanism is specified, attempt to use these, in this order
262                //Try CRAM-MD5 first as it's more secure than the others
263                foreach (['CRAM-MD5', 'LOGIN', 'PLAIN', 'XOAUTH2'] as $method) {
264                    if (\in_array($method, $this->serverCaps['AUTH'], true)) {
265                        $authtype = $method;
266                        break;
267                    }
268                }
269
270                if ($authtype === '') {
271                    return false;
272                }
273            }
274
275            if (!\in_array($authtype, $this->serverCaps['AUTH'], true)) {
276                return false;
277            }
278        } elseif ($authtype === '') {
279            $authtype = 'LOGIN';
280        }
281
282        switch ($authtype) {
283            case 'PLAIN':
284                // Start authentication
285                if (!$this->sendCommand('AUTH', 'AUTH PLAIN', [334])
286                    || !$this->sendCommand('User & Password',
287                            \base64_encode("\0" . $username . "\0" . $password),
288                            [235]
289                        )
290                ) {
291                    return false;
292                }
293                break;
294            case 'LOGIN':
295                // Start authentication
296                if (!$this->sendCommand('AUTH', 'AUTH LOGIN', [334])
297                    || !$this->sendCommand('Username', \base64_encode($username), [334])
298                    || !$this->sendCommand('Password', \base64_encode($password), [235])
299                ) {
300                    return false;
301                }
302                break;
303            case 'CRAM-MD5':
304                // Start authentication
305                if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', [334])) {
306                    return false;
307                }
308
309                $challenge = \base64_decode(\substr($this->lastReply, 4));
310                $response  = $username . ' ' . $this->hmac($challenge, $password);
311
312                // send encoded credentials
313                return $this->sendCommand('Username', \base64_encode($response), [235]);
314            case 'XOAUTH2':
315                //The OAuth instance must be set up prior to requesting auth.
316                if ($oauth === null) {
317                    return false;
318                }
319
320                $oauth = $oauth->getOauth64();
321                if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, [235])) {
322                    return false;
323                }
324                break;
325            default:
326                return false;
327        }
328
329        return true;
330    }
331
332    /**
333     * Calculate an MD5 HMAC hash.
334     *
335     * @param string $data The data to hash
336     * @param string $key  The key to hash with
337     *
338     * @return string
339     *
340     * @since 1.0.0
341     */
342    protected function hmac(string $data, string $key) : string
343    {
344        // RFC 2104 HMAC implementation for php.
345        // Creates an md5 HMAC.
346        // by Lance Rushing
347        $byteLen = 64;
348        if (\strlen($key) > $byteLen) {
349            $key = \pack('H*', \md5($key));
350        }
351
352        $key    = \str_pad($key, $byteLen, \chr(0x00));
353        $ipad   = \str_pad('', $byteLen, \chr(0x36));
354        $opad   = \str_pad('', $byteLen, \chr(0x5c));
355        $k_ipad = $key ^ $ipad;
356        $k_opad = $key ^ $opad;
357
358        return \md5($k_opad . \pack('H*', \md5($k_ipad . $data)));
359    }
360
361    /**
362     * Check connection state.
363     *
364     * @return bool
365     *
366     * @since 1.0.0
367     */
368    public function isConnected() : bool
369    {
370        if (!\is_resource($this->con)) {
371            return false;
372        }
373
374        $status = \stream_get_meta_data($this->con);
375        if ($status['eof']) {
376            $this->close();
377
378            return false;
379        }
380
381        return true;
382    }
383
384    /**
385     * Close the socket and clean up the state of the class.
386     * Try to QUIT first!
387     *
388     * @return void
389     *
390     * @since 1.0.0
391     */
392    public function close() : void
393    {
394        $this->serverCaps = [];
395        $this->heloRply   = '';
396
397        if (\is_resource($this->con)) {
398            \fclose($this->con);
399            $this->con = null;
400        }
401    }
402
403    /**
404     * Send an SMTP DATA command.
405     *
406     * @param string $msg_data Message data to send
407     *
408     * @return bool
409     *
410     * @since 1.0.0
411     */
412    public function data($msg_data, int $maxLineLength = 998) : bool
413    {
414        if (!$this->sendCommand('DATA', 'DATA', [354])) {
415            return false;
416        }
417
418        /* The server is ready to accept data!
419         * According to rfc821 we should not send more than 1000 characters on a single line (including the LE)
420         */
421        $lines = \explode("\n", \str_replace(["\r\n", "\r"], "\n", $msg_data));
422
423        /* To distinguish between a complete RFC822 message and a plain message body, we check if the first field
424         * of the first line (':' separated) does not contain a space then it _should_ be a header and we will
425         * process all lines before a blank line as headers.
426         */
427        $field     = \substr($lines[0], 0, \strpos($lines[0], ':'));
428        $inHeaders = !empty($field) && \strpos($field, ' ') === false;
429
430        foreach ($lines as $line) {
431            $linesOut = [];
432            if ($inHeaders && $line === '') {
433                $inHeaders = false;
434            }
435
436            while (isset($line[$maxLineLength])) {
437                $pos = \strrpos(\substr($line, 0, $maxLineLength), ' ');
438                if (!$pos) {
439                    $pos        = $maxLineLength - 1;
440                    $linesOut[] = \substr($line, 0, $pos);
441                    $line       = \substr($line, $pos);
442                } else {
443                    $linesOut[] = \substr($line, 0, $pos);
444                    $line       = \substr($line, $pos + 1);
445                }
446
447                if ($inHeaders) {
448                    $line = "\t" . $line;
449                }
450            }
451
452            $linesOut[] = $line;
453
454            foreach ($linesOut as $lineOut) {
455                if (!empty($lineOut) && $lineOut[0] === '.') {
456                    $lineOut = '.' . $lineOut;
457                }
458
459                $this->clientSend($lineOut . self::$LE, 'DATA');
460            }
461        }
462
463        $tmpTimeLimit     = $this->timeLimit;
464        $this->timeLimit *= 2;
465        $result           = $this->sendCommand('DATA END', '.', [250]);
466
467        $this->recordLastTransactionId();
468
469        $this->timeLimit = $tmpTimeLimit;
470
471        return $result;
472    }
473
474    /**
475     * Send an SMTP HELO or EHLO command.
476     *
477     * @param string $host The host name or IP to connect to
478     *
479     * @return bool
480     *
481     * @since 1.0.0
482     */
483    public function hello(string $host = '') : bool
484    {
485        if ($this->sendHello('EHLO', $host)) {
486            return true;
487        }
488
489        if (\substr($this->heloRply, 0, 3) === '421') {
490            return false;
491        }
492
493        return $this->sendHello('HELO', $host);
494    }
495
496    /**
497     * Send an SMTP HELO or EHLO command.
498     *
499     * @param string $hello The HELO string
500     * @param string $host  The hostname to say we are
501     *
502     * @return bool
503     *
504     * @since 1.0.0
505     */
506    protected function sendHello(string $hello, string $host) : bool
507    {
508        $status         = $this->sendCommand($hello, $hello . ' ' . $host, [250]);
509        $this->heloRply = $this->lastReply;
510
511        if ($status) {
512            $this->parseHelloFields($hello);
513        } else {
514            $this->serverCaps = [];
515        }
516
517        return $status;
518    }
519
520    /**
521     * Parse a reply to HELO/EHLO command to discover server extensions.
522     *
523     * @param string $type `HELO` or `EHLO`
524     *
525     * @return void
526     *
527     * @since 1.0.0
528     */
529    protected function parseHelloFields(string $type) : void
530    {
531        $this->serverCaps = [];
532        $lines            = \explode("\n", $this->heloRply);
533
534        foreach ($lines as $n => $s) {
535            //First 4 chars contain response code followed by - or space
536            $s = \trim(\substr($s, 4));
537            if (empty($s)) {
538                continue;
539            }
540
541            $fields = \explode(' ', $s);
542            if (empty($fields)) {
543                continue;
544            }
545
546            if ($n === 0) {
547                $name   = $type;
548                $fields = ($fields === false ? 0 : $fields[0]);
549            } else {
550                $name = \array_shift($fields);
551                switch ($name) {
552                    case 'SIZE':
553                        $fields = ($fields === false ? 0 : $fields[0]);
554                        break;
555                    case 'AUTH':
556                        if (!\is_array($fields)) {
557                            $fields = [];
558                        }
559                        break;
560                    default:
561                        $fields = true;
562                }
563            }
564
565            $this->serverCaps[$name] = $fields;
566        }
567    }
568
569    /**
570     * Send an SMTP MAIL command.
571     *
572     * @param string $from Source address of this message
573     *
574     * @return bool
575     *
576     * @since 1.0.0
577     */
578    public function mail(string $from) : bool
579    {
580        $useVerp = ($this->doVerp ? ' XVERP' : '');
581
582        return $this->sendCommand('MAIL FROM', 'MAIL FROM:<' . $from . '>' . $useVerp, [250]);
583    }
584
585    /**
586     * Send an SMTP QUIT command.
587     *
588     * @return bool
589     *
590     * @since 1.0.0
591     */
592    public function quit() : bool
593    {
594        $status = $this->sendCommand('QUIT', 'QUIT', [221]);
595        if ($status) {
596            $this->close();
597        }
598
599        return $status;
600    }
601
602    /**
603     * Send an SMTP RCPT command.
604     *
605     * @param string $address The address the message is being sent to
606     * @param string $dsn     Comma separated list of DSN notifications
607     *
608     * @return bool
609     *
610     * @since 1.0.0
611     */
612    public function recipient(string $address, string $dsn = DsnNotificationType::NONE) : bool
613    {
614        if ($dsn === '') {
615            $rcpt = 'RCPT TO:<' . $address . '>';
616        } else {
617            $dsn    = \strtoupper($dsn);
618            $notify = [];
619
620            if (\strpos($dsn, 'NEVER') !== false) {
621                $notify[] = 'NEVER';
622            } else {
623                $values = ['SUCCESS', 'FAILURE', 'DELAY'];
624                foreach ($values as $value) {
625                    if (\strpos($dsn, $value) !== false) {
626                        $notify[] = $value;
627                    }
628                }
629            }
630
631            $rcpt = 'RCPT TO:<' . $address . '> NOTIFY=' . \implode(',', $notify);
632        }
633
634        return $this->sendCommand('RCPT TO', $rcpt, [250, 251]);
635    }
636
637    /**
638     * Send an SMTP RSET command.
639     *
640     * @return bool s
641     *
642     * @since 1.0.0
643     */
644    public function reset() : bool
645    {
646        return $this->sendCommand('RSET', 'RSET', [250]);
647    }
648
649    /**
650     * Send a command to an SMTP server and check its return code.
651     *
652     * @param string $command       The command name - not sent to the server
653     * @param string $commandstring The actual command to send
654     * @param int[]  $expect        One or more expected integer success codes
655     *
656     * @return bool
657     *
658     * @since 1.0.0
659     */
660    protected function sendCommand(string $command, string $commandstring, array $expect) : bool
661    {
662        if (!$this->isConnected()) {
663            return false;
664        }
665
666        if (\strpos($commandstring, "\n") !== false
667            || \strpos($commandstring, "\r") !== false
668        ) {
669            return false;
670        }
671
672        $this->clientSend($commandstring . self::$LE, $command);
673
674        $this->lastReply = $this->getLines();
675
676        $matches = [];
677        if (\preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->lastReply, $matches)) {
678            $code = (int) $matches[1];
679        } else {
680            // Fall back to simple parsing if regex fails
681            $code = (int) \substr($this->lastReply, 0, 3);
682        }
683
684        return \in_array($code, $expect, true);
685    }
686
687    /**
688     * Send an SMTP SAML command.
689     * Starts a mail transaction from the email address specified in $from.
690     *
691     * @param string $from The address the message is from
692     *
693     * @return bool
694     *
695     * @since 1.0.0
696     */
697    public function sendAndMail(string $from) : bool
698    {
699        return $this->sendCommand('SAML', 'SAML FROM:' . $from, [250]);
700    }
701
702    /**
703     * Send an SMTP VRFY command.
704     *
705     * @param string $name The name to verify
706     *
707     * @return bool
708     *
709     * @since 1.0.0
710     */
711    public function verify(string $name) : bool
712    {
713        return $this->sendCommand('VRFY', 'VRFY ' . $name, [250, 251]);
714    }
715
716    /**
717     * Send an SMTP NOOP command.
718     * Used to keep keep-alives alive.
719     *
720     * @return bool
721     *
722     * @since 1.0.0
723     */
724    public function noop() : bool
725    {
726        return $this->sendCommand('NOOP', 'NOOP', [250]);
727    }
728
729    /**
730     * Send raw data to the server.
731     *
732     * @param string $data    The data to send
733     * @param string $command Optionally, the command this is part of, used only for controlling debug output
734     *
735     * @return int
736     *
737     * @since 1.0.0
738     */
739    public function clientSend(string $data, string $command = '') : int
740    {
741        $result = \fwrite($this->con, $data);
742
743        return $result === false ? -1 : $result;
744    }
745
746    /**
747     * Get SMTP extensions available on the server.
748     *
749     * @return array
750     *
751     * @since 1.0.0
752     */
753    public function getServerExtList() : array
754    {
755        return $this->serverCaps;
756    }
757
758    /**
759     * Get metadata about the SMTP server from its HELO/EHLO response.
760     *
761     * @param string $name Name of SMTP extension
762     *
763     * @return string
764     *
765     * @since 1.0.0
766     */
767    public function getServerExt(string $name) : string
768    {
769        if (empty($this->serverCaps)) {
770            // HELO/EHLO has not been sent
771            return '';
772        }
773
774        if (!isset($this->serverCaps[$name])) {
775            if ($name === 'HELO') {
776                // Server name
777                return $this->serverCaps['EHLO'];
778            }
779
780            return '';
781        }
782
783        return (string) ($this->serverCaps[$name] ?? '');
784    }
785
786    /**
787     * Get the last reply from the server.
788     *
789     * @return string
790     *
791     * @since 1.0.0
792     */
793    public function getLastReply() : string
794    {
795        return $this->lastReply;
796    }
797
798    /**
799     * Read the SMTP server's response.
800     *
801     * @return string
802     *
803     * @since 1.0.0
804     */
805    protected function getLines() : string
806    {
807        if (!\is_resource($this->con)) {
808            return '';
809        }
810
811        $data    = '';
812        $endTime = 0;
813
814        \stream_set_timeout($this->con, $this->timeout);
815        if ($this->timeLimit > 0) {
816            $endTime = \time() + $this->timeLimit;
817        }
818
819        $selR  = [$this->con];
820        $selW  = null;
821        $tries = 0;
822
823        while (\is_resource($this->con) && !\feof($this->con)) {
824            $n = \stream_select($selR, $selW, $selW, $this->timeLimit);
825            if ($n === false) {
826                if ($tries < 10) {
827                    \sleep(1);
828                    ++$tries;
829
830                    continue;
831                } else {
832                    break;
833                }
834            }
835
836            $str   = \fgets($this->con, self::MAX_REPLY_LENGTH);
837            $data .= $str;
838
839            // If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled),
840            // or 4th character is a space or a line break char, we are done reading, break the loop.
841            // String array access is a significant micro-optimisation over strlen
842            if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") {
843                break;
844            }
845
846            $info = \stream_get_meta_data($this->con);
847            if ($info['timed_out']) {
848                break;
849            }
850
851            // Now check if reads took too long
852            if ($endTime && \time() > $endTime) {
853                break;
854            }
855        }
856
857        return $data;
858    }
859
860    /**
861     * Extract and return the ID of the last SMTP transaction
862     *
863     * @return string
864     *
865     * @since 1.0.0
866     */
867    protected function recordLastTransactionId() : string
868    {
869        $reply = $this->getLastReply();
870
871        if ($reply === '') {
872            $this->lastSmtpTransactionId = '';
873        } else {
874            $this->lastSmtpTransactionId = '';
875            $patterns                    = SmtpTransactionPattern::getConstants();
876
877            foreach ($patterns as $pattern) {
878                $matches = [];
879                if (\preg_match($pattern, $reply, $matches) === 1) {
880                    $this->lastSmtpTransactionId = \trim($matches[1]);
881                    break;
882                }
883            }
884        }
885
886        return $this->lastSmtpTransactionId;
887    }
888
889    /**
890     * Get the queue/transaction ID of the last SMTP transaction
891     *
892     * @return string
893     *
894     * @since 1.0.0
895     */
896    public function getLastTransactionId() : string
897    {
898        return $this->lastSmtpTransactionId;
899    }
900}