Skip to content

Commit 162d96b

Browse files
Backend: Enhance email validation in EvalAIAccountAdapter to reject role-based, disposable, and typo'd addresses. (#5057)
* Backend: Enhance email validation in EvalAIAccountAdapter to reject role-based, disposable, and typo'd addresses. Update settings to allow additional blocked domains via environment variable. Add comprehensive unit tests for new validation rules and typo detection. * Refactor email validation in EvalAIAccountAdapter: streamline role-based and disposable email checks, enhance typo detection logic, and update unit tests. Remove unused JSON import and related comments from settings. * Implement additional email validation tests in EvalAIAccountAdapter: add checks for role-based and disposable email addresses, enhance typo detection, and ensure comprehensive coverage in unit tests. Update imports and settings for improved functionality.
1 parent 4ba45f9 commit 162d96b

File tree

3 files changed

+153
-6
lines changed

3 files changed

+153
-6
lines changed

apps/accounts/adapter.py

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,79 @@
22

33
import dns.resolver
44
from allauth.account.adapter import DefaultAccountAdapter
5+
from disposable_email_domains import blocklist
6+
from django.conf import settings
57
from django.contrib.auth.models import User
68
from django.core.exceptions import ValidationError
79

810
logger = logging.getLogger(__name__)
911

12+
DISPOSABLE_DOMAINS = frozenset(blocklist)
13+
14+
ROLE_BASED_LOCAL_PARTS = frozenset(
15+
{
16+
"abuse",
17+
"admin",
18+
"billing",
19+
"compliance",
20+
"devnull",
21+
"ftp",
22+
"hostmaster",
23+
"info",
24+
"mailer-daemon",
25+
"marketing",
26+
"no-reply",
27+
"noc",
28+
"noreply",
29+
"postmaster",
30+
"root",
31+
"sales",
32+
"security",
33+
"support",
34+
"webmaster",
35+
"www",
36+
}
37+
)
38+
39+
DOMAIN_TYPO_MAP = {
40+
"gamil.com": "gmail.com",
41+
"gail.com": "gmail.com",
42+
"gmal.com": "gmail.com",
43+
"gmaill.com": "gmail.com",
44+
"gmali.com": "gmail.com",
45+
"gmial.com": "gmail.com",
46+
"gmil.com": "gmail.com",
47+
"gnail.com": "gmail.com",
48+
"gogglemail.com": "googlemail.com",
49+
"googlmail.com": "googlemail.com",
50+
"hotamil.com": "hotmail.com",
51+
"hotmai.com": "hotmail.com",
52+
"hotmal.com": "hotmail.com",
53+
"hotmial.com": "hotmail.com",
54+
"hotmil.com": "hotmail.com",
55+
"iclod.com": "icloud.com",
56+
"iclould.com": "icloud.com",
57+
"outloo.com": "outlook.com",
58+
"outlok.com": "outlook.com",
59+
"outlool.com": "outlook.com",
60+
"outook.com": "outlook.com",
61+
"protonmal.com": "protonmail.com",
62+
"protonmaill.com": "protonmail.com",
63+
"yaho.com": "yahoo.com",
64+
"yahooo.com": "yahoo.com",
65+
"yaho.co.in": "yahoo.co.in",
66+
"yhaoo.com": "yahoo.com",
67+
"yhoo.com": "yahoo.com",
68+
}
69+
1070

1171
class EvalAIAccountAdapter(DefaultAccountAdapter):
1272
"""
1373
Custom allauth adapter that:
14-
1. Validates email domains have valid MX records at registration time.
15-
2. Suppresses outgoing allauth emails to addresses flagged as bounced.
16-
3. Clears the email_bounced flag when a new email address is confirmed.
74+
1. Rejects duplicate, role-based, typo'd, and disposable email addresses.
75+
2. Validates email domains have valid MX records at registration time.
76+
3. Suppresses outgoing allauth emails to addresses flagged as bounced.
77+
4. Clears the email_bounced flag when a new email address is confirmed.
1778
"""
1879

1980
def clean_email(self, email):
@@ -24,7 +85,31 @@ def clean_email(self, email):
2485
"A user is already registered with this email address."
2586
)
2687

27-
domain = email.rsplit("@", 1)[-1]
88+
local_part, domain = email.rsplit("@", 1)
89+
domain = domain.lower()
90+
91+
if local_part.lower() in ROLE_BASED_LOCAL_PARTS:
92+
raise ValidationError(
93+
"Role-based email addresses (e.g. noreply@, admin@) are "
94+
"not accepted. Please use a personal email address."
95+
)
96+
97+
# Check typo before disposable so users get helpful suggestions
98+
# (typo domains may also appear in the disposable blocklist)
99+
suggested = DOMAIN_TYPO_MAP.get(domain)
100+
if suggested:
101+
raise ValidationError(
102+
"Did you mean {}@{}? The domain '{}' looks like a typo.".format(
103+
local_part, suggested, domain
104+
)
105+
)
106+
107+
extra_blocked = set(getattr(settings, "BLOCKED_EMAIL_DOMAINS", []))
108+
if domain in DISPOSABLE_DOMAINS or domain in extra_blocked:
109+
raise ValidationError(
110+
"Disposable email addresses are not allowed. "
111+
"Please use a permanent email address."
112+
)
28113

29114
if not self._domain_has_mx_records(domain):
30115
raise ValidationError(

requirements/common.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ botocore==1.31.78
44
celery[sqs]==4.3.0
55
cfn-lint==0.48.3
66
commonmark==0.9.1
7+
disposable-email-domains>=0.0.167
78
django-allauth==0.43.0
89
dnspython==2.6.1
910
django-cors-headers==3.5.0

tests/unit/accounts/test_adapter.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import dns.exception
44
import dns.resolver
5-
from accounts.adapter import EvalAIAccountAdapter
5+
from accounts.adapter import DISPOSABLE_DOMAINS, EvalAIAccountAdapter
66
from django.contrib.auth.models import User
77
from django.core.exceptions import ValidationError
8-
from django.test import TestCase
8+
from django.test import TestCase, override_settings
99

1010

1111
class TestEvalAIAccountAdapterMXValidation(TestCase):
@@ -78,6 +78,67 @@ def test_clean_email_allows_new_address(self, mock_resolve):
7878
self.assertEqual(result, "fresh@example.com")
7979

8080

81+
class TestRoleBasedEmailRejection(TestCase):
82+
def setUp(self):
83+
self.adapter = EvalAIAccountAdapter()
84+
85+
@patch("accounts.adapter.dns.resolver.resolve")
86+
def test_rejects_noreply_address(self, mock_resolve):
87+
mock_resolve.return_value = [MagicMock()]
88+
with self.assertRaises(ValidationError) as ctx:
89+
self.adapter.clean_email("noreply@example.com")
90+
self.assertIn("Role-based email", str(ctx.exception))
91+
92+
@patch("accounts.adapter.dns.resolver.resolve")
93+
def test_rejects_admin_address(self, mock_resolve):
94+
mock_resolve.return_value = [MagicMock()]
95+
with self.assertRaises(ValidationError) as ctx:
96+
self.adapter.clean_email("admin@example.com")
97+
self.assertIn("Role-based email", str(ctx.exception))
98+
99+
100+
class TestDomainTypoDetection(TestCase):
101+
def setUp(self):
102+
self.adapter = EvalAIAccountAdapter()
103+
104+
@patch("accounts.adapter.dns.resolver.resolve")
105+
def test_rejects_gmial_with_suggestion(self, mock_resolve):
106+
mock_resolve.return_value = [MagicMock()]
107+
with self.assertRaises(ValidationError) as ctx:
108+
self.adapter.clean_email("alice@gmial.com")
109+
msg = str(ctx.exception)
110+
self.assertIn("Did you mean alice@gmail.com", msg)
111+
self.assertIn("looks like a typo", msg)
112+
113+
@patch("accounts.adapter.dns.resolver.resolve")
114+
def test_rejects_typo_case_insensitive(self, mock_resolve):
115+
mock_resolve.return_value = [MagicMock()]
116+
with self.assertRaises(ValidationError) as ctx:
117+
self.adapter.clean_email("user@GMIAL.COM")
118+
self.assertIn("Did you mean", str(ctx.exception))
119+
120+
121+
class TestDisposableEmailRejection(TestCase):
122+
def setUp(self):
123+
self.adapter = EvalAIAccountAdapter()
124+
125+
@patch("accounts.adapter.dns.resolver.resolve")
126+
def test_rejects_known_disposable_domain(self, mock_resolve):
127+
mock_resolve.return_value = [MagicMock()]
128+
self.assertIn("mailinator.com", DISPOSABLE_DOMAINS)
129+
with self.assertRaises(ValidationError) as ctx:
130+
self.adapter.clean_email("user@mailinator.com")
131+
self.assertIn("Disposable email", str(ctx.exception))
132+
133+
@patch("accounts.adapter.dns.resolver.resolve")
134+
@override_settings(BLOCKED_EMAIL_DOMAINS=["custom-blocked.com"])
135+
def test_rejects_admin_blocked_domain(self, mock_resolve):
136+
mock_resolve.return_value = [MagicMock()]
137+
with self.assertRaises(ValidationError) as ctx:
138+
self.adapter.clean_email("user@custom-blocked.com")
139+
self.assertIn("Disposable email", str(ctx.exception))
140+
141+
81142
class TestEvalAIAccountAdapterSendMail(TestCase):
82143
def setUp(self):
83144
self.adapter = EvalAIAccountAdapter()

0 commit comments

Comments
 (0)