diff --git a/django/core/validators.py b/django/core/validators.py index fe1e8b066c59..11c28c88615e 100644 --- a/django/core/validators.py +++ b/django/core/validators.py @@ -1,16 +1,19 @@ import ipaddress import math import re +import warnings from pathlib import Path from urllib.parse import urlsplit from django.core.exceptions import ValidationError from django.utils.deconstruct import deconstructible +from django.utils.deprecation import RemovedInDjango2029Warning, warn_about_external_use from django.utils.http import MAX_URL_LENGTH from django.utils.ipv6 import is_valid_ipv6_address from django.utils.regex_helper import _lazy_re_compile from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext_lazy +from django.utils.warnings import django_file_prefixes # These values, if given to validate(), will trigger the self.required check. EMPTY_VALUES = (None, "", [], (), {}) @@ -233,6 +236,27 @@ class EmailValidator: ) domain_allowlist = ["localhost"] + # RemovedInDjango2029Warning. + validate_domain_part_deprecated_msg = ( + "EmailValidator.validate_domain_part() is deprecated. Migrate to " + "validate_domain(), raising ValidationError for invalid domains." + ) + # RemovedInDjango2029Warning: tracks if a subclass overrides the deprecated + # `validate_domain_part()` hook (set in __init_subclass__()). + validate_domain_part_overridden = False + + # RemovedInDjango2029Warning: warn once if a subclass overrides the + # deprecated `validate_domain_part()` hook. + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "validate_domain_part" in cls.__dict__: + cls.validate_domain_part_overridden = True + warnings.warn( + cls.validate_domain_part_deprecated_msg, + RemovedInDjango2029Warning, + skip_file_prefixes=django_file_prefixes(), + ) + def __init__(self, message=None, code=None, allowlist=None): if message is not None: self.message = message @@ -242,22 +266,75 @@ def __init__(self, message=None, code=None, allowlist=None): self.domain_allowlist = allowlist def __call__(self, value): - # The maximum length of an email is 320 characters per RFC 3696 - # section 3. + username, domain = self.parse_address(value) + self.validate(value, username, domain) + + def validate(self, value, username, domain): + self.validate_username(username, value) + + # RemovedInDjango2029Warning: honor a legacy validate_domain_part() + # override (the warning is issued in __init_subclass__()). The + # allowlist is checked here for the legacy path only, preserving that + # hook's contract of never being called with an allowlisted domain; + # validate_domain() enforces the allowlist itself. When the deprecation + # ends, remove this `if` branch entirely, leaving just: + # self.validate_domain(domain, value) + if self.validate_domain_part_overridden: + if domain not in self.domain_allowlist and not self.validate_domain_part( + domain + ): + raise ValidationError( + self.message, + code=self.code, + params={"value": value, "domain": domain}, + ) + else: + self.validate_domain(domain, value) + + def parse_address(self, value): + # The maximum length of an email is 320 chars per RFC 3696 section 3. if not value or "@" not in value or len(value) > 320: raise ValidationError(self.message, code=self.code, params={"value": value}) + return value.rsplit("@", 1) - user_part, domain_part = value.rsplit("@", 1) + def validate_username(self, username, value): + if not self.user_regex.match(username): + raise ValidationError( + self.message, + code=self.code, + params={"value": value, "username": username}, + ) - if not self.user_regex.match(user_part): - raise ValidationError(self.message, code=self.code, params={"value": value}) + def validate_domain(self, domain, value): + if domain in self.domain_allowlist: + return - if domain_part not in self.domain_allowlist and not self.validate_domain_part( - domain_part - ): - raise ValidationError(self.message, code=self.code, params={"value": value}) + if self.domain_regex.match(domain): + return + + literal_match = self.literal_regex.match(domain) + if literal_match: + ip_address = literal_match[1] + try: + validate_ipv46_address(ip_address) + return + except ValidationError: + pass + raise ValidationError( + self.message, + code=self.code, + params={"value": value, "domain": domain}, + ) + # RemovedInDjango2029Warning. def validate_domain_part(self, domain_part): + # Warn external callers, except when reached through this class' own + # validate() dispatch (i.e. a legacy override calling super()). + warn_about_external_use( + self.validate_domain_part_deprecated_msg, + RemovedInDjango2029Warning, + skip_frames=1, + ) if self.domain_regex.match(domain_part): return True diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index df925c6ee825..118d86bdd8de 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -28,6 +28,9 @@ details on these changes. the current timezone in migrations when the ``tzinfo`` argument is omitted and :setting:`USE_TZ` is ``True``. +* The undocumented ``EmailValidator.validate_domain_part()`` method will be + removed. + .. _deprecation-removed-in-2028: 2028 diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt index 39b712695e81..83b19dc51a03 100644 --- a/docs/ref/validators.txt +++ b/docs/ref/validators.txt @@ -136,7 +136,8 @@ to, or in lieu of custom ``field.clean()`` methods. An :class:`EmailValidator` ensures that a value looks like an email address, and raises a :exc:`~django.core.exceptions.ValidationError` with :attr:`message` and :attr:`code` if it doesn't. Values longer than 320 - characters are always considered invalid. + characters are always considered invalid. The raised error includes the + invalid ``username`` or ``domain`` part in its ``params``. .. attribute:: message @@ -159,6 +160,46 @@ to, or in lieu of custom ``field.clean()`` methods. validation, so you'd need to add them to the ``allowlist`` as necessary. + .. method:: parse_address(value) + + .. versionadded:: 6.2 + + Returns a ``(username, domain)`` pair, where ``username`` is the + portion of ``value`` before the last ``@`` and ``domain`` is the + portion after (this is not a full :rfc:`5322` address parser though). + Raises a :exc:`~django.core.exceptions.ValidationError` if ``value`` is + empty, has no ``@``, or exceeds the maximum length. This method can be + overridden to customize how the address is parsed. + + .. method:: validate(value, username, domain) + + .. versionadded:: 6.2 + + Runs :meth:`validate_username` and :meth:`validate_domain` on the + parsed ``value`` (resulting from :meth:`parse_address`), raising a + :exc:`~django.core.exceptions.ValidationError` if either part is + invalid. This method can be overridden to customize validation + depending on both the ``username`` and ``domain`` parts. + + .. method:: validate_domain(domain, value) + + .. versionadded:: 6.2 + + Validates ``domain`` (the portion after the ``@``), raising a + :exc:`~django.core.exceptions.ValidationError` if it is invalid. A + ``domain`` in the :attr:`allowlist` is always considered valid. + ``value`` is the full address being validated. This method can be + overridden to customize domain validation. + + .. method:: validate_username(username, value) + + .. versionadded:: 6.2 + + Validates ``username`` (the portion before the ``@``), raising a + :exc:`~django.core.exceptions.ValidationError` if it is invalid. + ``value`` is the full address being validated. This method can be + overridden to customize username validation. + ``DomainNameValidator`` ----------------------- diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index 1bd58f0aac04..4690c110b71d 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -263,7 +263,14 @@ Utilities Validators ~~~~~~~~~~ -* ... +* :class:`~django.core.validators.EmailValidator` now includes the invalid + ``username`` or ``domain`` in the ``params`` dict of the + :exc:`~django.core.exceptions.ValidationError` it raises. The new + :meth:`~django.core.validators.EmailValidator.parse_address`, + :meth:`~django.core.validators.EmailValidator.validate`, + :meth:`~django.core.validators.EmailValidator.validate_domain`, and + :meth:`~django.core.validators.EmailValidator.validate_username` methods can + be overridden to customize how the address is parsed and validated. .. _backwards-incompatible-6.2: @@ -345,6 +352,13 @@ Miscellaneous Features deprecated in 6.2 ========================== +Validators +---------- + +* The undocumented ``EmailValidator.validate_domain_part()`` method is + deprecated in favor of the new + :meth:`~django.core.validators.EmailValidator.validate_domain` method. + Miscellaneous ------------- diff --git a/tests/validators/tests.py b/tests/validators/tests.py index 2937c519f5dc..cb0d0be11077 100644 --- a/tests/validators/tests.py +++ b/tests/validators/tests.py @@ -1,6 +1,7 @@ import ipaddress import re import types +import warnings from datetime import datetime, timedelta from decimal import Decimal from unittest import TestCase, mock @@ -33,7 +34,8 @@ validate_slug, validate_unicode_slug, ) -from django.test import SimpleTestCase +from django.test import SimpleTestCase, ignore_warnings +from django.utils.deprecation import RemovedInDjango2029Warning try: from PIL import Image # noqa @@ -761,6 +763,268 @@ def test_max_length_validator_message(self): v("djangoproject.com") +class EmailValidatorTests(SimpleTestCase): + def test_invalid_domain_error(self): + with self.assertRaises(ValidationError) as cm: + EmailValidator()("local@invalid_domain") + self.assertEqual(cm.exception.code, "invalid") + self.assertEqual( + cm.exception.params, + {"value": "local@invalid_domain", "domain": "invalid_domain"}, + ) + + def test_invalid_username_error(self): + with self.assertRaises(ValidationError) as cm: + EmailValidator()("not valid@example.com") + self.assertEqual(cm.exception.code, "invalid") + self.assertEqual( + cm.exception.params, + {"value": "not valid@example.com", "username": "not valid"}, + ) + + def test_validate_domain_override(self): + class ExampleComDomainValidator(EmailValidator): + def validate_domain(self, domain, value): + if domain != "example.com": + raise ValidationError(self.message, code=self.code) + + validator = ExampleComDomainValidator() + self.assertIsNone(validator("local@example.com")) + with self.assertRaises(ValidationError): + validator("local@example.org") + + def test_validate_username_override(self): + class NotAdminUsernameValidator(EmailValidator): + def validate_username(self, username, value): + if username == "admin": + raise ValidationError(self.message, code=self.code) + + validator = NotAdminUsernameValidator() + self.assertIsNone(validator("other@example.com")) + with self.assertRaises(ValidationError): + validator("admin@example.com") + + def test_validate_override(self): + class UsernameMatchingDomainValidator(EmailValidator): + def validate(self, value, username, domain): + super().validate(value, username, domain) + # Require the username to match the domain's leftmost label. + if username != domain.split(".")[0]: + raise ValidationError(self.message, code=self.code) + + validator = UsernameMatchingDomainValidator() + self.assertIsNone(validator("example@example.com")) + with self.assertRaises(ValidationError): + validator("admin@example.com") + + def test_parse_address(self): + validator = EmailValidator() + username, domain = validator.parse_address("local@example.com") + self.assertEqual((username, domain), ("local", "example.com")) + # Unparseable or oversized values raise ValidationError. + for value in ["", "no-at-sign", "local@" + "d" * 320]: + with self.subTest(value=value): + with self.assertRaises(ValidationError): + validator.parse_address(value) + + def test_parse_address_override(self): + class SplitOnFirstAtValidator(EmailValidator): + def parse_address(self, value): + # Split on the first "@" instead of the last. + return value.split("@", 1) + + with self.assertRaises(ValidationError) as cm: + SplitOnFirstAtValidator()("a@b@example.com") + self.assertEqual(cm.exception.params["domain"], "b@example.com") + + def test_validate_domain_override_can_raise_custom_error(self): + class CustomDomainErrorValidator(EmailValidator): + def validate_domain(self, domain, value): + try: + super().validate_domain(domain, value) + except ValidationError: + raise ValidationError( + "%(domain)s is not allowed.", + code="invalid_domain", + params={"value": value, "domain": domain}, + ) from None + + with self.assertRaises(ValidationError) as cm: + CustomDomainErrorValidator()("local@invalid_domain") + self.assertEqual(cm.exception.messages, ["invalid_domain is not allowed."]) + self.assertEqual(cm.exception.code, "invalid_domain") + self.assertEqual( + cm.exception.params, + {"value": "local@invalid_domain", "domain": "invalid_domain"}, + ) + + def test_validate_username_override_can_raise_custom_error(self): + class CustomUsernameErrorValidator(EmailValidator): + def validate_username(self, username, value): + try: + super().validate_username(username, value) + except ValidationError: + raise ValidationError( + "%(username)s is not a valid local part.", + code="invalid_username", + params={"value": value, "username": username}, + ) from None + + with self.assertRaises(ValidationError) as cm: + CustomUsernameErrorValidator()("not valid@example.com") + self.assertEqual( + cm.exception.messages, ["not valid is not a valid local part."] + ) + self.assertEqual(cm.exception.code, "invalid_username") + self.assertEqual( + cm.exception.params, + {"value": "not valid@example.com", "username": "not valid"}, + ) + + def test_subclass_without_overrides(self): + class PlainEmailValidator(EmailValidator): + pass + + with warnings.catch_warnings(): + warnings.simplefilter("error", RemovedInDjango2029Warning) + validator = PlainEmailValidator() + self.assertIsNone(validator("local@example.com")) + with self.assertRaises(ValidationError): + validator("local@invalid_domain") + + def test_validate_domain_allows_allowlisted_domain(self): + validator = EmailValidator() + # Allowlisted domains are accepted even if failing the domain regex. + self.assertIsNone(validator.validate_domain("localhost", "local@localhost")) + # A non-allowlisted domain that fails the regex still raises. + with self.assertRaises(ValidationError): + validator.validate_domain("invalid_domain", "local@invalid_domain") + + +# RemovedInDjango2029Warning. +class EmailValidatorDeprecationTests(SimpleTestCase): + def test_legacy_validate_domain_part_override_warns(self): + # The deprecated override is detected once, when the class is defined. + with self.assertWarnsMessage( + RemovedInDjango2029Warning, + EmailValidator.validate_domain_part_deprecated_msg, + ): + + class LegacyEmailValidator(EmailValidator): + def validate_domain_part(self, domain_part): + return domain_part == "example.com" + + def test_legacy_validate_domain_part_override_with_super_warns_once(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + + class LegacyEmailValidator(EmailValidator): + def validate_domain_part(self, domain_part): + return super().validate_domain_part(domain_part) + + LegacyEmailValidator()("local@example.com") + relevant = [w for w in recorded if w.category is RemovedInDjango2029Warning] + self.assertEqual(len(relevant), 1) + + def test_call_override_using_stock_validate_domain_part_warns(self): + # A subclass that overrides __call__ and calls the inherited + # validate_domain_part() should be warned that it is deprecated. + class StockDomainPartCallValidator(EmailValidator): + def __call__(self, value): + user_part, domain_part = value.rsplit("@", 1) + if not self.validate_domain_part(domain_part): + raise ValidationError(self.message, code=self.code) + + with self.assertWarnsMessage( + RemovedInDjango2029Warning, + EmailValidator.validate_domain_part_deprecated_msg, + ): + StockDomainPartCallValidator()("local@example.com") + + def test_validate_domain_part_direct_call_warns(self): + with self.assertWarnsMessage( + RemovedInDjango2029Warning, + EmailValidator.validate_domain_part_deprecated_msg, + ): + EmailValidator().validate_domain_part("example.com") + + @ignore_warnings(category=RemovedInDjango2029Warning) + def test_validate_domain_part_ignores_allowlist(self): + validator = EmailValidator() + self.assertIs(validator.validate_domain_part("example.com"), True) + self.assertIs(validator.validate_domain_part("invalid_domain"), False) + # The allowlist is checked by validate(), not validate_domain_part(), + # so an allowlisted domain fails the plain domain check. + self.assertIs(validator.validate_domain_part("localhost"), False) + + @ignore_warnings(category=RemovedInDjango2029Warning) + def test_legacy_validate_domain_part_override_with_super_is_honored(self): + class LegacyEmailValidator(EmailValidator): + def validate_domain_part(self, domain_part): + return ( + super().validate_domain_part(domain_part) + and domain_part != "blocked.example.com" + ) + + validator = LegacyEmailValidator() + self.assertIsNone(validator("local@example.com")) + # Rejected by the subclass's extra check. + with self.assertRaises(ValidationError): + validator("local@blocked.example.com") + # Rejected by the inherited (super) check. + with self.assertRaises(ValidationError): + validator("local@invalid_domain") + + @ignore_warnings(category=RemovedInDjango2029Warning) + def test_override_both_prefers_legacy_validate_domain_part(self): + class LegacyPreferredEmailValidator(EmailValidator): + def validate_domain(self, domain, value): + raise AssertionError("validate_domain() should not be called") + + def validate_domain_part(self, domain_part): + return domain_part == "example.com" + + validator = LegacyPreferredEmailValidator() + self.assertIsNone(validator("local@example.com")) + # validate_domain_part() takes precedence over validate_domain(). + with self.assertRaises(ValidationError): + validator("local@example.org") + + @ignore_warnings(category=RemovedInDjango2029Warning) + def test_legacy_override_with_colliding_validate_domain(self): + # Any subclass may define validate_domain() as its own helper, with its + # own signature and call chain. `__call__` must route through the + # overridden `validate_domain_part()` and not `validate_domain()` + # itself, which would pass the wrong arguments. + class LegacyEmailValidator(EmailValidator): + def validate_domain(self, domain): # Own 1-argument helper. + return domain == "example.com" + + def validate_domain_part(self, domain_part): + return self.validate_domain(domain_part) + + validator = LegacyEmailValidator() + self.assertIsNone(validator("local@example.com")) + with self.assertRaises(ValidationError): + validator("local@example.org") + + @ignore_warnings(category=RemovedInDjango2029Warning) + def test_allowlisted_domain_skips_legacy_override(self): + class OrgOnlyEmailValidator(EmailValidator): + def validate_domain_part(self, domain_part): + if domain_part == "myexception.com": + raise AssertionError("allowlisted domains must skip the override") + return domain_part.endswith(".org") + + validator = OrgOnlyEmailValidator(allowlist=["myexception.com"]) + # The allowlisted domain is accepted without consulting the override. + self.assertIsNone(validator("local@myexception.com")) + # Non-allowlisted domains are still handed to the override. + self.assertIsNone(validator("local@myexception.org")) + with self.assertRaises(ValidationError): + validator("local@reallymyexception.com") + + class TestValidatorEquality(TestCase): """ Validators have valid equality operators (#21638)