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.
- {email_validator-2.2.0/email_validator.egg-info → email_validator-2.4.0}/PKG-INFO +16 -12
- {email_validator-2.2.0 → email_validator-2.4.0}/README.md +8 -6
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/__init__.py +4 -2
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/__main__.py +14 -8
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/deliverability.py +4 -4
- email_validator-2.4.0/email_validator/exceptions.py +13 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/rfc_constants.py +14 -2
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/syntax.py +134 -24
- email_validator-2.2.0/email_validator/exceptions_types.py → email_validator-2.4.0/email_validator/types.py +8 -22
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/validate_email.py +61 -28
- email_validator-2.4.0/email_validator/version.py +1 -0
- {email_validator-2.2.0 → email_validator-2.4.0/email_validator.egg-info}/PKG-INFO +16 -12
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/SOURCES.txt +3 -1
- {email_validator-2.2.0 → email_validator-2.4.0}/setup.cfg +5 -4
- email_validator-2.4.0/tests/conftest.py +21 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/tests/mocked-dns-answers.json +25 -1
- {email_validator-2.2.0 → email_validator-2.4.0}/tests/mocked_dns_response.py +2 -2
- {email_validator-2.2.0 → email_validator-2.4.0}/tests/test_deliverability.py +8 -2
- email_validator-2.4.0/tests/test_main.py +154 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/tests/test_syntax.py +168 -10
- email_validator-2.2.0/email_validator/version.py +0 -1
- email_validator-2.2.0/tests/test_main.py +0 -67
- {email_validator-2.2.0 → email_validator-2.4.0}/LICENSE +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/MANIFEST.in +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator/py.typed +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/dependency_links.txt +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/entry_points.txt +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/requires.txt +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/email_validator.egg-info/top_level.txt +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/pyproject.toml +0 -0
- {email_validator-2.2.0 → email_validator-2.4.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
2
|
-
Name:
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
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
|
|
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:"-
|
|
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
|
|
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.
|
|
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
|
|
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:"-
|
|
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
|
|
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 .
|
|
5
|
-
|
|
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
|
|
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,
|
|
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 .
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
1
|
+
from typing import Any, Optional, TypedDict
|
|
2
2
|
|
|
3
3
|
import ipaddress
|
|
4
4
|
|
|
5
|
-
from .
|
|
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":
|
|
22
|
+
"mx": list[tuple[int, str]],
|
|
23
23
|
"mx_fallback_type": Optional[str],
|
|
24
24
|
"unknown-deliverability": str,
|
|
25
25
|
}, total=False)
|
|
@@ -66,7 +66,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option
|
|
|
66
66
|
deliverability_info["mx_fallback_type"] = None
|
|
67
67
|
|
|
68
68
|
except dns.resolver.NoAnswer:
|
|
69
|
-
# If there was no MX record, fall back to an A or
|
|
69
|
+
# If there was no MX record, fall back to an A or AAAA record
|
|
70
70
|
# (RFC 5321 Section 5). Check A first since it's more common.
|
|
71
71
|
|
|
72
72
|
# If the A/AAAA response has no Globally Reachable IP address,
|
|
@@ -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
|
|
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 .
|
|
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,
|
|
11
|
+
from typing import Optional, TypedDict
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
def split_email(email: str) ->
|
|
14
|
-
# Return the display name,
|
|
15
|
-
#
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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) ->
|
|
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
|
|
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
|
-
|
|
203
|
+
local_part_unquoted, is_quoted_local_part = unquote_quoted_string(local_part)
|
|
175
204
|
|
|
176
|
-
return display_name, 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
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 {
|