Validator   F
last analyzed

Complexity

Total Complexity 113

Size/Duplication

Total Lines 1290
Duplicated Lines 0 %

Test Coverage

Coverage 92.63%

Importance

Changes 8
Bugs 3 Features 0
Metric Value
wmc 113
eloc 344
c 8
b 3
f 0
dl 0
loc 1290
ccs 314
cts 339
cp 0.9263
rs 2

52 Methods

Rating   Name   Duplication   Size   Complexity  
A loop() 0 15 2
A acceptsAnyRecipient() 0 26 4
A __destruct() 0 3 1
A validate() 0 18 4
A __construct() 0 7 3
A performSmtpDance() 0 17 4
B attemptMailCommands() 0 44 7
A buildMxs() 0 19 2
A getResults() 0 9 2
A setDomainResults() 0 4 2
A attemptConnection() 0 12 4
A connected() 0 3 1
A splitEmail() 0 7 1
A resetState() 0 5 1
A getLog() 0 3 1
A disconnect() 0 13 3
A parseBindAddress() 0 17 3
A helo() 0 37 3
A throwIfNotConnected() 0 4 2
A getCatchAllValidity() 0 3 1
A setConnectPort() 0 3 1
A setBindAddress() 0 5 1
A setEmails() 0 14 4
A __call() 0 8 2
A enableCatchAllTest() 0 3 1
A setConnectTimeout() 0 3 1
A setSender() 0 5 1
A isCatchAllEnabled() 0 3 1
A getConnectTimeout() 0 3 1
A log() 0 3 1
A camelize() 0 10 1
A getConnectPort() 0 3 1
A mxQuery() 0 13 2
A mail() 0 29 3
A debug() 0 11 3
A clearLog() 0 3 1
A connect() 0 34 3
A recv() 0 26 4
A ehlo() 0 10 2
A stamp() 0 3 1
A quit() 0 9 2
A getLogDate() 0 10 2
A sendingNoops() 0 3 1
A setCatchAllValidity() 0 3 1
A disableCatchAllTest() 0 3 1
A send() 0 15 2
A getBindAddress() 0 3 1
A sendNoops() 0 3 1
B expect() 0 32 8
A rcpt() 0 33 5
A noop() 0 22 2
A rset() 0 15 1

How to fix   Complexity   

Complex Class

Complex classes like Validator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Validator, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
namespace SMTPValidateEmail;
4
5
use SMTPValidateEmail\Exceptions\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
    public const CRLF = "\r\n";
103
104
    // Some smtp response codes
105
    public const SMTP_CONNECT_SUCCESS = 220;
106
    public const SMTP_QUIT_SUCCESS    = 221;
107
    public const SMTP_GENERIC_SUCCESS = 250;
108
    public const SMTP_USER_NOT_LOCAL  = 251;
109
    public const SMTP_CANNOT_VRFY     = 252;
110
111
    public 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
    public const SMTP_MAIL_ACTION_NOT_TAKEN = 450;
116
    // 451 Requested action aborted: local error in processing
117
    public const SMTP_MAIL_ACTION_ABORTED = 451;
118
    // 452 Requested action not taken: insufficient system storage
119
    public const SMTP_REQUESTED_ACTION_NOT_TAKEN = 452;
120
121
    // 500 Syntax error (may be due to a denied command)
122
    public const SMTP_SYNTAX_ERROR = 500;
123
    // 502 Comment not implemented
124
    public const SMTP_NOT_IMPLEMENTED = 502;
125
    // 503 Bad sequence of commands (may happen due to a denied command)
126
    public 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
    public const SMTP_MBOX_UNAVAILABLE = 550;
131
132
    // 554 Seen this from hotmail MTAs, in response to RSET :(
133
    public 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;
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 = [], ?string $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(string $domain): bool
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 = [], ?string $sender = null): array
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(): void
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));
0 ignored issues
show
Bug introduced by
Are you sure print_r($mxs, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

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