email-validator 2.2.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.2.0/email_validator.egg-info → email_validator-2.4.0}/PKG-INFO +16 -12
  2. {email_validator-2.2.0 → email_validator-2.4.0}/README.md +8 -6
  3. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/__init__.py +4 -2
  4. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/__main__.py +14 -8
  5. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/deliverability.py +4 -4
  6. email_validator-2.4.0/email_validator/exceptions.py +13 -0
  7. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/rfc_constants.py +14 -2
  8. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/syntax.py +134 -24
  9. email_validator-2.2.0/email_validator/exceptions_types.py → email_validator-2.4.0/email_validator/types.py +8 -22
  10. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/validate_email.py +61 -28
  11. email_validator-2.4.0/email_validator/version.py +1 -0
  12. {email_validator-2.2.0 → email_validator-2.4.0/email_validator.egg-info}/PKG-INFO +16 -12
  13. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/SOURCES.txt +3 -1
  14. {email_validator-2.2.0 → email_validator-2.4.0}/setup.cfg +5 -4
  15. email_validator-2.4.0/tests/conftest.py +21 -0
  16. {email_validator-2.2.0 → email_validator-2.4.0}/tests/mocked-dns-answers.json +25 -1
  17. {email_validator-2.2.0 → email_validator-2.4.0}/tests/mocked_dns_response.py +2 -2
  18. {email_validator-2.2.0 → email_validator-2.4.0}/tests/test_deliverability.py +8 -2
  19. email_validator-2.4.0/tests/test_main.py +154 -0
  20. {email_validator-2.2.0 → email_validator-2.4.0}/tests/test_syntax.py +168 -10
  21. email_validator-2.2.0/email_validator/version.py +0 -1
  22. email_validator-2.2.0/tests/test_main.py +0 -67
  23. {email_validator-2.2.0 → email_validator-2.4.0}/LICENSE +0 -0
  24. {email_validator-2.2.0 → email_validator-2.4.0}/MANIFEST.in +0 -0
  25. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/py.typed +0 -0
  26. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/dependency_links.txt +0 -0
  27. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/entry_points.txt +0 -0
  28. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/requires.txt +0 -0
  29. {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/top_level.txt +0 -0
  30. {email_validator-2.2.0 → email_validator-2.4.0}/pyproject.toml +0 -0
  31. {email_validator-2.2.0 → email_validator-2.4.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
2
- Name: email_validator
3
- Version: 2.2.0
1
+ Metadata-Version: 2.4
2
+ Name: email-validator
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,23 +11,25 @@ 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
24
25
  Requires-Dist: idna>=2.0.0
26
+ Dynamic: license-file
25
27
 
26
28
  email-validator: Validate Email Addresses
27
29
  =========================================
28
30
 
29
31
  A robust email address syntax and deliverability validation library for
30
- Python 3.8+ by [Joshua Tauberer](https://joshdata.me).
32
+ Python 3.10+ by [Joshua Tauberer](https://joshdata.me).
31
33
 
32
34
  This library validates that a string is of the form `name@example.com`
33
35
  and optionally checks that the domain name is set up to receive email.
@@ -37,7 +39,7 @@ users by their email address like on a registration form.
37
39
  Key features:
38
40
 
39
41
  * Checks that an email address has the correct syntax --- great for
40
- email-based registration/login forms or validing data.
42
+ email-based registration/login forms or validating data.
41
43
  * Gives friendly English error messages when validation fails that you
42
44
  can display to end-users.
43
45
  * Checks deliverability (optional): Does the domain name resolve?
@@ -168,13 +170,14 @@ The `validate_email` function also accepts the following keyword arguments
168
170
 
169
171
  `allow_quoted_local=False`: Set to `True` to allow obscure and potentially problematic email addresses in which the part of the address before the @-sign contains spaces, @-signs, or other surprising characters when the local part is surrounded in quotes (so-called quoted-string local parts). In the object returned by `validate_email`, the normalized local part removes any unnecessary backslash-escaping and even removes the surrounding quotes if the address would be valid without them. You can also set `email_validator.ALLOW_QUOTED_LOCAL` to `True` to turn this on for all calls by default.
170
172
 
171
- `allow_domain_literal=False`: Set to `True` to allow bracketed IPv4 and "IPv6:"-prefixd IPv6 addresses in the domain part of the email address. No deliverability checks are performed for these addresses. In the object returned by `validate_email`, the normalized domain will use the condensed IPv6 format, if applicable. The object's `domain_address` attribute will hold the parsed `ipaddress.IPv4Address` or `ipaddress.IPv6Address` object if applicable. You can also set `email_validator.ALLOW_DOMAIN_LITERAL` to `True` to turn this on for all calls by default.
173
+ `allow_domain_literal=False`: Set to `True` to allow bracketed IPv4 and "IPv6:"-prefixed IPv6 addresses in the domain part of the email address. No deliverability checks are performed for these addresses. In the object returned by `validate_email`, the normalized domain will use the condensed IPv6 format, if applicable. The object's `domain_address` attribute will hold the parsed `ipaddress.IPv4Address` or `ipaddress.IPv6Address` object if applicable. You can also set `email_validator.ALLOW_DOMAIN_LITERAL` to `True` to turn this on for all calls by default.
172
174
 
173
175
  `allow_display_name=False`: Set to `True` to allow a display name and bracketed address in the input string, like `My Name <me@example.org>`. It's implemented in the spirit but not the letter of RFC 5322 3.4, so it may be stricter or more relaxed than what you want. The display name, if present, is provided in the returned object's `display_name` field after being unquoted and unescaped. You can also set `email_validator.ALLOW_DISPLAY_NAME` to `True` to turn this on for all calls by default.
174
176
 
175
177
  `allow_empty_local=False`: Set to `True` to allow an empty local part (i.e.
176
178
  `@example.com`), e.g. for validating Postfix aliases.
177
179
 
180
+ `strict=False`: Set to `True` to perform additional syntax checks (currently only a local part length check). This should be used by mail service providers at address creation to ensure email addresses meet broad compatibility requirements.
178
181
 
179
182
  ### DNS timeout and cache
180
183
 
@@ -381,7 +384,9 @@ ValidatedEmail(
381
384
 
382
385
  Now `smtputf8` is `True` and `ascii_email` is `None` because the local
383
386
  part of the address is internationalized. The `local_part` and `normalized` fields
384
- 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.)
385
390
 
386
391
  Return value
387
392
  ------------
@@ -449,8 +454,7 @@ To release:
449
454
  * Update CHANGELOG.md.
450
455
  * Update the version number in `email_validator/version.py`.
451
456
  * Make & push a commit with the new version number and make sure tests pass.
452
- * Make & push a tag (see command below).
453
- * Make a release at https://github.com/JoshData/python-email-validator/releases/new.
457
+ * Make a release at https://github.com/JoshData/python-email-validator/releases/new creating a new tag (or use command below).
454
458
  * Publish a source and wheel distribution to pypi (see command below).
455
459
 
456
460
  ```sh
@@ -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.
@@ -12,7 +12,7 @@ users by their email address like on a registration form.
12
12
  Key features:
13
13
 
14
14
  * Checks that an email address has the correct syntax --- great for
15
- email-based registration/login forms or validing data.
15
+ email-based registration/login forms or validating data.
16
16
  * Gives friendly English error messages when validation fails that you
17
17
  can display to end-users.
18
18
  * Checks deliverability (optional): Does the domain name resolve?
@@ -143,13 +143,14 @@ The `validate_email` function also accepts the following keyword arguments
143
143
 
144
144
  `allow_quoted_local=False`: Set to `True` to allow obscure and potentially problematic email addresses in which the part of the address before the @-sign contains spaces, @-signs, or other surprising characters when the local part is surrounded in quotes (so-called quoted-string local parts). In the object returned by `validate_email`, the normalized local part removes any unnecessary backslash-escaping and even removes the surrounding quotes if the address would be valid without them. You can also set `email_validator.ALLOW_QUOTED_LOCAL` to `True` to turn this on for all calls by default.
145
145
 
146
- `allow_domain_literal=False`: Set to `True` to allow bracketed IPv4 and "IPv6:"-prefixd IPv6 addresses in the domain part of the email address. No deliverability checks are performed for these addresses. In the object returned by `validate_email`, the normalized domain will use the condensed IPv6 format, if applicable. The object's `domain_address` attribute will hold the parsed `ipaddress.IPv4Address` or `ipaddress.IPv6Address` object if applicable. You can also set `email_validator.ALLOW_DOMAIN_LITERAL` to `True` to turn this on for all calls by default.
146
+ `allow_domain_literal=False`: Set to `True` to allow bracketed IPv4 and "IPv6:"-prefixed IPv6 addresses in the domain part of the email address. No deliverability checks are performed for these addresses. In the object returned by `validate_email`, the normalized domain will use the condensed IPv6 format, if applicable. The object's `domain_address` attribute will hold the parsed `ipaddress.IPv4Address` or `ipaddress.IPv6Address` object if applicable. You can also set `email_validator.ALLOW_DOMAIN_LITERAL` to `True` to turn this on for all calls by default.
147
147
 
148
148
  `allow_display_name=False`: Set to `True` to allow a display name and bracketed address in the input string, like `My Name <me@example.org>`. It's implemented in the spirit but not the letter of RFC 5322 3.4, so it may be stricter or more relaxed than what you want. The display name, if present, is provided in the returned object's `display_name` field after being unquoted and unescaped. You can also set `email_validator.ALLOW_DISPLAY_NAME` to `True` to turn this on for all calls by default.
149
149
 
150
150
  `allow_empty_local=False`: Set to `True` to allow an empty local part (i.e.
151
151
  `@example.com`), e.g. for validating Postfix aliases.
152
152
 
153
+ `strict=False`: Set to `True` to perform additional syntax checks (currently only a local part length check). This should be used by mail service providers at address creation to ensure email addresses meet broad compatibility requirements.
153
154
 
154
155
  ### DNS timeout and cache
155
156
 
@@ -356,7 +357,9 @@ ValidatedEmail(
356
357
 
357
358
  Now `smtputf8` is `True` and `ascii_email` is `None` because the local
358
359
  part of the address is internationalized. The `local_part` and `normalized` fields
359
- 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.)
360
363
 
361
364
  Return value
362
365
  ------------
@@ -424,8 +427,7 @@ To release:
424
427
  * Update CHANGELOG.md.
425
428
  * Update the version number in `email_validator/version.py`.
426
429
  * Make & push a commit with the new version number and make sure tests pass.
427
- * Make & push a tag (see command below).
428
- * Make a release at https://github.com/JoshData/python-email-validator/releases/new.
430
+ * Make a release at https://github.com/JoshData/python-email-validator/releases/new creating a new tag (or use command below).
429
431
  * Publish a source and wheel distribution to pypi (see command below).
430
432
 
431
433
  ```sh
@@ -1,8 +1,8 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
3
  # Export the main method, helper methods, and the public data types.
4
- from .exceptions_types import ValidatedEmail, EmailNotValidError, \
5
- EmailSyntaxError, EmailUndeliverableError
4
+ from .exceptions import EmailNotValidError, EmailSyntaxError, EmailUndeliverableError
5
+ from .types import ValidatedEmail
6
6
  from .validate_email import validate_email
7
7
  from .version import __version__
8
8
 
@@ -27,9 +27,11 @@ else:
27
27
  # Default values for keyword arguments.
28
28
 
29
29
  ALLOW_SMTPUTF8 = True
30
+ ALLOW_EMPTY_LOCAL = False
30
31
  ALLOW_QUOTED_LOCAL = False
31
32
  ALLOW_DOMAIN_LITERAL = False
32
33
  ALLOW_DISPLAY_NAME = False
34
+ STRICT = False
33
35
  GLOBALLY_DELIVERABLE = True
34
36
  CHECK_DELIVERABILITY = True
35
37
  TEST_ENVIRONMENT = False
@@ -12,33 +12,39 @@
12
12
  # When using STDIN, no output will be given for valid email addresses.
13
13
  #
14
14
  # Keyword arguments to validate_email can be set in environment variables
15
- # of the same name but upprcase (see below).
15
+ # of the same name but uppercase (see below).
16
16
 
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
24
- from .exceptions_types import EmailNotValidError
24
+ from .exceptions import EmailNotValidError
25
25
 
26
26
 
27
27
  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] = {}
32
- for varname in ('ALLOW_SMTPUTF8', 'ALLOW_QUOTED_LOCAL', 'ALLOW_DOMAIN_LITERAL',
31
+ options: dict[str, Any] = {}
32
+ for varname in ('ALLOW_SMTPUTF8', 'ALLOW_EMPTY_LOCAL', 'ALLOW_QUOTED_LOCAL', 'ALLOW_DOMAIN_LITERAL',
33
+ 'ALLOW_DISPLAY_NAME',
33
34
  'GLOBALLY_DELIVERABLE', 'CHECK_DELIVERABILITY', 'TEST_ENVIRONMENT'):
34
35
  if varname in os.environ:
35
- 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')
36
39
  for varname in ('DEFAULT_TIMEOUT',):
37
40
  if varname in os.environ:
38
- 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])
39
45
 
40
46
  if len(sys.argv) == 1:
41
- # Validate the email addresses pased line-by-line on STDIN.
47
+ # Validate the email addresses passed line-by-line on STDIN.
42
48
  dns_resolver = dns_resolver or caching_resolver()
43
49
  for line in sys.stdin:
44
50
  email = line.strip()
@@ -1,8 +1,8 @@
1
- from typing import Any, List, Optional, Tuple, TypedDict
1
+ from typing import Any, Optional, TypedDict
2
2
 
3
3
  import ipaddress
4
4
 
5
- from .exceptions_types import EmailUndeliverableError
5
+ from .exceptions import EmailUndeliverableError
6
6
 
7
7
  import dns.resolver
8
8
  import dns.exception
@@ -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,
@@ -0,0 +1,13 @@
1
+ class EmailNotValidError(ValueError):
2
+ """Parent class of all exceptions raised by this module."""
3
+ pass
4
+
5
+
6
+ class EmailSyntaxError(EmailNotValidError):
7
+ """Exception raised when an email address fails validation because of its form."""
8
+ pass
9
+
10
+
11
+ class EmailUndeliverableError(EmailNotValidError):
12
+ """Exception raised when an email address fails validation because its domain name does not appear deliverable."""
13
+ pass
@@ -36,12 +36,24 @@ DOMAIN_LITERAL_CHARS = re.compile(r"[\u0021-\u00FA\u005E-\u007E]")
36
36
  QTEXT_INTL = re.compile(r"[\u0020-\u007E\u0080-\U0010FFFF]")
37
37
 
38
38
  # Length constants
39
+
39
40
  # RFC 3696 + errata 1003 + errata 1690 (https://www.rfc-editor.org/errata_search.php?rfc=3696&eid=1690)
40
- # explains the maximum length of an email address is 254 octets.
41
+ # explains the maximum length of an email address is 254 octets based on RFC 5321 4.5.3.1.3. A
42
+ # maximum local part length is also given at RFC 5321 4.5.3.1.1.
43
+ #
44
+ # But RFC 5321 4.5.3.1 says that these (and other) limits are in a sense suggestions, and longer
45
+ # local parts have been seen in the wild. Consequntely, the local part length is only checked
46
+ # in "strict" mode. Although the email address maximum length is also somewhat of a suggestion,
47
+ # I don't like the idea of having no length checks performed, so I'm leaving that to always be
48
+ # checked.
41
49
  EMAIL_MAX_LENGTH = 254
42
50
  LOCAL_PART_MAX_LENGTH = 64
51
+
52
+ # Although RFC 5321 4.5.3.1.2 gives a (suggested, see above) limit of 255 octets, RFC 1035 2.3.4 also
53
+ # imposes a length limit (255 octets). But per https://stackoverflow.com/questions/32290167/what-is-the-maximum-length-of-a-dns-name,
54
+ # two of those octets are taken up by the optional final dot and null root label.
43
55
  DNS_LABEL_LENGTH_LIMIT = 63 # in "octets", RFC 1035 2.3.1
44
- DOMAIN_MAX_LENGTH = 253 # in "octets" as transmitted, RFC 1035 2.3.4 and RFC 5321 4.5.3.1.2, and see https://stackoverflow.com/questions/32290167/what-is-the-maximum-length-of-a-dns-name
56
+ DOMAIN_MAX_LENGTH = 253 # in "octets" as transmitted
45
57
 
46
58
  # RFC 2142
47
59
  CASE_INSENSITIVE_MAILBOX_NAMES = [
@@ -1,4 +1,5 @@
1
- from .exceptions_types import EmailSyntaxError, ValidatedEmail
1
+ from .exceptions import EmailSyntaxError
2
+ from .types import ValidatedEmail
2
3
  from .rfc_constants import EMAIL_MAX_LENGTH, LOCAL_PART_MAX_LENGTH, DOMAIN_MAX_LENGTH, \
3
4
  DOT_ATOM_TEXT, DOT_ATOM_TEXT_INTL, ATEXT_RE, ATEXT_INTL_DOT_RE, ATEXT_HOSTNAME_INTL, QTEXT_INTL, \
4
5
  DNS_LABEL_LENGTH_LIMIT, DOT_ATOM_TEXT_HOSTNAME, DOMAIN_NAME_REGEX, DOMAIN_LITERAL_CHARS
@@ -7,12 +8,12 @@ import re
7
8
  import unicodedata
8
9
  import idna # implements IDNA 2008; Python's codec is only IDNA 2003
9
10
  import ipaddress
10
- from typing import Optional, Tuple, TypedDict, Union
11
+ from typing import Optional, TypedDict
11
12
 
12
13
 
13
- def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
14
- # Return the display name, unescaped local part, and domain part
15
- # 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
16
17
  # display name was present and angle brackets do not surround
17
18
  # the address, display name will be None; otherwise, it will be
18
19
  # set to the display name or the empty string if there were
@@ -35,7 +36,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
35
36
  #
36
37
  # Display names are either basic characters (the same basic characters
37
38
  # permitted in email addresses, but periods are not allowed and spaces
38
- # 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
39
40
  # the same rules as a quoted local part. (Multiple quoted strings might
40
41
  # be allowed? Unclear.) Optional space (RFC 5322 3.4 CFWS) and then the
41
42
  # email address follows in angle brackets.
@@ -46,7 +47,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
46
47
  # We assume the input string is already stripped of leading and
47
48
  # trailing CFWS.
48
49
 
49
- 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]:
50
51
  # Split the string at the first character in specials (an @-sign
51
52
  # or left angle bracket) that does not occur within quotes and
52
53
  # is not followed by a Unicode combining character.
@@ -57,10 +58,17 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
57
58
  for i, c in enumerate(text):
58
59
  # < plus U+0338 (Combining Long Solidus Overlay) normalizes to
59
60
  # ≮ U+226E (Not Less-Than), and it would be confusing to treat
60
- # the < as the start of "<email>" syntax in that case. Liekwise,
61
+ # the < as the start of "<email>" syntax in that case. Likewise,
61
62
  # if anything combines with an @ or ", we should probably not
62
63
  # treat it as a special character.
63
- 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:
64
72
  left_part += c
65
73
 
66
74
  elif inside_quote:
@@ -82,7 +90,28 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
82
90
  else:
83
91
  left_part += c
84
92
 
93
+ # No special symbol found. The special symbols always
94
+ # include an at-sign, so this always indicates a missing
95
+ # at-sign. The other symbol is optional.
85
96
  if len(left_part) == len(text):
97
+ # The full-width at-sign might occur in CJK contexts.
98
+ # We can't accept it because we only accept addresess
99
+ # that are actually valid. But if this is common we
100
+ # may want to consider accepting and normalizing full-
101
+ # width characters for the other special symbols (and
102
+ # full-width dot is already accepted in internationalized
103
+ # domains) with a new option.
104
+ # See https://news.ycombinator.com/item?id=42235268.
105
+ if "@" in text:
106
+ raise EmailSyntaxError("The email address has the \"full-width\" at-sign (@) character instead of a regular at-sign.")
107
+
108
+ # Check another near-homoglyph for good measure because
109
+ # homoglyphs in place of required characters could be
110
+ # very confusing. We may want to consider checking for
111
+ # homoglyphs anywhere we look for a special symbol.
112
+ if "﹫" in text:
113
+ raise EmailSyntaxError('The email address has the "small commercial at" character instead of a regular at-sign.')
114
+
86
115
  raise EmailSyntaxError("An email address must have an @-sign.")
87
116
 
88
117
  # The right part is whatever is left.
@@ -90,7 +119,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
90
119
 
91
120
  return left_part, right_part
92
121
 
93
- def unquote_quoted_string(text: str) -> Tuple[str, bool]:
122
+ def unquote_quoted_string(text: str) -> tuple[str, bool]:
94
123
  # Remove surrounding quotes and unescape escaped backslashes
95
124
  # and quotes. Escapes are parsed liberally. I think only
96
125
  # backslashes and quotes can be escaped but we'll allow anything
@@ -156,7 +185,7 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
156
185
  raise EmailSyntaxError("There can't be anything after the email address.")
157
186
 
158
187
  # Remove the initial and trailing angle brackets.
159
- addr_spec = right_part[1:].rstrip(">")
188
+ addr_spec = right_part[1:-1]
160
189
 
161
190
  # Split the email address at the first unquoted @-sign.
162
191
  local_part, domain_part = split_string_at_unquoted_special(addr_spec, ("@",))
@@ -171,9 +200,9 @@ def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
171
200
  domain_part = domain_part[1:]
172
201
 
173
202
  # Unquote the local part if it is quoted.
174
- local_part, is_quoted_local_part = unquote_quoted_string(local_part)
203
+ local_part_unquoted, is_quoted_local_part = unquote_quoted_string(local_part)
175
204
 
176
- 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
177
206
 
178
207
 
179
208
  def get_length_reason(addr: str, limit: int) -> str:
@@ -207,7 +236,7 @@ class LocalPartValidationResult(TypedDict):
207
236
 
208
237
 
209
238
  def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_empty_local: bool = False,
210
- quoted_local_part: bool = False) -> LocalPartValidationResult:
239
+ quoted_local_part: bool = False, strict: bool = False) -> LocalPartValidationResult:
211
240
  """Validates the syntax of the local part of an email address."""
212
241
 
213
242
  if len(local) == 0:
@@ -229,7 +258,7 @@ def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_emp
229
258
  # internationalized, then the UTF-8 encoding may be longer, but
230
259
  # that may not be relevant. We will check the total address length
231
260
  # instead.
232
- if len(local) > LOCAL_PART_MAX_LENGTH:
261
+ if strict and len(local) > LOCAL_PART_MAX_LENGTH:
233
262
  reason = get_length_reason(local, limit=LOCAL_PART_MAX_LENGTH)
234
263
  raise EmailSyntaxError(f"The email address is too long before the @-sign {reason}.")
235
264
 
@@ -280,8 +309,8 @@ def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_emp
280
309
  valid = "dot-atom"
281
310
  requires_smtputf8 = True
282
311
 
283
- # There are no syntactic restrictions on quoted local parts, so if
284
- # it was originally quoted, it is probably valid. More characters
312
+ # There are no dot-atom syntax restrictions on quoted local parts, so
313
+ # if it was originally quoted, it is probably valid. More characters
285
314
  # are allowed, like @-signs, spaces, and quotes, and there are no
286
315
  # restrictions on the placement of dots, as in dot-atom local parts.
287
316
  elif quoted_local_part:
@@ -438,6 +467,36 @@ def check_dot_atom(label: str, start_descr: str, end_descr: str, is_hostname: bo
438
467
  raise EmailSyntaxError("An email address cannot have a period and a hyphen next to each other.")
439
468
 
440
469
 
470
+ def uts46_valid_char(char: str) -> bool:
471
+ # By exhaustively searching for characters rejected by
472
+ # for c in (chr(i) for i in range(0x110000)):
473
+ # idna.uts46_remap(c, std3_rules=False, transitional=False)
474
+ # I found the following rules are pretty close.
475
+ c = ord(char)
476
+ if 0x80 <= c <= 0x9f:
477
+ # 8-bit ASCII range.
478
+ return False
479
+ elif ((0x2010 <= c <= 0x2060 and not (0x2024 <= c <= 0x2026) and not (0x2028 <= c <= 0x202E))
480
+ or c in (0x00AD, 0x2064, 0xFF0E)
481
+ or 0x200B <= c <= 0x200D
482
+ or 0x1BCA0 <= c <= 0x1BCA3):
483
+ # Characters that are permitted but fall into one of the
484
+ # tests below.
485
+ return True
486
+ elif unicodedata.category(chr(c)) in ("Cf", "Cn", "Co", "Cs", "Zs", "Zl", "Zp"):
487
+ # There are a bunch of Zs characters including regular space
488
+ # that are allowed by UTS46 but are not allowed in domain
489
+ # names anyway.
490
+ #
491
+ # There are some Cn (unassigned) characters that the idna
492
+ # package doesn't reject but we can, I think.
493
+ return False
494
+ elif "002E" in unicodedata.decomposition(chr(c)).split(" "):
495
+ # Characters that decompose into a sequence with a dot.
496
+ return False
497
+ return True
498
+
499
+
441
500
  class DomainNameValidationResult(TypedDict):
442
501
  ascii_domain: str
443
502
  domain: str
@@ -446,6 +505,14 @@ class DomainNameValidationResult(TypedDict):
446
505
  def validate_email_domain_name(domain: str, test_environment: bool = False, globally_deliverable: bool = True) -> DomainNameValidationResult:
447
506
  """Validates the syntax of the domain part of an email address."""
448
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
+
449
516
  # Check for invalid characters.
450
517
  # (RFC 952 plus RFC 6531 section 3.3 for internationalized addresses)
451
518
  bad_chars = {
@@ -462,6 +529,15 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
462
529
  # they may not be valid, safe, or sensible Unicode strings.
463
530
  check_unsafe_chars(domain)
464
531
 
532
+ # Reject characters that would be rejected by UTS-46 normalization next but
533
+ # with an error message under our control.
534
+ bad_chars = {
535
+ safe_character_display(c) for c in domain
536
+ if not uts46_valid_char(c)
537
+ }
538
+ if bad_chars:
539
+ raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".")
540
+
465
541
  # Perform UTS-46 normalization, which includes casefolding, NFC normalization,
466
542
  # and converting all label separators (the period/full stop, fullwidth full stop,
467
543
  # ideographic full stop, and halfwidth ideographic full stop) to regular dots.
@@ -518,6 +594,14 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
518
594
  # doesn't give a nice error, so we call the underlying idna.alabel method
519
595
  # directly. idna.alabel checks label length and doesn't give great messages,
520
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.
521
605
  try:
522
606
  ascii_domain = ".".join(
523
607
  idna.alabel(label).decode("ascii")
@@ -583,6 +667,13 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
583
667
  if ascii_domain == d or ascii_domain.endswith("." + d):
584
668
  raise EmailSyntaxError("The part after the @-sign is a special-use or reserved name that cannot be used with email.")
585
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
+
586
677
  # We may have been given an IDNA ASCII domain to begin with. Check
587
678
  # that the domain actually conforms to IDNA. It could look like IDNA
588
679
  # but not be actual IDNA. For ASCII-only domains, the conversion out
@@ -607,12 +698,26 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob
607
698
  # case for this.
608
699
  bad_chars = {
609
700
  safe_character_display(c)
610
- for c in domain
701
+ for c in domain_i18n
611
702
  if not ATEXT_HOSTNAME_INTL.match(c)
612
703
  }
613
704
  if bad_chars:
614
705
  raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".")
615
- check_unsafe_chars(domain)
706
+ check_unsafe_chars(domain_i18n)
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
616
721
 
617
722
  # Check that it can be encoded back to IDNA ASCII. We have no test
618
723
  # case for this.
@@ -642,7 +747,7 @@ def validate_email_length(addrinfo: ValidatedEmail) -> None:
642
747
  # form is checked first because it is the original input.
643
748
  # 2) The normalized email address. We perform Unicode NFC normalization of
644
749
  # the local part, we normalize the domain to internationalized characters
645
- # (if originaly IDNA ASCII) which also includes Unicode normalization,
750
+ # (if originally IDNA ASCII) which also includes Unicode normalization,
646
751
  # and we may remove quotes in quoted local parts. We recommend that
647
752
  # callers use this string, so it must be valid.
648
753
  # 3) The email address with the IDNA ASCII representation of the domain
@@ -697,7 +802,7 @@ def validate_email_length(addrinfo: ValidatedEmail) -> None:
697
802
 
698
803
 
699
804
  class DomainLiteralValidationResult(TypedDict):
700
- domain_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
805
+ domain_address: ipaddress.IPv4Address | ipaddress.IPv6Address
701
806
  domain: str
702
807
 
703
808
 
@@ -706,7 +811,7 @@ def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidatio
706
811
  # a compressed/normalized address.
707
812
  # RFC 5321 4.1.3 and RFC 5322 3.4.1.
708
813
 
709
- addr: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
814
+ addr: ipaddress.IPv4Address | ipaddress.IPv6Address
710
815
 
711
816
  # Try to parse the domain literal as an IPv4 address.
712
817
  # There is no tag for IPv4 addresses, so we can never
@@ -723,13 +828,18 @@ def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidatio
723
828
  "domain": f"[{addr}]",
724
829
  }
725
830
 
726
- # If it begins with "IPv6:" it's an IPv6 address.
727
- 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:":
728
834
  try:
729
835
  addr = ipaddress.IPv6Address(domain_literal[5:])
730
836
  except ValueError as e:
731
837
  raise EmailSyntaxError(f"The IPv6 address in brackets after the @-sign is not valid ({e}).") from e
732
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
+
733
843
  # Return the IPv6Address object and construct a normalized
734
844
  # domain literal.
735
845
  return {