From 9b174a591f850cfa28f8392ddda58ca2b03d2516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Fri, 10 Apr 2020 12:53:10 +0200 Subject: [PATCH] Beautifying code, updating AUTHORS+CHANGELOG --- AUTHORS | 1 + CHANGELOG.txt | 6 ++++ tests/test_blacklist_check.py | 26 ++++++-------- tests/test_mx_check.py | 15 ++++---- tests/test_regex_check.py | 3 +- validate_email/exceptions.py | 54 ++++++++++------------------ validate_email/mx_check.py | 62 +++++++++++++++++++------------- validate_email/regex_check.py | 2 +- validate_email/validate_email.py | 4 ++- 9 files changed, 87 insertions(+), 86 deletions(-) diff --git a/AUTHORS b/AUTHORS index df09f87..d0568db 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,4 @@ +- April 2020: Extending with logging and raising errors by @reinhard-mueller - March 2019: extending and upgrading with blacklists by László Károlyi in May 2018. - validate_email was created by Syrus Akbary in April 2012. diff --git a/CHANGELOG.txt b/CHANGELOG.txt index ea43077..5c9e68f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +0.2.1: +- Added a validate_email_or_fail function that will raise an exception + (base class validate_email.exceptions.EmailValidationError) when the + passed email check fails, while logging a warning with the validation + result. + 0.2.0: - Added automatic auto-updater for updating built-in blacklists. diff --git a/tests/test_blacklist_check.py b/tests/test_blacklist_check.py index 21167bd..9078773 100644 --- a/tests/test_blacklist_check.py +++ b/tests/test_blacklist_check.py @@ -19,26 +19,20 @@ class BlacklistCheckTestCase(TestCase): domainlist_check(user_part='pa2', domain_part='mailinator.com') with self.assertRaises(DomainBlacklistedError): validate_email_or_fail( - email_address='pa2@mailinator.com', - check_regex=False, - use_blacklist=True) + email_address='pa2@mailinator.com', check_regex=False, + use_blacklist=True) with self.assertRaises(DomainBlacklistedError): validate_email_or_fail( - email_address='pa2@mailinator.com', - check_regex=True, - use_blacklist=True) + email_address='pa2@mailinator.com', check_regex=True, + use_blacklist=True) with self.assertLogs(): - self.assertFalse( - validate_email( - email_address='pa2@mailinator.com', - check_regex=False, - use_blacklist=True)) + self.assertFalse(expr=validate_email( + email_address='pa2@mailinator.com', check_regex=False, + use_blacklist=True)) with self.assertLogs(): - self.assertFalse( - validate_email( - email_address='pa2@mailinator.com', - check_regex=True, - use_blacklist=True)) + self.assertFalse(expr=validate_email( + email_address='pa2@mailinator.com', check_regex=True, + use_blacklist=True)) def test_blacklist_negative(self): 'Allows a domain not in the blacklist.' diff --git a/tests/test_mx_check.py b/tests/test_mx_check.py index dfcfdb2..f1979b5 100644 --- a/tests/test_mx_check.py +++ b/tests/test_mx_check.py @@ -65,16 +65,18 @@ class GetMxRecordsTestCase(TestCase): 'Fails when an MX hostname is "."' TEST_QUERY.return_value = [ SimpleNamespace(exchange=DnsNameStub(value='.'))] - with self.assertRaises(NoValidMXError): + with self.assertRaises(NoValidMXError) as exc: _get_mx_records(domain='testdomain1', timeout=10) + self.assertTupleEqual(exc.exception.args, ()) @patch.object(target=mx_module, attribute='query', new=TEST_QUERY) def test_fails_with_null_hostnames(self): 'Fails when an MX hostname is invalid.' TEST_QUERY.return_value = [ SimpleNamespace(exchange=DnsNameStub(value='asdqwe'))] - with self.assertRaises(NoValidMXError): + with self.assertRaises(NoValidMXError) as exc: _get_mx_records(domain='testdomain2', timeout=10) + self.assertTupleEqual(exc.exception.args, ()) @patch.object(target=mx_module, attribute='query', new=TEST_QUERY) def test_filters_out_invalid_hostnames(self): @@ -92,12 +94,13 @@ class GetMxRecordsTestCase(TestCase): def test_raises_exception_on_dns_timeout(self): 'Raises exception on DNS timeout.' TEST_QUERY.side_effect = Timeout() - with self.assertRaises(DNSTimeoutError): + with self.assertRaises(DNSTimeoutError) as exc: _get_mx_records(domain='testdomain3', timeout=10) + self.assertTupleEqual(exc.exception.args, ()) def test_returns_false_on_idna_failure(self): 'Returns `False` on IDNA failure.' - with self.assertRaises(AddressFormatError): + with self.assertRaises(AddressFormatError) as exc: mx_module.mx_check( - email_address='test@♥web.de', - from_address='mail@example.com') + email_address='test@♥web.de', from_address='mail@example.com') + self.assertTupleEqual(exc.exception.args, ()) diff --git a/tests/test_regex_check.py b/tests/test_regex_check.py index 350b61c..efba69f 100644 --- a/tests/test_regex_check.py +++ b/tests/test_regex_check.py @@ -57,7 +57,8 @@ class FormatValidity(TestCase): for address in INVALID_EXAMPLES: user_part, domain_part = address.rsplit('@', 1) with self.assertRaises( - AddressFormatError, msg=f'Test failed for {address}'): + expected_exception=AddressFormatError, + msg=f'Test failed for {address}'): regex_check(user_part=user_part, domain_part=domain_part), def test_unparseable_email(self): diff --git a/validate_email/exceptions.py b/validate_email/exceptions.py index ccad1a4..5a067af 100644 --- a/validate_email/exceptions.py +++ b/validate_email/exceptions.py @@ -1,7 +1,8 @@ +from typing import Iterable + + class EmailValidationError(Exception): - """ - Base class for all exceptions indicating validation failure. - """ + 'Base class for all exceptions indicating validation failure.' message = 'Unknown error.' def __str__(self): @@ -9,74 +10,55 @@ class EmailValidationError(Exception): class AddressFormatError(EmailValidationError): - """ - Raised when the email address has an invalid format. - """ + 'Raised when the email address has an invalid format.' message = 'Invalid email address.' class DomainBlacklistedError(EmailValidationError): """ Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. + https://github.com/martenson/disposable-email-domains. """ message = 'Domain blacklisted.' class DomainNotFoundError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ + 'Raised when the domain is not found.' message = 'Domain not found.' class NoNameserverError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ + 'Raised when the domain does not resolve by nameservers in time.' message = 'No nameserver found for domain.' class DNSTimeoutError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ + 'Raised when the domain lookup times out.' message = 'Domain lookup timed out.' class DNSConfigurationError(EmailValidationError): """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. + Raised when the DNS entries for this domain are falsely configured. """ message = 'Misconfigurated DNS entries for domain.' class NoMXError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ + 'Raised then the domain has no MX records configured.' message = 'No MX record for domain found.' class NoValidMXError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ message = 'No valid MX record for domain found.' class AddressNotDeliverableError(EmailValidationError): - """ - Raised when the domain of the email address is blacklisted on - https://git.com/martenson/disposable-email-domains. - """ - message = 'Non-deliverable email address:' + 'Raised when a non-ambigious resulted lookup fails.' + message = 'Email address undeliverable:' - def __init__(self, error_messages): - self.message = '\n'.join([self.message] + error_messages) + def __init__(self, error_messages: Iterable): + self.error_messages = error_messages + + def __str__(self) -> str: + return '\n'.join([self.message] + self.error_messages) diff --git a/validate_email/mx_check.py b/validate_email/mx_check.py index a75c2df..0717063 100644 --- a/validate_email/mx_check.py +++ b/validate_email/mx_check.py @@ -61,11 +61,40 @@ def _get_mx_records(domain: str, timeout: int) -> list: 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 len(result): + 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 + + def _check_mx_records( mx_records: list, smtp_timeout: int, helo_host: str, from_address: str, email_address: str @@ -77,32 +106,15 @@ def _check_mx_records( found_ambigious = False for mx_record in mx_records: 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: - found_ambigious = True - continue - except SocketError as error: - error_messages.append(f'{mx_record}: {error}') - continue - if code == 250: + 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 - elif 400 <= code <= 499: - # Ambigious return code, can be graylist, temporary - # problems, quota or mailsystem error - found_ambigious = True - else: - message = message.decode(errors='ignore') - error_messages.append(f'{mx_record}: {code} {message}') - # If any of the mx servers behaved ambigious, return None, otherwise raise - # an exceptin containing the collected error messages. - if found_ambigious: - return None - else: + # an exception containing the collected error messages. + if not found_ambigious: raise AddressNotDeliverableError(error_messages) diff --git a/validate_email/regex_check.py b/validate_email/regex_check.py index 4c8d4cc..b8fc64a 100644 --- a/validate_email/regex_check.py +++ b/validate_email/regex_check.py @@ -53,7 +53,7 @@ class RegexValidator(object): raise AddressFormatError return True - def validate_domain_part(self, domain_part): + def validate_domain_part(self, domain_part: str): if HOST_REGEX.match(domain_part): return True diff --git a/validate_email/validate_email.py b/validate_email/validate_email.py index ea9905c..22b8b8b 100644 --- a/validate_email/validate_email.py +++ b/validate_email/validate_email.py @@ -6,6 +6,8 @@ from .exceptions import AddressFormatError, EmailValidationError from .mx_check import mx_check from .regex_check import regex_check +logger = getLogger(name='validate_email') + def validate_email_or_fail( email_address: str, check_regex: bool = True, check_mx: bool = True, @@ -43,5 +45,5 @@ def validate_email(email_address: str, *args, **kwargs): return validate_email_or_fail(email_address, *args, **kwargs) except EmailValidationError as error: message = f'Validation for {email_address!r} failed: {error}' - getLogger('validate_email').info(message) + logger.warning(msg=message) return False