deliverability.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. from typing import Any, List, Optional, Tuple, TypedDict
  2. import ipaddress
  3. from .exceptions_types import EmailUndeliverableError
  4. import dns.resolver
  5. import dns.exception
  6. def caching_resolver(*, timeout: Optional[int] = None, cache: Any = None, dns_resolver: Optional[dns.resolver.Resolver] = None) -> dns.resolver.Resolver:
  7. if timeout is None:
  8. from . import DEFAULT_TIMEOUT
  9. timeout = DEFAULT_TIMEOUT
  10. resolver = dns_resolver or dns.resolver.Resolver()
  11. resolver.cache = cache or dns.resolver.LRUCache()
  12. resolver.lifetime = timeout # timeout, in seconds
  13. return resolver
  14. DeliverabilityInfo = TypedDict("DeliverabilityInfo", {
  15. "mx": List[Tuple[int, str]],
  16. "mx_fallback_type": Optional[str],
  17. "unknown-deliverability": str,
  18. }, total=False)
  19. def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Optional[int] = None, dns_resolver: Optional[dns.resolver.Resolver] = None) -> DeliverabilityInfo:
  20. # Check that the domain resolves to an MX record. If there is no MX record,
  21. # try an A or AAAA record which is a deprecated fallback for deliverability.
  22. # Raises an EmailUndeliverableError on failure. On success, returns a dict
  23. # with deliverability information.
  24. # If no dns.resolver.Resolver was given, get dnspython's default resolver.
  25. # Override the default resolver's timeout. This may affect other uses of
  26. # dnspython in this process.
  27. if dns_resolver is None:
  28. from . import DEFAULT_TIMEOUT
  29. if timeout is None:
  30. timeout = DEFAULT_TIMEOUT
  31. dns_resolver = dns.resolver.get_default_resolver()
  32. dns_resolver.lifetime = timeout
  33. elif timeout is not None:
  34. raise ValueError("It's not valid to pass both timeout and dns_resolver.")
  35. deliverability_info: DeliverabilityInfo = {}
  36. try:
  37. try:
  38. # Try resolving for MX records (RFC 5321 Section 5).
  39. response = dns_resolver.resolve(domain, "MX")
  40. # For reporting, put them in priority order and remove the trailing dot in the qnames.
  41. mtas = sorted([(r.preference, str(r.exchange).rstrip('.')) for r in response])
  42. # RFC 7505: Null MX (0, ".") records signify the domain does not accept email.
  43. # Remove null MX records from the mtas list (but we've stripped trailing dots,
  44. # so the 'exchange' is just "") so we can check if there are no non-null MX
  45. # records remaining.
  46. mtas = [(preference, exchange) for preference, exchange in mtas
  47. if exchange != ""]
  48. if len(mtas) == 0: # null MX only, if there were no MX records originally a NoAnswer exception would have occurred
  49. raise EmailUndeliverableError(f"The domain name {domain_i18n} does not accept email.")
  50. deliverability_info["mx"] = mtas
  51. deliverability_info["mx_fallback_type"] = None
  52. except dns.resolver.NoAnswer:
  53. # If there was no MX record, fall back to an A or AAA record
  54. # (RFC 5321 Section 5). Check A first since it's more common.
  55. # If the A/AAAA response has no Globally Reachable IP address,
  56. # treat the response as if it were NoAnswer, i.e., the following
  57. # address types are not allowed fallbacks: Private-Use, Loopback,
  58. # Link-Local, and some other obscure ranges. See
  59. # https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
  60. # https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
  61. # (Issue #134.)
  62. def is_global_addr(address: Any) -> bool:
  63. try:
  64. ipaddr = ipaddress.ip_address(address)
  65. except ValueError:
  66. return False
  67. return ipaddr.is_global
  68. try:
  69. response = dns_resolver.resolve(domain, "A")
  70. if not any(is_global_addr(r.address) for r in response):
  71. raise dns.resolver.NoAnswer # fall back to AAAA
  72. deliverability_info["mx"] = [(0, domain)]
  73. deliverability_info["mx_fallback_type"] = "A"
  74. except dns.resolver.NoAnswer:
  75. # If there was no A record, fall back to an AAAA record.
  76. # (It's unclear if SMTP servers actually do this.)
  77. try:
  78. response = dns_resolver.resolve(domain, "AAAA")
  79. if not any(is_global_addr(r.address) for r in response):
  80. raise dns.resolver.NoAnswer
  81. deliverability_info["mx"] = [(0, domain)]
  82. deliverability_info["mx_fallback_type"] = "AAAA"
  83. except dns.resolver.NoAnswer as e:
  84. # If there was no MX, A, or AAAA record, then mail to
  85. # this domain is not deliverable, although the domain
  86. # name has other records (otherwise NXDOMAIN would
  87. # have been raised).
  88. raise EmailUndeliverableError(f"The domain name {domain_i18n} does not accept email.") from e
  89. # Check for a SPF (RFC 7208) reject-all record ("v=spf1 -all") which indicates
  90. # no emails are sent from this domain (similar to a Null MX record
  91. # but for sending rather than receiving). In combination with the
  92. # absence of an MX record, this is probably a good sign that the
  93. # domain is not used for email.
  94. try:
  95. response = dns_resolver.resolve(domain, "TXT")
  96. for rec in response:
  97. value = b"".join(rec.strings)
  98. if value.startswith(b"v=spf1 "):
  99. if value == b"v=spf1 -all":
  100. raise EmailUndeliverableError(f"The domain name {domain_i18n} does not send email.")
  101. except dns.resolver.NoAnswer:
  102. # No TXT records means there is no SPF policy, so we cannot take any action.
  103. pass
  104. except dns.resolver.NXDOMAIN as e:
  105. # The domain name does not exist --- there are no records of any sort
  106. # for the domain name.
  107. raise EmailUndeliverableError(f"The domain name {domain_i18n} does not exist.") from e
  108. except dns.resolver.NoNameservers:
  109. # All nameservers failed to answer the query. This might be a problem
  110. # with local nameservers, maybe? We'll allow the domain to go through.
  111. return {
  112. "unknown-deliverability": "no_nameservers",
  113. }
  114. except dns.exception.Timeout:
  115. # A timeout could occur for various reasons, so don't treat it as a failure.
  116. return {
  117. "unknown-deliverability": "timeout",
  118. }
  119. except EmailUndeliverableError:
  120. # Don't let these get clobbered by the wider except block below.
  121. raise
  122. except Exception as e:
  123. # Unhandled conditions should not propagate.
  124. raise EmailUndeliverableError(
  125. "There was an error while checking if the domain name in the email address is deliverable: " + str(e)
  126. ) from e
  127. return deliverability_info