py3-validate-email/validate_email/smtp_check.py

230 lines
8.7 KiB
Python
Raw Normal View History

from logging import getLogger
2021-02-23 21:28:13 +01:00
from smtplib import (
SMTP, SMTPNotSupportedError, SMTPResponseException, SMTPServerDisconnected)
from ssl import SSLContext, SSLError
2021-02-28 15:01:37 +01:00
from typing import List, Optional, Tuple
2019-03-01 23:29:01 +01:00
from .email_address import EmailAddress
from .exceptions import (
AddressNotDeliverableError, SMTPCommunicationError, SMTPMessage,
SMTPTemporaryError, TLSNegotiationError)
LOGGER = getLogger(name=__name__)
2021-02-23 21:28:13 +01:00
class _SMTPChecker(SMTP):
2020-10-11 13:51:37 +02:00
"""
2021-02-23 21:28:13 +01:00
A specialized variant of `smtplib.SMTP` for checking the validity of
email addresses.
2020-10-11 13:51:37 +02:00
2021-02-23 21:28:13 +01:00
All the commands used in the check process are modified to raise
appropriate exceptions: `SMTPServerDisconnected` on connection
issues and `SMTPResponseException` on negative SMTP server
responses. Note that the methods of `smtplib.SMTP` already raise
these exceptions on some conditions.
Also, a new method `check` is added to run the check for a given
list of SMTP servers.
2020-10-11 13:51:37 +02:00
"""
2021-02-28 15:01:37 +01:00
2021-02-23 21:28:13 +01:00
def __init__(
2021-11-16 23:38:37 +01:00
self, local_hostname: Optional[str], timeout: float, debug: bool,
sender: EmailAddress, recip: EmailAddress,
2022-06-28 19:11:07 +02:00
skip_tls: bool = False, tls_context: Optional[SSLContext] = None):
2021-02-23 21:28:13 +01:00
"""
Initialize the object with all the parameters which remain
constant during the check of one email address on all the SMTP
servers.
"""
super().__init__(local_hostname=local_hostname, timeout=timeout)
self.set_debuglevel(debuglevel=2 if debug else False)
self.__sender = sender
self.__recip = recip
self.__temporary_errors = {}
self.__skip_tls = skip_tls
self.__tls_context = tls_context
2021-02-23 21:28:13 +01:00
# Avoid error on close() after unsuccessful connect
self.sock = None
2021-02-28 15:01:37 +01:00
def putcmd(self, cmd: str, args: str = ''):
2021-02-23 21:28:13 +01:00
"""
Like `smtplib.SMTP.putcmd`, but remember the command for later
use in error messages.
"""
if args:
self.__command = f'{cmd} {args}'
else:
self.__command = cmd
2021-02-28 15:01:37 +01:00
super().putcmd(cmd=cmd, args=args)
2021-02-23 21:28:13 +01:00
2021-02-28 15:01:37 +01:00
def connect(
self, host: str = 'localhost', port: int = 0,
2022-06-28 19:11:07 +02:00
source_address: Optional[str] = None) -> Tuple[int, str]:
2021-02-23 21:28:13 +01:00
"""
Like `smtplib.SMTP.connect`, but raise appropriate exceptions on
connection failure or negative SMTP server response.
"""
self.__command = 'connect' # Used for error messages.
2021-02-28 15:01:37 +01:00
self._host = host # Workaround: Missing in standard smtplib!
2021-11-16 23:38:37 +01:00
# Use an OS assigned source port if source_address is passed
_source_address = None if source_address is None \
else (source_address, 0)
2021-02-23 21:28:13 +01:00
try:
2021-02-28 15:01:37 +01:00
code, message = super().connect(
2021-11-16 23:38:37 +01:00
host=host, port=port, source_address=_source_address)
2021-02-23 21:28:13 +01:00
except OSError as error:
raise SMTPServerDisconnected(str(error))
if code >= 400:
2021-03-01 17:15:58 +01:00
raise SMTPResponseException(code=code, msg=message)
2021-11-16 23:38:37 +01:00
return code, message.decode()
2021-02-23 21:28:13 +01:00
def starttls(self, *args, **kwargs):
"""
Like `smtplib.SMTP.starttls`, but continue without TLS in case
either end of the connection does not support it.
"""
2019-03-25 11:13:56 +01:00
try:
2021-02-23 21:28:13 +01:00
super().starttls(*args, **kwargs)
except SMTPNotSupportedError:
# The server does not support the STARTTLS extension
pass
except RuntimeError:
# SSL/TLS support is not available to your Python interpreter
pass
except SSLError as exc:
raise TLSNegotiationError(exc)
2021-02-23 21:28:13 +01:00
2021-02-28 15:01:37 +01:00
def mail(self, sender: str, options: tuple = ()):
2021-02-23 21:28:13 +01:00
"""
Like `smtplib.SMTP.mail`, but raise an appropriate exception on
negative SMTP server response.
2021-02-28 15:01:37 +01:00
A code > 400 is an error here.
2021-02-23 21:28:13 +01:00
"""
2021-02-28 15:01:37 +01:00
code, message = super().mail(sender=sender, options=options)
2021-02-23 21:28:13 +01:00
if code >= 400:
2021-03-01 17:15:58 +01:00
raise SMTPResponseException(code=code, msg=message)
2021-02-23 21:28:13 +01:00
return code, message
2021-02-28 15:01:37 +01:00
def rcpt(self, recip: str, options: tuple = ()):
2021-02-23 21:28:13 +01:00
"""
Like `smtplib.SMTP.rcpt`, but handle negative SMTP server
responses directly.
"""
2021-02-28 15:01:37 +01:00
code, message = super().rcpt(recip=recip, options=options)
2021-02-23 21:28:13 +01:00
if code >= 500:
# Address clearly invalid: issue negative result
2021-02-28 15:01:37 +01:00
raise AddressNotDeliverableError({
self._host: SMTPMessage(
command='RCPT TO', code=code,
text=message.decode(errors='ignore'), exceptions=())})
2021-02-23 21:28:13 +01:00
elif code >= 400:
2021-03-01 17:15:58 +01:00
raise SMTPResponseException(code=code, msg=message)
2021-02-23 21:28:13 +01:00
return code, message
def quit(self):
"""
Like `smtplib.SMTP.quit`, but make sure that everything is
cleaned up properly even if the connection has been lost before.
"""
try:
return super().quit()
except SMTPServerDisconnected:
self.ehlo_resp = self.helo_resp = None
self.esmtp_features = {}
self.does_esmtp = False
self.close()
def _handle_smtpresponseexception(
self, exc: SMTPResponseException) -> bool:
'Handle an `SMTPResponseException`.'
2021-11-16 23:38:37 +01:00
smtp_error = exc.smtp_error.decode(errors='ignore') \
if type(exc.smtp_error) is bytes else exc.smtp_error
smtp_message = SMTPMessage(
command=self.__command, code=exc.smtp_code,
2021-11-16 23:38:37 +01:00
text=smtp_error, exceptions=(exc,))
if exc.smtp_code >= 500:
raise SMTPCommunicationError(
error_messages={self._host: smtp_message})
else:
self.__temporary_errors[self._host] = smtp_message
return False
2021-02-23 21:28:13 +01:00
def _check_one(self, host: str) -> bool:
"""
2021-02-28 15:01:37 +01:00
Run the check for one SMTP server.
Return `True` on positive result.
Return `False` on ambiguous result (4xx response to `RCPT TO`),
while collecting the error message for later use.
Raise `AddressNotDeliverableError`. on negative result.
2021-02-23 21:28:13 +01:00
"""
try:
2021-02-28 15:01:37 +01:00
self.connect(host=host)
if not self.__skip_tls:
self.starttls(context=self.__tls_context)
2021-02-23 21:28:13 +01:00
self.ehlo_or_helo_if_needed()
2021-02-28 15:01:37 +01:00
self.mail(sender=self.__sender.ace)
2021-11-16 23:38:37 +01:00
code, _ = self.rcpt(recip=self.__recip.ace)
except SMTPServerDisconnected as exc:
self.__temporary_errors[self._host] = SMTPMessage(
command=self.__command, code=451, text=str(exc),
exceptions=(exc,))
2021-02-23 21:28:13 +01:00
return False
except SMTPResponseException as exc:
return self._handle_smtpresponseexception(exc=exc)
except TLSNegotiationError as exc:
self.__temporary_errors[self._host] = SMTPMessage(
command=self.__command, code=-1, text=str(exc),
exceptions=exc.args)
2021-03-01 11:37:48 +01:00
return False
2021-02-23 21:28:13 +01:00
finally:
self.quit()
2021-02-28 15:01:37 +01:00
return code < 400
2021-02-23 21:28:13 +01:00
2021-03-01 11:37:48 +01:00
def check(self, hosts: List[str]) -> bool:
2021-02-23 21:28:13 +01:00
"""
Run the check for all given SMTP servers. On positive result,
return `True`, else raise exceptions described in `smtp_check`.
2021-02-23 21:28:13 +01:00
"""
for host in hosts:
LOGGER.debug(msg=f'Trying {host} ...')
2021-02-28 15:01:37 +01:00
if self._check_one(host=host):
return True
# Raise exception for collected temporary errors
if self.__temporary_errors:
2021-03-01 11:37:48 +01:00
raise SMTPTemporaryError(error_messages=self.__temporary_errors)
2021-11-16 23:38:37 +01:00
return False
2019-03-25 12:33:11 +01:00
def smtp_check(
2021-03-14 13:24:24 +01:00
email_address: EmailAddress, mx_records: List[str], timeout: float = 10,
helo_host: Optional[str] = None,
from_address: Optional[EmailAddress] = None,
skip_tls: bool = False, tls_context: Optional[SSLContext] = None,
debug: bool = False
2021-03-14 13:24:24 +01:00
) -> bool:
2019-03-25 12:33:11 +01:00
"""
Returns `True` as soon as the any of the given server accepts the
recipient address.
2021-02-28 15:01:37 +01:00
Raise an `AddressNotDeliverableError` if any server unambiguously
and permanently refuses to accept the recipient address.
Raise `SMTPTemporaryError` if all the servers answer with a
temporary error code during the SMTP communication. This means that
the validity of the email address can not be determined. Greylisting
or server delivery issues can be a cause for this.
Raise `SMTPCommunicationError` if any SMTP server replies with an
2021-02-28 15:01:37 +01:00
error message to any of the communication steps before the recipient
address is checked, and the validity of the email address can not be
determined either.
2019-03-25 12:33:11 +01:00
"""
2021-02-23 21:28:13 +01:00
smtp_checker = _SMTPChecker(
local_hostname=helo_host, timeout=timeout, debug=debug,
sender=from_address or email_address, recip=email_address,
skip_tls=skip_tls, tls_context=tls_context)
2021-02-28 15:01:37 +01:00
return smtp_checker.check(hosts=mx_records)