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