Skip to content
Merged
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
7 changes: 7 additions & 0 deletions news/30.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Fix email validation:
* allow apostrophes
* allow accented characters
* allow ampersand in the user part
* do not allows spaces.
* accept TLDs with more than 4 characters
[maurits]
2 changes: 2 additions & 0 deletions news/5.tests.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Refactor the ``jsonfield`` doctest to a simpler unit test.
[maurits]
2 changes: 2 additions & 0 deletions news/5.tests.2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add basic tests for the email field.
[maurits]
86 changes: 80 additions & 6 deletions plone/schema/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@
from zope.schema.interfaces import INativeStringLine
from zope.schema.interfaces import ValidationError

import re


_ = MessageFactory("plone")

# Taken from http://www.regular-expressions.info/email.html
_isemail = r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}"
_isemail = re.compile(_isemail).match


class IEmail(INativeStringLine):
"""A field containing an email address"""
Expand All @@ -23,6 +17,78 @@ class InvalidEmail(ValidationError):
__doc__ = _("""The specified email is not valid.""")


def _isemail(value):
r"""Is this a valid email?

https://www.regular-expressions.info/email.html has some hints on how to
check for a valid email with regular expressions. It also gives reasons
for why you may *not* want to use them. A too simple regex will work for
most cases, but may give false negatives: it will treat a rare but valid
email address as invalid. A complex regex may still allow some invalid
email addresses, and is hard to debug in case of errors.

Originally we had this regex, unchanged between 2013 and 2024:

import re
_isemail = r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}"
_isemail = re.compile(_isemail).match

Problems:

1. It does not allow apostrophes.
Example: o'hara@example.org
See: https://github.com/plone/plone.schema/issues/6

2. It does not allow accented characters.
Example: jens@österreich.example.com
See: https://github.com/plone/plone.schema/issues/9

3. It does not allow ampersand in the user part.
Example: q&a@.example.com
See: https://github.com/plone/plone.schema/issues/15

4. It allows spaces.
Example: "test@aha.ok hello"
See: https://github.com/plone/plone.schema/issues/30

5. It correctly accepts TLDs with more than 4 characters,
but this seems by accident, so it could get lost in an update.
Example: me@you.online
See: https://github.com/plone/plone.schema/issues/30
"""
# We only accept a string.
if not isinstance(value, str):
return False

# It is up to the caller to strip spaces, newlines, etc.
if value != value.strip():
return False
if len(value.split()) != 1:
return False

# only one @ sign
if value.count("@") != 1:
return False

# At least one dot in the domain. And when split on dot,
# each part must not be empty.
user, domain = value.split("@")
if not all(domain.partition(".")):
Comment thread
mauritsvanrees marked this conversation as resolved.
return False

# user part must not be empty
if not user:
return False

# The maximum length of an email address that can be handled by SMTP
# is 254 characters.
if len(value) > 254:
return False

# We have found no problems.
return True


@implementer(IEmail, IFromUnicode)
class Email(NativeStringLine):
"""Email schema field"""
Expand All @@ -34,6 +100,14 @@ def _validate(self, value):

raise InvalidEmail(value)

def fromBytes(self, value):
# Originally, fromBytes was not defined.
# Upstream NativeStringLine had it, but without the 'strip'
# that we added in fromUnicode.
value = value.strip().decode("utf-8")
self.validate(value)
return value

def fromUnicode(self, value):
v = str(value.strip())
self.validate(v)
Expand Down
9 changes: 5 additions & 4 deletions plone/schema/jsonfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ def fromUnicode(self, value):

Value can be a valid JSON object:

>>> JSONField().fromUnicode('{"items": []}')
{'items': []}
JSONField().fromUnicode('{"items": []}')

or it can be a Python dict stored as string:

>>> JSONField().fromUnicode("{'items': []}")
{'items': []}
JSONField().fromUnicode("{'items': []}")

In both cases the result is:

{"items": []}
"""
try:
v = json.loads(value)
Expand Down
8 changes: 0 additions & 8 deletions plone/schema/tests/test_doctests.py

This file was deleted.

87 changes: 87 additions & 0 deletions plone/schema/tests/test_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import unittest


class TestEmail(unittest.TestCase):

def test_fromUnicode(self):
from plone.schema.email import Email
from plone.schema.email import InvalidEmail

# Value must be string
self.assertEqual(
Email().fromUnicode("arthur@example.org"),
"arthur@example.org",
)

# We strip spaces.
self.assertEqual(
Email().fromUnicode(" arthur@example.org "),
"arthur@example.org",
)

# We do some validation
with self.assertRaises(InvalidEmail):
Email().fromUnicode("arthur")
with self.assertRaises(InvalidEmail):
Email().fromUnicode("arthur@")
with self.assertRaises(InvalidEmail):
Email().fromUnicode("arthur@one")
Comment thread
mauritsvanrees marked this conversation as resolved.
with self.assertRaises(InvalidEmail):
Email().fromUnicode("@one.two")

def test_fromBytes(self):
from plone.schema.email import Email
from plone.schema.email import InvalidEmail

# Value must be bytes
self.assertEqual(
Email().fromBytes(b"arthur@example.org"),
"arthur@example.org",
)

# We strip spaces.
self.assertEqual(
Email().fromBytes(b" arthur@example.org "),
"arthur@example.org",
)

# We do some validation
with self.assertRaises(InvalidEmail):
Email().fromBytes(b"arthur@one")

def test_validation(self):
# Let's test the email validation directly, without the field.
from plone.schema.email import _isemail

# Some good:
self.assertTrue(_isemail("arthur@example.org"))

# Some bad:
self.assertFalse(_isemail(""))
self.assertFalse(_isemail(" "))
self.assertFalse(_isemail(" arthur@example.org"))
self.assertFalse(_isemail("arthur@example.org\n"))
self.assertFalse(_isemail("arthur\t@example.org"))
self.assertFalse(_isemail("arthur@one"))
Comment thread
mauritsvanrees marked this conversation as resolved.
self.assertFalse(_isemail("arthur@example@org"))
self.assertFalse(_isemail("me@.me"))
self.assertFalse(_isemail("x" * 254 + "@too.long"))

# Explicitly test some examples from the docstring,
# reported in the issue tracker.

# 1. allow apostrophes
self.assertTrue(_isemail("o'hara@example.org"))

# 2. allow accented characters
self.assertTrue(_isemail("jens@österreich.example.com"))

# 3. allow ampersand in the user part
self.assertTrue(_isemail("q&a@example.com"))

# 4. do not allows spaces.
self.assertFalse(_isemail("test@aha.ok hello"))

# 5. accept TLDs with more than 4 characters
self.assertTrue(_isemail("me@you.online"))
self.assertTrue(_isemail("me@example.museum"))
Comment thread
mauritsvanrees marked this conversation as resolved.
16 changes: 16 additions & 0 deletions plone/schema/tests/test_jsonfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import unittest


class TestJsonField(unittest.TestCase):

def test_fromUnicode(self):
from plone.schema.jsonfield import JSONField

# Value can be a valid JSON object:
self.assertDictEqual(
JSONField().fromUnicode('{"items": []}'),
{"items": []},
)

# or it can be a Python dict stored as string:
self.assertDictEqual(JSONField().fromUnicode("{'items': []}"), {"items": []})
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@
"Framework :: Zope :: 5",
"Framework :: Plone",
"Framework :: Plone :: 6.0",
"Framework :: Plone :: 6.1",
"Framework :: Plone :: Core",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: BSD License",
],
Expand Down