Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mailbox validator #194

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions EmailValidator/Helper/SmtpSocketHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Egulias\EmailValidator\Helper;

class SmtpSocketHelper
{
/**
* @var int
*/
private $port;

/**
* @var int
*/
private $timeout;

/**
* @var resource
*/
private $handle;

public function __construct($port = 25, $timeout = 15)
{
$this->port = $port;
$this->timeout = $timeout;
}

/**
* Checks is resource
*
* @return bool
*/
public function isResource()
{
return is_resource($this->handle);
}

/**
* Opens resource
*
* @param string $hostname
* @param int $errno
* @param string $errstr
*/
public function open($hostname, &$errno, &$errstr)
{
$this->handle = @fsockopen($hostname, $this->port, $errno, $errstr, $this->timeout);
}

/**
* Writes message
*
* @param string $message
*
* @return bool|int
*/
public function write($message)
{
if (!$this->isResource()) {
return false;
}

return @fwrite($this->handle, $message);
}

/**
* Get last response code
*
* @return int
*/
public function getResponseCode()
{
if (!$this->isResource()) {
return -1;
}

$data = '';
while (substr($data, 3, 1) !== ' ') {
if (!($data = @fgets($this->handle, 256))) {
return -1;
}
}

return intval(substr($data, 0, 3));
}

/**
* Closes resource
*/
public function close()
{
if (!$this->isResource()) {
return;
}

@fclose($this->handle);

$this->handle = null;
}
}
49 changes: 49 additions & 0 deletions EmailValidator/Validation/Error/IllegalMailbox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace Egulias\EmailValidator\Validation\Error;

use Egulias\EmailValidator\Exception\InvalidEmail;

class IllegalMailbox extends InvalidEmail
{
const CODE = 995;
const REASON = "The mailbox is illegal.";

/**
* @var int
*/
private $responseCode;

/**
* IllegalMailbox constructor.
*
* @param int $responseCode
*/
public function __construct($responseCode)
{
parent::__construct();

$this->responseCode = $responseCode;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't this line nor the private var. It is enough to define constants as you have done.
Please leave the class with only the constants.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this place private var is necessary because responseCode contains SMTP response code https://www.greenend.org.uk/rjk/tech/smtpreplies.html

Users can understand the reason, why mailbox is illegal.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right.
What happens is that InvalidEmail does not provide a method for accessing responseCode, which is private. Thus, user won't be able to get the code.
The only way for user, AFAIK, to get the code would be to overwrite intherited __toString (see https://www.php.net/manual/en/class.exception.php and https://www.php.net/manual/en/language.exceptions.extending.php), the only method allowed to be overwritten:

public function __toString()
{
  return ": SMTP response code was {$this->responseCode}, exception reason,code {$this->message} - {$this->code}";
}

Another solution would be create an empty interface EmailValidationException, make InvalidEmail implement it and then

InvalidMailbox extends \InvalidArgumentException implements EmailValidationException
{
    private $reasons = [550 => 'Requested action not taken: mailbox unavailable', 551 => ...]
    public function __construct($responseCode)
    {
        $selectedReason = isset($reasons[$responseCode]) ? $reasons[$responseCode] : "Unknown reason";
        parent::__construct($selectedReason, $responseCode);
    }
}

And adapt all the necesary code to allow for this new type of exception by requiring the interface EmailValidationException instead of the concrete class InvalidEmail.

What do you think?

}

/**
* @return int
*/
public function getResponseCode()
{
return $this->responseCode;
}

/**
* @return string
*/
public function __toString()
{
return sprintf(
"%s SMTP response code: %s. Internal code: %s.",
$this->message,
$this->responseCode,
$this->code
);
}
}
181 changes: 181 additions & 0 deletions EmailValidator/Validation/MailboxCheckValidation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

namespace Egulias\EmailValidator\Validation;

use Egulias\EmailValidator\EmailLexer;
use Egulias\EmailValidator\Exception\InvalidEmail;
use Egulias\EmailValidator\Helper\SmtpSocketHelper;
use Egulias\EmailValidator\Validation\Error\IllegalMailbox;
use Egulias\EmailValidator\Warning\NoDNSMXRecord;
use Egulias\EmailValidator\Warning\SocketWarning;
use Egulias\EmailValidator\Warning\Warning;

class MailboxCheckValidation implements EmailValidation
{
const END_OF_LINE = "\r\n";

/**
* @var InvalidEmail
*/
private $error;

/**
* @var Warning[]
*/
private $warnings = [];

/**
* @var SmtpSocketHelper
*/
private $socketHelper;

/**
* @var string
*/
private $fromEmail;

/**
* @var int
*/
private $lastResponseCode;

/**
* MailboxCheckValidation constructor.
*
* @param SmtpSocketHelper $socketHelper
* @param string $fromEmail
*/
public function __construct(SmtpSocketHelper $socketHelper, $fromEmail)
{
if (!extension_loaded('intl')) {
throw new \LogicException(sprintf('The %s class requires the Intl extension.', __CLASS__));
}

$this->socketHelper = $socketHelper;
$this->fromEmail = $fromEmail;
}

/**
* @inheritDoc
*/
public function getError()
{
return $this->error;
}

/**
* @inheritDoc
*/
public function getWarnings()
{
return $this->warnings;
}

/**
* @inheritDoc
*/
public function isValid($email, EmailLexer $emailLexer)
{
$mxHosts = $this->getMXHosts($email);

$isValid = false;
foreach ($mxHosts as $mxHost) {
if ($this->checkMailboxExists($mxHost, $email)) {
$isValid = true;
break;
egulias marked this conversation as resolved.
Show resolved Hide resolved
}
}

if (!$isValid) {
$this->error = new IllegalMailbox($this->lastResponseCode);
}

return $this->error === null;
}

/**
* Gets MX Hosts from email
*
* @param string $email
*
* @return array
*/
protected function getMXHosts($email)
{
$hostname = $this->extractHostname($email);

$result = getmxrr($hostname, $mxHosts);
if (!$result) {
$this->warnings[NoDNSMXRecord::CODE] = new NoDNSMXRecord();
}

return $mxHosts;
}

/**
* Extracts hostname from email
*
* @param string $email
*
* @return string
*/
private function extractHostname($email)
{
$variant = defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003;

$lastAtPos = strrpos($email, '@');
if ((bool) $lastAtPos) {
$hostname = substr($email, $lastAtPos + 1);
return rtrim(idn_to_ascii($hostname, IDNA_DEFAULT, $variant), '.') . '.';
}

return rtrim(idn_to_ascii($email, IDNA_DEFAULT, $variant), '.') . '.';
}

/**
* Checks mailbox
*
* @param string $hostname
* @param string $email
* @return bool
*/
private function checkMailboxExists($hostname, $email)
{
$this->socketHelper->open($hostname, $errno, $errstr);

if (!$this->socketHelper->isResource()) {
$this->warnings[SocketWarning::CODE][] = new SocketWarning($hostname, $errno, $errstr);

return false;
}

$this->lastResponseCode = $this->socketHelper->getResponseCode();
if ($this->lastResponseCode !== 220) {
return false;
}

$this->socketHelper->write("EHLO {$hostname}" . self::END_OF_LINE);
$this->lastResponseCode = $this->socketHelper->getResponseCode();
if ($this->lastResponseCode !== 250) {
return false;
}

$this->socketHelper->write("MAIL FROM: <{$this->fromEmail}>" . self::END_OF_LINE);
$this->lastResponseCode = $this->socketHelper->getResponseCode();
if ($this->lastResponseCode !== 250) {
return false;
}

$this->socketHelper->write("RCPT TO: <{$email}>" . self::END_OF_LINE);
$this->lastResponseCode = $this->socketHelper->getResponseCode();
if ($this->lastResponseCode !== 250) {
return false;
}

$this->socketHelper->write('QUIT' . self::END_OF_LINE);

$this->socketHelper->close();

return true;
}
}
13 changes: 13 additions & 0 deletions EmailValidator/Warning/SocketWarning.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Egulias\EmailValidator\Warning;

class SocketWarning extends Warning
{
const CODE = 996;

public function __construct($hostname, $errno, $errstr)
{
$this->message = "Error connecting to {$hostname} ({$errno}) ({$errstr})";
}
}
Loading