email-validator 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {email_validator-2.3.0/email_validator.egg-info → email_validator-2.4.0}/PKG-INFO +9 -6
- {email_validator-2.3.0 → email_validator-2.4.0}/README.md +4 -2
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/__main__.py +9 -4
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/deliverability.py +3 -3
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/syntax.py +64 -15
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/types.py +8 -7
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/validate_email.py +24 -20
- email_validator-2.4.0/email_validator/version.py +1 -0
- {email_validator-2.3.0 → email_validator-2.4.0/email_validator.egg-info}/PKG-INFO +9 -6
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/SOURCES.txt +1 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/setup.cfg +4 -3
- email_validator-2.4.0/tests/conftest.py +21 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/tests/mocked-dns-answers.json +25 -1
- {email_validator-2.3.0 → email_validator-2.4.0}/tests/mocked_dns_response.py +2 -2
- {email_validator-2.3.0 → email_validator-2.4.0}/tests/test_deliverability.py +8 -2
- email_validator-2.4.0/tests/test_main.py +154 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/tests/test_syntax.py +130 -1
- email_validator-2.3.0/email_validator/version.py +0 -1
- email_validator-2.3.0/tests/test_main.py +0 -67
- {email_validator-2.3.0 → email_validator-2.4.0}/LICENSE +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/MANIFEST.in +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/__init__.py +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/exceptions.py +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/py.typed +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/rfc_constants.py +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/dependency_links.txt +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/entry_points.txt +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/requires.txt +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/top_level.txt +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/pyproject.toml +0 -0
- {email_validator-2.3.0 → email_validator-2.4.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: email-validator
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: A robust email address syntax and deliverability validation library.
|
|
5
5
|
Home-page: https://github.com/JoshData/python-email-validator
|
|
6
6
|
Author: Joshua Tauberer
|
|
@@ -11,13 +11,14 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.10
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
License-File: LICENSE
|
|
23
24
|
Requires-Dist: dnspython>=2.0.0
|
|
@@ -28,7 +29,7 @@ email-validator: Validate Email Addresses
|
|
|
28
29
|
=========================================
|
|
29
30
|
|
|
30
31
|
A robust email address syntax and deliverability validation library for
|
|
31
|
-
Python 3.
|
|
32
|
+
Python 3.10+ by [Joshua Tauberer](https://joshdata.me).
|
|
32
33
|
|
|
33
34
|
This library validates that a string is of the form `name@example.com`
|
|
34
35
|
and optionally checks that the domain name is set up to receive email.
|
|
@@ -383,7 +384,9 @@ ValidatedEmail(
|
|
|
383
384
|
|
|
384
385
|
Now `smtputf8` is `True` and `ascii_email` is `None` because the local
|
|
385
386
|
part of the address is internationalized. The `local_part` and `normalized` fields
|
|
386
|
-
return the normalized form of the address.
|
|
387
|
+
return the normalized form of the address. (Because of Unicode normalization,
|
|
388
|
+
it is possible in edge cases for `smtputf8` to be `True` based on the original
|
|
389
|
+
email address when `ascii_email` is also present in the ValidatedEmail.)
|
|
387
390
|
|
|
388
391
|
Return value
|
|
389
392
|
------------
|
|
@@ -2,7 +2,7 @@ email-validator: Validate Email Addresses
|
|
|
2
2
|
=========================================
|
|
3
3
|
|
|
4
4
|
A robust email address syntax and deliverability validation library for
|
|
5
|
-
Python 3.
|
|
5
|
+
Python 3.10+ by [Joshua Tauberer](https://joshdata.me).
|
|
6
6
|
|
|
7
7
|
This library validates that a string is of the form `name@example.com`
|
|
8
8
|
and optionally checks that the domain name is set up to receive email.
|
|
@@ -357,7 +357,9 @@ ValidatedEmail(
|
|
|
357
357
|
|
|
358
358
|
Now `smtputf8` is `True` and `ascii_email` is `None` because the local
|
|
359
359
|
part of the address is internationalized. The `local_part` and `normalized` fields
|
|
360
|
-
return the normalized form of the address.
|
|
360
|
+
return the normalized form of the address. (Because of Unicode normalization,
|
|
361
|
+
it is possible in edge cases for `smtputf8` to be `True` based on the original
|
|
362
|
+
email address when `ascii_email` is also present in the ValidatedEmail.)
|
|
361
363
|
|
|
362
364
|
Return value
|
|
363
365
|
------------
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
19
|
import sys
|
|
20
|
-
from typing import Any,
|
|
20
|
+
from typing import Any, Optional
|
|
21
21
|
|
|
22
22
|
from .validate_email import validate_email, _Resolver
|
|
23
23
|
from .deliverability import caching_resolver
|
|
@@ -28,15 +28,20 @@ def main(dns_resolver: Optional[_Resolver] = None) -> None:
|
|
|
28
28
|
# The dns_resolver argument is for tests.
|
|
29
29
|
|
|
30
30
|
# Set options from environment variables.
|
|
31
|
-
options:
|
|
31
|
+
options: dict[str, Any] = {}
|
|
32
32
|
for varname in ('ALLOW_SMTPUTF8', 'ALLOW_EMPTY_LOCAL', 'ALLOW_QUOTED_LOCAL', 'ALLOW_DOMAIN_LITERAL',
|
|
33
33
|
'ALLOW_DISPLAY_NAME',
|
|
34
34
|
'GLOBALLY_DELIVERABLE', 'CHECK_DELIVERABILITY', 'TEST_ENVIRONMENT'):
|
|
35
35
|
if varname in os.environ:
|
|
36
|
-
|
|
36
|
+
# bool() would make any non-empty string True, so e.g.
|
|
37
|
+
# CHECK_DELIVERABILITY=false would enable the check.
|
|
38
|
+
options[varname.lower()] = os.environ[varname].strip().lower() not in ('', '0', 'false', 'no', 'off')
|
|
37
39
|
for varname in ('DEFAULT_TIMEOUT',):
|
|
38
40
|
if varname in os.environ:
|
|
39
|
-
|
|
41
|
+
# DEFAULT_TIMEOUT is a module-level default read by
|
|
42
|
+
# caching_resolver(), not a keyword argument of validate_email.
|
|
43
|
+
import email_validator
|
|
44
|
+
email_validator.DEFAULT_TIMEOUT = int(os.environ[varname])
|
|
40
45
|
|
|
41
46
|
if len(sys.argv) == 1:
|
|
42
47
|
# Validate the email addresses passed line-by-line on STDIN.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, Optional, TypedDict
|
|
2
2
|
|
|
3
3
|
import ipaddress
|
|
4
4
|
|
|
@@ -19,7 +19,7 @@ def caching_resolver(*, timeout: Optional[int] = None, cache: Any = None, dns_re
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
DeliverabilityInfo = TypedDict("DeliverabilityInfo", {
|
|
22
|
-
"mx":
|
|
22
|
+
"mx": list[tuple[int, str]],
|
|
23
23
|
"mx_fallback_type": Optional[str],
|
|
24
24
|
"unknown-deliverability": str,
|
|
25
25
|
}, total=False)
|
|
@@ -66,7 +66,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option
|
|
|
66
66
|
deliverability_info["mx_fallback_type"] = None
|
|
67
67
|
|
|
68
68
|
except dns.resolver.NoAnswer:
|
|
69
|
-
# If there was no MX record, fall back to an A or
|
|
69
|
+
# If there was no MX record, fall back to an A or AAAA record
|
|
70
70
|
# (RFC 5321 Section 5). Check A first since it's more common.
|
|
71
71
|
|
|
72
72
|
# If the A/AAAA response has no Globally Reachable IP address,
|
|
@@ -8,12 +8,12 @@ import re
|
|
|
8
8
|
import unicodedata
|
|
9
9
|
import idna # implements IDNA 2008; Python's codec is only IDNA 2003
|
|
10
10
|
import ipaddress
|
|
11
|
-
from typing import Optional,
|
|
11
|
+
from typing import Optional, TypedDict
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def split_email(email: str) ->
|
|
15
|
-
# Return the display name,
|
|
16
|
-
#
|
|
14
|
+
def split_email(email: str) -> tuple[Optional[str], str, str, bool, str]:
|
|
15
|
+
# Return the display name, the original local part, the unescaped
|
|
16
|
+
# local part, and the domain part of the address. If no
|
|
17
17
|
# display name was present and angle brackets do not surround
|
|
18
18
|
# the address, display name will be None; otherwise, it will be
|
|
19
19
|
# set to the display name or the empty string if there were
|
|
@@ -36,7 +36,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
36
36
|
#
|
|
37
37
|
# Display names are either basic characters (the same basic characters
|
|
38
38
|
# permitted in email addresses, but periods are not allowed and spaces
|
|
39
|
-
# are allowed; see RFC 5322 Appendix A.1.2), or
|
|
39
|
+
# are allowed; see RFC 5322 Appendix A.1.2), or a quoted string with
|
|
40
40
|
# the same rules as a quoted local part. (Multiple quoted strings might
|
|
41
41
|
# be allowed? Unclear.) Optional space (RFC 5322 3.4 CFWS) and then the
|
|
42
42
|
# email address follows in angle brackets.
|
|
@@ -47,7 +47,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
47
47
|
# We assume the input string is already stripped of leading and
|
|
48
48
|
# trailing CFWS.
|
|
49
49
|
|
|
50
|
-
def split_string_at_unquoted_special(text: str, specials:
|
|
50
|
+
def split_string_at_unquoted_special(text: str, specials: tuple[str, ...]) -> tuple[str, str]:
|
|
51
51
|
# Split the string at the first character in specials (an @-sign
|
|
52
52
|
# or left angle bracket) that does not occur within quotes and
|
|
53
53
|
# is not followed by a Unicode combining character.
|
|
@@ -61,7 +61,14 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
61
61
|
# the < as the start of "<email>" syntax in that case. Likewise,
|
|
62
62
|
# if anything combines with an @ or ", we should probably not
|
|
63
63
|
# treat it as a special character.
|
|
64
|
-
|
|
64
|
+
#
|
|
65
|
+
# Only the characters right after c can combine with it, so
|
|
66
|
+
# look at a short window rather than normalizing the rest of
|
|
67
|
+
# the string at every position, which is quadratic in the
|
|
68
|
+
# length of the input. The window is c, the 30 combining
|
|
69
|
+
# characters in a row allowed by the Unicode Stream-Safe Text
|
|
70
|
+
# Format (UAX #15), and one more character.
|
|
71
|
+
if unicodedata.normalize("NFC", text[i:i + 32])[0] != c:
|
|
65
72
|
left_part += c
|
|
66
73
|
|
|
67
74
|
elif inside_quote:
|
|
@@ -112,7 +119,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
112
119
|
|
|
113
120
|
return left_part, right_part
|
|
114
121
|
|
|
115
|
-
def unquote_quoted_string(text: str) ->
|
|
122
|
+
def unquote_quoted_string(text: str) -> tuple[str, bool]:
|
|
116
123
|
# Remove surrounding quotes and unescape escaped backslashes
|
|
117
124
|
# and quotes. Escapes are parsed liberally. I think only
|
|
118
125
|
# backslashes and quotes can be escaped but we'll allow anything
|
|
@@ -178,7 +185,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
178
185
|
raise EmailSyntaxError("There can't be anything after the email address.")
|
|
179
186
|
|
|
180
187
|
# Remove the initial and trailing angle brackets.
|
|
181
|
-
addr_spec = right_part[1
|
|
188
|
+
addr_spec = right_part[1:-1]
|
|
182
189
|
|
|
183
190
|
# Split the email address at the first unquoted @-sign.
|
|
184
191
|
local_part, domain_part = split_string_at_unquoted_special(addr_spec, ("@",))
|
|
@@ -193,9 +200,9 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
|
|
|
193
200
|
domain_part = domain_part[1:]
|
|
194
201
|
|
|
195
202
|
# Unquote the local part if it is quoted.
|
|
196
|
-
|
|
203
|
+
local_part_unquoted, is_quoted_local_part = unquote_quoted_string(local_part)
|
|
197
204
|
|
|
198
|
-
return display_name, local_part,
|
|
205
|
+
return display_name, local_part, local_part_unquoted, is_quoted_local_part, domain_part
|
|
199
206
|
|
|
200
207
|
|
|
201
208
|
def get_length_reason(addr: str, limit: int) -> str:
|
|
@@ -498,6 +505,14 @@ class DomainNameValidationResult(TypedDict):
|
|
|
498
505
|
def validate_email_domain_name(domain: str, test_environment: bool = False, globally_deliverable: bool = True) -> DomainNameValidationResult:
|
|
499
506
|
"""Validates the syntax of the domain part of an email address."""
|
|
500
507
|
|
|
508
|
+
# A domain can get shorter through normalization (either from IDNA decoding or Unicode normalization),
|
|
509
|
+
# but the caller may use the original input, so the length check can apply up front. We also get a
|
|
510
|
+
# less helpful error via idna.uts46_remap and would rather do it ourselves. And it's important to
|
|
511
|
+
# prevent a DOS attack via idna: See below.
|
|
512
|
+
if len(domain) > DOMAIN_MAX_LENGTH:
|
|
513
|
+
reason = get_length_reason(domain, limit=DOMAIN_MAX_LENGTH)
|
|
514
|
+
raise EmailSyntaxError(f"The email address is too long after the @-sign {reason}.")
|
|
515
|
+
|
|
501
516
|
# Check for invalid characters.
|
|
502
517
|
# (RFC 952 plus RFC 6531 section 3.3 for internationalized addresses)
|
|
503
518
|
bad_chars = {
|
|
@@ -579,6 +594,14 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
|
|
|
579
594
|
# doesn't give a nice error, so we call the underlying idna.alabel method
|
|
580
595
|
# directly. idna.alabel checks label length and doesn't give great messages,
|
|
581
596
|
# but we can't easily go to lower level methods.
|
|
597
|
+
#
|
|
598
|
+
# idna.encode also rejects a domain that is too long before it looks
|
|
599
|
+
# at any label, because checking or decoding a long label can take time
|
|
600
|
+
# quadratic in its length (a long Punycode "xn--" label, or, before idna
|
|
601
|
+
# 3.15, a long run of Arabic-Indic digits). Since we call idna.alabel
|
|
602
|
+
# directly, check the length first above. A label never gets shorter
|
|
603
|
+
# when it is converted to IDNA ASCII, so a domain this long would fail
|
|
604
|
+
# the length check below anyway.
|
|
582
605
|
try:
|
|
583
606
|
ascii_domain = ".".join(
|
|
584
607
|
idna.alabel(label).decode("ascii")
|
|
@@ -644,6 +667,13 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
|
|
|
644
667
|
if ascii_domain == d or ascii_domain.endswith("." + d):
|
|
645
668
|
raise EmailSyntaxError("The part after the @-sign is a special-use or reserved name that cannot be used with email.")
|
|
646
669
|
|
|
670
|
+
# Domains without Punycode labels come back unchanged from the IDNA round trip below.
|
|
671
|
+
if "xn--" not in ascii_domain:
|
|
672
|
+
return {
|
|
673
|
+
"ascii_domain": ascii_domain,
|
|
674
|
+
"domain": ascii_domain,
|
|
675
|
+
}
|
|
676
|
+
|
|
647
677
|
# We may have been given an IDNA ASCII domain to begin with. Check
|
|
648
678
|
# that the domain actually conforms to IDNA. It could look like IDNA
|
|
649
679
|
# but not be actual IDNA. For ASCII-only domains, the conversion out
|
|
@@ -675,6 +705,20 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
|
|
|
675
705
|
raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".")
|
|
676
706
|
check_unsafe_chars(domain_i18n)
|
|
677
707
|
|
|
708
|
+
# If any label has right-to-left characters, the Bidi Rule applies to
|
|
709
|
+
# every label in the domain, including left-to-right ones (RFC 5893
|
|
710
|
+
# Section 1.4 and 2, UTS #46 CheckBidi). The idna package only checks
|
|
711
|
+
# each label on its own, so a label like "1" next to a right-to-left
|
|
712
|
+
# label was accepted, and such domains can be displayed identically to
|
|
713
|
+
# a different domain (e.g. 1.xn--4dbc.com and xn--4dbc.1.com, which
|
|
714
|
+
# are 1.אב.com and אב.1.com in Unicode, with Hebrew alef bet).
|
|
715
|
+
if any(unicodedata.bidirectional(c) in ("R", "AL", "AN") for c in domain_i18n):
|
|
716
|
+
for label in domain_i18n.split("."):
|
|
717
|
+
try:
|
|
718
|
+
idna.check_bidi(label, check_ltr=True)
|
|
719
|
+
except idna.IDNAError as e:
|
|
720
|
+
raise EmailSyntaxError(f"The part after the @-sign is not valid IDNA ({e}).") from e
|
|
721
|
+
|
|
678
722
|
# Check that it can be encoded back to IDNA ASCII. We have no test
|
|
679
723
|
# case for this.
|
|
680
724
|
try:
|
|
@@ -758,7 +802,7 @@ def validate_email_length(addrinfo: ValidatedEmail) -> None:
|
|
|
758
802
|
|
|
759
803
|
|
|
760
804
|
class DomainLiteralValidationResult(TypedDict):
|
|
761
|
-
domain_address:
|
|
805
|
+
domain_address: ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
762
806
|
domain: str
|
|
763
807
|
|
|
764
808
|
|
|
@@ -767,7 +811,7 @@ def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidatio
|
|
|
767
811
|
# a compressed/normalized address.
|
|
768
812
|
# RFC 5321 4.1.3 and RFC 5322 3.4.1.
|
|
769
813
|
|
|
770
|
-
addr:
|
|
814
|
+
addr: ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
771
815
|
|
|
772
816
|
# Try to parse the domain literal as an IPv4 address.
|
|
773
817
|
# There is no tag for IPv4 addresses, so we can never
|
|
@@ -784,13 +828,18 @@ def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidatio
|
|
|
784
828
|
"domain": f"[{addr}]",
|
|
785
829
|
}
|
|
786
830
|
|
|
787
|
-
# If it begins with "IPv6:" it's an IPv6 address.
|
|
788
|
-
|
|
831
|
+
# If it begins with "IPv6:" it's an IPv6 address. The tag is case-insensitive
|
|
832
|
+
# like all ABNF literal strings (RFC 5234 2.3), so "ipv6:" is accepted too.
|
|
833
|
+
if domain_literal[:5].lower() == "ipv6:":
|
|
789
834
|
try:
|
|
790
835
|
addr = ipaddress.IPv6Address(domain_literal[5:])
|
|
791
836
|
except ValueError as e:
|
|
792
837
|
raise EmailSyntaxError(f"The IPv6 address in brackets after the @-sign is not valid ({e}).") from e
|
|
793
838
|
|
|
839
|
+
# Python accepts a zone index ("%eth0") but RFC 5321 4.1.3 does not.
|
|
840
|
+
if addr.scope_id is not None:
|
|
841
|
+
raise EmailSyntaxError("The IPv6 address in brackets after the @-sign is not valid (It has a zone index).")
|
|
842
|
+
|
|
794
843
|
# Return the IPv6Address object and construct a normalized
|
|
795
844
|
# domain literal.
|
|
796
845
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import warnings
|
|
2
|
-
from typing import Any,
|
|
2
|
+
from typing import Any, Optional
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class ValidatedEmail:
|
|
@@ -41,7 +41,7 @@ class ValidatedEmail:
|
|
|
41
41
|
|
|
42
42
|
"""If a deliverability check is performed and if it succeeds, a list of (priority, domain)
|
|
43
43
|
tuples of MX records specified in the DNS for the domain."""
|
|
44
|
-
mx:
|
|
44
|
+
mx: list[tuple[int, str]]
|
|
45
45
|
|
|
46
46
|
"""If no MX records are actually specified in DNS and instead are inferred, through an obsolete
|
|
47
47
|
mechanism, from A or AAAA records, the value is the type of DNS record used instead (`A` or `AAAA`)."""
|
|
@@ -68,7 +68,7 @@ class ValidatedEmail:
|
|
|
68
68
|
|
|
69
69
|
"""For backwards compatibility, some fields are also exposed through a dict-like interface. Note
|
|
70
70
|
that some of the names changed when they became attributes."""
|
|
71
|
-
def __getitem__(self, key: str) ->
|
|
71
|
+
def __getitem__(self, key: str) -> Optional[str] | bool | list[tuple[int, str]]:
|
|
72
72
|
warnings.warn("dict-like access to the return value of validate_email is deprecated and may not be supported in the future.", DeprecationWarning, stacklevel=2)
|
|
73
73
|
if key == "email":
|
|
74
74
|
return self.normalized
|
|
@@ -93,7 +93,8 @@ class ValidatedEmail:
|
|
|
93
93
|
if not isinstance(other, ValidatedEmail):
|
|
94
94
|
return False
|
|
95
95
|
return (
|
|
96
|
-
self
|
|
96
|
+
getattr(self, 'original', None) == getattr(other, 'original', None)
|
|
97
|
+
and self.normalized == other.normalized
|
|
97
98
|
and self.local_part == other.local_part
|
|
98
99
|
and self.domain == other.domain
|
|
99
100
|
and getattr(self, 'ascii_email', None) == getattr(other, 'ascii_email', None)
|
|
@@ -110,7 +111,7 @@ class ValidatedEmail:
|
|
|
110
111
|
def as_constructor(self) -> str:
|
|
111
112
|
return "ValidatedEmail(" \
|
|
112
113
|
+ ",".join(f"\n {key}={repr(getattr(self, key))}"
|
|
113
|
-
for key in ('normalized', 'local_part', 'domain',
|
|
114
|
+
for key in ('original', 'normalized', 'local_part', 'domain',
|
|
114
115
|
'ascii_email', 'ascii_local_part', 'ascii_domain',
|
|
115
116
|
'smtputf8', 'mx', 'mx_fallback_type',
|
|
116
117
|
'display_name')
|
|
@@ -119,8 +120,8 @@ class ValidatedEmail:
|
|
|
119
120
|
+ ")"
|
|
120
121
|
|
|
121
122
|
"""Convenience method for accessing ValidatedEmail as a dict"""
|
|
122
|
-
def as_dict(self) ->
|
|
123
|
-
d = self.__dict__
|
|
123
|
+
def as_dict(self) -> dict[str, Any]:
|
|
124
|
+
d = dict(self.__dict__)
|
|
124
125
|
if d.get('domain_address'):
|
|
125
126
|
d['domain_address'] = repr(d['domain_address'])
|
|
126
127
|
return d
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Optional,
|
|
1
|
+
from typing import Optional, TYPE_CHECKING
|
|
2
2
|
import unicodedata
|
|
3
3
|
|
|
4
4
|
from .exceptions import EmailSyntaxError
|
|
@@ -14,7 +14,7 @@ else:
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def validate_email(
|
|
17
|
-
email:
|
|
17
|
+
email: str | bytes,
|
|
18
18
|
/, # prior arguments are positional-only
|
|
19
19
|
*, # subsequent arguments are keyword-only
|
|
20
20
|
allow_smtputf8: Optional[bool] = None,
|
|
@@ -77,7 +77,7 @@ def validate_email(
|
|
|
77
77
|
# Normally, there is only one @-sign. But the awkward "quoted string"
|
|
78
78
|
# local part form (RFC 5321 4.1.2) allows @-signs in the local
|
|
79
79
|
# part if the local part is quoted.
|
|
80
|
-
display_name,
|
|
80
|
+
display_name, original_local_part, unescaped_local_part, is_quoted_local_part, domain_part \
|
|
81
81
|
= split_email(email)
|
|
82
82
|
|
|
83
83
|
if display_name:
|
|
@@ -90,22 +90,21 @@ def validate_email(
|
|
|
90
90
|
|
|
91
91
|
# Collect return values in this instance.
|
|
92
92
|
ret = ValidatedEmail()
|
|
93
|
-
ret.original =
|
|
94
|
-
else ('"' + local_part + '"'))
|
|
95
|
-
+ "@" + domain_part) # drop the display name, if any, for email length tests at the end
|
|
93
|
+
ret.original = original_local_part + "@" + domain_part # drop the display name, if any, for email length tests at the end
|
|
96
94
|
ret.display_name = display_name
|
|
97
95
|
|
|
98
96
|
# Validate the email address's local part syntax and get a normalized form.
|
|
99
97
|
# If the original address was quoted and the decoded local part is a valid
|
|
100
98
|
# unquoted local part, then we'll get back a normalized (unescaped) local
|
|
101
99
|
# part.
|
|
102
|
-
local_part_info = validate_email_local_part(
|
|
100
|
+
local_part_info = validate_email_local_part(unescaped_local_part,
|
|
103
101
|
allow_smtputf8=allow_smtputf8,
|
|
104
102
|
allow_empty_local=allow_empty_local,
|
|
105
103
|
quoted_local_part=is_quoted_local_part,
|
|
106
104
|
strict=strict)
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
|
|
106
|
+
# Report whether SMTPUTF8 is required for the address as given, prior to
|
|
107
|
+
# Unicode normalization next.
|
|
109
108
|
ret.smtputf8 = local_part_info["smtputf8"]
|
|
110
109
|
|
|
111
110
|
# RFC 6532 section 3.1 says that Unicode NFC normalization should be applied,
|
|
@@ -120,17 +119,24 @@ def validate_email(
|
|
|
120
119
|
# this library. (UTS #39 seems to require that the *input* be NKFC normalized
|
|
121
120
|
# and has other requirements that are hard to check without additional Unicode
|
|
122
121
|
# data, and I don't know whether the rules really apply in the wild.)
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
#
|
|
123
|
+
# Normalize the unquoted and unescaped local part, not ret.local_part, and
|
|
124
|
+
# take the quoting from the second validation. ret.local_part has already
|
|
125
|
+
# had unnecessary quotes removed, so normalizing it would turn e.g.
|
|
126
|
+
# "a\u037eb" (quoted, with a Greek question mark) into a;b without quotes.
|
|
127
|
+
normalized_local_part = unicodedata.normalize("NFC", unescaped_local_part)
|
|
128
|
+
if normalized_local_part != unescaped_local_part:
|
|
125
129
|
try:
|
|
126
|
-
validate_email_local_part(normalized_local_part,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
local_part_info = validate_email_local_part(normalized_local_part,
|
|
131
|
+
allow_smtputf8=allow_smtputf8,
|
|
132
|
+
allow_empty_local=allow_empty_local,
|
|
133
|
+
quoted_local_part=is_quoted_local_part,
|
|
134
|
+
strict=strict)
|
|
131
135
|
except EmailSyntaxError as e:
|
|
132
136
|
raise EmailSyntaxError("After Unicode normalization: " + str(e)) from e
|
|
133
|
-
|
|
137
|
+
|
|
138
|
+
ret.local_part = local_part_info["local_part"]
|
|
139
|
+
ret.ascii_local_part = local_part_info["ascii_local_part"]
|
|
134
140
|
|
|
135
141
|
# If a quoted local part isn't allowed but is present, now raise an exception.
|
|
136
142
|
# This is done after any exceptions raised by validate_email_local_part so
|
|
@@ -173,9 +179,7 @@ def validate_email(
|
|
|
173
179
|
ret.normalized = ret.local_part + "@" + ret.domain
|
|
174
180
|
|
|
175
181
|
# If the email address has an ASCII form, add it.
|
|
176
|
-
if
|
|
177
|
-
if not ret.ascii_domain:
|
|
178
|
-
raise Exception("Missing ASCII domain.")
|
|
182
|
+
if ret.ascii_local_part and ret.ascii_domain:
|
|
179
183
|
ret.ascii_email = (ret.ascii_local_part or "") + "@" + ret.ascii_domain
|
|
180
184
|
else:
|
|
181
185
|
ret.ascii_email = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.4.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: email-validator
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: A robust email address syntax and deliverability validation library.
|
|
5
5
|
Home-page: https://github.com/JoshData/python-email-validator
|
|
6
6
|
Author: Joshua Tauberer
|
|
@@ -11,13 +11,14 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.10
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
License-File: LICENSE
|
|
23
24
|
Requires-Dist: dnspython>=2.0.0
|
|
@@ -28,7 +29,7 @@ email-validator: Validate Email Addresses
|
|
|
28
29
|
=========================================
|
|
29
30
|
|
|
30
31
|
A robust email address syntax and deliverability validation library for
|
|
31
|
-
Python 3.
|
|
32
|
+
Python 3.10+ by [Joshua Tauberer](https://joshdata.me).
|
|
32
33
|
|
|
33
34
|
This library validates that a string is of the form `name@example.com`
|
|
34
35
|
and optionally checks that the domain name is set up to receive email.
|
|
@@ -383,7 +384,9 @@ ValidatedEmail(
|
|
|
383
384
|
|
|
384
385
|
Now `smtputf8` is `True` and `ascii_email` is `None` because the local
|
|
385
386
|
part of the address is internationalized. The `local_part` and `normalized` fields
|
|
386
|
-
return the normalized form of the address.
|
|
387
|
+
return the normalized form of the address. (Because of Unicode normalization,
|
|
388
|
+
it is possible in edge cases for `smtputf8` to be `True` based on the original
|
|
389
|
+
email address when `ascii_email` is also present in the ValidatedEmail.)
|
|
387
390
|
|
|
388
391
|
Return value
|
|
389
392
|
------------
|
|
@@ -20,6 +20,7 @@ email_validator.egg-info/dependency_links.txt
|
|
|
20
20
|
email_validator.egg-info/entry_points.txt
|
|
21
21
|
email_validator.egg-info/requires.txt
|
|
22
22
|
email_validator.egg-info/top_level.txt
|
|
23
|
+
tests/conftest.py
|
|
23
24
|
tests/mocked-dns-answers.json
|
|
24
25
|
tests/mocked_dns_response.py
|
|
25
26
|
tests/test_deliverability.py
|
|
@@ -14,11 +14,12 @@ classifiers =
|
|
|
14
14
|
Intended Audience :: Developers
|
|
15
15
|
License :: OSI Approved :: The Unlicense (Unlicense)
|
|
16
16
|
Programming Language :: Python :: 3
|
|
17
|
-
Programming Language :: Python :: 3.8
|
|
18
|
-
Programming Language :: Python :: 3.9
|
|
19
17
|
Programming Language :: Python :: 3.10
|
|
20
18
|
Programming Language :: Python :: 3.11
|
|
21
19
|
Programming Language :: Python :: 3.12
|
|
20
|
+
Programming Language :: Python :: 3.13
|
|
21
|
+
Programming Language :: Python :: 3.14
|
|
22
|
+
Programming Language :: Python :: 3.15
|
|
22
23
|
Topic :: Software Development :: Libraries :: Python Modules
|
|
23
24
|
keywords = email address validator
|
|
24
25
|
|
|
@@ -27,7 +28,7 @@ packages = find:
|
|
|
27
28
|
install_requires =
|
|
28
29
|
dnspython>=2.0.0 # optional if deliverability check isn't needed
|
|
29
30
|
idna>=2.0.0
|
|
30
|
-
python_requires = >=3.
|
|
31
|
+
python_requires = >=3.10
|
|
31
32
|
|
|
32
33
|
[options.package_data]
|
|
33
34
|
* = py.typed
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from email_validator import ValidatedEmail
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def pytest_assertrepr_compare(config: Any, op: str, left: Any, right: Any) -> list[str] | None:
|
|
8
|
+
if isinstance(left, ValidatedEmail) and isinstance(right, ValidatedEmail) and op == "==":
|
|
9
|
+
leftDict = left.as_dict()
|
|
10
|
+
rightDict = right.as_dict()
|
|
11
|
+
keys = set(leftDict) | set(rightDict)
|
|
12
|
+
table = [[key, leftDict.get(key), rightDict.get(key)]
|
|
13
|
+
for key in keys]
|
|
14
|
+
maxLen = [max(len(repr(row[col])) for row in table) for col in (0, 1, 2)]
|
|
15
|
+
return ([repr(left) + " != " + repr(right)]
|
|
16
|
+
+ [(str(t[0]).rjust(maxLen[0])
|
|
17
|
+
+ " " + repr(t[1]).ljust(maxLen[1])
|
|
18
|
+
+ (" ==" if t[1] == t[2] else " !=")
|
|
19
|
+
+ " " + repr(t[2]).ljust(maxLen[2]))
|
|
20
|
+
for t in table])
|
|
21
|
+
return None
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"type": "A",
|
|
130
130
|
"class": "IN"
|
|
131
131
|
},
|
|
132
|
-
"answer": []
|
|
132
|
+
"answer": ["127.0.0.1"]
|
|
133
133
|
},
|
|
134
134
|
{
|
|
135
135
|
"query": {
|
|
@@ -139,6 +139,30 @@
|
|
|
139
139
|
},
|
|
140
140
|
"answer": []
|
|
141
141
|
},
|
|
142
|
+
{
|
|
143
|
+
"query": {
|
|
144
|
+
"name": "ipv6only.joshdata.me",
|
|
145
|
+
"type": "MX",
|
|
146
|
+
"class": "IN"
|
|
147
|
+
},
|
|
148
|
+
"answer": []
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"query": {
|
|
152
|
+
"name": "ipv6only.joshdata.me",
|
|
153
|
+
"type": "A",
|
|
154
|
+
"class": "IN"
|
|
155
|
+
},
|
|
156
|
+
"answer": []
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"query": {
|
|
160
|
+
"name": "ipv6only.joshdata.me",
|
|
161
|
+
"type": "AAAA",
|
|
162
|
+
"class": "IN"
|
|
163
|
+
},
|
|
164
|
+
"answer": ["::1"]
|
|
165
|
+
},
|
|
142
166
|
{
|
|
143
167
|
"query": {
|
|
144
168
|
"name": "mail.example",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, Iterator, Optional
|
|
2
2
|
|
|
3
3
|
import dns.exception
|
|
4
4
|
import dns.rdataset
|
|
@@ -42,7 +42,7 @@ class MockedDnsResponseData:
|
|
|
42
42
|
return caching_resolver(cache=MockedDnsResponseData.INSTANCE, dns_resolver=dns_resolver)
|
|
43
43
|
|
|
44
44
|
def __init__(self) -> None:
|
|
45
|
-
self.data:
|
|
45
|
+
self.data: dict[dns.resolver.CacheKey, Optional[MockedDnsResponseData.Ans]] = {}
|
|
46
46
|
|
|
47
47
|
# Loads the saved DNS response data from the JSON file and
|
|
48
48
|
# re-structures it into dnspython classes.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any
|
|
1
|
+
from typing import Any
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
import re
|
|
@@ -35,6 +35,7 @@ def test_deliverability_found(domain: str, expected_response: str) -> None:
|
|
|
35
35
|
# No MX or A/AAAA records, but some other DNS records must
|
|
36
36
|
# exist such that the response is NOANSWER instead of NXDOMAIN.
|
|
37
37
|
('justtxt.joshdata.me', 'The domain name {domain} does not accept email'),
|
|
38
|
+
('ipv6only.joshdata.me', 'The domain name {domain} does not accept email'),
|
|
38
39
|
],
|
|
39
40
|
)
|
|
40
41
|
def test_deliverability_fails(domain: str, error: str) -> None:
|
|
@@ -65,11 +66,16 @@ def test_deliverability_dns_timeout() -> None:
|
|
|
65
66
|
assert response.get("unknown-deliverability") == "timeout"
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
def test_timeout_and_resolver() -> None:
|
|
70
|
+
with pytest.raises(ValueError, match="It's not valid to pass both timeout and dns_resolver."):
|
|
71
|
+
validate_email_deliverability('timeout.com', 'timeout.com', timeout=1, dns_resolver=RESOLVER)
|
|
72
|
+
|
|
73
|
+
|
|
68
74
|
@pytest.mark.network
|
|
69
75
|
def test_caching_dns_resolver() -> None:
|
|
70
76
|
class TestCache:
|
|
71
77
|
def __init__(self) -> None:
|
|
72
|
-
self.cache:
|
|
78
|
+
self.cache: dict[Any, Any] = {}
|
|
73
79
|
|
|
74
80
|
def get(self, key: Any) -> Any:
|
|
75
81
|
return self.cache.get(key)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import typing
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from email_validator import validate_email, EmailSyntaxError
|
|
6
|
+
# Let's test main but rename it to be clear
|
|
7
|
+
from email_validator.__main__ import main as validator_command_line_tool
|
|
8
|
+
|
|
9
|
+
from mocked_dns_response import MockedDnsResponseData, MockedDnsResponseDataCleanup # noqa: F401
|
|
10
|
+
|
|
11
|
+
RESOLVER = MockedDnsResponseData.create_resolver()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_dict_accessor() -> None:
|
|
15
|
+
input_email = "testaddr@example.tld"
|
|
16
|
+
valid_email = validate_email(input_email, check_deliverability=False)
|
|
17
|
+
assert isinstance(valid_email.as_dict(), dict)
|
|
18
|
+
assert valid_email.as_dict()["original"] == input_email
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_dict_accessor_with_domain_address() -> None:
|
|
22
|
+
input_email = "me@[127.0.0.1]"
|
|
23
|
+
valid_email = validate_email(input_email, check_deliverability=False, allow_domain_literal=True)
|
|
24
|
+
assert valid_email.domain == "[127.0.0.1]"
|
|
25
|
+
as_dict = valid_email.as_dict()
|
|
26
|
+
assert isinstance(as_dict, dict)
|
|
27
|
+
assert as_dict["domain_address"] == "IPv4Address('127.0.0.1')"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_dict_accessor_does_not_modify_validated_email() -> None:
|
|
31
|
+
# as_dict() is only for reading: it must not replace the attributes of the
|
|
32
|
+
# object it is called on. The domain_address attribute is documented to hold
|
|
33
|
+
# the ipaddress.IPv4Address object.
|
|
34
|
+
input_email = "me@[127.0.0.1]"
|
|
35
|
+
valid_email = validate_email(input_email, check_deliverability=False, allow_domain_literal=True)
|
|
36
|
+
domain_address = valid_email.domain_address
|
|
37
|
+
assert isinstance(domain_address, ipaddress.IPv4Address)
|
|
38
|
+
|
|
39
|
+
first_dict = valid_email.as_dict()
|
|
40
|
+
assert first_dict["domain_address"] == "IPv4Address('127.0.0.1')"
|
|
41
|
+
|
|
42
|
+
# The object the dict was made from is unchanged, ...
|
|
43
|
+
assert valid_email.domain_address is domain_address
|
|
44
|
+
assert isinstance(valid_email.domain_address, ipaddress.IPv4Address)
|
|
45
|
+
|
|
46
|
+
# ... and asking for the dict again gives the same result rather than
|
|
47
|
+
# repr() of a string.
|
|
48
|
+
assert valid_email.as_dict() == first_dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_main_single_good_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
52
|
+
import json
|
|
53
|
+
test_email = "google@google.com"
|
|
54
|
+
monkeypatch.setattr('sys.argv', ['email_validator', test_email])
|
|
55
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
56
|
+
stdout, _ = capsys.readouterr()
|
|
57
|
+
output = json.loads(str(stdout))
|
|
58
|
+
assert isinstance(output, dict)
|
|
59
|
+
assert validate_email(test_email, dns_resolver=RESOLVER).original == output["original"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_main_single_bad_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
63
|
+
bad_email = 'test@..com'
|
|
64
|
+
monkeypatch.setattr('sys.argv', ['email_validator', bad_email])
|
|
65
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
66
|
+
stdout, _ = capsys.readouterr()
|
|
67
|
+
assert stdout == 'An email address cannot have a period immediately after the @-sign.\n'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_main_multi_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
71
|
+
import io
|
|
72
|
+
test_cases = ["google1@google.com", "google2@google.com", "test@.com", "test3@.com"]
|
|
73
|
+
test_input = io.StringIO("\n".join(test_cases))
|
|
74
|
+
monkeypatch.setattr('sys.stdin', test_input)
|
|
75
|
+
monkeypatch.setattr('sys.argv', ['email_validator'])
|
|
76
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
77
|
+
stdout, _ = capsys.readouterr()
|
|
78
|
+
assert test_cases[0] not in stdout
|
|
79
|
+
assert test_cases[1] not in stdout
|
|
80
|
+
assert test_cases[2] in stdout
|
|
81
|
+
assert test_cases[3] in stdout
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize('value', ['false', 'False', 'FALSE', '0', 'no', 'off', ' '])
|
|
85
|
+
def test_main_falsy_boolean_option_from_env(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], value: str) -> None:
|
|
86
|
+
# Options are set from environment variables of the same name in upper
|
|
87
|
+
# case. A falsy value there must turn the option off, not on.
|
|
88
|
+
import json
|
|
89
|
+
test_email = 'test@xkxufoekjvjfjeodlfmdfjcu.com' # this domain does not exist in DNS
|
|
90
|
+
monkeypatch.setenv('CHECK_DELIVERABILITY', value)
|
|
91
|
+
monkeypatch.setattr('sys.argv', ['email_validator', test_email])
|
|
92
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
93
|
+
stdout, _ = capsys.readouterr()
|
|
94
|
+
assert json.loads(str(stdout))["normalized"] == test_email
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize('value', ['false', 'False', '0', 'no', 'off', ' '])
|
|
98
|
+
def test_main_falsy_test_environment_from_env(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], value: str) -> None:
|
|
99
|
+
# test_environment off means the reserved "test" domain names are rejected.
|
|
100
|
+
monkeypatch.setenv('TEST_ENVIRONMENT', value)
|
|
101
|
+
monkeypatch.setattr('sys.argv', ['email_validator', 'me@foo.test'])
|
|
102
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
103
|
+
stdout, _ = capsys.readouterr()
|
|
104
|
+
assert stdout == 'The part after the @-sign is a special-use or reserved name that cannot be used with email.\n'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_main_default_timeout_from_env(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
108
|
+
import json
|
|
109
|
+
import email_validator
|
|
110
|
+
test_email = 'google@google.com'
|
|
111
|
+
# Set up the restore of the module-level default, which main() overwrites.
|
|
112
|
+
monkeypatch.setattr('email_validator.DEFAULT_TIMEOUT', email_validator.DEFAULT_TIMEOUT)
|
|
113
|
+
monkeypatch.setenv('DEFAULT_TIMEOUT', '5')
|
|
114
|
+
monkeypatch.setattr('sys.argv', ['email_validator', test_email])
|
|
115
|
+
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
116
|
+
stdout, _ = capsys.readouterr()
|
|
117
|
+
assert json.loads(str(stdout))["normalized"] == test_email
|
|
118
|
+
assert email_validator.DEFAULT_TIMEOUT == 5
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_bytes_input() -> None:
|
|
122
|
+
input_email = b"testaddr@example.tld"
|
|
123
|
+
valid_email = validate_email(input_email, check_deliverability=False)
|
|
124
|
+
assert isinstance(valid_email.as_dict(), dict)
|
|
125
|
+
assert valid_email.as_dict()["normalized"] == input_email.decode("utf8")
|
|
126
|
+
|
|
127
|
+
input_email = "testaddr中example.tld".encode("utf32")
|
|
128
|
+
with pytest.raises(EmailSyntaxError):
|
|
129
|
+
validate_email(input_email, check_deliverability=False)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_deprecation() -> None:
|
|
133
|
+
input_email = b"testaddr@example.tld"
|
|
134
|
+
valid_email = validate_email(input_email, check_deliverability=False)
|
|
135
|
+
with pytest.deprecated_call():
|
|
136
|
+
assert valid_email.email is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize('invalid_email', [
|
|
140
|
+
None,
|
|
141
|
+
12345,
|
|
142
|
+
[],
|
|
143
|
+
{},
|
|
144
|
+
lambda x: x,
|
|
145
|
+
])
|
|
146
|
+
def test_invalid_type(invalid_email: typing.Any) -> None:
|
|
147
|
+
with pytest.raises(TypeError, match="email must be str or bytes"):
|
|
148
|
+
validate_email(invalid_email, check_deliverability=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_invalid_ascii() -> None:
|
|
152
|
+
invalid_email = b'\xd0\xba\xd0\xb2\xd1\x96\xd1\x82\xd0\xbe\xd1\x87\xd0\xba\xd0\xb0@\xd0\xbf\xd0\xbe\xd1\x88\xd1\x82\xd0\xb0.test'
|
|
153
|
+
with pytest.raises(EmailSyntaxError, match="The email address is not valid ASCII."):
|
|
154
|
+
validate_email(invalid_email, check_deliverability=False)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import time
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
@@ -101,9 +102,34 @@ def MakeValidatedEmail(**kwargs: Any) -> ValidatedEmail:
|
|
|
101
102
|
ascii_email='de-quoted.local.part@example.org'
|
|
102
103
|
),
|
|
103
104
|
),
|
|
105
|
+
(
|
|
106
|
+
'"unnecessary.escaped.\\x.local.part"@example.org',
|
|
107
|
+
MakeValidatedEmail(
|
|
108
|
+
local_part='unnecessary.escaped.x.local.part',
|
|
109
|
+
ascii_local_part='unnecessary.escaped.x.local.part',
|
|
110
|
+
smtputf8=False,
|
|
111
|
+
ascii_domain='example.org',
|
|
112
|
+
domain='example.org',
|
|
113
|
+
normalized='unnecessary.escaped.x.local.part@example.org',
|
|
114
|
+
ascii_email='unnecessary.escaped.x.local.part@example.org'
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
(
|
|
118
|
+
'"escaped.quote.\\".local.part"@example.org',
|
|
119
|
+
MakeValidatedEmail(
|
|
120
|
+
local_part='"escaped.quote.\\".local.part"',
|
|
121
|
+
ascii_local_part='"escaped.quote.\\".local.part"',
|
|
122
|
+
smtputf8=False,
|
|
123
|
+
ascii_domain='example.org',
|
|
124
|
+
domain='example.org',
|
|
125
|
+
normalized='"escaped.quote.\\".local.part"@example.org',
|
|
126
|
+
ascii_email='"escaped.quote.\\".local.part"@example.org'
|
|
127
|
+
),
|
|
128
|
+
),
|
|
104
129
|
(
|
|
105
130
|
'MyName <me@example.org>',
|
|
106
131
|
MakeValidatedEmail(
|
|
132
|
+
original='me@example.org',
|
|
107
133
|
local_part='me',
|
|
108
134
|
ascii_local_part='me',
|
|
109
135
|
smtputf8=False,
|
|
@@ -117,6 +143,7 @@ def MakeValidatedEmail(**kwargs: Any) -> ValidatedEmail:
|
|
|
117
143
|
(
|
|
118
144
|
'My Name <me@example.org>',
|
|
119
145
|
MakeValidatedEmail(
|
|
146
|
+
original='me@example.org',
|
|
120
147
|
local_part='me',
|
|
121
148
|
ascii_local_part='me',
|
|
122
149
|
smtputf8=False,
|
|
@@ -130,6 +157,7 @@ def MakeValidatedEmail(**kwargs: Any) -> ValidatedEmail:
|
|
|
130
157
|
(
|
|
131
158
|
r'"My.\"Na\\me\".Is" <"me \" \\ me"@example.org>',
|
|
132
159
|
MakeValidatedEmail(
|
|
160
|
+
original=r'"me \" \\ me"@example.org',
|
|
133
161
|
local_part=r'"me \" \\ me"',
|
|
134
162
|
ascii_local_part=r'"me \" \\ me"',
|
|
135
163
|
smtputf8=False,
|
|
@@ -140,9 +168,26 @@ def MakeValidatedEmail(**kwargs: Any) -> ValidatedEmail:
|
|
|
140
168
|
display_name='My."Na\\me".Is'
|
|
141
169
|
),
|
|
142
170
|
),
|
|
171
|
+
(
|
|
172
|
+
'me@גבריאל.xn--4dbrk0ce.il',
|
|
173
|
+
MakeValidatedEmail(
|
|
174
|
+
local_part='me',
|
|
175
|
+
ascii_local_part='me',
|
|
176
|
+
smtputf8=False,
|
|
177
|
+
ascii_domain='xn--4dbbc3aq3e.xn--4dbrk0ce.il',
|
|
178
|
+
ascii_email='me@xn--4dbbc3aq3e.xn--4dbrk0ce.il',
|
|
179
|
+
domain='גבריאל.ישראל.il',
|
|
180
|
+
normalized='me@גבריאל.ישראל.il',
|
|
181
|
+
),
|
|
182
|
+
),
|
|
143
183
|
],
|
|
144
184
|
)
|
|
145
185
|
def test_email_valid(email_input: str, output: ValidatedEmail) -> None:
|
|
186
|
+
# Simplfiy hard-coded expected values above by filling
|
|
187
|
+
# in 'original' when it isn't set.
|
|
188
|
+
if not hasattr(output, 'original'):
|
|
189
|
+
output.original = email_input
|
|
190
|
+
|
|
146
191
|
# These addresses do not require SMTPUTF8. See test_email_valid_intl_local_part
|
|
147
192
|
# for addresses that are valid but require SMTPUTF8. Check that it passes with
|
|
148
193
|
# allow_smtput8 both on and off.
|
|
@@ -297,6 +342,7 @@ def test_email_valid(email_input: str, output: ValidatedEmail) -> None:
|
|
|
297
342
|
(
|
|
298
343
|
'\"s\u0323\u0307\" <s\u0323\u0307@nfc.tld>',
|
|
299
344
|
MakeValidatedEmail(
|
|
345
|
+
original='s\u0323\u0307@nfc.tld',
|
|
300
346
|
local_part='\u1E69',
|
|
301
347
|
smtputf8=True,
|
|
302
348
|
ascii_domain='nfc.tld',
|
|
@@ -315,9 +361,26 @@ def test_email_valid(email_input: str, output: ValidatedEmail) -> None:
|
|
|
315
361
|
normalized='@@fullwidth.at',
|
|
316
362
|
),
|
|
317
363
|
),
|
|
364
|
+
(
|
|
365
|
+
'\u1FEF@example.com',
|
|
366
|
+
MakeValidatedEmail(
|
|
367
|
+
local_part='`',
|
|
368
|
+
ascii_local_part='`',
|
|
369
|
+
smtputf8=True,
|
|
370
|
+
ascii_domain='example.com',
|
|
371
|
+
ascii_email='`@example.com',
|
|
372
|
+
domain='example.com',
|
|
373
|
+
normalized='`@example.com',
|
|
374
|
+
),
|
|
375
|
+
),
|
|
318
376
|
],
|
|
319
377
|
)
|
|
320
378
|
def test_email_valid_intl_local_part(email_input: str, output: ValidatedEmail) -> None:
|
|
379
|
+
# Simplfiy hard-coded expected values above by filling
|
|
380
|
+
# in 'original' when it isn't set.
|
|
381
|
+
if not hasattr(output, 'original'):
|
|
382
|
+
output.original = email_input
|
|
383
|
+
|
|
321
384
|
# Check that it passes when allow_smtputf8 is True.
|
|
322
385
|
assert validate_email(email_input, check_deliverability=False, allow_display_name=True) == output
|
|
323
386
|
|
|
@@ -338,6 +401,8 @@ def test_email_valid_intl_local_part(email_input: str, output: ValidatedEmail) -
|
|
|
338
401
|
('"unnecessarily.quoted.with.unicode.λ"@example.com', 'unnecessarily.quoted.with.unicode.λ'),
|
|
339
402
|
('"quoted.with..unicode.λ"@example.com', '"quoted.with..unicode.λ"'),
|
|
340
403
|
('"quoted.with.extraneous.\\escape"@example.com', 'quoted.with.extraneous.escape'),
|
|
404
|
+
# U+037E (Greek question mark) is valid unquoted but NFC normalizes to a semicolon, which needs the quotes.
|
|
405
|
+
('"quoted.after.normalization.\u037e"@example.com', '"quoted.after.normalization.;"'),
|
|
341
406
|
])
|
|
342
407
|
def test_email_valid_only_if_quoted_local_part(email_input: str, normalized_local_part: str) -> None:
|
|
343
408
|
# These addresses are invalid with the default allow_quoted_local=False option.
|
|
@@ -352,6 +417,12 @@ def test_email_valid_only_if_quoted_local_part(email_input: str, normalized_loca
|
|
|
352
417
|
# and even the quoting if they weren't necessary.
|
|
353
418
|
assert validated.local_part == normalized_local_part
|
|
354
419
|
|
|
420
|
+
# If the normalized address is ASCII-only, check that it's reported in ascii_local_part.
|
|
421
|
+
if normalized_local_part.isascii():
|
|
422
|
+
assert validated.ascii_local_part == normalized_local_part
|
|
423
|
+
else:
|
|
424
|
+
assert not validated.ascii_local_part
|
|
425
|
+
|
|
355
426
|
|
|
356
427
|
def test_domain_literal() -> None:
|
|
357
428
|
# Check parsing IPv4 addresses.
|
|
@@ -369,6 +440,22 @@ def test_domain_literal() -> None:
|
|
|
369
440
|
assert validated.domain == "[IPv6:::1]"
|
|
370
441
|
assert repr(validated.domain_address) == "IPv6Address('::1')"
|
|
371
442
|
|
|
443
|
+
# Check that the IPv6 tag is case-insensitive and normalized.
|
|
444
|
+
for email_input in ("me@[ipv6:::1]", "me@[IPV6:::1]"):
|
|
445
|
+
validated = validate_email(email_input, allow_domain_literal=True)
|
|
446
|
+
assert validated.domain == "[IPv6:::1]"
|
|
447
|
+
assert repr(validated.domain_address) == "IPv6Address('::1')"
|
|
448
|
+
|
|
449
|
+
# Check that an IPv4 address in an IPv6 address is accepted.
|
|
450
|
+
validated = validate_email("me@[IPv6:::ffff:1.2.3.4]", allow_domain_literal=True)
|
|
451
|
+
assert validated.domain == "[IPv6:::ffff:1.2.3.4]"
|
|
452
|
+
|
|
453
|
+
# Check that IPv6 zone indexes are rejected.
|
|
454
|
+
for email_input in ("me@[IPv6:fe80::1%eth0]", "me@[IPv6:::1%]]"):
|
|
455
|
+
with pytest.raises(EmailSyntaxError) as exc_info:
|
|
456
|
+
validate_email(email_input, allow_domain_literal=True)
|
|
457
|
+
assert str(exc_info.value) == "The IPv6 address in brackets after the @-sign is not valid (It has a zone index)."
|
|
458
|
+
|
|
372
459
|
|
|
373
460
|
@pytest.mark.parametrize(
|
|
374
461
|
'email_input,error_msg',
|
|
@@ -424,7 +511,7 @@ def test_domain_literal() -> None:
|
|
|
424
511
|
('meme@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.com', 'The email address is too long (4 characters too many).'),
|
|
425
512
|
('my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info', 'The email address is too long (2 characters too many).'),
|
|
426
513
|
('my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'),
|
|
427
|
-
('my.long.address@\uFB2C111111111222222222233333333334444444444555555555.
|
|
514
|
+
('my.long.address@\uFB2C111111111222222222233333333334444444444555555555.a666666666777777777788888888889999999999000000000.a111111111222222222233333333334444444444555555555.a666666666777777777788888888889999999999000000000.a111111111222222222233333333334444.info', 'The email address is too long (1-3 characters too many).'),
|
|
428
515
|
('my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info', 'The email address is too long (1 character too many).'),
|
|
429
516
|
('my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'),
|
|
430
517
|
('my.\u0073\u0323\u0307.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'),
|
|
@@ -435,6 +522,11 @@ def test_domain_literal() -> None:
|
|
|
435
522
|
('me@bad-tld-1', 'The part after the @-sign is not valid. It should have a period.'),
|
|
436
523
|
('me@bad.tld-2', 'The part after the @-sign is not valid. It is not within a valid top-level domain.'),
|
|
437
524
|
('me@xn--0.tld', 'The part after the @-sign is not valid IDNA (Invalid A-label).'),
|
|
525
|
+
# 1.xn--4dbc.com is 1.אב.com and xn--4dbc.1.com is אב.1.com
|
|
526
|
+
# (alef bet in Hebrew). Both display as the same string, so both are rejected.
|
|
527
|
+
('me@1.xn--4dbc.com', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'),
|
|
528
|
+
('me@xn--4dbc.1.com', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'),
|
|
529
|
+
('me@1.xn--4dbrk0ce', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'),
|
|
438
530
|
('me@yy--0.tld', 'An email address cannot have two letters followed by two dashes immediately after the @-sign or after a period, except Punycode.'),
|
|
439
531
|
('me@yy--0.tld', 'An email address cannot have two letters followed by two dashes immediately after the @-sign or after a period, except Punycode.'),
|
|
440
532
|
('me@[127.0.0.1]', 'A bracketed IP address after the @-sign is not allowed here.'),
|
|
@@ -448,6 +540,7 @@ def test_domain_literal() -> None:
|
|
|
448
540
|
('<me@example.com', 'An open angle bracket at the start of the email address has to be followed by a close angle bracket at the end.'),
|
|
449
541
|
('<me@example.com> !', 'There can\'t be anything after the email address.'),
|
|
450
542
|
('<\u0338me@example.com', 'The email address contains invalid characters before the @-sign: \'<\'.'),
|
|
543
|
+
('<\u0301\u0338me@example.com', 'The email address contains invalid characters before the @-sign: \'<\'.'),
|
|
451
544
|
('DisplayName <me@-example.com>', 'An email address cannot have a hyphen immediately after the @-sign.'),
|
|
452
545
|
('DisplayName <me@example.com>', 'A display name and angle brackets around the email address are not permitted here.'),
|
|
453
546
|
('Display Name <me@example.com>', 'A display name and angle brackets around the email address are not permitted here.'),
|
|
@@ -464,6 +557,28 @@ def test_email_invalid_syntax(email_input: str, error_msg: str) -> None:
|
|
|
464
557
|
assert str(exc_info.value) == error_msg
|
|
465
558
|
|
|
466
559
|
|
|
560
|
+
def test_email_long_input_is_not_quadratic() -> None:
|
|
561
|
+
# split_email used to NFC-normalize the rest of the string at every
|
|
562
|
+
# character, so rejecting this took about 15 seconds.
|
|
563
|
+
start = time.perf_counter()
|
|
564
|
+
with pytest.raises(EmailSyntaxError, match="unsafe characters"):
|
|
565
|
+
validate_email("\u0301" * 50000 + "@example.com", check_deliverability=False)
|
|
566
|
+
assert time.perf_counter() - start < 2
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def test_email_long_idna_domain_is_not_quadratic() -> None:
|
|
570
|
+
# Internationalized domains went to idna.alabel without the up-front
|
|
571
|
+
# length check idna.encode does. idna.ulabel Punycode-decodes the whole
|
|
572
|
+
# "xn--" label first, so rejecting the first address took about 5 seconds,
|
|
573
|
+
# and before idna 3.15 the Arabic-Indic digits in the second took about 35.
|
|
574
|
+
for email in ("me@\u00e9.xn--" + "9" * 200000 + ".com",
|
|
575
|
+
"me@" + "\u0660" * 40000 + ".com"):
|
|
576
|
+
start = time.perf_counter()
|
|
577
|
+
with pytest.raises(EmailSyntaxError, match="too long after the @-sign"):
|
|
578
|
+
validate_email(email, check_deliverability=False)
|
|
579
|
+
assert time.perf_counter() - start < 2
|
|
580
|
+
|
|
581
|
+
|
|
467
582
|
@pytest.mark.parametrize(
|
|
468
583
|
'email_input,error_msg',
|
|
469
584
|
[
|
|
@@ -480,6 +595,20 @@ def test_email_invalid_syntax_strict(email_input: str, error_msg: str) -> None:
|
|
|
480
595
|
assert str(exc_info.value) == error_msg
|
|
481
596
|
|
|
482
597
|
|
|
598
|
+
@pytest.mark.parametrize(
|
|
599
|
+
'email_input',
|
|
600
|
+
[
|
|
601
|
+
'<me@example.com>>',
|
|
602
|
+
'Display Name <me@example.com>>',
|
|
603
|
+
'Display Name <me@example.com>>>',
|
|
604
|
+
])
|
|
605
|
+
def test_email_invalid_syntax_display_name(email_input: str) -> None:
|
|
606
|
+
# Only one close angle bracket may follow the address.
|
|
607
|
+
with pytest.raises(EmailSyntaxError) as exc_info:
|
|
608
|
+
validate_email(email_input, check_deliverability=False, allow_display_name=True)
|
|
609
|
+
assert str(exc_info.value) == "The part after the @-sign contains invalid characters: '>'."
|
|
610
|
+
|
|
611
|
+
|
|
483
612
|
@pytest.mark.parametrize(
|
|
484
613
|
'email_input',
|
|
485
614
|
[
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.3.0"
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from email_validator import validate_email, EmailSyntaxError
|
|
4
|
-
# Let's test main but rename it to be clear
|
|
5
|
-
from email_validator.__main__ import main as validator_command_line_tool
|
|
6
|
-
|
|
7
|
-
from mocked_dns_response import MockedDnsResponseData, MockedDnsResponseDataCleanup # noqa: F401
|
|
8
|
-
|
|
9
|
-
RESOLVER = MockedDnsResponseData.create_resolver()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_dict_accessor() -> None:
|
|
13
|
-
input_email = "testaddr@example.tld"
|
|
14
|
-
valid_email = validate_email(input_email, check_deliverability=False)
|
|
15
|
-
assert isinstance(valid_email.as_dict(), dict)
|
|
16
|
-
assert valid_email.as_dict()["original"] == input_email
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_main_single_good_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
20
|
-
import json
|
|
21
|
-
test_email = "google@google.com"
|
|
22
|
-
monkeypatch.setattr('sys.argv', ['email_validator', test_email])
|
|
23
|
-
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
24
|
-
stdout, _ = capsys.readouterr()
|
|
25
|
-
output = json.loads(str(stdout))
|
|
26
|
-
assert isinstance(output, dict)
|
|
27
|
-
assert validate_email(test_email, dns_resolver=RESOLVER).original == output["original"]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_main_single_bad_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
31
|
-
bad_email = 'test@..com'
|
|
32
|
-
monkeypatch.setattr('sys.argv', ['email_validator', bad_email])
|
|
33
|
-
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
34
|
-
stdout, _ = capsys.readouterr()
|
|
35
|
-
assert stdout == 'An email address cannot have a period immediately after the @-sign.\n'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_main_multi_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
39
|
-
import io
|
|
40
|
-
test_cases = ["google1@google.com", "google2@google.com", "test@.com", "test3@.com"]
|
|
41
|
-
test_input = io.StringIO("\n".join(test_cases))
|
|
42
|
-
monkeypatch.setattr('sys.stdin', test_input)
|
|
43
|
-
monkeypatch.setattr('sys.argv', ['email_validator'])
|
|
44
|
-
validator_command_line_tool(dns_resolver=RESOLVER)
|
|
45
|
-
stdout, _ = capsys.readouterr()
|
|
46
|
-
assert test_cases[0] not in stdout
|
|
47
|
-
assert test_cases[1] not in stdout
|
|
48
|
-
assert test_cases[2] in stdout
|
|
49
|
-
assert test_cases[3] in stdout
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_bytes_input() -> None:
|
|
53
|
-
input_email = b"testaddr@example.tld"
|
|
54
|
-
valid_email = validate_email(input_email, check_deliverability=False)
|
|
55
|
-
assert isinstance(valid_email.as_dict(), dict)
|
|
56
|
-
assert valid_email.as_dict()["normalized"] == input_email.decode("utf8")
|
|
57
|
-
|
|
58
|
-
input_email = "testaddr中example.tld".encode("utf32")
|
|
59
|
-
with pytest.raises(EmailSyntaxError):
|
|
60
|
-
validate_email(input_email, check_deliverability=False)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_deprecation() -> None:
|
|
64
|
-
input_email = b"testaddr@example.tld"
|
|
65
|
-
valid_email = validate_email(input_email, check_deliverability=False)
|
|
66
|
-
with pytest.deprecated_call():
|
|
67
|
-
assert valid_email.email is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|