diff --git a/news/30.bugfix b/news/30.bugfix new file mode 100644 index 0000000..f269604 --- /dev/null +++ b/news/30.bugfix @@ -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] diff --git a/news/5.tests.1 b/news/5.tests.1 new file mode 100644 index 0000000..85a364d --- /dev/null +++ b/news/5.tests.1 @@ -0,0 +1,2 @@ +Refactor the ``jsonfield`` doctest to a simpler unit test. +[maurits] \ No newline at end of file diff --git a/news/5.tests.2 b/news/5.tests.2 new file mode 100644 index 0000000..e7f681e --- /dev/null +++ b/news/5.tests.2 @@ -0,0 +1,2 @@ +Add basic tests for the email field. +[maurits] \ No newline at end of file diff --git a/plone/schema/email.py b/plone/schema/email.py index 962f863..0233ae0 100644 --- a/plone/schema/email.py +++ b/plone/schema/email.py @@ -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""" @@ -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(".")): + 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""" @@ -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) diff --git a/plone/schema/jsonfield.py b/plone/schema/jsonfield.py index c488b1f..83a2e7b 100644 --- a/plone/schema/jsonfield.py +++ b/plone/schema/jsonfield.py @@ -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) diff --git a/plone/schema/tests/test_doctests.py b/plone/schema/tests/test_doctests.py deleted file mode 100644 index fe4d706..0000000 --- a/plone/schema/tests/test_doctests.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Tests""" - -import doctest -import unittest - - -def test_suite(): - return unittest.TestSuite((doctest.DocTestSuite("plone.schema.jsonfield"),)) diff --git a/plone/schema/tests/test_email.py b/plone/schema/tests/test_email.py new file mode 100644 index 0000000..3f500fc --- /dev/null +++ b/plone/schema/tests/test_email.py @@ -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") + 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")) + 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")) diff --git a/plone/schema/tests/test_jsonfield.py b/plone/schema/tests/test_jsonfield.py new file mode 100644 index 0000000..9721924 --- /dev/null +++ b/plone/schema/tests/test_jsonfield.py @@ -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": []}) diff --git a/setup.py b/setup.py index 2b4d068..c52b664 100644 --- a/setup.py +++ b/setup.py @@ -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", ],