diff --git a/src/openforms/formio/components/vanilla.py b/src/openforms/formio/components/vanilla.py index 19c9abba27..a886829ef8 100644 --- a/src/openforms/formio/components/vanilla.py +++ b/src/openforms/formio/components/vanilla.py @@ -30,6 +30,7 @@ from openforms.config.constants import UploadFileType from openforms.config.models import GlobalConfiguration from openforms.submissions.attachments import temporary_upload_from_url +from openforms.submissions.models import EmailVerification from openforms.typing import DataMapping from openforms.utils.urls import build_absolute_uri from openforms.validations.service import PluginValidator @@ -66,6 +67,7 @@ SelectComponent, TextFieldComponent, ) +from ..typing.base import OpenFormsConfig from .translations import translate_options from .utils import _normalize_pattern @@ -130,6 +132,27 @@ def build_serializer_field( return serializers.ListField(child=base) if multiple else base +class EmailVerificationValidator: + message = _("The email address {value} has not been verified yet.") + requires_context = True + + def __init__(self, component_key: str): + self.component_key = component_key + + def __call__(self, value: str, field: serializers.Field) -> None: + submission: Submission = field.context["submission"] + has_verification = EmailVerification.objects.filter( + submission=submission, + component_key=self.component_key, + email=value, + verified_on__isnull=False, + ).exists() + if not has_verification: + raise serializers.ValidationError( + self.message.format(value=value), code="unverified" + ) + + @register("email") class Email(BasePlugin): formatter = EmailFormatter @@ -137,9 +160,11 @@ class Email(BasePlugin): def build_serializer_field( self, component: Component ) -> serializers.EmailField | serializers.ListField: + extensions: OpenFormsConfig = component.get("openForms", {}) multiple = component.get("multiple", False) validate = component.get("validate", {}) required = validate.get("required", False) + verification_required = extensions.get("requireVerification", False) # dynamically add in more kwargs based on the component configuration extra = {} @@ -150,6 +175,9 @@ def build_serializer_field( if plugin_ids := validate.get("plugins", []): validators.append(PluginValidator(plugin_ids)) + if verification_required: + validators.append(EmailVerificationValidator(component["key"])) + if validators: extra["validators"] = validators diff --git a/src/openforms/formio/tests/validation/test_email.py b/src/openforms/formio/tests/validation/test_email.py index e44b5e8e38..6b76f94e1d 100644 --- a/src/openforms/formio/tests/validation/test_email.py +++ b/src/openforms/formio/tests/validation/test_email.py @@ -1,7 +1,11 @@ -from django.test import SimpleTestCase +from django.test import SimpleTestCase, TestCase from rest_framework import serializers +from openforms.submissions.tests.factories import ( + EmailVerificationFactory, + SubmissionFactory, +) from openforms.typing import JSONValue from openforms.validations.base import BasePlugin @@ -94,3 +98,64 @@ def test_email_with_plugin_validator(self): ) self.assertFalse(is_valid) + + +class EmailValidationDBTests(TestCase): + def test_require_email_verification(self): + submission = SubmissionFactory.create() + # create some *unrelated* verification records + ev = EmailVerificationFactory.create( + component_key="foo", email="foobar@example.com", verified=True + ) + assert ev.submission != submission + EmailVerificationFactory( + submission=submission, + component_key="foo", + email="foobar@example.com", + verified=False, + ) + EmailVerificationFactory( + submission=submission, + component_key="bar", + email="foobar@example.com", + verified=True, + ) + EmailVerificationFactory( + submission=submission, + component_key="foo", + email="other@example.com", + verified=True, + ) + + component: Component = { + "type": "email", + "key": "foo", + "label": "Test", + "openForms": { + "requireVerification": True, + }, + } + data: JSONValue = {"foo": "foobar@example.com"} + + with self.subTest("not verified"): + is_valid, errors = validate_formio_data( + component, data, submission=submission + ) + + self.assertFalse(is_valid) + error = extract_error(errors, "foo") + self.assertEqual(error.code, "unverified") + + with self.subTest("verified"): + # multiple successful verifications should not be a problem + EmailVerificationFactory.create_batch( + 2, + submission=submission, + component_key="foo", + email="foobar@example.com", + verified=True, + ) + + is_valid, _ = validate_formio_data(component, data, submission=submission) + + self.assertTrue(is_valid) diff --git a/src/openforms/formio/typing/base.py b/src/openforms/formio/typing/base.py index 7e1fe42a8e..c1d0be18f1 100644 --- a/src/openforms/formio/typing/base.py +++ b/src/openforms/formio/typing/base.py @@ -43,6 +43,7 @@ class OpenFormsConfig(TypedDict): maxDate: NotRequired[DateConstraintConfiguration | None] translations: NotRequired[TranslationsDict] components: NotRequired[AddressValidationComponents] + requireVerification: NotRequired[bool] class OpenFormsOptionExtension(TypedDict): diff --git a/src/openforms/js/lang/formio/nl.json b/src/openforms/js/lang/formio/nl.json index f93f0ba069..e0a9815189 100644 --- a/src/openforms/js/lang/formio/nl.json +++ b/src/openforms/js/lang/formio/nl.json @@ -401,5 +401,7 @@ "{{ labels }} or {{ lastLabel }}": "{{ labels }} of {{ lastLabel }}", "Note that any family member without a BSN will not be displayed.": "Let op, gezinsleden zonder BSN worden niet getoond.", "invalidDatetime": "Ongeldige datum/tijd", + "Verify": "Bevestigen", + "You must verify this email address to continue.": "Om door te gaan moet je dit e-mailadres bevestigen.", "": "" }