Merge pull request #35103 from Z4nzu/fix/email-validation-rfc5322-27337

feat(utils): replace custom email parsing with RFC-compliant validation
This commit is contained in:
Akhil Narang 2025-12-10 15:54:13 +05:30 committed by GitHub
commit 4751bc06d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 31 deletions

View file

@ -577,6 +577,39 @@ class TestValidationUtils(IntegrationTestCase):
"erp+Job%20Applicant=JA00004@frappe.com",
)
# RFC 5322 format - Display name with comma (main bug fix)
self.assertEqual(
validate_email_address('"Lastname, Firstname" <test@example.com>'), "test@example.com"
)
self.assertEqual(validate_email_address('"Doe, John" <john.doe@example.com>'), "john.doe@example.com")
# RFC 5322 format - Display name without comma
self.assertEqual(validate_email_address("Test User <test@example.com>"), "test@example.com")
# RFC 5322 format - Multiple emails
self.assertEqual(
validate_email_address('"Last, First" <test1@example.com>, "Another, Name" <test2@example.com>'),
"test1@example.com, test2@example.com",
)
# RFC 5322 format - Mixed with plain emails
self.assertEqual(
validate_email_address("Test User <test@example.com>, plain@example.com"),
"test@example.com, plain@example.com",
)
# Emails with newlines
self.assertEqual(
validate_email_address("test1@example.com\ntest2@example.com"),
"test1@example.com, test2@example.com",
)
# Undisclosed recipients should be filtered
self.assertEqual(validate_email_address("undisclosed-recipients:;"), "")
self.assertEqual(
validate_email_address("test@example.com, undisclosed-recipients:;"), "test@example.com"
)
def test_valid_phone(self):
valid_phones = ["+91 1234567890", ""]

View file

@ -18,7 +18,7 @@ from collections.abc import (
Sequence,
)
from email.header import decode_header, make_header
from email.utils import formataddr, parseaddr
from email.utils import formataddr, getaddresses, parseaddr
from typing import Any, Generic, TypeAlias, TypedDict
import orjson
@ -180,45 +180,42 @@ def validate_name(name, throw=False):
def validate_email_address(email_str, throw=False):
"""Validates the email string"""
email = email_str = (email_str or "").strip()
def _check(e):
_valid = True
if not e:
_valid = False
email_str = (email_str or "").strip()
out = []
if "undisclosed-recipient" in e:
return False
# Replace newlines with commas so getaddresses can handle them
# getaddresses expects comma-separated values
email_str = email_str.replace("\n", ",").replace("\r", ",")
elif " " in e and "<" not in e:
# example: "test@example.com test2@example.com" will return "test@example.comtest2" after parseaddr!!!
_valid = False
# Parse using stdlib (handles commas in display names correctly)
addresses = getaddresses([email_str])
else:
email_id = extract_email_id(e)
match = EMAIL_MATCH_PATTERN.match(email_id) if email_id else None
if not match:
_valid = False
if not _valid:
for name, addr in addresses:
if not addr:
if throw:
invalid_email = frappe.utils.escape_html(e)
frappe.throw(
frappe._("{0} is not a valid Email Address").format(invalid_email),
frappe._("{0} is not a valid Email Address").format(
frappe.utils.escape_html(name or email_str)
),
frappe.InvalidEmailAddressError,
)
return None
else:
return email_id
out = []
for e in email_str.split(","):
if not e:
continue
email = _check(e.strip())
if email:
out.append(email)
# Skip undisclosed recipients
if "undisclosed-recipient" in addr:
continue
match = EMAIL_MATCH_PATTERN.match(addr)
if not match:
if throw:
frappe.throw(
frappe._("{0} is not a valid Email Address").format(frappe.utils.escape_html(addr)),
frappe.InvalidEmailAddressError,
)
continue
out.append(addr)
return ", ".join(out)