Passed
Push — master ( 6c1142...03ac2d )
by zyt
06:13 queued 01:37
created

Validator::expect()   C

Complexity

Conditions 8
Paths 14

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 8.7021

Importance

Changes 0
Metric Value
dl 0
loc 32
ccs 14
cts 18
cp 0.7778
rs 5.3846
c 0
b 0
f 0
cc 8
eloc 18
nc 14
nop 3
crap 8.7021
1
<?php
2
3
namespace SMTPValidateEmail;
4
5
use \SMTPValidateEmail\Exceptions\Exception as Exception;
6
use \SMTPValidateEmail\Exceptions\Timeout as TimeoutException;
7
use \SMTPValidateEmail\Exceptions\NoTimeout as NoTimeoutException;
8
use \SMTPValidateEmail\Exceptions\NoConnection as NoConnectionException;
9
use \SMTPValidateEmail\Exceptions\UnexpectedResponse as UnexpectedResponseException;
10
use \SMTPValidateEmail\Exceptions\NoHelo as NoHeloException;
11
use \SMTPValidateEmail\Exceptions\NoMailFrom as NoMailFromException;
12
use \SMTPValidateEmail\Exceptions\NoResponse as NoResponseException;
13
use \SMTPValidateEmail\Exceptions\SendFailed as SendFailedException;
14
15
class Validator
16
{
17
18
    public $log = [];
19
20
    /**
21
     * Print stuff as it happens or not
22
     *
23
     * @var bool
24
     */
25
    public $debug = false;
26
27
    /**
28
     * Default smtp port to connect to
29
     *
30
     * @var int
31
     */
32
    public $connect_port = 25;
33
34
    /**
35
     * Are "catch-all" accounts considered valid or not?
36
     * If not, the class checks for a "catch-all" and if it determines the box
37
     * has a "catch-all", sets all the emails on that domain as invalid.
38
     *
39
     * @var bool
40
     */
41
    public $catchall_is_valid = true;
42
43
    /**
44
     * Whether to perform the "catch-all" test or not
45
     *
46
     * @var bool
47
     */
48
    public $catchall_test = false; // Set to true to perform a catchall test
49
50
    /**
51
     * Being unable to communicate with the remote MTA could mean an address
52
     * is invalid, but it might not, depending on your use case, set the
53
     * value appropriately.
54
     *
55
     * @var bool
56
     */
57
    public $no_comm_is_valid = false;
58
59
    /**
60
     * Being unable to connect with the remote host could mean a server
61
     * configuration issue, but it might not, depending on your use case,
62
     * set the value appropriately.
63
     */
64
    public $no_conn_is_valid = false;
65
66
    /**
67
     * Whether "greylisted" responses are considered as valid or invalid addresses
68
     *
69
     * @var bool
70
     */
71
    public $greylisted_considered_valid = true;
72
73
    /**
74
     * Timeout values for various commands (in seconds) per RFC 2821
75
     *
76
     * @var array
77
     */
78
    protected $command_timeouts = [
79
        'ehlo' => 120,
80
        'helo' => 120,
81
        'tls'  => 180, // start tls
82
        'mail' => 300, // mail from
83
        'rcpt' => 300, // rcpt to,
84
        'rset' => 30,
85
        'quit' => 60,
86
        'noop' => 60
87
    ];
88
89
    const CRLF = "\r\n";
90
91
    // Some smtp response codes
92
    const SMTP_CONNECT_SUCCESS = 220;
93
    const SMTP_QUIT_SUCCESS    = 221;
94
    const SMTP_GENERIC_SUCCESS = 250;
95
    const SMTP_USER_NOT_LOCAL  = 251;
96
    const SMTP_CANNOT_VRFY     = 252;
97
98
    const SMTP_SERVICE_UNAVAILABLE = 421;
99
100
    // 450 Requested mail action not taken: mailbox unavailable (e.g.,
101
    // mailbox busy or temporarily blocked for policy reasons)
102
    const SMTP_MAIL_ACTION_NOT_TAKEN = 450;
103
    // 451 Requested action aborted: local error in processing
104
    const SMTP_MAIL_ACTION_ABORTED = 451;
105
    // 452 Requested action not taken: insufficient system storage
106
    const SMTP_REQUESTED_ACTION_NOT_TAKEN = 452;
107
108
    // 500 Syntax error (may be due to a denied command)
109
    const SMTP_SYNTAX_ERROR = 500;
110
    // 502 Comment not implemented
111
    const SMTP_NOT_IMPLEMENTED = 502;
112
    // 503 Bad sequence of commands (may be due to a denied command)
113
    const SMTP_BAD_SEQUENCE = 503;
114
115
    // 550 Requested action not taken: mailbox unavailable (e.g., mailbox
116
    // not found, no access, or command rejected for policy reasons)
117
    const SMTP_MBOX_UNAVAILABLE = 550;
118
119
    // 554 Seen this from hotmail MTAs, in response to RSET :(
120
    const SMTP_TRANSACTION_FAILED = 554;
121
122
    /**
123
     * List of response codes considered as "greylisted"
124
     *
125
     * @var array
126
     */
127
    private $greylisted = [
128
        self::SMTP_MAIL_ACTION_NOT_TAKEN,
129
        self::SMTP_MAIL_ACTION_ABORTED,
130
        self::SMTP_REQUESTED_ACTION_NOT_TAKEN
131
    ];
132
133
    /**
134
     * Internal states we can be in
135
     *
136
     * @var array
137
     */
138
    private $state = [
139
        'helo' => false,
140
        'mail' => false,
141
        'rcpt' => false
142
    ];
143
144
    /**
145
     * Holds the socket connection resource
146
     *
147
     * @var resource
148
     */
149
    private $socket;
150
151
    /**
152
     * Holds all the domains we'll validate accounts on
153
     *
154
     * @var array
155
     */
156
    private $domains = [];
157
158
    /**
159
     * @var array
160
     */
161
    private $domains_info = [];
162
163
    /**
164
     * Default connect timeout for each MTA attempted (seconds)
165
     *
166
     * @var int
167
     */
168
    private $connect_timeout = 10;
169
170
    /**
171
     * Default sender username
172
     *
173
     * @var string
174
     */
175
    private $from_user = 'user';
176
177
    /**
178
     * Default sender host
179
     *
180
     * @var string
181
     */
182
    private $from_domain = 'localhost';
183
184
    /**
185
     * The host we're currently connected to
186
     *
187
     * @var string|null
188
     */
189
    private $host = null;
190
191
    /**
192
     * List of validation results
193
     *
194
     * @var array
195
     */
196
    private $results = [];
197
198
    /**
199
     * @param array|string $emails Email(s) to validate
200
     * @param string|null $sender Sender's email address
201
     */
202 12
    public function __construct($emails = [], $sender = null)
203
    {
204 12
        if (!empty($emails)) {
205 7
            $this->setEmails($emails);
206
        }
207 12
        if (null !== $sender) {
208 7
            $this->setSender($sender);
209
        }
210 12
    }
211
212
    /**
213
     * Disconnects from the SMTP server if needed to release resources
214
     */
215 12
    public function __destruct()
216
    {
217 12
        $this->disconnect(false);
218 12
    }
219
220 3
    public function acceptsAnyRecipient($domain)
221
    {
222 3
        if (!$this->catchall_test) {
223 1
            return false;
224
        }
225
226 2
        $test     = 'catch-all-test-' . time();
227 2
        $accepted = $this->rcpt($test . '@' . $domain);
228 2
        if ($accepted) {
229
            // Success on a non-existing address is a "catch-all"
230 2
            $this->domains_info[$domain]['catchall'] = true;
231 2
            return true;
232
        }
233
234
        // Log when we get disconnected while trying catchall detection
235
        $this->noop();
236
        if (!$this->connected()) {
237
            $this->debug('Disconnected after trying a non-existing recipient on ' . $domain);
238
        }
239
240
        /**
241
         * N.B.:
242
         * Disconnects are considered as a non-catch-all case this way, but
243
         * that might not always be the case.
244
         */
245
        return false;
246
    }
247
248
    /**
249
     * Performs validation of specified email addresses.
250
     *
251
     * @param array|string $emails Emails to validate (or a single one as a string)
252
     * @param string|null $sender Sender email address
253
     * @return array List of emails and their results
254
     */
255 7
    public function validate($emails = [], $sender = null)
256
    {
257 7
        $this->results = [];
258
259 7
        if (!empty($emails)) {
260 1
            $this->setEmails($emails);
261
        }
262 7
        if (null !== $sender) {
263 1
            $this->setSender($sender);
264
        }
265
266 7
        if (!is_array($this->domains) || empty($this->domains)) {
267 1
            return $this->results;
268
        }
269
270
        // Query the MTAs on each domain if we have them
271 6
        foreach ($this->domains as $domain => $users) {
272 6
            $mxs = [];
273
274
            // Query the MX records for the current domain
275 6
            list($hosts, $weights) = $this->mxQuery($domain);
276
277
            // Sort out the MX priorities
278 6
            foreach ($hosts as $k => $host) {
279
                $mxs[$host] = $weights[$k];
280
            }
281 6
            asort($mxs);
282
283
            // Add the hostname itself with 0 weight (RFC 2821)
284 6
            $mxs[$domain] = 0;
285
286 6
            $this->debug('MX records (' . $domain . '): ' . print_r($mxs, true));
287 6
            $this->domains_info[$domain]          = [];
288 6
            $this->domains_info[$domain]['users'] = $users;
289 6
            $this->domains_info[$domain]['mxs']   = $mxs;
290
291
            // Try each host, $_weight unused in the foreach body, but array_keys() doesn't guarantee the order
292 6
            foreach ($mxs as $host => $_weight) {
293
                // try connecting to the remote host
294
                try {
295 6
                    $this->connect($host);
296 4
                    if ($this->connected()) {
297 4
                        break;
298
                    }
299 2
                } catch (NoConnectionException $e) {
300
                    // Unable to connect to host, so these addresses are invalid?
301 2
                    $this->debug('Unable to connect. Exception caught: ' . $e->getMessage());
302 2
                    $this->setDomainResults($users, $domain, $this->no_conn_is_valid);
303
                }
304
            }
305
306
            // Are we connected?
307 6
            if ($this->connected()) {
308
                try {
309
                    // Say helo, and continue if we can talk
310 4
                    if ($this->helo()) {
311
                        // try issuing MAIL FROM
312 4
                        if (!$this->mail($this->from_user . '@' . $this->from_domain)) {
313
                            // MAIL FROM not accepted, we can't talk
314 1
                            $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
315
                        }
316
317
                        /**
318
                         * If we're still connected, proceed (cause we might get
319
                         * disconnected, or banned, or greylisted temporarily etc.)
320
                         * see mail() for more
321
                         */
322 4
                        if ($this->connected()) {
323 3
                            $this->noop();
324
325
                            // Attempt a catch-all test for the domain (if configured to do so)
326 3
                            $is_catchall_domain = $this->acceptsAnyRecipient($domain);
327
328
                            // If a catchall domain is detected, and we consider
329
                            // accounts on such domains as invalid, mark all the
330
                            // users as invalid and move on
331 3
                            if ($is_catchall_domain) {
332 2
                                if (!$this->catchall_is_valid) {
333 1
                                    $this->setDomainResults($users, $domain, $this->catchall_is_valid);
334 1
                                    continue;
335
                                }
336
                            }
337
338
                            // If we're still connected, try issuing rcpts
339 2
                            if ($this->connected()) {
340 2
                                $this->noop();
341
                                // RCPT for each user
342 2
                                foreach ($users as $user) {
343 2
                                    $address                 = $user . '@' . $domain;
344 2
                                    $this->results[$address] = $this->rcpt($address);
345 2
                                    $this->noop();
346
                                }
347
                            }
348
349
                            // Saying bye-bye if we're still connected, cause we're done here
350 2
                            if ($this->connected()) {
351
                                // Issue a RSET for all the things we just made the MTA do
352 2
                                $this->rset();
353 3
                                $this->disconnect();
354
                            }
355
                        }
356
                    } else {
357
                        // We didn't get a good response to helo and should be disconnected already
358 3
                        $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
359
                    }
360
                } catch (UnexpectedResponseException $e) {
361
                    // Unexpected responses handled as $this->no_comm_is_valid, that way anyone can
362
                    // decide for themselves if such results are considered valid or not
363
                    $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
364
                } catch (TimeoutException $e) {
365
                    // A timeout is a comm failure, so treat the results on that domain
366
                    // according to $this->no_comm_is_valid as well
367 5
                    $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
368
                }
369
            }
370
        } // outermost foreach
371
372 6
        return $this->getResults();
373
    }
374
375
    /**
376
     * Get validation results
377
     *
378
     * @param bool $include_domains_info Whether to include extra info in the results
379
     *
380
     * @return array
381
     */
382 7
    public function getResults($include_domains_info = true)
383
    {
384 7
        if ($include_domains_info) {
385 7
            $this->results['domains'] = $this->domains_info;
386
        } else {
387 1
            unset($this->results['domains']);
388
        }
389
390 7
        return $this->results;
391
    }
392
393
    /**
394
     * Helper to set results for all the users on a domain to a specific value
395
     *
396
     * @param array $users Users (usernames)
397
     * @param string $domain The domain for the users/usernames
398
     * @param bool $val Value to set
399
     *
400
     * @return void
401
     */
402 4
    private function setDomainResults(array $users, $domain, $val)
403
    {
404 4
        foreach ($users as $user) {
405 4
            $this->results[$user . '@' . $domain] = $val;
406
        }
407 4
    }
408
409
    /**
410
     * Returns true if we're connected to an MTA
411
     *
412
     * @return bool
413
     */
414 12
    protected function connected()
415
    {
416 12
        return is_resource($this->socket);
417
    }
418
419
    /**
420
     * Tries to connect to the specified host on the pre-configured port.
421
     *
422
     * @param string $host Host to connect to
423
     *
424
     * @throws NoConnectionException
425
     * @throws NoTimeoutException
426
     *
427
     * @return void
428
     */
429 6
    protected function connect($host)
430
    {
431 6
        $remote_socket = $host . ':' . $this->connect_port;
432 6
        $errnum        = 0;
433 6
        $errstr        = '';
434 6
        $this->host    = $remote_socket;
435
436
        // Open connection
437 6
        $this->debug('Connecting to ' . $this->host);
438
        // @codingStandardsIgnoreLine
439 6
        $this->socket = /** @scrutinizer ignore-unhandled */ @stream_socket_client(
440 6
            $this->host,
441 6
            $errnum,
442 6
            $errstr,
443 6
            $this->connect_timeout,
444 6
            STREAM_CLIENT_CONNECT,
445 6
            stream_context_create([])
446
        );
447
448
        // Check and throw if not connected
449 6
        if (!$this->connected()) {
450 2
            $this->debug('Connect failed: ' . $errstr . ', error number: ' . $errnum . ', host: ' . $this->host);
451 2
            throw new NoConnectionException('Cannot open a connection to remote host (' . $this->host . ')');
452
        }
453
454 4
        $result = stream_set_timeout($this->socket, $this->connect_timeout);
455 4
        if (!$result) {
456
            throw new NoTimeoutException('Cannot set timeout');
457
        }
458
459 4
        $this->debug('Connected to ' . $this->host . ' successfully');
460 4
    }
461
462
    /**
463
     * Disconnects the currently connected MTA.
464
     *
465
     * @param bool $quit Whether to send QUIT command before closing the socket on our end
466
     *
467
     * @return void
468
     */
469 12
    protected function disconnect($quit = true)
470
    {
471 12
        if ($quit) {
472 2
            $this->quit();
473
        }
474
475 12
        if ($this->connected()) {
476 4
            $this->debug('Closing socket to ' . $this->host);
477 4
            fclose($this->socket);
478
        }
479
480 12
        $this->host = null;
481 12
        $this->resetState();
482 12
    }
483
484
    /**
485
     * Resets internal state flags to defaults
486
     *
487
     * @return void
488
     */
489 12
    private function resetState()
490
    {
491 12
        $this->state['helo'] = false;
492 12
        $this->state['mail'] = false;
493 12
        $this->state['rcpt'] = false;
494 12
    }
495
496
    /**
497
     * Sends a HELO/EHLO sequence.
498
     *
499
     * @todo Implement TLS
500
     *
501
     * @return bool|null True if successful, false otherwise. Null if already done.
502
     */
503 4
    protected function helo()
504
    {
505
        // Don't do it if already done
506 4
        if ($this->state['helo']) {
507
            return null;
508
        }
509
510 4
        $result = false;
511
        try {
512 4
            $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['helo']);
513 4
            $this->ehlo();
514
515
            // Session started
516 4
            $this->state['helo'] = true;
517
518
            // Are we going for a TLS connection?
519
            /*
0 ignored issues
show
Unused Code Comprehensibility introduced by
57% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
520
            if ($this->tls) {
521
                // send STARTTLS, wait 3 minutes
522
                $this->send('STARTTLS');
523
                $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['tls']);
524
                $result = stream_socket_enable_crypto($this->socket, true,
525
                    STREAM_CRYPTO_METHOD_TLS_CLIENT);
526
                if (!$result) {
527
                    throw new SMTP_Validate_Email_Exception_No_TLS('Cannot enable TLS');
528
                }
529
            }
530
            */
531
532 4
            $result = true;
533
        } catch (UnexpectedResponseException $e) {
534
            // Connected, but got an unexpected response, so disconnect
535
            $result = false;
536
            $this->debug('Unexpected response after connecting: ' . $e->getMessage());
537
            $this->disconnect(false);
538
        }
539
540 4
        return $result;
541
    }
542
543
    /**
544
     * Sends `EHLO` or `HELO`, depending on what's supported by the remote host.
545
     *
546
     * @return void
547
     */
548 4
    protected function ehlo()
549
    {
550
        try {
551
            // Modern
552 4
            $this->send('EHLO ' . $this->from_domain);
553 4
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['ehlo']);
554
        } catch (UnexpectedResponseException $e) {
555
            // Legacy
556
            $this->send('HELO ' . $this->from_domain);
557
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['helo']);
558
        }
559 4
    }
560
561
    /**
562
     * Sends a `MAIL FROM` command which indicates the sender.
563
     *
564
     * @param string $from The "From:" address
565
     *
566
     * @throws NoHeloException
567
     *
568
     * @return bool Whether the command was accepted or not
569
     */
570 4
    protected function mail($from)
571
    {
572 4
        if (!$this->state['helo']) {
573
            throw new NoHeloException('Need HELO before MAIL FROM');
574
        }
575
576
        // Issue MAIL FROM, 5 minute timeout
577 4
        $this->send('MAIL FROM:<' . $from . '>');
578
579
        try {
580 4
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['mail']);
581
582
            // Set state flags
583 3
            $this->state['mail'] = true;
584 3
            $this->state['rcpt'] = false;
585
586 3
            $result = true;
587 1
        } catch (UnexpectedResponseException $e) {
588 1
            $result = false;
589
590
            // Got something unexpected in response to MAIL FROM
591 1
            $this->debug("Unexpected response to MAIL FROM\n:" . $e->getMessage());
592
593
            // Hotmail has been known to do this + was closing the connection
594
            // forcibly on their end, so we're killing the socket here too
595 1
            $this->disconnect(false);
596
        }
597
598 4
        return $result;
599
    }
600
601
    /**
602
     * Sends a RCPT TO command to indicate a recipient.
603
     *
604
     * @param string $to Recipient's email address
605
     * @throws NoMailFromException
606
     *
607
     * @return bool Whether the recipient was accepted or not
608
     */
609 3
    protected function rcpt($to)
610
    {
611
        // Need to have issued MAIL FROM first
612 3
        if (!$this->state['mail']) {
613
            throw new NoMailFromException('Need MAIL FROM before RCPT TO');
614
        }
615
616 3
        $valid          = false;
617
        $expected_codes = [
618 3
            self::SMTP_GENERIC_SUCCESS,
619 3
            self::SMTP_USER_NOT_LOCAL
620
        ];
621
622 3
        if ($this->greylisted_considered_valid) {
623 3
            $expected_codes = array_merge($expected_codes, $this->greylisted);
624
        }
625
626
        // Issue RCPT TO, 5 minute timeout
627
        try {
628 3
            $this->send('RCPT TO:<' . $to . '>');
629
            // Handle response
630
            try {
631 3
                $this->expect($expected_codes, $this->command_timeouts['rcpt']);
632 3
                $this->state['rcpt'] = true;
633 3
                $valid               = true;
634
            } catch (UnexpectedResponseException $e) {
635 3
                $this->debug('Unexpected response to RCPT TO: ' . $e->getMessage());
636
            }
637
        } catch (Exception $e) {
638
            $this->debug('Sending RCPT TO failed: ' . $e->getMessage());
639
        }
640
641 3
        return $valid;
642
    }
643
644
    /**
645
     * Sends a RSET command and resets certain parts of internal state.
646
     *
647
     * @return void
648
     */
649 2
    protected function rset()
650
    {
651 2
        $this->send('RSET');
652
653
        // MS ESMTP doesn't follow RFC according to ZF tracker, see [ZF-1377]
654
        $expected = [
655 2
            self::SMTP_GENERIC_SUCCESS,
656 2
            self::SMTP_CONNECT_SUCCESS,
657 2
            self::SMTP_NOT_IMPLEMENTED,
658
            // hotmail returns this o_O
659 2
            self::SMTP_TRANSACTION_FAILED
660
        ];
661 2
        $this->expect($expected, $this->command_timeouts['rset'], true);
662 2
        $this->state['mail'] = false;
663 2
        $this->state['rcpt'] = false;
664 2
    }
665
666
    /**
667
     * Sends a QUIT command.
668
     *
669
     * @return void
670
     */
671 2
    protected function quit()
672
    {
673
        // Although RFC says QUIT can be issued at any time, we won't
674 2
        if ($this->state['helo']) {
675 2
            $this->send('QUIT');
676 2
            $this->expect(
677 2
                [self::SMTP_GENERIC_SUCCESS,self::SMTP_QUIT_SUCCESS],
678 2
                $this->command_timeouts['quit'],
679 2
                true
680
            );
681
        }
682 2
    }
683
684
    /**
685
     * Sends a NOOP command.
686
     *
687
     * @return void
688
     */
689 3
    protected function noop()
690
    {
691 3
        $this->send('NOOP');
692
693
        /**
694
         * The `SMTP` string is here to fix issues with some bad RFC implementations.
695
         * Found at least 1 server replying to NOOP without any code.
696
         */
697
        $expected_codes = [
698 3
            'SMTP',
699 3
            self::SMTP_BAD_SEQUENCE,
700 3
            self::SMTP_NOT_IMPLEMENTED,
701 3
            self::SMTP_GENERIC_SUCCESS,
702 3
            self::SMTP_SYNTAX_ERROR,
703 3
            self::SMTP_CONNECT_SUCCESS
704
        ];
705 3
        $this->expect($expected_codes, $this->command_timeouts['noop'], true);
0 ignored issues
show
Bug introduced by
$expected_codes of type array<integer,integer|string> is incompatible with the type integer[]|integer|string expected by parameter $codes of SMTPValidateEmail\Validator::expect(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

705
        $this->expect(/** @scrutinizer ignore-type */ $expected_codes, $this->command_timeouts['noop'], true);
Loading history...
706 3
    }
707
708
    /**
709
     * Sends a command to the remote host.
710
     *
711
     * @param string $cmd The command to send
712
     *
713
     * @return int|bool Number of bytes written to the stream
714
     * @throws NoConnectionException
715
     * @throws SendFailedException
716
     */
717 4
    protected function send($cmd)
718
    {
719
        // Must be connected
720 4
        $this->throwIfNotConnected();
721
722 4
        $this->debug('send>>>: ' . $cmd);
723
        // Write the cmd to the connection stream
724 4
        $result = fwrite($this->socket, $cmd . self::CRLF);
725
726
        // Did it work?
727 4
        if (false === $result) {
728
            throw new SendFailedException('Send failed on: ' . $this->host);
729
        }
730
731 4
        return $result;
732
    }
733
734
    /**
735
     * Receives a response line from the remote host.
736
     *
737
     * @param int $timeout Timeout in seconds
738
     *
739
     * @return string
740
     *
741
     * @throws NoConnectionException
742
     * @throws TimeoutException
743
     * @throws NoResponseException
744
     */
745 4
    protected function recv($timeout = null)
746
    {
747
        // Must be connected
748 4
        $this->throwIfNotConnected();
749
750
        // Has a custom timeout been specified?
751 4
        if (null !== $timeout) {
752 4
            stream_set_timeout($this->socket, $timeout);
753
        }
754
755
        // Retrieve response
756 4
        $line = fgets($this->socket, 1024);
757 4
        $this->debug('<<<recv: ' . $line);
758
759
        // Have we timed out?
760 4
        $info = stream_get_meta_data($this->socket);
761 4
        if (!empty($info['timed_out'])) {
762
            throw new TimeoutException('Timed out in recv');
763
        }
764
765
        // Did we actually receive anything?
766 4
        if (false === $line) {
767
            throw new NoResponseException('No response in recv');
768
        }
769
770 4
        return $line;
771
    }
772
773
    /**
774
     * Receives lines from the remote host and looks for expected response codes.
775
     *
776
     * @param int|int[]|string $codes List of one or more expected response codes
777
     * @param int $timeout The timeout for this individual command, if any
778
     * @param bool $empty_response_allowed When true, empty responses are allowed
779
     *
780
     * @return string The last text message received
781
     *
782
     * @throws UnexpectedResponseException
783
     */
784 4
    protected function expect($codes, $timeout = null, $empty_response_allowed = false)
785
    {
786 4
        if (!is_array($codes)) {
787 4
            $codes = (array) $codes;
788
        }
789
790 4
        $code = null;
791 4
        $text = '';
792
793
        try {
794 4
            $line = $this->recv($timeout);
795 4
            $text = $line;
796 4
            while (preg_match('/^[0-9]+-/', $line)) {
797
                $line  = $this->recv($timeout);
798
                $text .= $line;
799
            }
800 4
            sscanf($line, '%d%s', $code, $text);
801
            // TODO/FIXME: This is terrible to read/comprehend
802 4
            if ($code == self::SMTP_SERVICE_UNAVAILABLE ||
803 4
                (false === $empty_response_allowed && (null === $code || !in_array($code, $codes)))) {
804 4
                throw new UnexpectedResponseException($line);
805
            }
806 1
        } catch (NoResponseException $e) {
807
            /**
808
             * No response in expect() probably means that the remote server
809
             * forcibly closed the connection so lets clean up on our end as well?
810
             */
811
            $this->debug('No response in expect(): ' . $e->getMessage());
812
            $this->disconnect(false);
813
        }
814
815 4
        return $text;
816
    }
817
818
    /**
819
     * Splits the email address string into its respective user and domain parts
820
     * and returns those as an array.
821
     *
822
     * @param string $email Email address
823
     *
824
     * @return array ['user', 'domain']
825
     */
826 8
    protected function splitEmail($email)
827
    {
828 8
        $parts  = explode('@', $email);
829 8
        $domain = array_pop($parts);
830 8
        $user   = implode('@', $parts);
831
832 8
        return [$user, $domain];
833
    }
834
835
    /**
836
     * Sets the email addresses that should be validated.
837
     *
838
     * @param array|string $emails List of email addresses (or a single one a string).
839
     *
840
     * @return void
841
     */
842 7
    public function setEmails($emails)
843
    {
844 7
        if (!is_array($emails)) {
845 6
            $emails = (array) $emails;
846
        }
847
848 7
        $this->domains = [];
849
850 7
        foreach ($emails as $email) {
851 7
            list($user, $domain) = $this->splitEmail($email);
852 7
            if (!isset($this->domains[$domain])) {
853 7
                $this->domains[$domain] = [];
854
            }
855 7
            $this->domains[$domain][] = $user;
856
        }
857 7
    }
858
859
    /**
860
     * Sets the email address to use as the sender/validator.
861
     *
862
     * @param string $email
863
     *
864
     * @return void
865
     */
866 7
    public function setSender($email)
867
    {
868 7
        $parts             = $this->splitEmail($email);
869 7
        $this->from_user   = $parts[0];
870 7
        $this->from_domain = $parts[1];
871 7
    }
872
873
    /**
874
     * Queries the DNS server for MX entries of a certain domain.
875
     *
876
     * @param string $domain The domain for which to retrieve MX records
877
     * @return array MX hosts and their weights
878
     */
879 6
    protected function mxQuery($domain)
880
    {
881 6
        $hosts  = [];
882 6
        $weight = [];
883 6
        getmxrr($domain, $hosts, $weight);
884
885 6
        return [$hosts, $weight];
886
    }
887
888
    /**
889
     * Throws if not currently connected.
890
     *
891
     * @return void
892
     * @throws NoConnectionException
893
     */
894 4
    private function throwIfNotConnected()
895
    {
896 4
        if (!$this->connected()) {
897
            throw new NoConnectionException('No connection');
898
        }
899 4
    }
900
901
    /**
902
     * Debug helper. If it detects a CLI env, it just dumps given `$str` on a
903
     * new line, otherwise it prints stuff <pre>.
904
     *
905
     * @param string $str
906
     *
907
     * @return void
908
     */
909 6
    private function debug($str)
910
    {
911 6
        $str = $this->stamp($str);
912 6
        $this->log($str);
913 6
        if ($this->debug) {
914 1
            if ('cli' !== PHP_SAPI) {
915
                $str = '<br/><pre>' . htmlspecialchars($str) . '</pre>';
916
            }
917 1
            echo "\n" . $str;
918
        }
919 6
    }
920
921
    /**
922
     * Adds a message to the log array
923
     *
924
     * @param string $msg
925
     *
926
     * @return void
927
     */
928 6
    private function log($msg)
929
    {
930 6
        $this->log[] = $msg;
931 6
    }
932
933
    /**
934
     * Prepends the given $msg with the current date and time inside square brackets.
935
     *
936
     * @param string $msg
937
     *
938
     * @return string
939
     */
940 6
    private function stamp($msg)
941
    {
942 6
        $date = \DateTime::createFromFormat('U.u', sprintf('%.f', microtime(true)))->format('Y-m-d\TH:i:s.uO');
943 6
        $line = '[' . $date . '] ' . $msg;
944
945 6
        return $line;
946
    }
947
948
    /**
949
     * Returns the log array
950
     *
951
     * @return array
952
     */
953 2
    public function getLog()
954
    {
955 2
        return $this->log;
956
    }
957
958
    /**
959
     * Truncates the log array
960
     *
961
     * @return void
962
     */
963 1
    public function clearLog()
964
    {
965 1
        $this->log = [];
966 1
    }
967
968
    /**
969
     * Compat for old lower_cased method calls.
970
     *
971
     * @param string $name
972
     * @param array  $args
973
     *
974
     * @return void
975
     */
976 2
    public function __call($name, $args)
977
    {
978 2
        $camelized = self::camelize($name);
979 2
        if (\method_exists($this, $camelized)) {
980 2
            return \call_user_func_array([$this, $camelized], $args);
981
        } else {
982 1
            trigger_error('Fatal error: Call to undefined method ' . self::class . '::' . $name . '()', E_USER_ERROR);
983
        }
984
    }
985
986
    /**
987
     * Set the desired connect timeout.
988
     *
989
     * @param int $timeout Connect timeout in seconds
990
     *
991
     * @return void
992
     */
993 6
    public function setConnectTimeout($timeout)
994
    {
995 6
        $this->connect_timeout = (int) $timeout;
996 6
    }
997
998
    /**
999
     * Get the current connect timeout.
1000
     *
1001
     * @return int
1002
     */
1003 1
    public function getConnectTimeout()
1004
    {
1005 1
        return $this->connect_timeout;
1006
    }
1007
1008
    /**
1009
     * Set connect port.
1010
     *
1011
     * @param int $port
1012
     *
1013
     * @return void
1014
     */
1015 5
    public function setConnectPort($port)
1016
    {
1017 5
        $this->connect_port = (int) $port;
1018 5
    }
1019
1020
    /**
1021
     * Get current connect port.
1022
     *
1023
     * @return int
1024
     */
1025 1
    public function getConnectPort()
1026
    {
1027 1
        return $this->connect_port;
1028
    }
1029
1030
    /**
1031
     * Turn on "catch-all" detection.
1032
     *
1033
     * @return void
1034
     */
1035 3
    public function enableCatchAllTest()
1036
    {
1037 3
        $this->catchall_test = true;
1038 3
    }
1039
1040
    /**
1041
     * Turn off "catch-all" detection.
1042
     *
1043
     * @return void
1044
     */
1045 1
    public function disableCatchAllTest()
1046
    {
1047 1
        $this->catchall_test = false;
1048 1
    }
1049
1050
    /**
1051
     * Returns whether "catch-all" test is to be performed or not.
1052
     *
1053
     * @return bool
1054
     */
1055 1
    public function isCatchAllEnabled()
1056
    {
1057 1
        return $this->catchall_test;
1058
    }
1059
1060
    /**
1061
     * Set whether "catch-all" results are considered valid or not.
1062
     *
1063
     * @param bool $flag When true, "catch-all" accounts are considered valid
1064
     *
1065
     * @return void
1066
     */
1067 2
    public function setCatchAllValidity($flag)
1068
    {
1069 2
        $this->catchall_is_valid = (bool) $flag;
1070 2
    }
1071
1072
    /**
1073
     * Get current state of "catch-all" validity flag.
1074
     *
1075
     * @return bool
1076
     */
1077 1
    public function getCatchAllValidity()
1078
    {
1079 1
        return $this->catchall_is_valid;
1080
    }
1081
1082
    /**
1083
     * Camelizes a string.
1084
     *
1085
     * @param string $id A string to camelize
1086
     *
1087
     * @return string The camelized string
1088
     */
1089 2
    private static function camelize($id)
1090
    {
1091 2
        return strtr(
1092 2
            ucwords(
1093 2
                strtr(
1094 2
                    $id,
1095 2
                    ['_' => ' ', '.' => '_ ', '\\' => '_ ']
1096
                )
1097
            ),
1098 2
            [' ' => '']
1099
        );
1100
    }
1101
}
1102