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.
Files changed (31) hide show
  1. {email_validator-2.3.0/email_validator.egg-info → email_validator-2.4.0}/PKG-INFO +9 -6
  2. {email_validator-2.3.0 → email_validator-2.4.0}/README.md +4 -2
  3. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/__main__.py +9 -4
  4. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/deliverability.py +3 -3
  5. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/syntax.py +64 -15
  6. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/types.py +8 -7
  7. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/validate_email.py +24 -20
  8. email_validator-2.4.0/email_validator/version.py +1 -0
  9. {email_validator-2.3.0 → email_validator-2.4.0/email_validator.egg-info}/PKG-INFO +9 -6
  10. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/SOURCES.txt +1 -0
  11. {email_validator-2.3.0 → email_validator-2.4.0}/setup.cfg +4 -3
  12. email_validator-2.4.0/tests/conftest.py +21 -0
  13. {email_validator-2.3.0 → email_validator-2.4.0}/tests/mocked-dns-answers.json +25 -1
  14. {email_validator-2.3.0 → email_validator-2.4.0}/tests/mocked_dns_response.py +2 -2
  15. {email_validator-2.3.0 → email_validator-2.4.0}/tests/test_deliverability.py +8 -2
  16. email_validator-2.4.0/tests/test_main.py +154 -0
  17. {email_validator-2.3.0 → email_validator-2.4.0}/tests/test_syntax.py +130 -1
  18. email_validator-2.3.0/email_validator/version.py +0 -1
  19. email_validator-2.3.0/tests/test_main.py +0 -67
  20. {email_validator-2.3.0 → email_validator-2.4.0}/LICENSE +0 -0
  21. {email_validator-2.3.0 → email_validator-2.4.0}/MANIFEST.in +0 -0
  22. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/__init__.py +0 -0
  23. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/exceptions.py +0 -0
  24. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/py.typed +0 -0
  25. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator/rfc_constants.py +0 -0
  26. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/dependency_links.txt +0 -0
  27. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/entry_points.txt +0 -0
  28. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/requires.txt +0 -0
  29. {email_validator-2.3.0 → email_validator-2.4.0}/email_validator.egg-info/top_level.txt +0 -0
  30. {email_validator-2.3.0 → email_validator-2.4.0}/pyproject.toml +0 -0
  31. {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.0
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.8
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.8+ by [Joshua Tauberer](https://joshdata.me).
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.8+ by [Joshua Tauberer](https://joshdata.me).
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, Dict, Optional
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: Dict[str, Any] = {}
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
- options[varname.lower()] = bool(os.environ[varname])
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
- options[varname.lower()] = float(os.environ[varname])
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, List, Optional, Tuple, TypedDict
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": List[Tuple[int, str]],
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 AAA record
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, Tuple, TypedDict, Union
11
+ from typing import Optional, TypedDict
12
12
 
13
13
 
14
- def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
15
- # Return the display name, unescaped local part, and domain part
16
- # of the address, and whether the local part was quoted. If no
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 or a quoted string with
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: Tuple[str, ...]) -> Tuple[str, str]:
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
- if unicodedata.normalize("NFC", text[i:])[0] != c:
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) -> Tuple[str, bool]:
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:].rstrip(">")
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
- local_part, is_quoted_local_part = unquote_quoted_string(local_part)
203
+ local_part_unquoted, is_quoted_local_part = unquote_quoted_string(local_part)
197
204
 
198
- return display_name, local_part, domain_part, is_quoted_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: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
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: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
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
- if domain_literal.startswith("IPv6:"):
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, Dict, List, Optional, Tuple, Union
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: List[Tuple[int, str]]
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) -> Union[Optional[str], bool, List[Tuple[int, 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.normalized == other.normalized
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) -> Dict[str, Any]:
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, Union, TYPE_CHECKING
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: Union[str, bytes],
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, local_part, domain_part, is_quoted_local_part \
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 = ((local_part if not is_quoted_local_part
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(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
- ret.local_part = local_part_info["local_part"]
108
- ret.ascii_local_part = local_part_info["ascii_local_part"]
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
- normalized_local_part = unicodedata.normalize("NFC", ret.local_part)
124
- if normalized_local_part != ret.local_part:
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
- allow_smtputf8=allow_smtputf8,
128
- allow_empty_local=allow_empty_local,
129
- quoted_local_part=is_quoted_local_part,
130
- strict=strict)
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
- ret.local_part = normalized_local_part
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 not ret.smtputf8:
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.0
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.8
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.8+ by [Joshua Tauberer](https://joshdata.me).
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.8
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, Dict, Iterator, Optional
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: Dict[dns.resolver.CacheKey, Optional[MockedDnsResponseData.Ans]] = {}
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, Dict
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: Dict[Any, Any] = {}
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.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-3 characters too many).'),
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