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
3 changes: 2 additions & 1 deletion src/validators/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ def email(
(Literal[True]): If `value` is a valid eMail.
(ValidationError): If `value` is an invalid eMail.
"""
if not value or value.count("@") != 1:
if not value or "@" not in value:
return False

# Quoted local parts may contain at-signs; the final one separates the domain.
username_part, domain_part = value.rsplit("@", 1)

if len(username_part) > 64 or len(domain_part) > 253:
Expand Down
26 changes: 25 additions & 1 deletion tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
("łemłail@here.com",),
("email@localdomain.org",),
('"\\\011"@here.com',),
('"test@test"@example.com',),
],
)
def test_returns_true_on_valid_email(value: str):
Expand All @@ -45,7 +46,6 @@ def test_returns_true_on_valid_email(value: str):
("example@inv-.alid-.com",),
("example@inv-.-alid.com",),
("john56789.john56789.john56789.john56789.john56789.john56789.john5@example.com",),
('"test@test"@example.com',),
# Quoted-string format (CR not allowed)
('"\\\012"@here.com',),
# Non-quoted space/semicolon not allowed
Expand All @@ -66,3 +66,27 @@ def test_rejects_newline_after_local_part(local_part: str):
# 2026-10-11: A newline before the at-sign is not part of a valid local part.
assert email(f"{local_part}@example.com") is True
assert isinstance(email(f"{local_part}\n@example.com"), ValidationError)


@pytest.mark.parametrize("local_part", ['"@"', '"first@last"', '"first@@last"'])
def test_accepts_at_signs_inside_quoted_local_part(local_part: str):
"""Treat quoted at-signs as local-part content rather than separators."""
assert email(f"{local_part}@example.com") is True


@pytest.mark.parametrize(
"value",
[
"first@last@example.com",
"first@@example.com",
'"first@last@example.com',
'first@last"@example.com',
'"first"@last@example.com',
'"first@last"@@example.com',
'"first@last"@',
'"first@last"@-example.com',
],
)
def test_rejects_invalid_addresses_containing_multiple_at_signs(value: str):
"""Continue validating the complete local part and domain after splitting."""
assert isinstance(email(value), ValidationError)