Skip to content

Commit dad7b6c

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

File tree

3 files changed

+53
-29
lines changed

3 files changed

+53
-29
lines changed

README.md

+15-11
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ addresses are allowed when passing a `bytes`) and:
107107

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

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

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

133136
### Options
134137

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

142-
`check_deliverability=True`: Set to `False` to skip the domain name MX DNS record check. It is recommended to pass `False` when performing validation for login pages since re-validation of the domain by querying DNS at every login is probably undesirable.
145+
`check_deliverability=True`: Set to `False` to skip DNS record checks for the domain. It is recommended to pass `False` when performing validation for login pages since re-validation of the domain by querying DNS at every login is probably undesirable.
143146

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

332333
For the fictitious address `example@ツ.life`, which has an
@@ -393,6 +394,7 @@ are:
393394
| `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. |
394395
| `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. |
395396
| `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. |
397+
| `spf` | Any SPF record found while checking deliverability. |
396398

397399
Assumptions
398400
-----------
@@ -402,10 +404,12 @@ strictly conform to the standards. Many email address forms are obsolete
402404
or likely to cause trouble:
403405

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

email_validator/__init__.py

+36-16
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

+2-2
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)