# -*- coding: utf-8 -*-

import sys
import re
import unicodedata
import dns.resolver, dns.exception
import idna # implements IDNA 2008; Python's codec is only IDNA 2003


# Based on RFC 2822 section 3.2.4 / RFC 5322 section 3.2.3, these
# characters are permitted in email addresses (not taking into
# account internationalization):
ATEXT = r'a-zA-Z0-9_!#\$%&\'\*\+\-/=\?\^`\{\|\}~'

# A "dot atom text", per RFC 2822 3.2.4:
DOT_ATOM_TEXT = '[' + ATEXT + ']+(?:\\.[' + ATEXT + ']+)*'

# RFC 6531 section 3.3 extends the allowed characters in internationalized
# addresses to also include three specific ranges of UTF8 defined in
# RFC3629 section 4, which appear to be the Unicode code points from
# U+0080 to U+10FFFF.
ATEXT_UTF8 = ATEXT + u"\u0080-\U0010FFFF"
DOT_ATOM_TEXT_UTF8 = '[' + ATEXT_UTF8 + ']+(?:\\.[' + ATEXT_UTF8 + ']+)*'

# The domain part of the email address, after IDNA (ASCII) encoding,
# must also satisfy the requirements of RFC 952/RFC 1123 which restrict
# the allowed characters of hostnames further. The hyphen cannot be at
# the beginning or end of a *dot-atom component* of a hostname either.
ATEXT_HOSTNAME = r'(?:(?:[a-zA-Z0-9][a-zA-Z0-9\-]*)?[a-zA-Z0-9])'

# ease compatibility in type checking
if sys.version_info >= (3,):
	unicode_class = str
	chr_function = chr
else:
	unicode_class = unicode
	chr_function = unichr

	# turn regexes to unicode (because 'ur' literals are not allowed in Py3)
	ATEXT = ATEXT.decode("ascii")
	DOT_ATOM_TEXT = DOT_ATOM_TEXT.decode("ascii")
	ATEXT_HOSTNAME = ATEXT_HOSTNAME.decode("ascii")

class EmailNotValidError(ValueError):
	"""Parent class of all exceptions raised by this module."""
	pass

class EmailSyntaxError(EmailNotValidError):
	"""Exception raised when an email address fails validation because of its form."""
	pass

class EmailUndeliverableError(EmailNotValidError):
	"""Exception raised when an email address fails validation because its domain name does not appear deliverable."""
	pass

def validate_email(
	email,
	allow_smtputf8=True,
	allow_empty_local=False,
	check_deliverability=True):

	"""Validates an email address, raising an EmailNotValidError if the address is not valid or returning a dict of information
	when the address is valid. The email argument can be a str or a bytes instance, but if bytes it must be ASCII-only."""

	# Allow email to be a str or bytes instance. If bytes,
	# it must be ASCII because that's how the bytes work
	# on the wire with SMTP.
	if not isinstance(email, (str, unicode_class)):
		try:
			email = email.decode("ascii")
		except ValueError:
			raise EmailSyntaxError("The email address is not valid ASCII.")

	# At-sign.
	parts = email.split('@')
	if len(parts) != 2:
		raise EmailSyntaxError("The email address is not valid. It must have exactly one @-sign.")

	# Prepare a dict to return on success.
	ret = { }

	# Validate the email address's local part syntax and update the return
	# dict with metadata.
	ret.update(validate_email_local_part(parts[0], allow_smtputf8=allow_smtputf8, allow_empty_local=allow_empty_local))

	# Validate the email address's domain part syntax and update the return
	# dict with metadata.
	ret.update(validate_email_domain_part(parts[1]))

	if check_deliverability:
		# Validate the email address's deliverability and update the
		# return dict with metadata.
		ret.update(validate_email_deliverability(ret["domain"], ret["domain_i18n"]))

	# If the email address has an ASCII form, add it.
	ret["email"] = ret["local"] + "@" + ret["domain_i18n"]
	if not ret["smtputf8"]:
		ret["email_ascii"] = ret["local"] + "@" + ret["domain"]

	return ret

def _narrow_build_check(local):
	"""
	The regex doesn't compile correctly for narrow builds of Python, so
	try again.  This is not that pretty, but should catch these edge
	cases, which are hopefully rare
	"""
	if len(u"\U0010FFFF") > 1:
		#This is a narrow build, try to match this again.
		#The narrow build only checks up to 56319 instead
		#of 1114111 (0x10ffff).  Convert any unicode characters
		#in the valid range (0x0080 to 0x10ffff) to 0x0080 and
		#run the regex on that
		local_chars = [c for c in local]
		for i, char in enumerate(local_chars):
			if ord(char) > 0x0080 and ord(char) <= 0x10ffff:
				local_chars[i] = chr_function(0x0080)
		safe_fake_local = u"".join(local_chars)
		m = re.match(DOT_ATOM_TEXT_UTF8 + "$", safe_fake_local)
		return m
	#Wide build, nothing to match, return no match
	return None

def validate_email_local_part(local, allow_smtputf8=True, allow_empty_local=False):
	# Validates the local part of an email address.

	if len(local) == 0:
		if not allow_empty_local:
			raise EmailSyntaxError("There must be something before the @-sign.")
		else:
			# The caller allows an empty local part. Useful for validating certain
			# Postfix aliases.
			return {
				"local": local,
				"smtputf8": False,
			}

	# RFC 5321 4.5.3.1.1
	if len(local) > 64:
		raise EmailSyntaxError("The email address is too long before the @-sign.")

	# Check the local part against the regular expression for the older ASCII requirements.
	m = re.match(DOT_ATOM_TEXT + "$", local)
	if m:
		# Return the local part unchanged and flag that SMTPUTF8 is not needed.
		return {
			"local": local,
			"smtputf8": False,
		}

	else:
		# The local part failed the ASCII check. Now try the extended internationalized requirements.
		m = re.match(DOT_ATOM_TEXT_UTF8 + "$", local)
		if not m:
			#It's not looking good, but there's a chance this is a
			#"narrow" build of Python where the regex didn't compile
			#correctly, try to check that
			m = _narrow_build_check(local)
		if not m:
			# It's not a valid internationalized address either. Report which characters were not valid.
			bad_chars = ', '.join(sorted(set( c for c in local if not re.match(u"[" + (ATEXT if not allow_smtputf8 else ATEXT_UTF8) + u"]", c) )))
			raise EmailSyntaxError("The email address contains invalid characters before the @-sign: %s." % bad_chars)

		# It would be valid if internationalized characters were allowed by the caller.
		if not allow_smtputf8:
			raise EmailSyntaxError("Internationalized characters before the @-sign are not supported.")

		# It's valid.

		# RFC 6532 section 3.1 also says that Unicode NFC normalization should be applied,
		# so we'll return the normalized local part in the return value.
		local = unicodedata.normalize("NFC", local)

		# Flag that SMTPUTF8 will be required for deliverability.
		return {
			"local": local,
			"smtputf8": True,
		}

def validate_email_domain_part(domain):
	# Empty?
	if len(domain) == 0:
		raise EmailSyntaxError("There must be something after the @-sign.")

	# Perform UTS-46 normalization, which includes casefolding, NFC normalization,
	# and converting all label separators (the period/full stop, fullwidth full stop,
	# ideographic full stop, and halfwidth ideographic full stop) to basic periods.
	# It will also raise an exception if there is an invalid character in the input,
	# such as "⒈" which is invalid because it would expand to include a period.
	try:
		domain = idna.uts46_remap(domain, std3_rules=False, transitional=False)
	except idna.IDNAError as e:
		raise EmailSyntaxError("The domain name %s contains invalid characters (%s)." % (domain, str(e)))

	# Now we can perform basic checks on the use of periods (since equivalent
	# symbols have been mapped to periods). These checks are needed because the
	# IDNA library doesn't handle well domains that have empty labels (i.e. initial
	# dot, trailing dot, or two dots in a row).
	if domain.endswith("."):
		raise EmailSyntaxError("An email address cannot end with a period.")
	if domain.startswith("."):
		raise EmailSyntaxError("An email address cannot have a period immediately after the @-sign.")
	if ".." in domain:
		raise EmailSyntaxError("An email address cannot have two periods in a row.")

	# Regardless of whether international characters are actually used,
	# first convert to IDNA ASCII. For ASCII-only domains, the transformation
	# does nothing. If internationalized characters are present, the MTA
	# must either support SMTPUTF8 or the mail client must convert the
	# domain name to IDNA before submission.
	#
	# Unfortunately this step incorrectly 'fixes' domain names with leading
	# periods by removing them, so we have to check for this above. It also gives
	# a funky error message ("No input") when there are two periods in a
	# row, also checked separately above.
	try:
		domain = idna.encode(domain, uts46=False).decode("ascii")
	except idna.IDNAError as e:
		raise EmailSyntaxError("The domain name %s contains invalid characters (%s)." % (domain, str(e)))

	# We may have been given an IDNA ASCII domain to begin with. Check
	# that the domain actually conforms to IDNA. It could look like IDNA
	# but not be actual IDNA. For ASCII-only domains, the conversion out
	# of IDNA just gives the same thing back.
	#
	# This gives us the canonical internationalized form of the domain,
	# which we should use in all error messages.
	try:
		domain_i18n = idna.decode(domain.encode('ascii'))
	except idna.IDNAError as e:
		raise EmailSyntaxError("The domain name %s is not valid IDNA (%s)." % (domain, str(e)))

	# RFC 5321 4.5.3.1.2
	if len(domain) > 255:
		raise EmailSyntaxError("The email address is too long after the @-sign.")

	# A "dot atom text", per RFC 2822 3.2.4, but using the restricted
	# characters allowed in a hostname (see ATEXT_HOSTNAME above).
	DOT_ATOM_TEXT = ATEXT_HOSTNAME + r'(?:\.' + ATEXT_HOSTNAME + r')*'

	# Check the regular expression. This is probably entirely redundant
	# with idna.decode, which also checks this format.
	m = re.match(DOT_ATOM_TEXT + "$", domain)
	if not m:
		raise EmailSyntaxError("The email address contains invalid characters after the @-sign.")

	# All publicly deliverable addresses have domain named with at least
	# one period. We also know that all TLDs end with a letter.
	if "." not in domain:
		raise EmailSyntaxError("The domain name %s is not valid. It should have a period." % domain_i18n)
	if not re.search(r"[A-Za-z]$", domain):
		raise EmailSyntaxError("The domain name %s is not valid. It is not within a valid top-level domain." % domain_i18n)

	# Return the IDNA ASCII-encoded form of the domain, which is how it
	# would be transmitted on the wire (except when used with SMTPUTF8
	# possibly), as well as the canonical Unicode form of the domain,
	# which is better for display purposes. This should also take care
	# of RFC 6532 section 3.1's suggestion to apply Unicode NFC
	# normalization to addresses.
	return {
		"domain": domain,
		"domain_i18n": domain_i18n,
	}

def validate_email_deliverability(domain, domain_i18n):
	# Check that the domain resolves to an MX record. If there is no MX record,
	# try an A or AAAA record which is a deprecated fallback for deliverability.

	try:
		resolver = dns.resolver.get_default_resolver()

		try:
			# Try resolving for MX records and get them in sorted priority order.
			response = dns.resolver.query(domain, "MX")
			mtas = sorted([(r.preference, str(r.exchange).rstrip('.')) for r in response])
			mx_fallback = False
		except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):

			# If there was no MX record, fall back to an A record.
			try:
				response = dns.resolver.query(domain, "A")
				mtas = [(0, str(r)) for r in response]
				mx_fallback = "A"
			except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):

				# If there was no A record, fall back to an AAAA record.
				try:
					response = dns.resolver.query(domain, "AAAA")
					mtas = [(0, str(r)) for r in response]
					mx_fallback = "AAAA"
				except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):

					# If there was no MX, A, or AAAA record, then mail to
					# this domain is not deliverable.
					raise EmailUndeliverableError("The domain name %s does not exist." % domain_i18n)

	except dns.exception.Timeout:
		# A timeout could occur for various reasons, so don't treat it as a failure.
		return {
			"unknown-deliverability": "timeout",
		}

	except EmailUndeliverableError:
		# Don't let these get clobbered by the wider except block below.
		raise

	except Exception as e:
		# Unhandled conditions should not propagate.
		raise EmailUndeliverableError("There was an error while checking if the domain name in the email address is deliverable: " + str(e))

	return {
		"mx": mtas,
		"mx-fallback": mx_fallback,
	}

def main():
	import sys, json

	if sys.argv[-1] == "--tests":
		# Pass a file of valid/invalid email addresses.
		correct_answer = None
		failed = 0
		for line in sys.stdin:
			# Strip newlines and skip blank lines and comments.
			line = line.strip()
			if line == "" or line[0] == "#": continue
			if sys.version_info < (3,): line = line.decode("utf8") # assume utf8 in input

			# Pick up "[valid]"/"[invalid]" lines.
			if line == "[valid]":
				correct_answer = True
				continue
			elif line == "[invalid]":
				correct_answer = False
				continue
			elif correct_answer is None:
				raise Exception("Missing [valid]/[invalid] line.")

			# Run.
			try:
				email = line
				validate_email(email, check_deliverability=False)
				if correct_answer == False:
					# Should have failed.
					print(email, "was recognized as valid.")
					failed += 1
			except EmailNotValidError as e:
				if correct_answer == True:
					# Should have passed.
					print(email, e)
					failed += 1
		print("%d tests failed" % failed)
		sys.exit(0 if not failed else 1)

	elif len(sys.argv) == 1:
		# Read lines for STDIN and validate the email address on each line.
		allow_smtputf8 = True
		for line in sys.stdin:
			try:
				email = line.strip()
				if sys.version_info < (3,): email = email.decode("utf8") # assume utf8 in input
				validate_email(email, allow_smtputf8=allow_smtputf8)
			except EmailNotValidError as e:
				print(email, e)
	else:
		# Validate the email address passed on the command line.
		email = sys.argv[1]
		allow_smtputf8 = True
		check_deliverability = True
		if sys.version_info < (3,): email = email.decode("utf8") # assume utf8 in input
		try:
			result = validate_email(email, allow_smtputf8=allow_smtputf8, check_deliverability=check_deliverability)
			print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False))
		except EmailNotValidError as e:
			if sys.version_info < (3,):
				print(unicode(e).encode("utf8"))
			else:
				print(e)

if __name__ == "__main__":
	main()

