zytzagoo / smtp-validate-email

A PHP library for performing email addresses validation via SMTP
GNU General Public License v3.0
437 stars 155 forks source link

Huge error when cheking with catch-all with PHP 8 #75

Closed fabiochelly closed 11 months ago

fabiochelly commented 2 years ago

I always get an exception when I check catchalls with the last Validate class. It seems that the 550 error code is not in the list of authorized codes for RCPT TO command in that case. I don't have this error when I'm not checking if the domain is a catch-all.

PHP Fatal error: Uncaught Error: Class "SMTPValidateEmail\Exceptions\UnexpectedResponse" not found in /home/dqe/email3/Validator.php:929 Stack trace:

0 /home/dqe/email3/Validator.php(740): SMTPValidateEmail\Validator->expect()

1 /home/dqe/email3/Validator.php(258): SMTPValidateEmail\Validator->rcpt()

2 /home/dqe/email3/Validator.php(446): SMTPValidateEmail\Validator->acceptsAnyRecipient()

3 /home/dqe/email3/Validator.php(399): SMTPValidateEmail\Validator->attemptMailCommands()

4 /home/dqe/email3/Validator.php(334): SMTPValidateEmail\Validator->performSmtpDance()

5 /home/dqe/email3/Validator.php(308): SMTPValidateEmail\Validator->loop()

6 /home/dqe/email3/appel-smtp.php(155): SMTPValidateEmail\Validator->validate()

7 {main}

thrown in /home/dqe/email3/Validator.php on line 929

zytzagoo commented 2 years ago

Code that reproduces this reliably would help with looking into it. And/or (probably redacted) debug logs.

Also, the line numbers in the stack trace do not match with the latest master branch, which exact commit/version are you running?

fabiochelly commented 2 years ago

In fact it wasn't installed correctly and your new way to add the domain itself with a weight of 0 in the MX servers turned out to give false errors: when the domain does not exist, trying to connect to this domain returns an unexpected error.

zytzagoo commented 2 years ago

Not sure what you mean by "new way", i remember that logic being in there since the first commit 14 years ago: https://github.com/zytzagoo/smtp-validate-email/blame/dcf8030739d665d203809f5bb22a6a6088fe5a7d/smtp-validate-email.php#L257

And the comment there is referring to section 5 of RFC 2821 titled "Address Resolution and Mail Handling": https://datatracker.ietf.org/doc/html/rfc2821#section-5 -- whether it's correct or not, I don't even know anymore... but there haven't been complaints about that so far. I'll gladly merge PRs with tests and fixes.

Happy to hear you made some progress though!

fabiochelly commented 2 years ago

Here are my modifications on your validator file. I have added the ability to bind an IP address and the ability to disable the MX modification.

I have added 2 vars: [image: image.png]

And the code: [image: image.png]

[image: image.png]

Regards, Fabio Chelly

Le mer. 27 juil. 2022 à 16:51, zytzagoo @.***> a écrit :

Not sure what you mean by "new way", i remember that logic being in there since the first commit 14 years ago:

https://github.com/zytzagoo/smtp-validate-email/blame/dcf8030739d665d203809f5bb22a6a6088fe5a7d/smtp-validate-email.php#L257

And the comment there is referring to section 5 of RFC 2821 titled "Address Resolution and Mail Handling": https://datatracker.ietf.org/doc/html/rfc2821#section-5 -- whether it's correct or not, I don't even know anymore... but there haven't been complaints about that so far. I'll gladly merge PRs with tests and fixes.

Happy to hear you made some progress though!

— Reply to this email directly, view it on GitHub https://github.com/zytzagoo/smtp-validate-email/issues/75#issuecomment-1196863412, or unsubscribe https://github.com/notifications/unsubscribe-auth/AA67MUPWO2VJSDJLAPVJVXTVWFEIVANCNFSM524UBPAA . You are receiving this because you authored the thread.Message ID: @.***>

fabiochelly commented 2 years ago

By the way, you were right. I did comment the line $mxs[$domain] = 0 a few years ago and I completely forgot about it. That's why I thought it was a new feature.

By the way, I'm using your lib to control more than 1000 000 emails each day.

Regards, Fabio Chelly

Le mer. 27 juil. 2022 à 17:10, Fabio Chelly @.***> a écrit :

Here are my modifications on your validator file. I have added the ability to bind an IP address and the ability to disable the MX modification.

I have added 2 vars: [image: image.png]

And the code: [image: image.png]

[image: image.png]

Regards, Fabio Chelly

Le mer. 27 juil. 2022 à 16:51, zytzagoo @.***> a écrit :

Not sure what you mean by "new way", i remember that logic being in there since the first commit 14 years ago:

https://github.com/zytzagoo/smtp-validate-email/blame/dcf8030739d665d203809f5bb22a6a6088fe5a7d/smtp-validate-email.php#L257

And the comment there is referring to section 5 of RFC 2821 titled "Address Resolution and Mail Handling": https://datatracker.ietf.org/doc/html/rfc2821#section-5 -- whether it's correct or not, I don't even know anymore... but there haven't been complaints about that so far. I'll gladly merge PRs with tests and fixes.

Happy to hear you made some progress though!

— Reply to this email directly, view it on GitHub https://github.com/zytzagoo/smtp-validate-email/issues/75#issuecomment-1196863412, or unsubscribe https://github.com/notifications/unsubscribe-auth/AA67MUPWO2VJSDJLAPVJVXTVWFEIVANCNFSM524UBPAA . You are receiving this because you authored the thread.Message ID: @.***>

zytzagoo commented 2 years ago

Would be great to see those additions to the code somehow.. (images/attachments not showing up for me atm)

Others could potentially benefit from the changes too.

Also, would love to know how/why exactly this 0 weight mx addition is causing problems in the first place.

Great to hear you've had success using this!

fabiochelly commented 2 years ago

Here is the code renamed as txt to prevent deletion (I thought I had attached it already in my previous e-mail). In my tests, the 0 weight addition appears only when the domain does not exist (maybe it is in the RFC but I have NEVER seen a MX server working like that). So the connect call in that case is useless: it takes time and just returns a connect error in the log (very difficult to catch as it is not always the same message depending on the PHP version). I prefer to check if the MX list is empty to determine if the domain is invalid.

The bind addition is critical for me as I'm using 30 servers (each one containing 255 IPs). Without that my only IP would be blacklisted in a matter of minutes every day.

Regards, Fabio Chelly

Le mer. 27 juil. 2022 à 17:52, zytzagoo @.***> a écrit :

Would be great to see those additions to the code somehow.. (images/attachements not showing up for me atm)

Others could potentially benefit from the changes too.

Also, would love to know how/why exactly this 0 weight mx addition is causing problems in the first place.

Great to hear you've had success using this!

— Reply to this email directly, view it on GitHub https://github.com/zytzagoo/smtp-validate-email/issues/75#issuecomment-1196940428, or unsubscribe https://github.com/notifications/unsubscribe-auth/AA67MUOJCBOJBYMOXC5EBM3VWFLNBANCNFSM524UBPAA . You are receiving this because you authored the thread.Message ID: @.***>

<?php

namespace SMTPValidateEmail;

use \SMTPValidateEmail\Exceptions\Exception as Exception; use \SMTPValidateEmail\Exceptions\Timeout as TimeoutException; use \SMTPValidateEmail\Exceptions\NoTimeout as NoTimeoutException; use \SMTPValidateEmail\Exceptions\NoConnection as NoConnectionException; use \SMTPValidateEmail\Exceptions\UnexpectedResponse as UnexpectedResponseException; use \SMTPValidateEmail\Exceptions\NoHelo as NoHeloException; use \SMTPValidateEmail\Exceptions\NoMailFrom as NoMailFromException; use \SMTPValidateEmail\Exceptions\NoResponse as NoResponseException; use \SMTPValidateEmail\Exceptions\SendFailed as SendFailedException;

class Validator {

public $log = [];

/**
 * Print stuff as it happens or not
 *
 * @var bool
 */
public $debug = false;

/**
 * Default smtp port to connect to
 *
 * @var int
 */
public $connect_port = 25;

/**
 * Are "catch-all" accounts considered valid or not?
 * If not, the class checks for a "catch-all" and if it determines the box
 * has a "catch-all", sets all the emails on that domain as invalid.
 *
 * @var bool
 */
public $catchall_is_valid = true;

/**
 * Whether to perform the "catch-all" test or not
 *
 * @var bool
 */
public $catchall_test = false; // Set to true to perform a catchall test

/**
 * Being unable to communicate with the remote MTA could mean an address
 * is invalid, but it might not, depending on your use case, set the
 * value appropriately.
 *
 * @var bool
 */
public $no_comm_is_valid = false;

/**
 * Being unable to connect with the remote host could mean a server
 * configuration issue, but it might not, depending on your use case,
 * set the value appropriately.
 */
public $no_conn_is_valid = false;

/**
 * Whether "greylisted" responses are considered as valid or invalid addresses
 *
 * @var bool
 */
public $greylisted_considered_valid = true;

/**
 * Timeout values for various commands (in seconds) per RFC 2821
 *
 * @var array
 */
protected $command_timeouts = [
    'ehlo' => 120,
    'helo' => 120,
    'tls'  => 180, // start tls
    'mail' => 300, // mail from
    'rcpt' => 300, // rcpt to,
    'rset' => 30,
    'quit' => 60,
    'noop' => 60
];

const CRLF = "\r\n";

// Some smtp response codes
const SMTP_CONNECT_SUCCESS = 220;
const SMTP_QUIT_SUCCESS    = 221;
const SMTP_GENERIC_SUCCESS = 250;
const SMTP_USER_NOT_LOCAL  = 251;
const SMTP_CANNOT_VRFY     = 252;

const SMTP_SERVICE_UNAVAILABLE = 421;

// 450 Requested mail action not taken: mailbox unavailable (e.g.,
// mailbox busy or temporarily blocked for policy reasons)
const SMTP_MAIL_ACTION_NOT_TAKEN = 450;
// 451 Requested action aborted: local error in processing
const SMTP_MAIL_ACTION_ABORTED = 451;
// 452 Requested action not taken: insufficient system storage
const SMTP_REQUESTED_ACTION_NOT_TAKEN = 452;

// 500 Syntax error (may be due to a denied command)
const SMTP_SYNTAX_ERROR = 500;
// 502 Comment not implemented
const SMTP_NOT_IMPLEMENTED = 502;
// 503 Bad sequence of commands (may be due to a denied command)
const SMTP_BAD_SEQUENCE = 503;

// 550 Requested action not taken: mailbox unavailable (e.g., mailbox
// not found, no access, or command rejected for policy reasons)
const SMTP_MBOX_UNAVAILABLE = 550;

// 554 Seen this from hotmail MTAs, in response to RSET :(
const SMTP_TRANSACTION_FAILED = 554;

/**
 * List of response codes considered as "greylisted"
 *
 * @var array
 */
private $greylisted = [
    self::SMTP_MAIL_ACTION_NOT_TAKEN,
    self::SMTP_MAIL_ACTION_ABORTED,
    self::SMTP_REQUESTED_ACTION_NOT_TAKEN
];

/**
 * Internal states we can be in
 *
 * @var array
 */
private $state = [
    'helo' => false,
    'mail' => false,
    'rcpt' => false
];

/**
 * Holds the socket connection resource
 *
 * @var resource
 */
private $socket;

/**
 * Holds all the domains we'll validate accounts on
 *
 * @var array
 */
private $domains = [];

/**
 * @var array
 */
private $domains_info = [];

/**
 * Default connect timeout for each MTA attempted (seconds)
 *
 * @var int
 */
private $connect_timeout = 10;

/**
 * Default sender username
 *
 * @var string
 */
private $from_user = 'user';

/**
 * Default sender host
 *
 * @var string
 */
private $from_domain = 'localhost';

/**
 * The host we're currently connected to
 *
 * @var string|null
 */
private $host = null;

/**
 * The IP we're currently sending from
 *
 * @var string
 */
public string $bind = "";

/**
 * Whether we include the domain as MX server
 *
 * @var bool
 */
public bool $includes_domain_as_mx = true;

/**
 * List of validation results
 *
 * @var array
 */
private $results = [];

/**
 * @param array|string $emails Email(s) to validate
 * @param string|null $sender Sender's email address
 */
public function __construct($emails = [], $sender = null)
{
    if (!empty($emails)) {
        $this->setEmails($emails);
    }
    if (null !== $sender) {
        $this->setSender($sender);
    }
}

/**
 * Disconnects from the SMTP server if needed to release resources
 */
public function __destruct()
{
    $this->disconnect(false);
}

public function acceptsAnyRecipient($domain)
{
    if (!$this->catchall_test) {
        return false;
    }

    $test     = 'catch-all-test-' . time();
    $accepted = $this->rcpt($test . '@' . $domain);
    if ($accepted) {
        // Success on a non-existing address is a "catch-all"
        $this->domains_info[$domain]['catchall'] = true;
        return true;
    }

    // Log when we get disconnected while trying catchall detection
    $this->noop();
    if (!$this->connected()) {
        $this->debug('Disconnected after trying a non-existing recipient on ' . $domain);
    }

    /**
     * N.B.:
     * Disconnects are considered as a non-catch-all case this way, but
     * that might not always be the case.
     */
    return false;
}

/**
 * Performs validation of specified email addresses.
 *
 * @param array|string $emails Emails to validate (or a single one as a string)
 * @param string|null $sender Sender email address
 * @return array List of emails and their results
 */
public function validate($emails = [], $sender = null)
{
    $this->results = [];

    if (!empty($emails)) {
        $this->setEmails($emails);
    }
    if (null !== $sender) {
        $this->setSender($sender);
    }

    if (!is_array($this->domains) || empty($this->domains)) {
        return $this->results;
    }

    // Query the MTAs on each domain if we have them
    foreach ($this->domains as $domain => $users) {
        $mxs = [];

        // Query the MX records for the current domain
        list($hosts, $weights) = $this->mxQuery($domain);

        // Sort out the MX priorities
        foreach ($hosts as $k => $host) {
            $mxs[$host] = $weights[$k];
        }
        asort($mxs);

        // Add the hostname itself with 0 weight (RFC 2821)
        if ($this->includes_domain_as_mx) $mxs[$domain] = 0;

        $this->debug('MX records (' . $domain . '): ' . print_r($mxs, true));
        $this->domains_info[$domain]          = [];
        $this->domains_info[$domain]['users'] = $users;
        $this->domains_info[$domain]['mxs']   = $mxs;

        // Try each host, $_weight unused in the foreach body, but array_keys() doesn't guarantee the order
        foreach ($mxs as $host => $_weight) {
            // try connecting to the remote host
            try {
                $this->connect($host);
                if ($this->connected()) {
                    break;
                }
            } catch (NoConnectionException $e) {
                // Unable to connect to host, so these addresses are invalid?
                $this->debug('Unable to connect. Exception caught: ' . $e->getMessage());
                $this->setDomainResults($users, $domain, $this->no_conn_is_valid);
            }
        }

        // Are we connected?
        if ($this->connected()) {
            try {
                // Say helo, and continue if we can talk
                if ($this->helo()) {
                    // try issuing MAIL FROM
                    if (!$this->mail($this->from_user . '@' . $this->from_domain)) {
                        // MAIL FROM not accepted, we can't talk
                        $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
                    }

                    /**
                     * If we're still connected, proceed (cause we might get
                     * disconnected, or banned, or greylisted temporarily etc.)
                     * see mail() for more
                     */
                    if ($this->connected()) {
                        $this->noop();

                        // Attempt a catch-all test for the domain (if configured to do so)
                        $is_catchall_domain = $this->acceptsAnyRecipient($domain);

                        // If a catchall domain is detected, and we consider
                        // accounts on such domains as invalid, mark all the
                        // users as invalid and move on
                        if ($is_catchall_domain) {
                            if (!$this->catchall_is_valid) {
                                $this->setDomainResults($users, $domain, $this->catchall_is_valid);
                                continue;
                            }
                        }

                        // If we're still connected, try issuing rcpts
                        if ($this->connected()) {
                            $this->noop();
                            // RCPT for each user
                            foreach ($users as $user) {
                                $address                 = $user . '@' . $domain;
                                $this->results[$address] = $this->rcpt($address);
                                $this->noop();
                            }
                        }

                        // Saying bye-bye if we're still connected, cause we're done here
                        if ($this->connected()) {
                            // Issue a RSET for all the things we just made the MTA do
                            $this->rset();
                            $this->disconnect();
                        }
                    }
                } else {
                    // We didn't get a good response to helo and should be disconnected already
                    $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
                }
            } catch (UnexpectedResponseException $e) {
                // Unexpected responses handled as $this->no_comm_is_valid, that way anyone can
                // decide for themselves if such results are considered valid or not
                $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
            } catch (TimeoutException $e) {
                // A timeout is a comm failure, so treat the results on that domain
                // according to $this->no_comm_is_valid as well
                $this->setDomainResults($users, $domain, $this->no_comm_is_valid);
            }
        }
    } // outermost foreach

    return $this->getResults();
}

/**
 * Get validation results
 *
 * @param bool $include_domains_info Whether to include extra info in the results
 *
 * @return array
 */
public function getResults($include_domains_info = true)
{
    if ($include_domains_info) {
        $this->results['domains'] = $this->domains_info;
    } else {
        unset($this->results['domains']);
    }

    return $this->results;
}

/**
 * Helper to set results for all the users on a domain to a specific value
 *
 * @param array $users Users (usernames)
 * @param string $domain The domain for the users/usernames
 * @param bool $val Value to set
 *
 * @return void
 */
private function setDomainResults(array $users, $domain, $val)
{
    foreach ($users as $user) {
        $this->results[$user . '@' . $domain] = $val;
    }
}

/**
 * Returns true if we're connected to an MTA
 *
 * @return bool
 */
protected function connected()
{
    return is_resource($this->socket);
}

/**
 * Tries to connect to the specified host on the pre-configured port.
 *
 * @param string $host Host to connect to
 *
 * @throws NoConnectionException
 * @throws NoTimeoutException
 *
 * @return void
 */
protected function connect($host)
{
    $remote_socket = $host . ':' . $this->connect_port;
    $errnum        = 0;
    $errstr        = '';
    $this->host    = $remote_socket;

    $opts = $this->bind ? ['socket' => ['bindto' => $this->bind . ':0']] : [];

    // Open connection
    $this->debug('Connecting to ' . $this->host);
    // @codingStandardsIgnoreLine
    $this->socket = /** @scrutinizer ignore-unhandled */ @stream_socket_client(
        $this->host,
        $errnum,
        $errstr,
        $this->connect_timeout,
        STREAM_CLIENT_CONNECT,
        stream_context_create($opts)
    );

    // Check and throw if not connected
    if (!$this->connected()) {
        $this->debug('Connect failed: ' . $errstr . ', error number: ' . $errnum . ', host: ' . $this->host);
        throw new NoConnectionException('Cannot open a connection to remote host (' . $this->host . ')');
    }

    $result = stream_set_timeout($this->socket, $this->connect_timeout);
    if (!$result) {
        throw new NoTimeoutException('Cannot set timeout');
    }

    $this->debug('Connected to ' . $this->host . ' successfully');
}

/**
 * Disconnects the currently connected MTA.
 *
 * @param bool $quit Whether to send QUIT command before closing the socket on our end
 *
 * @return void
 */
protected function disconnect($quit = true)
{
    if ($quit) {
        $this->quit();
    }

    if ($this->connected()) {
        $this->debug('Closing socket to ' . $this->host);
        fclose($this->socket);
    }

    $this->host = null;
    $this->resetState();
}

/**
 * Resets internal state flags to defaults
 *
 * @return void
 */
private function resetState()
{
    $this->state['helo'] = false;
    $this->state['mail'] = false;
    $this->state['rcpt'] = false;
}

/**
 * Sends a HELO/EHLO sequence.
 *
 * @todo Implement TLS
 *
 * @return bool|null True if successful, false otherwise. Null if already done.
 */
protected function helo()
{
    // Don't do it if already done
    if ($this->state['helo']) {
        return null;
    }

    $result = false;
    try {
        $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['helo']);
        $this->ehlo();

        // Session started
        $this->state['helo'] = true;

        // Are we going for a TLS connection?
        /*
        if ($this->tls) {
            // send STARTTLS, wait 3 minutes
            $this->send('STARTTLS');
            $this->expect(self::SMTP_CONNECT_SUCCESS, $this->command_timeouts['tls']);
            $result = stream_socket_enable_crypto($this->socket, true,
                STREAM_CRYPTO_METHOD_TLS_CLIENT);
            if (!$result) {
                throw new SMTP_Validate_Email_Exception_No_TLS('Cannot enable TLS');
            }
        }
        */

        $result = true;
    } catch (UnexpectedResponseException $e) {
        // Connected, but got an unexpected response, so disconnect
        $result = false;
        $this->debug('Unexpected response after connecting: ' . $e->getMessage());
        $this->disconnect(false);
    }

    return $result;
}

/**
 * Sends `EHLO` or `HELO`, depending on what's supported by the remote host.
 *
 * @return void
 */
protected function ehlo()
{
    try {
        // Modern
        $this->send('EHLO ' . $this->from_domain);
        $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['ehlo']);
    } catch (UnexpectedResponseException $e) {
        // Legacy
        $this->send('HELO ' . $this->from_domain);
        $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['helo']);
    }
}

/**
 * Sends a `MAIL FROM` command which indicates the sender.
 *
 * @param string $from The "From:" address
 *
 * @throws NoHeloException
 *
 * @return bool Whether the command was accepted or not
 */
protected function mail($from)
{
    if (!$this->state['helo']) {
        throw new NoHeloException('Need HELO before MAIL FROM');
    }

    // Issue MAIL FROM, 5 minute timeout
    $this->send('MAIL FROM:<' . $from . '>');

    try {
        $this->expect(self::SMTP_GENERIC_SUCCESS, $this->command_timeouts['mail']);

        // Set state flags
        $this->state['mail'] = true;
        $this->state['rcpt'] = false;

        $result = true;
    } catch (UnexpectedResponseException $e) {
        $result = false;

        // Got something unexpected in response to MAIL FROM
        $this->debug("Unexpected response to MAIL FROM\n:" . $e->getMessage());

        // Hotmail has been known to do this + was closing the connection
        // forcibly on their end, so we're killing the socket here too
        $this->disconnect(false);
    }

    return $result;
}

/**
 * Sends a RCPT TO command to indicate a recipient.
 *
 * @param string $to Recipient's email address
 * @throws NoMailFromException
 *
 * @return bool Whether the recipient was accepted or not
 */
protected function rcpt($to)
{
    // Need to have issued MAIL FROM first
    if (!$this->state['mail']) {
        throw new NoMailFromException('Need MAIL FROM before RCPT TO');
    }

    $valid          = false;
    $expected_codes = [
        self::SMTP_GENERIC_SUCCESS,
        self::SMTP_USER_NOT_LOCAL
    ];

    if ($this->greylisted_considered_valid) {
        $expected_codes = array_merge($expected_codes, $this->greylisted);
    }

    // Issue RCPT TO, 5 minute timeout
    try {
        $this->send('RCPT TO:<' . $to . '>');
        // Handle response
        try {
            $this->expect($expected_codes, $this->command_timeouts['rcpt']);
            $this->state['rcpt'] = true;
            $valid               = true;
        } catch (UnexpectedResponseException $e) {
            $this->debug('Unexpected response to RCPT TO: ' . $e->getMessage());
        }
    } catch (Exception $e) {
        $this->debug('Sending RCPT TO failed: ' . $e->getMessage());
    }

    return $valid;
}

/**
 * Sends a RSET command and resets certain parts of internal state.
 *
 * @return void
 */
protected function rset()
{
    $this->send('RSET');

    // MS ESMTP doesn't follow RFC according to ZF tracker, see [ZF-1377]
    $expected = [
        self::SMTP_GENERIC_SUCCESS,
        self::SMTP_CONNECT_SUCCESS,
        self::SMTP_NOT_IMPLEMENTED,
        // hotmail returns this o_O
        self::SMTP_TRANSACTION_FAILED
    ];
    $this->expect($expected, $this->command_timeouts['rset'], true);
    $this->state['mail'] = false;
    $this->state['rcpt'] = false;
}

/**
 * Sends a QUIT command.
 *
 * @return void
 */
protected function quit()
{
    // Although RFC says QUIT can be issued at any time, we won't
    if ($this->state['helo']) {
        $this->send('QUIT');
        $this->expect(
            [self::SMTP_GENERIC_SUCCESS,self::SMTP_QUIT_SUCCESS],
            $this->command_timeouts['quit'],
            true
        );
    }
}

/**
 * Sends a NOOP command.
 *
 * @return void
 */
protected function noop()
{
    $this->send('NOOP');

    /**
     * The `SMTP` string is here to fix issues with some bad RFC implementations.
     * Found at least 1 server replying to NOOP without any code.
     */
    $expected_codes = [
        'SMTP',
        self::SMTP_BAD_SEQUENCE,
        self::SMTP_NOT_IMPLEMENTED,
        self::SMTP_GENERIC_SUCCESS,
        self::SMTP_SYNTAX_ERROR,
        self::SMTP_CONNECT_SUCCESS
    ];
    $this->expect($expected_codes, $this->command_timeouts['noop'], true);
}

/**
 * Sends a command to the remote host.
 *
 * @param string $cmd The command to send
 *
 * @return int|bool Number of bytes written to the stream
 * @throws NoConnectionException
 * @throws SendFailedException
 */
protected function send($cmd)
{
    // Must be connected
    $this->throwIfNotConnected();

    $this->debug('send>>>: ' . $cmd);
    // Write the cmd to the connection stream
    $result = fwrite($this->socket, $cmd . self::CRLF);

    // Did it work?
    if (false === $result) {
        throw new SendFailedException('Send failed on: ' . $this->host);
    }

    return $result;
}

/**
 * Receives a response line from the remote host.
 *
 * @param int $timeout Timeout in seconds
 *
 * @return string
 *
 * @throws NoConnectionException
 * @throws TimeoutException
 * @throws NoResponseException
 */
protected function recv($timeout = null)
{
    // Must be connected
    $this->throwIfNotConnected();

    // Has a custom timeout been specified?
    if (null !== $timeout) {
        stream_set_timeout($this->socket, $timeout);
    }

    // Retrieve response
    $line = fgets($this->socket, 1024);
    $this->debug('<<<recv: ' . $line);

    // Have we timed out?
    $info = stream_get_meta_data($this->socket);
    if (!empty($info['timed_out'])) {
        throw new TimeoutException('Timed out in recv');
    }

    // Did we actually receive anything?
    if (false === $line) {
        throw new NoResponseException('No response in recv');
    }

    return $line;
}

/**
 * Receives lines from the remote host and looks for expected response codes.
 *
 * @param int|int[] $codes List of one or more expected response codes
 * @param int $timeout The timeout for this individual command, if any
 * @param bool $empty_response_allowed When true, empty responses are allowed
 *
 * @return string The last text message received
 *
 * @throws UnexpectedResponseException
 */
protected function expect($codes, $timeout = null, $empty_response_allowed = false)
{
    if (!is_array($codes)) {
        $codes = (array) $codes;
    }

    $code = null;
    $text = '';

    try {
        $line = $this->recv($timeout);
        $text = $line;
        while (preg_match('/^[0-9]+-/', $line)) {
            $line  = $this->recv($timeout);
            $text .= $line;
        }
        sscanf($line, '%d%s', $code, $text);
        // TODO/FIXME: This is terrible to read/comprehend
        if ($code == self::SMTP_SERVICE_UNAVAILABLE ||
            (false === $empty_response_allowed && (null === $code || !in_array($code, $codes)))) {
            throw new UnexpectedResponseException($line);
        }
    } catch (NoResponseException $e) {
        /**
         * No response in expect() probably means that the remote server
         * forcibly closed the connection so lets clean up on our end as well?
         */
        $this->debug('No response in expect(): ' . $e->getMessage());
        $this->disconnect(false);
    }

    return $text;
}

/**
 * Splits the email address string into its respective user and domain parts
 * and returns those as an array.
 *
 * @param string $email Email address
 *
 * @return array ['user', 'domain']
 */
protected function splitEmail($email)
{
    $parts  = explode('@', $email);
    $domain = array_pop($parts);
    $user   = implode('@', $parts);

    return [$user, $domain];
}

/**
 * Sets the email addresses that should be validated.
 *
 * @param array|string $emails List of email addresses (or a single one a string).
 *
 * @return void
 */
public function setEmails($emails)
{
    if (!is_array($emails)) {
        $emails = (array) $emails;
    }

    $this->domains = [];

    foreach ($emails as $email) {
        list($user, $domain) = $this->splitEmail($email);
        if (!isset($this->domains[$domain])) {
            $this->domains[$domain] = [];
        }
        $this->domains[$domain][] = $user;
    }
}

/**
 * Sets the email address to use as the sender/validator.
 *
 * @param string $email
 *
 * @return void
 */
public function setSender($email)
{
    $parts             = $this->splitEmail($email);
    $this->from_user   = $parts[0];
    $this->from_domain = $parts[1];
}

/**
 * Queries the DNS server for MX entries of a certain domain.
 *
 * @param string $domain The domain for which to retrieve MX records
 * @return array MX hosts and their weights
 */
protected function mxQuery($domain)
{
    $hosts  = [];
    $weight = [];
    getmxrr($domain, $hosts, $weight);

    return [$hosts, $weight];
}

/**
 * Throws if not currently connected.
 *
 * @return void
 * @throws NoConnectionException
 */
private function throwIfNotConnected()
{
    if (!$this->connected()) {
        throw new NoConnectionException('No connection');
    }
}

/**
 * Debug helper. If it detects a CLI env, it just dumps given `$str` on a
 * new line, otherwise it prints stuff <pre>.
 *
 * @param string $str
 *
 * @return void
 */
private function debug($str)
{
    $str = $this->stamp($str);
    $this->log($str);
    if ($this->debug) {
        if ('cli' !== PHP_SAPI) {
            $str = '<br/><pre>' . htmlspecialchars($str) . '</pre>';
        }
        echo "\n" . $str;
    }
}

/**
 * Adds a message to the log array
 *
 * @param string $msg
 *
 * @return void
 */
private function log($msg)
{
    $this->log[] = $msg;
}

/**
 * Prepends the given $msg with the current date and time inside square brackets.
 *
 * @param string $msg
 *
 * @return string
 */
private function stamp($msg)
{
    $date = \DateTime::createFromFormat('U.u', sprintf('%.f', microtime(true)))->format('Y-m-d\TH:i:s.uO');
    $line = '[' . $date . '] ' . $msg;

    return $line;
}

/**
 * Returns the log array
 *
 * @return array
 */
public function getLog()
{
    return $this->log;
}

/**
 * Truncates the log array
 *
 * @return void
 */
public function clearLog()
{
    $this->log = [];
}

/**
 * Compat for old lower_cased method calls.
 *
 * @param string $name
 * @param array  $args
 *
 * @return void
 */
public function __call($name, $args)
{
    $camelized = self::camelize($name);
    if (\method_exists($this, $camelized)) {
        return \call_user_func_array([$this, $camelized], $args);
    } else {
        trigger_error('Fatal error: Call to undefined method ' . self::class . '::' . $name . '()', E_USER_ERROR);
    }
}

/**
 * Set the desired connect timeout.
 *
 * @param int $timeout Connect timeout in seconds
 *
 * @return void
 */
public function setConnectTimeout($timeout)
{
    $this->connect_timeout = (int) $timeout;
}

/**
 * Get the current connect timeout.
 *
 * @return int
 */
public function getConnectTimeout()
{
    return $this->connect_timeout;
}

/**
 * Set connect port.
 *
 * @param int $port
 *
 * @return void
 */
public function setConnectPort($port)
{
    $this->connect_port = (int) $port;
}

/**
 * Get current connect port.
 *
 * @return int
 */
public function getConnectPort()
{
    return $this->connect_port;
}

/**
 * Turn on "catch-all" detection.
 *
 * @return void
 */
public function enableCatchAllTest()
{
    $this->catchall_test = true;
}

/**
 * Turn off "catch-all" detection.
 *
 * @return void
 */
public function disableCatchAllTest()
{
    $this->catchall_test = false;
}

/**
 * Returns whether "catch-all" test is to be performed or not.
 *
 * @return bool
 */
public function isCatchAllEnabled()
{
    return $this->catchall_test;
}

/**
 * Set whether "catch-all" results are considered valid or not.
 *
 * @param bool $flag When true, "catch-all" accounts are considered valid
 *
 * @return void
 */
public function setCatchAllValidity($flag)
{
    $this->catchall_is_valid = (bool) $flag;
}

/**
 * Get current state of "catch-all" validity flag.
 *
 * @return void
 */
public function getCatchAllValidity()
{
    return $this->catchall_is_valid;
}

/**
 * Camelizes a string.
 *
 * @param string $id A string to camelize
 *
 * @return string The camelized string
 */
private static function camelize($id)
{
    return strtr(
        ucwords(
            strtr(
                $id,
                ['_' => ' ', '.' => '_ ', '\\' => '_ ']
            )
        ),
        [' ' => '']
    );
}

}

zytzagoo commented 2 years ago

Incorporated your changes into latest master (slightly differently + added tests etc.) -- thanks for sending over your modifications!

https://github.com/zytzagoo/smtp-validate-email/blob/b675f3a1231407a58c2fe54f34dc80dc7100a5ed/src/Validator.php#L1241

and here's where it's used in the tests:

https://github.com/zytzagoo/smtp-validate-email/blob/b675f3a1231407a58c2fe54f34dc80dc7100a5ed/tests/Functional/ValidatorTest.php#L45

You mind trying it out when you get a chance?