Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ In Development
* The IPv6 tag of a domain literal is now case-insensitive, as in the RFCs, so `me@[ipv6:::1]` is accepted (and normalized to `me@[IPv6:::1]`) when `allow_domain_literal=True`.
* Internationalized domains with a right-to-left label are now rejected if any other label breaks the IDNA Bidi Rule (RFC 5893), such as a label starting with a digit. Such domains, like `1.xn--4dbc.com` and `xn--4dbc.1.com` (`1.אב.com` and `אב.1.com` in Unicode), could be displayed identically.
* Faster validation of domains without Punycode (xn--) labels.
* With `allow_quoted_local=True`, a quoted local part containing U+037E (Greek question mark), which Unicode NFC normalization changes to a semicolon, was returned without its quotes in the normalized address (e.g. `a;b@example.com`), getting around the re-validation added in 2.2.0. The normalized local part is now quoted according to its normalized form, giving `"a;b"@example.com`.

2.3.0 (August 26, 2025)
-----------------------
Expand Down
21 changes: 13 additions & 8 deletions email_validator/validate_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,22 @@ def validate_email(
# this library. (UTS #39 seems to require that the *input* be NKFC normalized
# and has other requirements that are hard to check without additional Unicode
# data, and I don't know whether the rules really apply in the wild.)
normalized_local_part = unicodedata.normalize("NFC", ret.local_part)
if normalized_local_part != ret.local_part:
#
# Normalize the unquoted and unescaped local part, not ret.local_part, and
# take the quoting from the second validation. ret.local_part has already
# had unnecessary quotes removed, so normalizing it would turn e.g.
# "a\u037eb" (quoted, with a Greek question mark) into a;b without quotes.
normalized_local_part = unicodedata.normalize("NFC", unescaped_local_part)
if normalized_local_part != unescaped_local_part:
try:
validate_email_local_part(normalized_local_part,
allow_smtputf8=allow_smtputf8,
allow_empty_local=allow_empty_local,
quoted_local_part=is_quoted_local_part,
strict=strict)
normalized_local_part_info = validate_email_local_part(normalized_local_part,
allow_smtputf8=allow_smtputf8,
allow_empty_local=allow_empty_local,
quoted_local_part=is_quoted_local_part,
strict=strict)
except EmailSyntaxError as e:
raise EmailSyntaxError("After Unicode normalization: " + str(e)) from e
ret.local_part = normalized_local_part
ret.local_part = normalized_local_part_info["local_part"]

# If a quoted local part isn't allowed but is present, now raise an exception.
# This is done after any exceptions raised by validate_email_local_part so
Expand Down
2 changes: 2 additions & 0 deletions tests/test_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ def test_email_valid_intl_local_part(email_input: str, output: ValidatedEmail) -
('"unnecessarily.quoted.with.unicode.λ"@example.com', 'unnecessarily.quoted.with.unicode.λ'),
('"quoted.with..unicode.λ"@example.com', '"quoted.with..unicode.λ"'),
('"quoted.with.extraneous.\\escape"@example.com', 'quoted.with.extraneous.escape'),
# U+037E (Greek question mark) is valid unquoted but NFC normalizes to a semicolon, which needs the quotes.
('"quoted.after.normalization.\u037e"@example.com', '"quoted.after.normalization.;"'),
])
def test_email_valid_only_if_quoted_local_part(email_input: str, normalized_local_part: str) -> None:
# These addresses are invalid with the default allow_quoted_local=False option.
Expand Down
Loading