From 9a96e597b45d6bb702a3fd0cecb7680c16b23be2 Mon Sep 17 00:00:00 2001 From: pabszero <88653327+pabszero@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:32:43 +0300 Subject: [PATCH 1/2] Accept tagged IPv6 email address literals --- src/validators/email.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/validators/email.py b/src/validators/email.py index 46cc5946..d5c1e29d 100644 --- a/src/validators/email.py +++ b/src/validators/email.py @@ -5,6 +5,7 @@ # local from .hostname import hostname +from .ip_address import ipv6 from .utils import validator @@ -39,7 +40,8 @@ def email( value: eMail string to validate. ipv6_address: - When the domain part is an IPv6 address. + When the domain part is an IPv6 address, including an RFC 5321 + address literal such as `[IPv6:2001:db8::1]`. ipv4_address: When the domain part is an IPv4 address. simple_host: @@ -67,7 +69,12 @@ def email( if ipv6_address or ipv4_address: if domain_part.startswith("[") and domain_part.endswith("]"): # ref: RFC 5321 - domain_part = domain_part.lstrip("[").rstrip("]") + if domain_part[1:6].lower() == "ipv6:": + domain_part = domain_part[6:-1] + if not ipv6_address or "%" in domain_part or not ipv6(domain_part, cidr=False): + return False + else: + domain_part = domain_part.lstrip("[").rstrip("]") else: return False From ea748acb94491107564a32463258d710e6e48d54 Mon Sep 17 00:00:00 2001 From: pabszero <88653327+pabszero@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:33:04 +0300 Subject: [PATCH 2/2] Test tagged IPv6 literals and compatibility --- tests/test_email.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_email.py b/tests/test_email.py index 990ef8ee..acb07ecc 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -66,3 +66,39 @@ 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( + "address", + ["2001:db8::1", "2001:db8:0:0:0:0:0:1", "::ffff:192.0.2.1"], +) +def test_accepts_tagged_ipv6_literal(address: str): + """Accept RFC 5321 address literals when IPv6 addresses are enabled.""" + assert email(f"user@[IPv6:{address}]", ipv6_address=True) is True + assert email(f"user@[ipv6:{address}]", ipv6_address=True) is True + assert isinstance(email(f"user@[IPv6:{address}]"), ValidationError) + assert isinstance(email(f"user@[IPv6:{address}]", ipv4_address=True), ValidationError) + + +@pytest.mark.parametrize( + "address", ["", "example.com", "192.0.2.1", "2001:db8::xyz", "fe80::1%eth0"] +) +def test_rejects_invalid_tagged_ipv6_literal(address: str): + """An IPv6 tag must identify a valid IPv6 address, never a hostname or IPv4.""" + assert isinstance(email(f"user@[IPv6:{address}]", ipv6_address=True), ValidationError) + assert isinstance( + email(f"user@[IPv6:{address}]", ipv6_address=True, ipv4_address=True), ValidationError + ) + + +def test_rejects_extra_brackets_around_tagged_ipv6_literal(): + """Do not strip repeated brackets while interpreting an IPv6 tag.""" + assert isinstance(email("user@[[IPv6:2001:db8::1]]", ipv6_address=True), ValidationError) + + +def test_preserves_legacy_email_address_literals(): + """Keep bare IPv6 and IPv4 literal support compatible.""" + assert email("user@[2001:db8::1]", ipv6_address=True) is True + assert email("user@[192.0.2.1]", ipv4_address=True) is True + assert email("user@[IPv6:2001:db8::1]", ipv6_address=True, ipv4_address=True) is True + assert isinstance(email("invalid local@[IPv6:2001:db8::1]", ipv6_address=True), ValidationError)