diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3afa7..5bd062f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ----------------------- diff --git a/email_validator/validate_email.py b/email_validator/validate_email.py index 07b398a..ae49ca8 100644 --- a/email_validator/validate_email.py +++ b/email_validator/validate_email.py @@ -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 diff --git a/tests/test_syntax.py b/tests/test_syntax.py index 4580e7f..854e9a4 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -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.