Passed
Push — master ( 42d287...bb6813 )
by zyt
04:34
created

Validator::noop()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 17
c 0
b 0
f 0
ccs 9
cts 9
cp 1
rs 9.9666
cc 1
nc 1
nop 0
crap 1
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
     * Stream context arguments for connection socket, necessary to initiate
75
     * Server IP (in case reverse IP), see: https://stackoverflow.com/a/8968016
76
     */
77
    public $stream_context_args = [];
78
79
    /**
80
     * Timeout values for various commands (in seconds) per RFC 2821
81
     *
82
     * @var array
83
     */
84
    protected $command_timeouts = [
85
        'ehlo' => 120,
86
        'helo' => 120,
87
        'tls'  => 180, // start tls
88
        'mail' => 300, // mail from
89
        'rcpt' => 300, // rcpt to,
90
        'rset' => 30,
91
        'quit' => 60,
92
        'noop' => 60
93
    ];
94
95
    const CRLF = "\r\n";
96
97
    // Some smtp response codes
98
    const SMTP_CONNECT_SUCCESS = 220;
99
    const SMTP_QUIT_SUCCESS    = 221;
100
    const SMTP_GENERIC_SUCCESS = 250;
101
    const SMTP_USER_NOT_LOCAL  = 251;
102
    const SMTP_CANNOT_VRFY     = 252;
103
104
    const SMTP_SERVICE_UNAVAILABLE = 421;
105
106
    // 450 Requested mail action not taken: mailbox unavailable (e.g.,
107
    // mailbox busy or temporarily blocked for policy reasons)
108
    const SMTP_MAIL_ACTION_NOT_TAKEN = 450;
109
    // 451 Requested action aborted: local error in processing
110
    const SMTP_MAIL_ACTION_ABORTED = 451;
111
    // 452 Requested action not taken: insufficient system storage
112
    const SMTP_REQUESTED_ACTION_NOT_TAKEN = 452;
113
114
    // 500 Syntax error (may be due to a denied command)
115
    const SMTP_SYNTAX_ERROR = 500;
116
    // 502 Comment not implemented
117
    const SMTP_NOT_IMPLEMENTED = 502;
118
    // 503 Bad sequence of commands (may be due to a denied command)
119
    const SMTP_BAD_SEQUENCE = 503;
120
121
    // 550 Requested action not taken: mailbox unavailable (e.g., mailbox
122
    // not found, no access, or command rejected for policy reasons)
123
    const SMTP_MBOX_UNAVAILABLE = 550;
124
125
    // 554 Seen this from hotmail MTAs, in response to RSET :(
126
    const SMTP_TRANSACTION_FAILED = 554;
127
128
    /**
129
     * List of response codes considered as "greylisted"
130
     *
131
     * @var array
132
     */
133
    private $greylisted = [
134
        self::SMTP_MAIL_ACTION_NOT_TAKEN,
135
        self::SMTP_MAIL_ACTION_ABORTED,
136
        self::SMTP_REQUESTED_ACTION_NOT_TAKEN
137
    ];
138
139
    /**
140
     * Internal states we can be in
141
     *
142
     * @var array
143
     */
144
    private $state = [
145
        'helo' => false,
146
        'mail' => false,
147
        'rcpt' => false
148
    ];
149
150
    /**
151
     * Holds the socket connection resource
152
     *
153
     * @var resource
154
     */
155
    private $socket;
156
157
    /**
158
     * Holds all the domains we'll validate accounts on
159
     *
160
     * @var array
161
     */
162
    private $domains = [];
163
164
    /**
165
     * @var array
166
     */
167
    private $domains_info = [];
168
169
    /**
170
     * Default connect timeout for each MTA attempted (seconds)
171
     *
172
     * @var int
173
     */
174
    private $connect_timeout = 10;
175
176
    /**
177
     * Default sender username
178
     *
179
     * @var string
180
     */
181
    private $from_user = 'user';
182
183
    /**
184
     * Default sender host
185
     *
186
     * @var string
187
     */
188
    private $from_domain = 'localhost';
189
190
    /**
191
     * The host we're currently connected to
192
     *
193
     * @var string|null
194
     */
195
    private $host = null;
196
197
    /**
198
     * List of validation results
199
     *
200
     * @var array
201
     */
202
    private $results = [];
203
204
    /**
205
     * @param array|string $emails Email(s) to validate
206
     * @param string|null $sender Sender's email address
207
     */
208 15
    public function __construct($emails = [], $sender = null)
209
    {
210 15
        if (!empty($emails)) {
211 10
            $this->setEmails($emails);
212
        }
213 15
        if (null !== $sender) {
214 10
            $this->setSender($sender);
215
        }
216 15
    }
217
218
    /**
219
     * Disconnects from the SMTP server if needed to release resources
220
     */
221 15
    public function __destruct()
222
    {
223 15
        $this->disconnect(false);
224 15
    }
225
226 3
    public function acceptsAnyRecipient($domain)
227
    {
228 3
        if (!$this->catchall_test) {
229 1
            return false;
230
        }
231
232 2
        $test     = 'catch-all-test-' . time();
233 2
        $accepted = $this->rcpt($test . '@' . $domain);
234 2
        if ($accepted) {
235
            // Success on a non-existing address is a "catch-all"
236 2
            $this->domains_info[$domain]['catchall'] = true;
237 2
            return true;
238
        }
239
240
        // Log when we get disconnected while trying catchall detection
241
        $this->noop();
242
        if (!$this->connected()) {
243
            $this->debug('Disconnected after trying a non-existing recipient on ' . $domain);
244
        }
245
246
        /**
247
         * N.B.:
248
         * Disconnects are considered as a non-catch-all case this way, but
249
         * that might not always be the case.
250
         */
251
        return false;
252
    }
253
254
    /**
255
     * Performs validation of specified email addresses.
256
     *
257
     * @param array|string $emails Emails to validate (or a single one as a string)
258
     * @param string|null $sender Sender email address
259
     * @return array List of emails and their results
260
     */
261 10
    public function validate($emails = [], $sender = null)
262
    {
263 10
        $this->results = [];
264
265 10
        if (!empty($emails)) {
266 1
            $this->setEmails($emails);
267
        }
268 10
        if (null !== $sender) {
269 1
            $this->setSender($sender);
270
        }
271
272 10
        if (empty($this->domains)) {
273 1
            return $this->results;
274
        }
275
276 9
        $this->loop();
277
278 8
        return $this->getResults();
279
    }
280
281
    /**
282
     * @return void
283
     */
284 9
    protected function loop()
285
    {
286
        // Query the MTAs on each domain if we have them
287 9
        foreach ($this->domains as $domain => $users) {
288 9
            $mxs = $this->buildMxs($domain);
289
290 9
            $this->debug('MX records (' . $domain . '): ' . print_r($mxs, true));
291 9
            $this->domains_info[$domain]          = [];
292 9
            $this->domains_info[$domain]['users'] = $users;
293 9
            $this->domains_info[$domain]['mxs']   = $mxs;
294
295
            // Set default results as though we can't communicate at all...
296 9
            $this->setDomainResults($users, $domain, $this->no_conn_is_valid);
297 9
            $this->attemptConnection($mxs, $domain, $users);
0 ignored issues
show
Unused Code introduced by
The call to SMTPValidateEmail\Validator::attemptConnection() has too many arguments starting with $domain. ( Ignorable by Annotation )

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

297
            $this->/** @scrutinizer ignore-call */ 
298
                   attemptConnection($mxs, $domain, $users);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
298 9
            $this->performSmtpDance($domain, $users);
299
        }
300 8
    }
301
302
    /**
303
     * @param string $domain
304
     * @return array
305
     */
306 9
    protected function buildMxs($domain)
307
    {
308 9
        $mxs = [];
309
310
        // Query the MX records for the current domain
311 9
        list($hosts, $weights) = $this->mxQuery($domain);
312
313
        // Sort out the MX priorities
314 9
        foreach ($hosts as $k => $host) {
315
            $mxs[$host] = $weights[$k];
316
        }
317 9
        asort($mxs);
318
319
        // Add the hostname itself with 0 weight (RFC 2821)
320 9
        $mxs[$domain] = 0;
321
322 9
        return $mxs;
323
    }
324
325
    /**
326
     * @param array $mxs
327
     * @return void
328
     */
329 9
    protected function attemptConnection(array $mxs)
330
    {
331
        // Try each host, $_weight unused in the foreach body, but array_keys() doesn't guarantee the order
332 9
        foreach ($mxs as $host => $_weight) {
333
            // try connecting to the remote host
334
            try {
335 9
                $this->connect($host);
336 5
                if ($this->connected()) {
337 5
                    break;
338
                }
339 4
            } catch (NoConnectionException $e) {
340
                // Unable to connect to host, so these addresses are invalid?
341 4
                $this->debug('Unable to connect. Exception caught: ' . $e->getMessage());
342
                //$this->setDomainResults($users, $domain, $this->no_conn_is_valid);
343
            }
344
        }
345 9
    }
346
347 9
    protected function performSmtpDance($domain, array $users)
348
    {
349
        // Are we connected?
350 9
        if ($this->connected()) {
351
            try {
352
                // Say helo, and continue if we can talk
353 5
                if ($this->helo()) {
354
                    // try issuing MAIL FROM
355 4
                    if (!$this->mail($this->from_user . '@' . $this->from_domain)) {
356
                        // MAIL FROM not accepted, we can't talk
357 1
                        $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
358
                    }
359
360
                    /**
361
                     * If we're still connected, proceed (cause we might get
362
                     * disconnected, or banned, or greylisted temporarily etc.)
363
                     * see mail() for more
364
                     */
365 4
                    if ($this->connected()) {
366 3
                        $this->noop();
367
368
                        // Attempt a catch-all test for the domain (if configured to do so)
369 3
                        $is_catchall_domain = $this->acceptsAnyRecipient($domain);
370
371
                        // If a catchall domain is detected, and we consider
372
                        // accounts on such domains as invalid, mark all the
373
                        // users as invalid and move on
374 3
                        if ($is_catchall_domain) {
375 2
                            if (!$this->catchall_is_valid) {
376 1
                                $this->setDomainResults($users, $domain, $this->catchall_is_valid);
377 1
                                return;
378
                            }
379
                        }
380
381
                        // If we're still connected, try issuing rcpts
382 2
                        if ($this->connected()) {
383 2
                            $this->noop();
384
                            // RCPT for each user
385 2
                            foreach ($users as $user) {
386 2
                                $address                 = $user . '@' . $domain;
387 2
                                $this->results[$address] = $this->rcpt($address);
388 2
                                $this->noop();
389
                            }
390
                        }
391
392
                        // Saying bye-bye if we're still connected, cause we're done here
393 2
                        if ($this->connected()) {
394
                            // Issue a RSET for all the things we just made the MTA do
395 2
                            $this->rset();
396 3
                            $this->disconnect();
397
                        }
398
                    }
399
                }
400 1
            } catch (UnexpectedResponseException $e) {
401
                // Unexpected responses handled as $this->no_comm_is_valid, that way anyone can
402
                // decide for themselves if such results are considered valid or not
403
                $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
404 1
            } catch (TimeoutException $e) {
405
                // A timeout is a comm failure, so treat the results on that domain
406
                // according to $this->no_comm_is_valid as well
407
                $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
408
            }
409
        }
410 7
    }
411
412
    /**
413
     * Get validation results
414
     *
415
     * @param bool $include_domains_info Whether to include extra info in the results
416
     *
417
     * @return array
418
     */
419 9
    public function getResults($include_domains_info = true)
420
    {
421 9
        if ($include_domains_info) {
422 9
            $this->results['domains'] = $this->domains_info;
423
        } else {
424 1
            unset($this->results['domains']);
425
        }
426
427 9
        return $this->results;
428
    }
429
430
    /**
431
     * Helper to set results for all the users on a domain to a specific value
432
     *
433
     * @param array $users Users (usernames)
434
     * @param string $domain The domain for the users/usernames
435
     * @param bool $val Value to set
436
     *
437
     * @return void
438
     */
439 9
    private function setDomainResults(array $users, $domain, $val)
440
    {
441 9
        foreach ($users as $user) {
442 9
            $this->results[$user . '@' . $domain] = $val;
443
        }
444 9
    }
445
446
    /**
447
     * Returns true if we're connected to an MTA
448
     *
449
     * @return bool
450
     */
451 15
    protected function connected()
452
    {
453 15
        return is_resource($this->socket);
454
    }
455
456
    /**
457
     * Tries to connect to the specified host on the pre-configured port.
458
     *
459
     * @param string $host Host to connect to
460
     *
461
     * @throws NoConnectionException
462
     * @throws NoTimeoutException
463
     *
464
     * @return void
465
     */
466 9
    protected function connect($host)
467
    {
468 9
        $remote_socket = $host . ':' . $this->connect_port;
469 9
        $errnum        = 0;
470 9
        $errstr        = '';
471 9
        $this->host    = $remote_socket;
472
473
        // Open connection
474 9
        $this->debug('Connecting to ' . $this->host);
475
        // @codingStandardsIgnoreLine
476 9
        $this->socket = /** @scrutinizer ignore-unhandled */ @stream_socket_client(
0 ignored issues
show
Documentation Bug introduced by
It seems like @stream_socket_client($t...->stream_context_args)) can also be of type false. However, the property $socket is declared as type resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
477 9
            $this->host,
478 9
            $errnum,
479 9
            $errstr,
480 9
            $this->connect_timeout,
481 9
            STREAM_CLIENT_CONNECT,
482 9
            stream_context_create($this->stream_context_args)
483
        );
484
485
        // Check and throw if not connected
486 9
        if (!$this->connected()) {
487 4
            $this->debug('Connect failed: ' . $errstr . ', error number: ' . $errnum . ', host: ' . $this->host);
488 4
            throw new NoConnectionException('Cannot open a connection to remote host (' . $this->host . ')');
489
        }
490
491 5
        $result = stream_set_timeout($this->socket, $this->connect_timeout);
492 5
        if (!$result) {
493
            throw new NoTimeoutException('Cannot set timeout');
494
        }
495
496 5
        $this->debug('Connected to ' . $this->host . ' successfully');
497 5
    }
498
499
    /**
500
     * Disconnects the currently connected MTA.
501
     *
502
     * @param bool $quit Whether to send QUIT command before closing the socket on our end
503
     *
504
     * @return void
505
     */
506 15
    protected function disconnect($quit = true)
507
    {
508 15
        if ($quit) {
509 2
            $this->quit();
510
        }
511
512 15
        if ($this->connected()) {
513 5
            $this->debug('Closing socket to ' . $this->host);
514 5
            fclose($this->socket);
515
        }
516
517 15
        $this->host = null;
518 15
        $this->resetState();
519 15
    }
520
521
    /**
522
     * Resets internal state flags to defaults
523
     *
524
     * @return void
525
     */
526 15
    private function resetState()
527
    {
528 15
        $this->state['helo'] = false;
529 15
        $this->state['mail'] = false;
530 15
        $this->state['rcpt'] = false;
531 15
    }
532
533
    /**
534
     * Sends a HELO/EHLO sequence.
535
     *
536
     * @todo Implement TLS
537
     *
538
     * @return bool|null True if successful, false otherwise. Null if already done.
539
     */
540 5
    protected function helo()
541
    {
542
        // Don't do it if already done
543 5
        if ($this->state['helo']) {
544
            return null;
545
        }
546
547 5
        $result = false;
548
        try {
549 5
            $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['helo']);
550 5
            $this->ehlo();
551
552
            // Session started
553 4
            $this->state['helo'] = true;
554
555
            // Are we going for a TLS connection?
556
            /*
557
            if ($this->tls) {
558
                // send STARTTLS, wait 3 minutes
559
                $this->send('STARTTLS');
560
                $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['tls']);
561
                $result = stream_socket_enable_crypto($this->socket, true,
562
                    STREAM_CRYPTO_METHOD_TLS_CLIENT);
563
                if (!$result) {
564
                    throw new SMTP_Validate_Email_Exception_No_TLS('Cannot enable TLS');
565
                }
566
            }
567
            */
568
569 4
            $result = true;
570 1
        } catch (UnexpectedResponseException $e) {
571
            // Connected, but got an unexpected response, so disconnect
572
            $result = false;
573
            $this->debug('Unexpected response after connecting: ' . $e->getMessage());
574
            $this->disconnect(false);
575
        }
576
577 4
        return $result;
578
    }
579
580
    /**
581
     * Sends `EHLO` or `HELO`, depending on what's supported by the remote host.
582
     *
583
     * @return void
584
     */
585 5
    protected function ehlo()
586
    {
587
        try {
588
            // Modern
589 5
            $this->send('EHLO ' . $this->from_domain);
590 4
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['ehlo']);
591 1
        } catch (UnexpectedResponseException $e) {
592
            // Legacy
593
            $this->send('HELO ' . $this->from_domain);
594
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['helo']);
595
        }
596 4
    }
597
598
    /**
599
     * Sends a `MAIL FROM` command which indicates the sender.
600
     *
601
     * @param string $from The "From:" address
602
     *
603
     * @throws NoHeloException
604
     *
605
     * @return bool Whether the command was accepted or not
606
     */
607 4
    protected function mail($from)
608
    {
609 4
        if (!$this->state['helo']) {
610
            throw new NoHeloException('Need HELO before MAIL FROM');
611
        }
612
613
        // Issue MAIL FROM, 5 minute timeout
614 4
        $this->send('MAIL FROM:<' . $from . '>');
615
616
        try {
617 4
            $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['mail']);
618
619
            // Set state flags
620 3
            $this->state['mail'] = true;
621 3
            $this->state['rcpt'] = false;
622
623 3
            $result = true;
624 1
        } catch (UnexpectedResponseException $e) {
625 1
            $result = false;
626
627
            // Got something unexpected in response to MAIL FROM
628 1
            $this->debug("Unexpected response to MAIL FROM\n:" . $e->getMessage());
629
630
            // Hotmail has been known to do this + was closing the connection
631
            // forcibly on their end, so we're killing the socket here too
632 1
            $this->disconnect(false);
633
        }
634
635 4
        return $result;
636
    }
637
638
    /**
639
     * Sends a RCPT TO command to indicate a recipient.
640
     *
641
     * @param string $to Recipient's email address
642
     * @throws NoMailFromException
643
     *
644
     * @return bool Whether the recipient was accepted or not
645
     */
646 3
    protected function rcpt($to)
647
    {
648
        // Need to have issued MAIL FROM first
649 3
        if (!$this->state['mail']) {
650
            throw new NoMailFromException('Need MAIL FROM before RCPT TO');
651
        }
652
653 3
        $valid          = false;
654
        $expected_codes = [
655 3
            self::SMTP_GENERIC_SUCCESS,
656 3
            self::SMTP_USER_NOT_LOCAL
657
        ];
658
659 3
        if ($this->greylisted_considered_valid) {
660 3
            $expected_codes = array_merge($expected_codes, $this->greylisted);
661
        }
662
663
        // Issue RCPT TO, 5 minute timeout
664
        try {
665 3
            $this->send('RCPT TO:<' . $to . '>');
666
            // Handle response
667
            try {
668 3
                $this->expect($expected_codes, $this->command_timeouts['rcpt']);
669 3
                $this->state['rcpt'] = true;
670 3
                $valid               = true;
671
            } catch (UnexpectedResponseException $e) {
672 3
                $this->debug('Unexpected response to RCPT TO: ' . $e->getMessage());
673
            }
674
        } catch (Exception $e) {
675
            $this->debug('Sending RCPT TO failed: ' . $e->getMessage());
676
        }
677
678 3
        return $valid;
679
    }
680
681
    /**
682
     * Sends a RSET command and resets certain parts of internal state.
683
     *
684
     * @return void
685
     */
686 2
    protected function rset()
687
    {
688 2
        $this->send('RSET');
689
690
        // MS ESMTP doesn't follow RFC according to ZF tracker, see [ZF-1377]
691
        $expected = [
692 2
            self::SMTP_GENERIC_SUCCESS,
693 2
            self::SMTP_CONNECT_SUCCESS,
694 2
            self::SMTP_NOT_IMPLEMENTED,
695
            // hotmail returns this o_O
696 2
            self::SMTP_TRANSACTION_FAILED
697
        ];
698 2
        $this->expect($expected, $this->command_timeouts['rset'], true);
699 2
        $this->state['mail'] = false;
700 2
        $this->state['rcpt'] = false;
701 2
    }
702
703
    /**
704
     * Sends a QUIT command.
705
     *
706
     * @return void
707
     */
708 2
    protected function quit()
709
    {
710
        // Although RFC says QUIT can be issued at any time, we won't
711 2
        if ($this->state['helo']) {
712 2
            $this->send('QUIT');
713 2
            $this->expect(
714 2
                [self::SMTP_GENERIC_SUCCESS,self::SMTP_QUIT_SUCCESS],
715 2
                $this->command_timeouts['quit'],
716 2
                true
717
            );
718
        }
719 2
    }
720
721
    /**
722
     * Sends a NOOP command.
723
     *
724
     * @return void
725
     */
726 3
    protected function noop()
727
    {
728 3
        $this->send('NOOP');
729
730
        /**
731
         * The `SMTP` string is here to fix issues with some bad RFC implementations.
732
         * Found at least 1 server replying to NOOP without any code.
733
         */
734
        $expected_codes = [
735 3
            'SMTP',
736 3
            self::SMTP_BAD_SEQUENCE,
737 3
            self::SMTP_NOT_IMPLEMENTED,
738 3
            self::SMTP_GENERIC_SUCCESS,
739 3
            self::SMTP_SYNTAX_ERROR,
740 3
            self::SMTP_CONNECT_SUCCESS
741
        ];
742 3
        $this->expect($expected_codes, $this->command_timeouts['noop'], true);
743 3
    }
744
745
    /**
746
     * Sends a command to the remote host.
747
     *
748
     * @param string $cmd The command to send
749
     *
750
     * @return int|bool Number of bytes written to the stream
751
     * @throws NoConnectionException
752
     * @throws SendFailedException
753
     */
754 5
    protected function send($cmd)
755
    {
756
        // Must be connected
757 5
        $this->throwIfNotConnected();
758
759 4
        $this->debug('send>>>: ' . $cmd);
760
        // Write the cmd to the connection stream
761 4
        $result = fwrite($this->socket, $cmd . self::CRLF);
762
763
        // Did it work?
764 4
        if (false === $result) {
765
            throw new SendFailedException('Send failed on: ' . $this->host);
766
        }
767
768 4
        return $result;
769
    }
770
771
    /**
772
     * Receives a response line from the remote host.
773
     *
774
     * @param int $timeout Timeout in seconds
775
     *
776
     * @return string
777
     *
778
     * @throws NoConnectionException
779
     * @throws TimeoutException
780
     * @throws NoResponseException
781
     */
782 5
    protected function recv($timeout = null)
783
    {
784
        // Must be connected
785 5
        $this->throwIfNotConnected();
786
787
        // Has a custom timeout been specified?
788 5
        if (null !== $timeout) {
789 5
            stream_set_timeout($this->socket, $timeout);
790
        }
791
792
        // Retrieve response
793 5
        $line = fgets($this->socket, 1024);
794 5
        $this->debug('<<<recv: ' . $line);
795
796
        // Have we timed out?
797 5
        $info = stream_get_meta_data($this->socket);
798 5
        if (!empty($info['timed_out'])) {
799
            throw new TimeoutException('Timed out in recv');
800
        }
801
802
        // Did we actually receive anything?
803 5
        if (false === $line) {
804 1
            throw new NoResponseException('No response in recv');
805
        }
806
807 4
        return $line;
808
    }
809
810
    /**
811
     * Receives lines from the remote host and looks for expected response codes.
812
     *
813
     * @param int|int[]|array|string $codes List of one or more expected response codes
814
     * @param int $timeout The timeout for this individual command, if any
815
     * @param bool $empty_response_allowed When true, empty responses are allowed
816
     *
817
     * @return string The last text message received
818
     *
819
     * @throws UnexpectedResponseException
820
     */
821 5
    protected function expect($codes, $timeout = null, $empty_response_allowed = false)
822
    {
823 5
        if (!is_array($codes)) {
824 5
            $codes = (array) $codes;
825
        }
826
827 5
        $code = null;
828 5
        $text = '';
829
830
        try {
831 5
            $line = $this->recv($timeout);
832 4
            $text = $line;
833 4
            while (preg_match('/^[0-9]+-/', $line)) {
834 4
                $line  = $this->recv($timeout);
835 4
                $text .= $line;
836
            }
837 4
            sscanf($line, '%d%s', $code, $text);
838
            // TODO/FIXME: This is terrible to read/comprehend
839 4
            if ($code == self::SMTP_SERVICE_UNAVAILABLE ||
840 4
                (false === $empty_response_allowed && (null === $code || !in_array($code, $codes)))) {
841 4
                throw new UnexpectedResponseException($line);
842
            }
843 2
        } catch (NoResponseException $e) {
844
            /**
845
             * No response in expect() probably means that the remote server
846
             * forcibly closed the connection so lets clean up on our end as well?
847
             */
848 1
            $this->debug('No response in expect(): ' . $e->getMessage());
849 1
            $this->disconnect(false);
850
        }
851
852 5
        return $text;
853
    }
854
855
    /**
856
     * Splits the email address string into its respective user and domain parts
857
     * and returns those as an array.
858
     *
859
     * @param string $email Email address
860
     *
861
     * @return array ['user', 'domain']
862
     */
863 11
    protected function splitEmail($email)
864
    {
865 11
        $parts  = explode('@', $email);
866 11
        $domain = array_pop($parts);
867 11
        $user   = implode('@', $parts);
868
869 11
        return [$user, $domain];
870
    }
871
872
    /**
873
     * Sets the email addresses that should be validated.
874
     *
875
     * @param array|string $emails List of email addresses (or a single one a string).
876
     *
877
     * @return void
878
     */
879 10
    public function setEmails($emails)
880
    {
881 10
        if (!is_array($emails)) {
882 9
            $emails = (array) $emails;
883
        }
884
885 10
        $this->domains = [];
886
887 10
        foreach ($emails as $email) {
888 10
            list($user, $domain) = $this->splitEmail($email);
889 10
            if (!isset($this->domains[$domain])) {
890 10
                $this->domains[$domain] = [];
891
            }
892 10
            $this->domains[$domain][] = $user;
893
        }
894 10
    }
895
896
    /**
897
     * Sets the email address to use as the sender/validator.
898
     *
899
     * @param string $email
900
     *
901
     * @return void
902
     */
903 10
    public function setSender($email)
904
    {
905 10
        $parts             = $this->splitEmail($email);
906 10
        $this->from_user   = $parts[0];
907 10
        $this->from_domain = $parts[1];
908 10
    }
909
910
    /**
911
     * Queries the DNS server for MX entries of a certain domain.
912
     *
913
     * @param string $domain The domain for which to retrieve MX records
914
     * @return array MX hosts and their weights
915
     */
916 9
    protected function mxQuery($domain)
917
    {
918 9
        $hosts  = [];
919 9
        $weight = [];
920 9
        getmxrr($domain, $hosts, $weight);
921
922 9
        return [$hosts, $weight];
923
    }
924
925
    /**
926
     * Throws if not currently connected.
927
     *
928
     * @return void
929
     * @throws NoConnectionException
930
     */
931 5
    private function throwIfNotConnected()
932
    {
933 5
        if (!$this->connected()) {
934 1
            throw new NoConnectionException('No connection');
935
        }
936 5
    }
937
938
    /**
939
     * Debug helper. If it detects a CLI env, it just dumps given `$str` on a
940
     * new line, otherwise it prints stuff <pre>.
941
     *
942
     * @param string $str
943
     *
944
     * @return void
945
     */
946 9
    private function debug($str)
947
    {
948 9
        $str = $this->stamp($str);
949 9
        $this->log($str);
950 9
        if ($this->debug) {
951 1
            if ('cli' !== PHP_SAPI) {
952
                $str = '<br/><pre>' . htmlspecialchars($str) . '</pre>';
953
            }
954 1
            echo "\n" . $str;
955
        }
956 9
    }
957
958
    /**
959
     * Adds a message to the log array
960
     *
961
     * @param string $msg
962
     *
963
     * @return void
964
     */
965 9
    private function log($msg)
966
    {
967 9
        $this->log[] = $msg;
968 9
    }
969
970
    /**
971
     * Prepends the given $msg with the current date and time inside square brackets.
972
     *
973
     * @param string $msg
974
     *
975
     * @return string
976
     */
977 9
    private function stamp($msg)
978
    {
979 9
        $date = \DateTime::createFromFormat('U.u', sprintf('%.f', microtime(true)))->format('Y-m-d\TH:i:s.uO');
980 9
        $line = '[' . $date . '] ' . $msg;
981
982 9
        return $line;
983
    }
984
985
    /**
986
     * Returns the log array
987
     *
988
     * @return array
989
     */
990 4
    public function getLog()
991
    {
992 4
        return $this->log;
993
    }
994
995
    /**
996
     * Truncates the log array
997
     *
998
     * @return void
999
     */
1000 1
    public function clearLog()
1001
    {
1002 1
        $this->log = [];
1003 1
    }
1004
1005
    /**
1006
     * Compat for old lower_cased method calls.
1007
     *
1008
     * @param string $name
1009
     * @param array  $args
1010
     *
1011
     * @return void
1012
     */
1013 2
    public function __call($name, $args)
1014
    {
1015 2
        $camelized = self::camelize($name);
1016 2
        if (\method_exists($this, $camelized)) {
1017 2
            return \call_user_func_array([$this, $camelized], $args);
1018
        } else {
1019 1
            trigger_error('Fatal error: Call to undefined method ' . self::class . '::' . $name . '()', E_USER_ERROR);
1020
        }
1021
    }
1022
1023
    /**
1024
     * Set the desired connect timeout.
1025
     *
1026
     * @param int $timeout Connect timeout in seconds
1027
     *
1028
     * @return void
1029
     */
1030 7
    public function setConnectTimeout($timeout)
1031
    {
1032 7
        $this->connect_timeout = (int) $timeout;
1033 7
    }
1034
1035
    /**
1036
     * Get the current connect timeout.
1037
     *
1038
     * @return int
1039
     */
1040 1
    public function getConnectTimeout()
1041
    {
1042 1
        return $this->connect_timeout;
1043
    }
1044
1045
    /**
1046
     * Set connect port.
1047
     *
1048
     * @param int $port
1049
     *
1050
     * @return void
1051
     */
1052 6
    public function setConnectPort($port)
1053
    {
1054 6
        $this->connect_port = (int) $port;
1055 6
    }
1056
1057
    /**
1058
     * Get current connect port.
1059
     *
1060
     * @return int
1061
     */
1062 1
    public function getConnectPort()
1063
    {
1064 1
        return $this->connect_port;
1065
    }
1066
1067
    /**
1068
     * Turn on "catch-all" detection.
1069
     *
1070
     * @return void
1071
     */
1072 3
    public function enableCatchAllTest()
1073
    {
1074 3
        $this->catchall_test = true;
1075 3
    }
1076
1077
    /**
1078
     * Turn off "catch-all" detection.
1079
     *
1080
     * @return void
1081
     */
1082 1
    public function disableCatchAllTest()
1083
    {
1084 1
        $this->catchall_test = false;
1085 1
    }
1086
1087
    /**
1088
     * Returns whether "catch-all" test is to be performed or not.
1089
     *
1090
     * @return bool
1091
     */
1092 1
    public function isCatchAllEnabled()
1093
    {
1094 1
        return $this->catchall_test;
1095
    }
1096
1097
    /**
1098
     * Set whether "catch-all" results are considered valid or not.
1099
     *
1100
     * @param bool $flag When true, "catch-all" accounts are considered valid
1101
     *
1102
     * @return void
1103
     */
1104 2
    public function setCatchAllValidity($flag)
1105
    {
1106 2
        $this->catchall_is_valid = (bool) $flag;
1107 2
    }
1108
1109
    /**
1110
     * Get current state of "catch-all" validity flag.
1111
     *
1112
     * @return bool
1113
     */
1114 1
    public function getCatchAllValidity()
1115
    {
1116 1
        return $this->catchall_is_valid;
1117
    }
1118
1119
    /**
1120
     * Camelizes a string.
1121
     *
1122
     * @param string $id A string to camelize
1123
     *
1124
     * @return string The camelized string
1125
     */
1126 2
    private static function camelize($id)
1127
    {
1128 2
        return strtr(
1129 2
            ucwords(
1130 2
                strtr(
1131 2
                    $id,
1132 2
                    ['_' => ' ', '.' => '_ ', '\\' => '_ ']
1133
                )
1134
            ),
1135 2
            [' ' => '']
1136
        );
1137
    }
1138
}
1139