diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index b09003fddf..09ce4adf38 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -5457,6 +5457,12 @@ "value": "the variable" } ], + "mOh/pF": [ + { + "type": 0, + "value": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option." + } + ], "mOjKcm": [ { "type": 0, @@ -6273,6 +6279,12 @@ "value": "Do not display the configured label and top line as the header in the fieldset." } ], + "uwCD8e": [ + { + "type": 0, + "value": "Using a variable to decide to which email address the submission details will be sent" + } + ], "vAZDY4": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index 6e9966d2f5..95992ff191 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -5475,6 +5475,12 @@ "value": "de variabele" } ], + "mOh/pF": [ + { + "type": 0, + "value": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option." + } + ], "mOjKcm": [ { "type": 0, @@ -6291,6 +6297,12 @@ "value": "Verberg de koptekst en de lijn boven de veldengroep in het formulier." } ], + "uwCD8e": [ + { + "type": 0, + "value": "Using a variable to decide to which email address the submission details will be sent" + } + ], "vAZDY4": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js index fb6a8d8608..3871a0c8d8 100644 --- a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js +++ b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsForm.js @@ -51,6 +51,7 @@ EmailOptionsForm.propTypes = { emailSubject: PropTypes.string, paymentEmails: PropTypes.arrayOf(PropTypes.string), toEmails: PropTypes.arrayOf(PropTypes.string), + toEmailsFromVariable: PropTypes.string, }), onChange: PropTypes.func.isRequired, }; diff --git a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js index ae83aadfbd..d6692f05b3 100644 --- a/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js +++ b/src/openforms/js/components/admin/form_design/registrations/email/EmailOptionsFormFields.js @@ -17,6 +17,7 @@ import EmailHasAttachmentSelect from './fields/EmailHasAttachmentSelect'; import EmailPaymentSubject from './fields/EmailPaymentSubject'; import EmailPaymentUpdateRecipients from './fields/EmailPaymentUpdateRecipients'; import EmailRecipients from './fields/EmailRecipients'; +import EmailRecipientsFromVariable from './fields/EmailRecipientsFromVariable'; import EmailSubject from './fields/EmailSubject'; const EmailOptionsFormFields = ({name, schema}) => { @@ -37,6 +38,7 @@ const EmailOptionsFormFields = ({name, schema}) => {
+ diff --git a/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipientsFromVariable.js b/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipientsFromVariable.js new file mode 100644 index 0000000000..565092b7a6 --- /dev/null +++ b/src/openforms/js/components/admin/form_design/registrations/email/fields/EmailRecipientsFromVariable.js @@ -0,0 +1,46 @@ +import {useField} from 'formik'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Field from 'components/admin/forms/Field'; +import FormRow from 'components/admin/forms/FormRow'; +import VariableSelection from 'components/admin/forms/VariableSelection'; + +const EmailRecipientsFromVariable = () => { + const [fieldProps, , fieldHelpers] = useField('toEmailsFromVariable'); + const {setValue} = fieldHelpers; + return ( + + + } + helpText={ + + } + > + { + setValue(event.target.value); + }} + /> + + + ); +}; + +EmailRecipientsFromVariable.propTypes = {}; + +export default EmailRecipientsFromVariable; diff --git a/src/openforms/js/lang/en.json b/src/openforms/js/lang/en.json index 9ec99111ef..6542ac71fb 100644 --- a/src/openforms/js/lang/en.json +++ b/src/openforms/js/lang/en.json @@ -2524,6 +2524,11 @@ "description": "\"variable\" operand type", "originalDefault": "the variable" }, + "mOh/pF": { + "defaultMessage": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option.", + "description": "Email registration options 'toEmailsFromVariable' helpText", + "originalDefault": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option." + }, "mOjKcm": { "defaultMessage": "Steps and fields", "description": "Form design tab title", @@ -2914,6 +2919,11 @@ "description": "History link button", "originalDefault": "History" }, + "uwCD8e": { + "defaultMessage": "Using a variable to decide to which email address the submission details will be sent", + "description": "Email registration options 'toEmailsFromVariable' label", + "originalDefault": "Using a variable to decide to which email address the submission details will be sent" + }, "vAZDY4": { "defaultMessage": "Source path", "description": "Prefill / Objects API: column header for object type property selection", diff --git a/src/openforms/js/lang/nl.json b/src/openforms/js/lang/nl.json index 28136b3ddd..697caa3cfe 100644 --- a/src/openforms/js/lang/nl.json +++ b/src/openforms/js/lang/nl.json @@ -2545,6 +2545,11 @@ "description": "\"variable\" operand type", "originalDefault": "the variable" }, + "mOh/pF": { + "defaultMessage": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option.", + "description": "Email registration options 'toEmailsFromVariable' helpText", + "originalDefault": "The email address described in this variable will be used for the mailing. If a variable is selected, the general registration addresses will be used as fallback option." + }, "mOjKcm": { "defaultMessage": "Stappen en velden", "description": "Form design tab title", @@ -2935,6 +2940,11 @@ "description": "History link button", "originalDefault": "History" }, + "uwCD8e": { + "defaultMessage": "Using a variable to decide to which email address the submission details will be sent", + "description": "Email registration options 'toEmailsFromVariable' label", + "originalDefault": "Using a variable to decide to which email address the submission details will be sent" + }, "vAZDY4": { "defaultMessage": "Bronpad", "description": "Prefill / Objects API: column header for object type property selection", diff --git a/src/openforms/registrations/contrib/email/config.py b/src/openforms/registrations/contrib/email/config.py index 7cda616e80..f309d2a8a3 100644 --- a/src/openforms/registrations/contrib/email/config.py +++ b/src/openforms/registrations/contrib/email/config.py @@ -13,11 +13,40 @@ from .constants import AttachmentFormat +class Options(TypedDict): + """ + Shape of the email registration plugin options. + + This describes the shape of :attr:`EmailOptionsSerializer.validated_data`, after + the input data has been cleaned/validated. + """ + + to_emails: NotRequired[list[str]] + to_emails_from_variable: NotRequired[str] + attachment_formats: NotRequired[list[AttachmentFormat | str]] + payment_emails: NotRequired[list[str]] + attach_files_to_email: bool | None + email_subject: NotRequired[str] + email_payment_subject: NotRequired[str] + email_content_template_html: NotRequired[str] + email_content_template_text: NotRequired[str] + + class EmailOptionsSerializer(JsonSchemaSerializerMixin, serializers.Serializer): to_emails = serializers.ListField( child=serializers.EmailField(), label=_("The email addresses to which the submission details will be sent"), - required=True, + required=False, # Either to_emails or to_emails_from_variable should be required + ) + to_emails_from_variable = serializers.CharField( + label=_("Key of the target variable containing the email address"), + required=False, + allow_blank=True, + help_text=_( + "Key of the target variable whose value will be used for the mailing. " + "When using this field, the mailing will only be send to this email address. " + "The email addresses field would then be ignored. " + ), ) attachment_formats = serializers.ListField( child=serializers.ChoiceField(choices=AttachmentFormat.choices), @@ -99,23 +128,19 @@ class Meta: ), ] + def validate(self, attrs: Options) -> Options: + # The email registration requires either `to_emails` or `to_emails_from_variable` + # to determine which email address to use. + # Both may be set - in that case, `to_emails_from_variable` is preferred. + if not attrs.get("to_emails") and not attrs.get("to_emails_from_variable"): + raise serializers.ValidationError( + { + "to_emails": _("This field is required."), + }, + code="required", + ) -class Options(TypedDict): - """ - Shape of the email registration plugin options. - - This describes the shape of :attr:`EmailOptionsSerializer.validated_data`, after - the input data has been cleaned/validated. - """ - - to_emails: list[str] - attachment_formats: NotRequired[list[AttachmentFormat | str]] - payment_emails: NotRequired[list[str]] - attach_files_to_email: bool | None - email_subject: NotRequired[str] - email_payment_subject: NotRequired[str] - email_content_template_html: NotRequired[str] - email_content_template_text: NotRequired[str] + return attrs # sanity check for development - keep serializer and type definitions in sync diff --git a/src/openforms/registrations/contrib/email/plugin.py b/src/openforms/registrations/contrib/email/plugin.py index 1e0e7b6e69..92679273ab 100644 --- a/src/openforms/registrations/contrib/email/plugin.py +++ b/src/openforms/registrations/contrib/email/plugin.py @@ -3,6 +3,8 @@ from typing import Any from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.urls import reverse from django.utils import timezone from django.utils.translation import get_language_info, gettext_lazy as _ @@ -42,11 +44,41 @@ class EmailRegistration(BasePlugin[Options]): verbose_name = _("Email registration") configuration_options = EmailOptionsSerializer + def get_recipients(self, submission: Submission, options: Options) -> list[str]: + state = submission.load_submission_value_variables_state() + recipients = [] + + # If the 'recipients from variable' is used and it exists + if ( + (variable_key := options.get("to_emails_from_variable")) + and variable_key in state.variables + and (variable_value := state.variables[variable_key].value) + ): + # To simplify things, treat all variable values as lists + if type(variable_value) != list: + variable_value = [variable_value] + + # Only if all email addresses are valid, they will be used as recipients + try: + for value in variable_value: + validate_email(value) + except ValidationError: + pass + else: + recipients = variable_value + + # If the variable cannot be used, fallback to the general email addresses + if not recipients and "to_emails" in options: + recipients = options["to_emails"] + + return recipients + def register_submission(self, submission: Submission, options: Options) -> None: config = EmailConfig.get_solo() config.apply_defaults_to(options) - self.send_registration_email(options["to_emails"], submission, options) + recipients = self.get_recipients(submission, options) + self.send_registration_email(recipients, submission, options) # ensure that the payment email is also sent if registration is deferred until # payment is completed @@ -187,8 +219,9 @@ def send_registration_email( def update_payment_status(self, submission: "Submission", options: Options): recipients = options.get("payment_emails") + if not recipients: - recipients = options["to_emails"] + recipients = self.get_recipients(submission, options) order_ids = submission.payments.get_completed_public_order_ids() extra_context = { diff --git a/src/openforms/registrations/contrib/email/tests/test_backend.py b/src/openforms/registrations/contrib/email/tests/test_backend.py index 3fd74b4a4a..ceaee381c6 100644 --- a/src/openforms/registrations/contrib/email/tests/test_backend.py +++ b/src/openforms/registrations/contrib/email/tests/test_backend.py @@ -249,6 +249,158 @@ def test_submission_with_email_backend(self): self.assertIn(f"{expected_download_url_1} (my-foo.bin)", message_text) self.assertIn(f"{expected_download_url_2} (my-bar.txt)", message_text) + def test_submission_with_email_backend_using_to_emails_from_variable(self): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[ + {"key": "foo", "type": "textfield", "label": "foo"}, + ], + submitted_data={"foo": "bar"}, + form__registration_backend="email", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value="foo@example.com", + ) + email_form_options = dict( + to_emails_from_variable="email_recipient_variable", + ) + email_submission = EmailRegistration("email") + + set_submission_reference(submission) + + with patch("openforms.registrations.contrib.email.utils.EmailConfig.get_solo"): + email_submission.register_submission(submission, email_form_options) + + # Verify that email was sent + self.assertEqual(len(mail.outbox), 1) + + message = mail.outbox[0] + self.assertEqual(message.to, ["foo@example.com"]) + + def test_submission_with_email_backend_using_to_emails_from_variable_with_multiple_email_addresses( + self, + ): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[ + {"key": "foo", "type": "textfield", "label": "foo"}, + ], + submitted_data={"foo": "bar"}, + form__registration_backend="email", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value=["foo@example.com", "bar@example.com"], + ) + email_form_options = dict( + to_emails_from_variable="email_recipient_variable", + ) + email_submission = EmailRegistration("email") + + set_submission_reference(submission) + + with patch("openforms.registrations.contrib.email.utils.EmailConfig.get_solo"): + email_submission.register_submission(submission, email_form_options) + + # Verify that email was sent + self.assertEqual(len(mail.outbox), 1) + + message = mail.outbox[0] + self.assertEqual(message.to, ["foo@example.com", "bar@example.com"]) + + def test_submission_with_email_backend_unknown_to_emails_from_variable(self): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[ + {"key": "foo", "type": "textfield", "label": "foo"}, + ], + submitted_data={"foo": "bar"}, + form__registration_backend="email", + ) + email_form_options = dict( + to_emails_from_variable="email_recipient_variable", + ) + email_submission = EmailRegistration("email") + + set_submission_reference(submission) + + with patch("openforms.registrations.contrib.email.utils.EmailConfig.get_solo"): + email_submission.register_submission(submission, email_form_options) + + # Verify that email wasn't sent + self.assertEqual(len(mail.outbox), 0) + + def test_submission_with_email_backend_invalid_to_emails_from_variable(self): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[ + {"key": "foo", "type": "textfield", "label": "foo"}, + ], + submitted_data={"foo": "bar"}, + form__registration_backend="email", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value="foo.com", + ) + email_form_options = dict( + to_emails_from_variable="email_recipient_variable", + ) + email_submission = EmailRegistration("email") + + set_submission_reference(submission) + + with patch("openforms.registrations.contrib.email.utils.EmailConfig.get_solo"): + email_submission.register_submission(submission, email_form_options) + + # Verify that email wasn't sent + self.assertEqual(len(mail.outbox), 0) + + def test_submission_with_email_backend_invalid_to_emails_from_variable_with_fallback( + self, + ): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[ + {"key": "foo", "type": "textfield", "label": "foo"}, + ], + submitted_data={"foo": "bar"}, + form__registration_backend="email", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value="foo.com", + ) + email_form_options = dict( + to_emails_from_variable="email_recipient_variable", + to_emails=["bar@example.com"], + ) + email_submission = EmailRegistration("email") + + set_submission_reference(submission) + + with patch("openforms.registrations.contrib.email.utils.EmailConfig.get_solo"): + email_submission.register_submission(submission, email_form_options) + + # Verify that email was sent + self.assertEqual(len(mail.outbox), 1) + + message = mail.outbox[0] + self.assertEqual(message.to, ["bar@example.com"]) + def test_submission_with_email_backend_strip_out_urls(self): config = GlobalConfiguration.get_solo() config.email_template_netloc_allowlist = [] @@ -477,6 +629,71 @@ def test_register_and_update_paid_product_with_payment_email_recipient(self): # check we used the payment_emails self.assertEqual(message.to, ["payment@bar.nl", "payment@foo.nl"]) + def test_register_and_update_paid_product_with_payment_email_recipient_and_variable_email_recipient( + self, + ): + submission = SubmissionFactory.from_data( + {"voornaam": "Foo"}, + form__product__price=Decimal("11.35"), + form__payment_backend="demo", + registration_success=True, + public_registration_reference="XYZ", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value="foo@example.com", + ) + + email_form_options = dict( + to_emails=["foo@bar.nl", "bar@foo.nl"], + to_emails_from_variable="email_recipient_variable", + # payment_emails would override to_emails and to_emails_from_variable + payment_emails=["payment@bar.nl", "payment@foo.nl"], + ) + email_submission = EmailRegistration("email") + email_submission.update_payment_status(submission, email_form_options) + + self.assertEqual(len(mail.outbox), 1) + + message = mail.outbox[0] + # check we used the payment_emails + self.assertEqual(message.to, ["payment@bar.nl", "payment@foo.nl"]) + + def test_register_and_update_paid_product_with_variable_email_recipient( + self, + ): + submission = SubmissionFactory.from_data( + {"voornaam": "Foo"}, + form__product__price=Decimal("11.35"), + form__payment_backend="demo", + registration_success=True, + public_registration_reference="XYZ", + ) + SubmissionValueVariableFactory.create( + form_variable__source=FormVariableSources.user_defined, + form_variable__name="User defined var 1", + submission=submission, + key="email_recipient_variable", + value="foo@example.com", + ) + + email_form_options = dict( + to_emails=["foo@bar.nl", "bar@foo.nl"], + # to_emails_from_variable would override to_emails + to_emails_from_variable="email_recipient_variable", + ) + email_submission = EmailRegistration("email") + email_submission.update_payment_status(submission, email_form_options) + + self.assertEqual(len(mail.outbox), 1) + + message = mail.outbox[0] + # check we used the payment_emails + self.assertEqual(message.to, ["foo@example.com"]) + @override_settings(DEFAULT_FROM_EMAIL="info@open-forms.nl") def test_submission_with_email_backend_export_csv_xlsx(self): email_form_options = dict( diff --git a/src/openforms/registrations/tests/test_registration_hook.py b/src/openforms/registrations/tests/test_registration_hook.py index 65791261ec..201130f7c5 100644 --- a/src/openforms/registrations/tests/test_registration_hook.py +++ b/src/openforms/registrations/tests/test_registration_hook.py @@ -312,7 +312,7 @@ def test_registration_backend_invalid_options(self): completed=True, form__registration_backend="email", form__registration_backend_options={}, - ) # Missing "to_email" option + ) # Missing "to_emails" and "to_emails_from_variable" option with ( self.subTest("On completion - does NOT raise"), @@ -334,6 +334,48 @@ def test_registration_backend_invalid_options(self): ): register_submission(submission.id, PostSubmissionEvents.on_retry) + def test_email_registration_backend_with_to_emails(self): + submission = SubmissionFactory.create( + completed=True, + form__registration_backend="email", + form__registration_backend_options={"to_emails": ["foo@example.com"]}, + ) + + with ( + self.subTest("On completion - logs as successful"), + self.assertLogs(level="INFO") as logs, + ): + register_submission(submission.id, PostSubmissionEvents.on_completion) + + submission.refresh_from_db() + self.assertIn( + "Registration using plugin '%r' for submission '%s' succeeded", + logs.records[-1].msg, + ) + self.assertFalse(submission.needs_on_completion_retry) + + def test_email_registration_backend_with_to_emails_from_variable(self): + submission = SubmissionFactory.create( + completed=True, + form__registration_backend="email", + form__registration_backend_options={ + "to_emails_from_variable": "email_example_variable" + }, + ) + + with ( + self.subTest("On completion - logs as successful"), + self.assertLogs(level="INFO") as logs, + ): + register_submission(submission.id, PostSubmissionEvents.on_completion) + + submission.refresh_from_db() + self.assertIn( + "Registration using plugin '%r' for submission '%s' succeeded", + logs.records[-1].msg, + ) + self.assertFalse(submission.needs_on_completion_retry) + def test_calling_registration_task_with_serialized_args(self): submission = SubmissionFactory.create( completed=True,