Passed
Push — master ( 72911a...2e321c )
by zyt
04:24
created

Validator::rcpt()   A

Complexity

Conditions 5
Paths 17

Size

Total Lines 33
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5.3256

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 19
c 1
b 0
f 0
dl 0
loc 33
ccs 13
cts 17
cp 0.7647
rs 9.3222
cc 5
nc 17
nop 1
crap 5.3256
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
    /**
96
     * Whether NOOP commands are sent at all.
97
     *
98
     * @var bool
99
     */
100
    protected $send_noops = true;
101
102
    const CRLF = "\r\n";
103
104
    // Some smtp response codes
105
    const SMTP_CONNECT_SUCCESS = 220;
106
    const SMTP_QUIT_SUCCESS    = 221;
107
    const SMTP_GENERIC_SUCCESS = 250;
108
    const SMTP_USER_NOT_LOCAL  = 251;
109
    const SMTP_CANNOT_VRFY     = 252;
110
111
    const SMTP_SERVICE_UNAVAILABLE = 421;
112
113
    // 450 Requested mail action not taken: mailbox unavailable (e.g.,
114
    // mailbox busy or temporarily blocked for policy reasons)
115
    const SMTP_MAIL_ACTION_NOT_TAKEN = 450;
116
    // 451 Requested action aborted: local error in processing
117
    const SMTP_MAIL_ACTION_ABORTED = 451;
118
    // 452 Requested action not taken: insufficient system storage
119
    const SMTP_REQUESTED_ACTION_NOT_TAKEN = 452;
120
121
    // 500 Syntax error (may be due to a denied command)
122
    const SMTP_SYNTAX_ERROR = 500;
123
    // 502 Comment not implemented
124
    const SMTP_NOT_IMPLEMENTED = 502;
125
    // 503 Bad sequence of commands (may be due to a denied command)
126
    const SMTP_BAD_SEQUENCE = 503;
127
128
    // 550 Requested action not taken: mailbox unavailable (e.g., mailbox
129
    // not found, no access, or command rejected for policy reasons)
130
    const SMTP_MBOX_UNAVAILABLE = 550;
131
132
    // 554 Seen this from hotmail MTAs, in response to RSET :(
133
    const SMTP_TRANSACTION_FAILED = 554;
134
135
    /**
136
     * List of response codes considered as "greylisted"
137
     *
138
     * @var array
139
     */
140
    private $greylisted = [
141
        self::SMTP_MAIL_ACTION_NOT_TAKEN,
142
        self::SMTP_MAIL_ACTION_ABORTED,
143
        self::SMTP_REQUESTED_ACTION_NOT_TAKEN
144
    ];
145
146
    /**
147
     * Internal states we can be in
148
     *
149
     * @var array
150
     */
151
    private $state = [
152
        'helo' => false,
153
        'mail' => false,
154
        'rcpt' => false
155
    ];
156
157
    /**
158
     * Holds the socket connection resource
159
     *
160
     * @var resource
161
     */
162
    private $socket;
163
164
    /**
165
     * Holds all the domains we'll validate accounts on
166
     *
167
     * @var array
168
     */
169
    private $domains = [];
170
171
    /**
172
     * @var array
173
     */
174
    private $domains_info = [];
175
176
    /**
177
     * Default connect timeout for each MTA attempted (seconds)
178
     *
179
     * @var int
180
     */
181
    private $connect_timeout = 10;
182
183
    /**
184
     * Default sender username
185
     *
186
     * @var string
187
     */
188
    private $from_user = 'user';
189
190
    /**
191
     * Default sender host
192
     *
193
     * @var string
194
     */
195
    private $from_domain = 'localhost';
196
197
    /**
198
     * The host we're currently connected to
199
     *
200
     * @var string|null
201
     */
202
    private $host = null;
203
204
    /**
205
     * List of validation results
206
     *
207
     * @var array
208
     */
209
    private $results = [];
210
211
    /**
212
     * @param array|string $emails Email(s) to validate
213
     * @param string|null $sender Sender's email address
214
     */
215 19
    public function __construct($emails = [], $sender = null)
216
    {
217 19
        if (!empty($emails)) {
218 13
            $this->setEmails($emails);
219
        }
220 19
        if (null !== $sender) {
221 13
            $this->setSender($sender);
222
        }
223 19
    }
224
225
    /**
226
     * Disconnects from the SMTP server if needed to release resources.
227
     *
228
     * @throws NoConnectionException
229
     * @throws SendFailedException
230
     * @throws TimeoutException
231
     * @throws UnexpectedResponseException
232
     */
233 19
    public function __destruct()
234
    {
235 19
        $this->disconnect(false);
236 19
    }
237
238
    /**
239
     * Does a catch-all test for the given domain.
240
     *
241
     * @param string $domain
242
     *
243
     * @return bool Whether the MTA accepts any random recipient.
244
     *
245
     * @throws NoConnectionException
246
     * @throws NoMailFromException
247
     * @throws SendFailedException
248
     * @throws TimeoutException
249
     * @throws UnexpectedResponseException
250
     */
251 5
    public function acceptsAnyRecipient($domain)
252
    {
253 5
        if (!$this->catchall_test) {
254 3
            return false;
255
        }
256
257 2
        $test     = 'catch-all-test-' . time();
258 2
        $accepted = $this->rcpt($test . '@' . $domain);
259 2
        if ($accepted) {
260
            // Success on a non-existing address is a "catch-all"
261 2
            $this->domains_info[$domain]['catchall'] = true;
262 2
            return true;
263
        }
264
265
        // Log when we get disconnected while trying catchall detection
266
        $this->noop();
267
        if (!$this->connected()) {
268
            $this->debug('Disconnected after trying a non-existing recipient on ' . $domain);
269
        }
270
271
        /**
272
         * N.B.:
273
         * Disconnects are considered as a non-catch-all case this way, but
274
         * that might not always be the case.
275
         */
276
        return false;
277
    }
278
279
    /**
280
     * Performs validation of specified email addresses.
281
     *
282
     * @param array|string $emails Emails to validate (or a single one as a string).
283
     * @param string|null $sender Sender email address.
284
     *
285
     * @return array List of emails and their results.
286
     *
287
     * @throws NoConnectionException
288
     * @throws NoHeloException
289
     * @throws NoMailFromException
290
     * @throws NoTimeoutException
291
     * @throws SendFailedException
292
     */
293 13
    public function validate($emails = [], $sender = null)
294
    {
295 13
        $this->results = [];
296
297 13
        if (!empty($emails)) {
298 1
            $this->setEmails($emails);
299
        }
300 13
        if (null !== $sender) {
301 1
            $this->setSender($sender);
302
        }
303
304 13
        if (empty($this->domains)) {
305 1
            return $this->results;
306
        }
307
308 12
        $this->loop();
309
310 10
        return $this->getResults();
311
    }
312
313
    /**
314
     * @throws NoConnectionException
315
     * @throws NoHeloException
316
     * @throws NoMailFromException
317
     * @throws NoTimeoutException
318
     * @throws SendFailedException
319
     */
320 12
    protected function loop()
321
    {
322
        // Query the MTAs on each domain if we have them
323 12
        foreach ($this->domains as $domain => $users) {
324 12
            $mxs = $this->buildMxs($domain);
325
326 12
            $this->debug('MX records (' . $domain . '): ' . print_r($mxs, true));
327 12
            $this->domains_info[$domain]          = [];
328 12
            $this->domains_info[$domain]['users'] = $users;
329 12
            $this->domains_info[$domain]['mxs']   = $mxs;
330
331
            // Set default results as though we can't communicate at all...
332 12
            $this->setDomainResults($users, $domain, $this->no_conn_is_valid);
333 12
            $this->attemptConnection($mxs);
334 12
            $this->performSmtpDance($domain, $users);
335
        }
336 10
    }
337
338
    /**
339
     * @param string $domain
340
     * @return array
341
     */
342 12
    protected function buildMxs($domain)
343
    {
344 12
        $mxs = [];
345
346
        // Query the MX records for the current domain
347 12
        list($hosts, $weights) = $this->mxQuery($domain);
348
349
        // Sort out the MX priorities
350 12
        foreach ($hosts as $k => $host) {
351
            $mxs[$host] = $weights[$k];
352
        }
353 12
        asort($mxs);
354
355
        // Add the hostname itself with 0 weight (RFC 2821)
356 12
        $mxs[$domain] = 0;
357
358 12
        return $mxs;
359
    }
360
361
    /**
362
     * @param array $mxs
363
     *
364
     * @throws NoTimeoutException
365
     */
366 12
    protected function attemptConnection(array $mxs)
367
    {
368
        // Try each host, $_weight unused in the foreach body, but array_keys() doesn't guarantee the order
369 12
        foreach ($mxs as $host => $_weight) {
370
            try {
371 12
                $this->connect($host);
372 8
                if ($this->connected()) {
373 8
                    break;
374
                }
375 4
            } catch (NoConnectionException $e) {
376
                // Unable to connect to host, so these addresses are invalid?
377 4
                $this->debug('Unable to connect. Exception caught: ' . $e->getMessage());
378
            }
379
        }
380 12
    }
381
382
    /**
383
     * @param string $domain
384
     * @param array $users
385
     *
386
     * @throws NoConnectionException
387
     * @throws NoHeloException
388
     * @throws NoMailFromException
389
     * @throws SendFailedException
390
     */
391 12
    protected function performSmtpDance($domain, array $users)
392
    {
393
        // Bail early if not connected for whatever reason...
394 12
        if (!$this->connected()) {
395 4
            return;
396
        }
397
398
        try {
399 8
            $this->attemptMailCommands($domain, $users);
400 2
        } 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 2
        } 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 6
    }
410
411
    /**
412
     * @param string $domain
413
     * @param array $users
414
     *
415
     * @throws NoConnectionException
416
     * @throws NoHeloException
417
     * @throws NoMailFromException
418
     * @throws SendFailedException
419
     * @throws TimeoutException
420
     * @throws UnexpectedResponseException
421
     */
422 8
    protected function attemptMailCommands($domain, array $users)
423
    {
424
        // Bail if HELO doesn't go through...
425 8
        if (!$this->helo()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->helo() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

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