py3-validate-email/validate_email/mx_check.py

143 lines
4.9 KiB
Python
Raw Normal View History

2019-05-03 18:44:44 +02:00
from functools import lru_cache
2019-03-02 02:53:53 +01:00
from smtplib import SMTP, SMTPServerDisconnected
2019-03-25 11:13:56 +01:00
from socket import error as SocketError
2019-03-01 23:45:29 +01:00
from socket import gethostname
2019-05-03 18:44:44 +02:00
from typing import Optional, Tuple
2019-03-01 23:29:01 +01:00
2019-05-25 13:51:02 +02:00
from dns.exception import Timeout
2019-05-03 18:44:44 +02:00
from dns.rdatatype import MX as rdtype_mx
from dns.rdtypes.ANY.MX import MX
2019-05-25 14:32:09 +02:00
from dns.resolver import (
NXDOMAIN, YXDOMAIN, Answer, NoAnswer, NoNameservers, query)
2019-06-26 14:35:30 +02:00
from idna.core import IDNAError, encode
2019-04-03 21:49:22 +02:00
from .constants import EMAIL_EXTRACT_HOST_REGEX, HOST_REGEX
from .exceptions import (
AddressFormatError, AddressNotDeliverableError, DNSConfigurationError,
DNSTimeoutError, DomainNotFoundError, NoMXError, NoNameserverError,
NoValidMXError)
2019-03-01 23:45:29 +01:00
2018-06-01 14:29:44 +02:00
2019-05-03 18:44:44 +02:00
@lru_cache(maxsize=10)
def _dissect_email(email_address: str) -> Tuple[str, str]:
'Return a tuple of the user and domain part.'
2018-06-01 14:29:44 +02:00
try:
2019-05-03 18:44:44 +02:00
domain = EMAIL_EXTRACT_HOST_REGEX.search(string=email_address)[1]
2018-06-01 14:29:44 +02:00
except TypeError:
2020-04-09 00:35:51 +02:00
raise AddressFormatError
2018-06-01 14:29:44 +02:00
except IndexError:
2020-04-09 00:35:51 +02:00
raise AddressFormatError
2019-05-03 18:44:44 +02:00
return email_address[:-(len(domain) + 1)], domain
@lru_cache(maxsize=10)
def _get_idna_address(email_address: str) -> str:
'Return an IDNA converted email address.'
user, domain = _dissect_email(email_address=email_address)
idna_resolved_domain = encode(s=domain).decode('ascii')
return f'{user}@{idna_resolved_domain}'
2019-05-25 13:51:02 +02:00
def _get_mx_records(domain: str, timeout: int) -> list:
"""
Return a list of hostnames in the MX record, raise an exception on
2019-05-25 13:51:02 +02:00
any issues.
"""
try:
2019-05-25 13:51:02 +02:00
records = query(
qname=domain, rdtype=rdtype_mx, lifetime=timeout) # type: Answer
2019-03-03 22:49:39 +01:00
except NXDOMAIN:
raise DomainNotFoundError
except NoNameservers:
raise NoNameserverError
2019-05-25 13:51:02 +02:00
except Timeout:
raise DNSTimeoutError
2019-05-25 13:51:02 +02:00
except YXDOMAIN:
raise DNSConfigurationError
except NoAnswer:
raise NoMXError
to_check = dict()
for record in records: # type: MX
dns_str = record.exchange.to_text() # type: str
to_check[dns_str] = dns_str[:-1] if dns_str.endswith('.') else dns_str
result = [k for k, v in to_check.items() if HOST_REGEX.search(string=v)]
if not result:
raise NoValidMXError
return result
def _check_one_mx(
smtp: SMTP, error_messages: list, mx_record: str, helo_host: str,
from_address: str, email_address: str) -> bool:
"""
Check one MX server, return the `is_ambigious` boolean or raise
`StopIteration` if this MX accepts the email.
"""
try:
smtp.connect(host=mx_record)
smtp.helo(name=helo_host)
smtp.mail(sender=from_address)
code, message = smtp.rcpt(recip=email_address)
smtp.quit()
except SMTPServerDisconnected:
return True
except SocketError as error:
error_messages.append(f'{mx_record}: {error}')
return False
if code == 250:
raise StopIteration
elif 400 <= code <= 499:
# Ambigious return code, can be graylist, temporary problems,
# quota or mailsystem error
return True
message = message.decode(errors='ignore')
error_messages.append(f'{mx_record}: {code} {message}')
return False
2019-03-25 12:33:11 +01:00
def _check_mx_records(
mx_records: list, smtp_timeout: int, helo_host: str, from_address: str,
email_address: str
2019-03-25 12:43:30 +01:00
) -> Optional[bool]:
2019-03-25 12:33:11 +01:00
'Check the mx records for a given email address.'
2019-03-01 23:45:29 +01:00
smtp = SMTP(timeout=smtp_timeout)
2019-03-25 12:42:10 +01:00
smtp.set_debuglevel(debuglevel=0)
error_messages = []
found_ambigious = False
for mx_record in mx_records:
2019-03-25 11:13:56 +01:00
try:
found_ambigious |= _check_one_mx(
smtp=smtp, error_messages=error_messages, mx_record=mx_record,
helo_host=helo_host, from_address=from_address,
email_address=email_address)
except StopIteration:
return True
# If any of the mx servers behaved ambigious, return None, otherwise raise
# an exception containing the collected error messages.
if not found_ambigious:
raise AddressNotDeliverableError(error_messages)
2019-03-25 12:33:11 +01:00
def mx_check(
email_address: str, from_address: Optional[str] = None,
2019-05-25 13:51:02 +02:00
helo_host: Optional[str] = None, smtp_timeout: int = 10,
dns_timeout: int = 10
2019-03-25 12:33:11 +01:00
) -> Optional[bool]:
"""
Return `True` if the host responds with a deliverable response code,
`False` if not-deliverable.
2019-03-25 12:48:23 +01:00
Also, return `None` if there if couldn't provide a conclusive result
(e.g. temporary errors or graylisting).
2019-03-25 12:33:11 +01:00
"""
host = helo_host or gethostname()
2019-05-03 18:44:44 +02:00
idna_from = _get_idna_address(email_address=from_address or email_address)
2019-06-26 14:31:52 +02:00
try:
idna_to = _get_idna_address(email_address=email_address)
except IDNAError:
2020-04-09 00:35:51 +02:00
raise AddressFormatError
2019-05-03 18:44:44 +02:00
_user, domain = _dissect_email(email_address=email_address)
mx_records = _get_mx_records(domain=domain, timeout=dns_timeout)
2019-03-25 12:33:11 +01:00
return _check_mx_records(
mx_records=mx_records, smtp_timeout=smtp_timeout, helo_host=host,
2019-05-03 18:44:44 +02:00
from_address=idna_from, email_address=idna_to)