Skip to content

Commit 2342467

Browse files
committed
Check for 'v=spf1 -all' SPF records as a way to reject more bad domains
1 parent b87f8d3 commit 2342467

File tree

3 files changed

+53
-29
lines changed

3 files changed

+53
-29
lines changed

README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ addresses are allowed when passing a `bytes`) and:
106106

107107
When an email address is not valid, `validate_email` raises either an
108108
`EmailSyntaxError` if the form of the address is invalid or an
109-
`EmailUndeliverableError` if the domain name fails the DNS check. Both
109+
`EmailUndeliverableError` if the domain name fails DNS checks. Both
110110
exception classes are subclasses of `EmailNotValidError`, which in turn
111111
is a subclass of `ValueError`.
112112

@@ -120,14 +120,17 @@ they will probably give you grief if you're using email for login. (See
120120
later in the document about that.)
121121

122122
The validator checks that the domain name in the email address has a
123-
(non-null) MX DNS record indicating that it is configured for email.
123+
DNS MX record (except a NULL MX record) indicating that it can receive
124+
email and that it does not have a reject-all SPF record (`v=spf1 -all`)
125+
which would indicate that it cannot send email.
126+
(A/AAAA-record MX fallback is also checked.)
124127
There is nothing to be gained by trying to actually contact an SMTP
125128
server, so that's not done here. For privacy, security, and practicality
126129
reasons servers are good at not giving away whether an address is
127130
deliverable or not: email addresses that appear to accept mail at first
128131
can bounce mail after a delay, and bounced mail may indicate a temporary
129132
failure of a good email address (sometimes an intentional failure, like
130-
greylisting). (A/AAAA-record fallback is also checked.)
133+
greylisting).
131134

132135
### Options
133136

@@ -138,7 +141,7 @@ The `validate_email` function also accepts the following keyword arguments
138141
require the
139142
[SMTPUTF8](https://tools.ietf.org/html/rfc6531) extension.
140143

141-
`check_deliverability=True`: Set to `False` to skip the domain name MX DNS record check.
144+
`check_deliverability=True`: Set to `False` to skip the domain name DNS record checks.
142145

143146
`allow_empty_local=False`: Set to `True` to allow an empty local part (i.e.
144147
`@example.com`), e.g. for validating Postfix aliases.
@@ -323,9 +326,7 @@ ValidatedEmail(
323326
ascii_email='test@joshdata.me',
324327
ascii_local_part='test',
325328
ascii_domain='joshdata.me',
326-
smtputf8=False,
327-
mx=[(10, 'box.occams.info')],
328-
mx_fallback_type=None)
329+
smtputf8=False)
329330
```
330331

331332
For the fictitious address `example@ツ.life`, which has an
@@ -392,6 +393,7 @@ are:
392393
| `smtputf8` | A boolean indicating that the [SMTPUTF8](https://tools.ietf.org/html/rfc6531) feature of your mail relay will be required to transmit messages to this address because the local part of the address has non-ASCII characters (the local part cannot be IDNA-encoded). If `allow_smtputf8=False` is passed as an argument, this flag will always be false because an exception is raised if it would have been true. |
393394
| `mx` | A list of (priority, domain) tuples of MX records specified in the DNS for the domain (see [RFC 5321 section 5](https://tools.ietf.org/html/rfc5321#section-5)). May be `None` if the deliverability check could not be completed because of a temporary issue like a timeout. |
394395
| `mx_fallback_type` | `None` if an `MX` record is found. If no MX records are actually specified in DNS and instead are inferred, through an obsolete mechanism, from A or AAAA records, the value is the type of DNS record used instead (`A` or `AAAA`). May be `None` if the deliverability check could not be completed because of a temporary issue like a timeout. |
396+
| `spf` | Any SPF record found while checking deliverability. |
395397

396398
Assumptions
397399
-----------
@@ -401,10 +403,12 @@ strictly conform to the standards. Many email address forms are obsolete
401403
or likely to cause trouble:
402404

403405
* The validator assumes the email address is intended to be
404-
deliverable on the public Internet. The domain part
405-
of the email address must be a resolvable domain name.
406-
[Special Use Domain Names](https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml)
407-
and their subdomains are always considered invalid (except see
406+
usable on the public Internet. The domain part
407+
of the email address must be a resolvable domain name
408+
(without NULL MX or SPF -all DNS records) if deliverability
409+
checks are turned on.
410+
Most [Special Use Domain Names](https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml)
411+
and their subdomains are considered invalid (except see
408412
the `test_environment` parameter above).
409413
* The "quoted string" form of the local part of the email address (RFC
410414
5321 4.1.2) is not permitted --- no one uses this anymore anyway.

email_validator/__init__.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,8 @@ def validate_email(
356356
deliverability_info = validate_email_deliverability(
357357
ret["domain"], ret["domain_i18n"], timeout, dns_resolver
358358
)
359-
if "mx" in deliverability_info:
360-
ret.mx = deliverability_info["mx"]
361-
ret.mx_fallback_type = deliverability_info["mx-fallback"]
359+
for key, value in deliverability_info.items():
360+
setattr(ret, key, value)
362361

363362
return ret
364363

@@ -588,6 +587,8 @@ def validate_email_deliverability(domain, domain_i18n, timeout=DEFAULT_TIMEOUT,
588587
dns_resolver = dns.resolver.get_default_resolver()
589588
dns_resolver.lifetime = timeout
590589

590+
deliverability_info = {}
591+
591592
def dns_resolver_resolve_shim(domain, record):
592593
try:
593594
# dns.resolver.Resolver.resolve is new to dnspython 2.x.
@@ -611,39 +612,61 @@ def dns_resolver_resolve_shim(domain, record):
611612
raise dns.exception.Timeout()
612613

613614
try:
614-
# Try resolving for MX records and get them in sorted priority order
615-
# as (priority, qname) pairs.
615+
# Try resolving for MX records.
616616
response = dns_resolver_resolve_shim(domain, "MX")
617+
618+
# For reporting, put them in priority order and remove the trailing dot in the qnames.
617619
mtas = sorted([(r.preference, str(r.exchange).rstrip('.')) for r in response])
618-
mx_fallback = None
619620

620-
# Do not permit delivery if there is only a "null MX" record (whose value is
621-
# (0, ".") but we've stripped trailing dots, so the 'exchange' is just "").
621+
# Remove "null MX" records from the list (their value is (0, ".") but we've stripped
622+
# trailing dots, so the 'exchange' is just ""). If there was only a null MX record,
623+
# email is not deliverable.
622624
mtas = [(preference, exchange) for preference, exchange in mtas
623625
if exchange != ""]
624626
if len(mtas) == 0:
625627
raise EmailUndeliverableError("The domain name %s does not accept email." % domain_i18n)
626628

629+
deliverability_info["mx"] = mtas
630+
deliverability_info["mx_fallback_type"] = None
631+
627632
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
628633

629634
# If there was no MX record, fall back to an A record.
630635
try:
631636
response = dns_resolver_resolve_shim(domain, "A")
632-
mtas = [(0, str(r)) for r in response]
633-
mx_fallback = "A"
637+
deliverability_info["mx"] = [(0, str(r)) for r in response]
638+
deliverability_info["mx_fallback_type"] = "A"
634639
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
635640

636641
# If there was no A record, fall back to an AAAA record.
637642
try:
638643
response = dns_resolver_resolve_shim(domain, "AAAA")
639-
mtas = [(0, str(r)) for r in response]
640-
mx_fallback = "AAAA"
644+
deliverability_info["mx"] = [(0, str(r)) for r in response]
645+
deliverability_info["mx_fallback_type"] = "AAAA"
641646
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
642647

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

652+
try:
653+
# Check for a SPF reject all ("v=spf1 -all") record which indicates
654+
# no emails are sent from this domain, which like a NULL MX record
655+
# would indicate that the domain is not used for email.
656+
response = dns_resolver_resolve_shim(domain, "TXT")
657+
for rec in response:
658+
value = b"".join(rec.strings)
659+
if value.startswith(b"v=spf1 "):
660+
deliverability_info["spf"] = value.decode("ascii", errors='replace')
661+
if value == b"v=spf1 -all":
662+
raise EmailUndeliverableError("The domain name %s does not send email." % domain_i18n)
663+
except dns.resolver.NoAnswer:
664+
# No TXT records means there is no SPF policy, so we cannot take any action.
665+
pass
666+
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN):
667+
# Failure to resolve at this step will be ignored.
668+
pass
669+
647670
except dns.exception.Timeout:
648671
# A timeout could occur for various reasons, so don't treat it as a failure.
649672
return {
@@ -660,10 +683,7 @@ def dns_resolver_resolve_shim(domain, record):
660683
"There was an error while checking if the domain name in the email address is deliverable: " + str(e)
661684
)
662685

663-
return {
664-
"mx": mtas,
665-
"mx-fallback": mx_fallback,
666-
}
686+
return deliverability_info
667687

668688

669689
def main():

tests/test_main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,8 @@ def test_dict_accessor():
329329

330330
def test_deliverability_found():
331331
response = validate_email_deliverability('gmail.com', 'gmail.com')
332-
assert response.keys() == {'mx', 'mx-fallback'}
333-
assert response['mx-fallback'] is None
332+
assert response.keys() == {'mx', 'mx_fallback_type', 'spf'}
333+
assert response['mx_fallback_type'] is None
334334
assert len(response['mx']) > 1
335335
assert len(response['mx'][0]) == 2
336336
assert isinstance(response['mx'][0][0], int)

0 commit comments

Comments
 (0)