\n"
for record in self.records:
factory = properties[record["field_id"]].get("factory", "")
- value = format_property(factory, record["value"])
+ value = utils.format_property(factory, record["value"])
template_vars[record["field_id"]] = value
if factory == "hidden" and not admin:
@@ -169,7 +86,7 @@ def format_property(factory, value):
)
form_fields += "\n
\n"
template_vars["form_fields"] = form_fields
- message = substitute_variables(template, template_vars)
+ message = utils.substitute_variables(template, template_vars)
return message
def send_mail(self, msg: EmailMessage, charset: str) -> None:
@@ -178,7 +95,9 @@ def send_mail(self, msg: EmailMessage, charset: str) -> None:
# by default (False) exceptions are handled by MailHost and we can't catch them.
host.send(msg, charset=charset, immediate=True)
- def _prepare_msg_admin(self, mfrom: str, mto: str, subject: str) -> EmailMessage:
+ def _prepare_msg_admin(
+ self, mfrom: str, mto: str, subject: str, bcc: str
+ ) -> EmailMessage:
body = self.prepare_message(True)
body_text = self._body_as_plain_text(body)
@@ -189,20 +108,20 @@ def _prepare_msg_admin(self, mfrom: str, mto: str, subject: str) -> EmailMessage
if header_value:
headers[header] = header_value
- return create_message(
+ return utils.create_message(
mfrom=mfrom,
mto=mto,
subject=subject,
body=body,
body_txt=body_text,
reply_to=mfrom,
- bcc=self.get_bcc(),
+ bcc=bcc,
headers=headers,
attachments=self.attachments,
)
def _prepare_msg_confirmation(
- self, mfrom: str, mto: str, subject: str
+ self, mfrom: str, mto: str, subject: str, bcc: str = ""
) -> EmailMessage:
body = self.prepare_message()
body_text = self._body_as_plain_text(body)
@@ -211,38 +130,43 @@ def _prepare_msg_confirmation(
if self.block.get("fixed_attachment"):
attachments["fixed_attachment"] = self.block["fixed_attachment"]
- return create_message(
+ return utils.create_message(
mfrom=mfrom,
mto=mto,
subject=subject,
body=body,
body_txt=body_text,
reply_to="",
+ bcc=bcc,
headers={},
attachments=attachments,
)
def __call__(self) -> None:
queued: list[EmailMessage] = []
- send_to_admin = bool(self.block.get("send"))
- send_confirmation = bool(self.block.get("send_confirmation"))
+ block = self.block
+ addresses = self.addresses
+ send_to_admin = bool(block.get("send"))
+ send_confirmation = bool(block.get("send_confirmation"))
if not send_to_admin and not send_confirmation:
return
- if not is_mailhost_configured():
+ if not utils.is_mailhost_configured():
raise BadRequest("MailHost is not configured.")
subject = self.get_subject()
- mfrom = self.get_sender()
+ mfrom = addresses.sender
if send_to_admin:
- mto = self.block.get("recipients", self.mail_settings.email_from_address)
- msg = self._prepare_msg_admin(mfrom, mto, subject)
+ mto = addresses.admin_recipients
+ bcc = addresses.bcc
+ msg = self._prepare_msg_admin(mfrom, mto, subject, bcc)
queued.append(msg)
- if send_confirmation and (mto := self.get_confirmation_recipients()):
+ if send_confirmation and (mto := addresses.confirmation_recipients.strip()):
msg = self._prepare_msg_confirmation(mfrom, mto, subject)
queued.append(msg)
+ charset = api.portal.get_registry_record("plone.email_charset", default="utf-8")
for msg in queued:
- self.send_mail(msg=msg, charset=self.charset)
+ self.send_mail(msg=msg, charset=charset)
diff --git a/backend/src/plone/formblock/profiles/default/metadata.xml b/backend/src/plone/formblock/profiles/default/metadata.xml
index 2d4b5fc..fa3beaa 100644
--- a/backend/src/plone/formblock/profiles/default/metadata.xml
+++ b/backend/src/plone/formblock/profiles/default/metadata.xml
@@ -1,6 +1,6 @@
- 1000
+ 1001profile-plone.restapi:defaultprofile-plone.volto:default
diff --git a/backend/src/plone/formblock/restapi/services/submit_form/post.py b/backend/src/plone/formblock/restapi/services/submit_form/post.py
index 87b7d89..3a39896 100644
--- a/backend/src/plone/formblock/restapi/services/submit_form/post.py
+++ b/backend/src/plone/formblock/restapi/services/submit_form/post.py
@@ -6,6 +6,7 @@
from plone.formblock.restapi.services.base import BaseService
from plone.formblock.utils import fix_block_schema
from plone.formblock.utils import get_blocks
+from plone.formblock.utils.email import addresses_from_block
from plone.protect.interfaces import IDisableCSRFProtection
from plone.restapi.deserializer import json_body
from zExceptions import BadRequest
@@ -174,18 +175,19 @@ def _process_post(self) -> None:
self.block_id = ""
self.block = {}
schema = {}
-
if block_id := body.get("block_id", ""):
self.block_id = block_id
self.block = self.get_block_data(block_id=block_id)
schema = self.block.get("schema", {})
self.form_data = self.cleanup_data(schema, body.get("data", {}))
+ addresses = addresses_from_block(self.block, self.form_data)
self.submission_context = FormSubmissionContext(
context=self.context,
request=self.request,
block=self.block,
form_data=self.form_data,
+ addresses=addresses,
)
def reply(self) -> dict:
diff --git a/backend/src/plone/formblock/testing/__init__.py b/backend/src/plone/formblock/testing/__init__.py
index 09e1aa0..dbd6e26 100644
--- a/backend/src/plone/formblock/testing/__init__.py
+++ b/backend/src/plone/formblock/testing/__init__.py
@@ -1,4 +1,3 @@
-from plone import api
from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE
from plone.app.testing import applyProfile
from plone.app.testing import FunctionalTesting
@@ -24,16 +23,10 @@ def setUpZope(self, app, configurationContext):
def setUpPloneSite(self, portal):
applyProfile(portal, "plone.restapi:blocks")
applyProfile(portal, "plone.formblock:default")
+ applyProfile(portal, "plone.formblock.testing:testing")
quickInstallProduct(portal, "collective.MockMailHost")
applyProfile(portal, "collective.MockMailHost:default")
- # Set the email from address and name to avoid validation errors
- # when sending emails
- api.portal.set_registry_record(
- "plone.email_from_address", "site_addr@plone.com"
- )
- api.portal.set_registry_record("plone.email_from_name", "Plone test site")
-
# Mock the validate email token function
def validate_email_token_mock(*args, **kwargs):
return True
diff --git a/backend/src/plone/formblock/testing/configure.zcml b/backend/src/plone/formblock/testing/configure.zcml
index a3196c2..633cdf3 100644
--- a/backend/src/plone/formblock/testing/configure.zcml
+++ b/backend/src/plone/formblock/testing/configure.zcml
@@ -1,4 +1,7 @@
-
+
@@ -7,4 +10,13 @@
for="plone.formblock.interfaces.IPostEvent"
handler="plone.formblock.testing.event_handler.event_handler"
/>
+
+
+
diff --git a/backend/src/plone/formblock/testing/profiles/testing/actions.xml b/backend/src/plone/formblock/testing/profiles/testing/actions.xml
new file mode 100644
index 0000000..45453ac
--- /dev/null
+++ b/backend/src/plone/formblock/testing/profiles/testing/actions.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/backend/src/plone/formblock/testing/profiles/testing/metadata.xml b/backend/src/plone/formblock/testing/profiles/testing/metadata.xml
new file mode 100644
index 0000000..76bffff
--- /dev/null
+++ b/backend/src/plone/formblock/testing/profiles/testing/metadata.xml
@@ -0,0 +1,9 @@
+
+
+ 1000
+
+ profile-collective.z3cform.norobots:default
+ profile-plone.formwidget.hcaptcha:default
+ profile-plone.formwidget.recaptcha:default
+
+
diff --git a/backend/src/plone/formblock/testing/profiles/testing/registry/plone.base.interfaces.controlpanel.IMailSchema.xml b/backend/src/plone/formblock/testing/profiles/testing/registry/plone.base.interfaces.controlpanel.IMailSchema.xml
new file mode 100644
index 0000000..f5e8e40
--- /dev/null
+++ b/backend/src/plone/formblock/testing/profiles/testing/registry/plone.base.interfaces.controlpanel.IMailSchema.xml
@@ -0,0 +1,12 @@
+
+
+
+ utf-8
+ site_addr@plone.com
+ Plone test site
+ localhost
+ 25
+
+
diff --git a/backend/src/plone/formblock/testing/profiles/testing/registry/plone.formblock.interfaces.IFormSettings.xml b/backend/src/plone/formblock/testing/profiles/testing/registry/plone.formblock.interfaces.IFormSettings.xml
new file mode 100644
index 0000000..15564be
--- /dev/null
+++ b/backend/src/plone/formblock/testing/profiles/testing/registry/plone.formblock.interfaces.IFormSettings.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ {'default': '\n${mail_header}\n<hr />\n${form_fields}\n<hr />\n${mail_footer}\n'}
+
+
diff --git a/backend/src/plone/formblock/upgrades/__init__.py b/backend/src/plone/formblock/upgrades/__init__.py
index e69de29..a2d3755 100644
--- a/backend/src/plone/formblock/upgrades/__init__.py
+++ b/backend/src/plone/formblock/upgrades/__init__.py
@@ -0,0 +1,19 @@
+from plone import api
+from plone.formblock import logger
+from plone.formblock.interfaces import DEFAULT_TEMPLATE
+from Products.GenericSetup.tool import SetupTool
+
+
+def migrate_mail_templates(context: SetupTool) -> None:
+ """Migrate mail templates from the old registry record to the new one."""
+ default_templates = {"default": DEFAULT_TEMPLATE}
+ old_templates: dict[str, str] = api.portal.get_registry_record(
+ "schemaform.mail_templates", default=default_templates
+ )
+ api.portal.set_registry_record("schemaform.mail_templates_json", old_templates)
+ # Set the old registry record to an empty dict to avoid confusion
+ api.portal.set_registry_record("schemaform.mail_templates", {})
+ logger.info(
+ "Migrated mail templates from 'schemaform.mail_templates' to "
+ "'schemaform.mail_templates_json'."
+ )
diff --git a/backend/src/plone/formblock/upgrades/configure.zcml b/backend/src/plone/formblock/upgrades/configure.zcml
index 56cc451..45915e9 100644
--- a/backend/src/plone/formblock/upgrades/configure.zcml
+++ b/backend/src/plone/formblock/upgrades/configure.zcml
@@ -3,6 +3,21 @@
xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
>
+
+
+
+
+
diff --git a/backend/src/plone/formblock/utils/email.py b/backend/src/plone/formblock/utils/email.py
index 6f75e46..6424908 100644
--- a/backend/src/plone/formblock/utils/email.py
+++ b/backend/src/plone/formblock/utils/email.py
@@ -1,13 +1,104 @@
from email import policy
from email.message import EmailMessage
+from email.utils import formataddr
+from plone import api
+from plone.formblock import _
+from plone.formblock.interfaces import AddressesFromBlock
+from plone.formblock.interfaces import DEFAULT_TEMPLATE
+from plone.formblock.interfaces import SchemaFormBlock
import codecs
import os
+import re
CTE = os.environ.get("MAIL_CONTENT_TRANSFER_ENCODING", None)
+def is_mailhost_configured() -> bool:
+ """Check if MailHost is configured with SMTP host and email from address."""
+ smtp_host = api.portal.get_registry_record("plone.smtp_host")
+ email_from_address = api.portal.get_registry_record("plone.email_from_address")
+ return bool(smtp_host and email_from_address)
+
+
+def available_templates() -> dict[str, str]:
+ """Return a dictionary of available email templates."""
+ templates: dict[str, str] = api.portal.get_registry_record(
+ "schemaform.mail_templates_json"
+ )
+ return templates or {}
+
+
+def get_template_from_block(block: SchemaFormBlock) -> str:
+ """Return the email template for the given template name."""
+ templates: dict[str, str] = available_templates()
+ template_name = block.get("mail_template", "default")
+ return templates.get(template_name) or DEFAULT_TEMPLATE
+
+
+def substitute_variables(
+ value: str, context: dict | None = None, form_data: dict | None = None
+) -> str:
+ """Substitute variables in the form ${variable_name} with values from the context
+ or form_data.
+ """
+ if context is None and form_data is not None:
+ context = form_data
+ elif context is None:
+ context = {}
+
+ def replace(match):
+ name = match.group(1)
+ return context.get(name, "")
+
+ pattern = r"\$\{([^}]+)\}"
+ return re.sub(pattern, replace, value)
+
+
+def format_property(factory: str, value: str | bool | list | None) -> str:
+ response = str(value)
+ if factory == "label_boolean_field" or factory == "termsAccepted":
+ response = (
+ api.portal.translate(_("Yes"))
+ if value is True
+ else api.portal.translate(_("No"))
+ )
+ elif factory == "checkbox_group" and isinstance(value, list):
+ response = " ".join(value)
+ elif factory in ("label_date_field", "label_datetime_field"):
+ util = api.portal.get_tool("translation_service")
+ response = util.toLocalizedTime(
+ value, long_format=factory == "label_datetime_field"
+ )
+ return response
+
+
+def addresses_from_block(block: SchemaFormBlock, form_data: dict) -> AddressesFromBlock:
+ """Extract addresses from a form block, substituting variables if necessary."""
+ default_addr: str = api.portal.get_registry_record("plone.email_from_address")
+ default_name: str = api.portal.get_registry_record("plone.email_from_name")
+ admin_recipients: str = block.get("recipients", default_addr)
+ _sender = (
+ substitute_variables(block.get("sender", ""), context=form_data) or default_addr
+ )
+ _sender_name = (
+ substitute_variables(block.get("sender_name", ""), context=form_data)
+ or default_name
+ )
+ sender: str = formataddr((_sender_name, _sender))
+ bcc: str = substitute_variables(block.get("bcc", ""), context=form_data) or ""
+ rcpts: str = substitute_variables(
+ block.get("confirmation_recipients", ""), context=form_data
+ )
+ return AddressesFromBlock(
+ sender=sender,
+ admin_recipients=admin_recipients,
+ confirmation_recipients=rcpts,
+ bcc=bcc,
+ )
+
+
def add_attachaments_to_msg(
msg: EmailMessage, attachments: dict[str, dict | str]
) -> None:
diff --git a/backend/src/plone/formblock/vocabularies/templates.py b/backend/src/plone/formblock/vocabularies/templates.py
index 40a518d..39fd3cd 100644
--- a/backend/src/plone/formblock/vocabularies/templates.py
+++ b/backend/src/plone/formblock/vocabularies/templates.py
@@ -5,8 +5,8 @@
@provider(IVocabularyFactory)
-def mail_templates_vocabulary_factory(context):
- name = "schemaform.mail_templates"
+def mail_templates_vocabulary_factory(context) -> SimpleVocabulary:
+ name: str = "schemaform.mail_templates_json"
registry_record_value = api.portal.get_registry_record(name)
items = list(registry_record_value.keys())
return SimpleVocabulary.fromItems([[item, item, item] for item in items])
diff --git a/backend/tests/functional/captcha/test_vocabulary.py b/backend/tests/functional/captcha/test_vocabulary.py
index ecdfcf8..7b80636 100644
--- a/backend/tests/functional/captcha/test_vocabulary.py
+++ b/backend/tests/functional/captcha/test_vocabulary.py
@@ -38,15 +38,19 @@ def test_no_adapters(self, manager_request):
# no adapters configured
assert data["@id"].endswith(self.endpoint)
- # honeypot is always active if it's in buildout
- assert data["items_total"] == 1
- assert data["items"] == [{"title": "Honeypot Support", "token": "honeypot"}]
+ assert data["items_total"] == 2
+ assert data["items"][0] == {
+ "title": "NoRobots ReCaptcha Support",
+ "token": "norobots-captcha",
+ }
+ assert data["items"][1] == {"title": "Honeypot Support", "token": "honeypot"}
@pytest.mark.parametrize(
"token,title",
[
["recaptcha", "Google ReCaptcha"],
["hcaptcha", "HCaptcha"],
+ ["norobots-captcha", "NoRobots ReCaptcha Support"],
],
)
def test_adapters(self, manager_request, configure_captcha_adapters, token, title):
diff --git a/backend/tests/setup/test_setup_install.py b/backend/tests/setup/test_setup_install.py
index 8ee8cae..7bb7a13 100644
--- a/backend/tests/setup/test_setup_install.py
+++ b/backend/tests/setup/test_setup_install.py
@@ -14,4 +14,4 @@ def test_browserlayer(self, browser_layers):
def test_latest_version(self, profile_last_version):
"""Test latest version of default profile."""
- assert profile_last_version(f"{PACKAGE_NAME}:default") == "1000"
+ assert profile_last_version(f"{PACKAGE_NAME}:default") == "1001"
diff --git a/backend/tests/utils/test_email.py b/backend/tests/utils/test_email.py
index ff0fdac..795b1b3 100644
--- a/backend/tests/utils/test_email.py
+++ b/backend/tests/utils/test_email.py
@@ -1,14 +1,19 @@
"""Unit tests for plone.formblock.utils.email.
-These cover the two pure helpers used to build outgoing form e-mails:
-``add_attachaments_to_msg`` and ``create_message``. They do not need a Plone
-site, so they run as plain unit tests with fixtures and parametrization.
+The pure helpers (``add_attachaments_to_msg``, ``create_message`` and
+``substitute_variables``) need no Plone site and run as plain unit tests with
+fixtures and parametrization. The helpers that read the registry or translate
+strings (``is_mailhost_configured``, ``available_templates``,
+``get_template_from_block``, ``format_property`` and ``addresses_from_block``)
+need ``api`` access and live in :class:`TestApiSettings`.
"""
from email import policy
from email.message import EmailMessage
+from plone import api
from plone.formblock.utils.email import add_attachaments_to_msg
from plone.formblock.utils.email import create_message
+from plone.formblock.utils.email import substitute_variables
import base64
import pytest
@@ -224,3 +229,177 @@ def test_with_attachments(self, b64_attachment):
assert len(attachments) == 1
assert attachments[0].get_filename() == "x.png"
assert attachments[0].get_content_type() == "image/png"
+
+
+class TestSubstituteVariables:
+ """``substitute_variables`` is a pure ``${var}`` interpolation helper."""
+
+ def test_no_variables_returns_unchanged(self):
+ assert substitute_variables("plain text") == "plain text"
+
+ @pytest.mark.parametrize(
+ "template,context,expected",
+ [
+ ("hi ${name}", {"name": "Bob"}, "hi Bob"),
+ ("${a}-${b}", {"a": "1", "b": "2"}, "1-2"),
+ ("${g}, ${name}!", {"g": "Hello", "name": "Ana"}, "Hello, Ana!"),
+ ],
+ )
+ def test_substitution_with_context(self, template, context, expected):
+ assert substitute_variables(template, context=context) == expected
+
+ def test_missing_variable_becomes_empty(self):
+ assert substitute_variables("a${x}b", context={}) == "ab"
+
+ def test_form_data_used_when_context_is_none(self):
+ assert substitute_variables("hi ${name}", form_data={"name": "X"}) == "hi X"
+
+ def test_context_takes_precedence_over_form_data(self):
+ # When both are given, ``form_data`` is ignored entirely.
+ result = substitute_variables(
+ "${a}", context={"a": "ctx"}, form_data={"a": "fd"}
+ )
+ assert result == "ctx"
+
+ def test_both_none_treats_all_variables_as_missing(self):
+ assert substitute_variables("x${y}z") == "xz"
+
+ def test_non_string_substitution_value_raises(self):
+ # ``re.sub`` requires the replacement to be a string, so a non-string
+ # value in the context surfaces as a TypeError. Documents a latent
+ # constraint: callers must pass stringified values.
+ with pytest.raises(TypeError):
+ substitute_variables("${n}", context={"n": 5})
+
+
+class TestApiSettings:
+ @pytest.fixture(autouse=True)
+ def _setup(self, portal_class):
+ self.portal = portal_class
+
+ def test_is_mailhost_configured(self):
+ from plone.formblock.utils.email import is_mailhost_configured
+
+ assert is_mailhost_configured() is True
+
+ def test_available_templates(self):
+ from plone.formblock.utils.email import available_templates
+
+ templates = available_templates()
+ assert isinstance(templates, dict)
+ assert "default" in templates
+ assert isinstance(templates["default"], str)
+
+ def test_get_template_from_block_defaults_to_default_template(self):
+ from plone.formblock.interfaces import DEFAULT_TEMPLATE
+ from plone.formblock.utils.email import get_template_from_block
+
+ # No ``mail_template`` key -> resolves the "default" entry.
+ assert get_template_from_block({}) == DEFAULT_TEMPLATE
+
+ def test_get_template_from_block_unknown_name_falls_back(self):
+ from plone.formblock.interfaces import DEFAULT_TEMPLATE
+ from plone.formblock.utils.email import get_template_from_block
+
+ block = {"mail_template": "does-not-exist"}
+ assert get_template_from_block(block) == DEFAULT_TEMPLATE
+
+ def test_get_template_from_block_returns_selected_template(self):
+ from plone.formblock.utils.email import get_template_from_block
+
+ record = "schemaform.mail_templates_json"
+ original = api.portal.get_registry_record(record)
+ try:
+ api.portal.set_registry_record(
+ record, {**original, "custom": "CUSTOM BODY"}
+ )
+ block = {"mail_template": "custom"}
+ assert get_template_from_block(block) == "CUSTOM BODY"
+ finally:
+ api.portal.set_registry_record(record, original)
+
+ @pytest.mark.parametrize(
+ "factory,value,expected",
+ [
+ ("label_boolean_field", True, "Yes"),
+ ("label_boolean_field", False, "No"),
+ ("termsAccepted", True, "Yes"),
+ ("termsAccepted", False, "No"),
+ # Identity check: only ``True`` is "Yes"; a truthy non-True is "No".
+ ("label_boolean_field", "true", "No"),
+ ],
+ )
+ def test_format_property_boolean(self, factory, value, expected):
+ from plone.formblock.utils.email import format_property
+
+ assert format_property(factory, value) == expected
+
+ def test_format_property_checkbox_group_joins_list(self):
+ from plone.formblock.utils.email import format_property
+
+ assert format_property("checkbox_group", ["a", "b", "c"]) == "a b c"
+
+ def test_format_property_checkbox_group_non_list_is_stringified(self):
+ from plone.formblock.utils.email import format_property
+
+ # Only list values are joined; anything else falls through to ``str``.
+ assert format_property("checkbox_group", "single") == "single"
+
+ @pytest.mark.parametrize("factory", ["label_date_field", "label_datetime_field"])
+ def test_format_property_dates_are_localized(self, factory):
+ from plone.formblock.utils.email import format_property
+
+ result = format_property(factory, "2026-07-06")
+ assert "2026" in result
+
+ @pytest.mark.parametrize(
+ "value,expected",
+ [(42, "42"), ("plain", "plain"), (None, "None")],
+ )
+ def test_format_property_default_stringifies(self, value, expected):
+ from plone.formblock.utils.email import format_property
+
+ assert format_property("text", value) == expected
+
+ def test_addresses_from_block_defaults(self):
+ from plone.formblock.utils.email import addresses_from_block
+
+ addresses = addresses_from_block({}, {})
+ assert addresses.sender == "Plone test site "
+ assert addresses.admin_recipients == "site_addr@plone.com"
+ assert addresses.bcc == ""
+ assert addresses.confirmation_recipients == ""
+
+ def test_addresses_from_block_explicit_values(self):
+ from plone.formblock.utils.email import addresses_from_block
+
+ block = {
+ "sender": "me@x.com",
+ "sender_name": "Me",
+ "recipients": "admin@x.com",
+ "bcc": "b@x.com",
+ "confirmation_recipients": "c@x.com",
+ }
+ addresses = addresses_from_block(block, {})
+ assert addresses.sender == "Me "
+ assert addresses.admin_recipients == "admin@x.com"
+ assert addresses.bcc == "b@x.com"
+ assert addresses.confirmation_recipients == "c@x.com"
+
+ def test_addresses_from_block_substitutes_variables(self):
+ from plone.formblock.utils.email import addresses_from_block
+
+ block = {"sender": "${email}", "confirmation_recipients": "${email}"}
+ form_data = {"email": "user@x.com"}
+ addresses = addresses_from_block(block, form_data)
+ # sender_name falls back to the site default; the address is substituted.
+ assert addresses.sender == "Plone test site "
+ assert addresses.confirmation_recipients == "user@x.com"
+
+ def test_addresses_from_block_empty_recipients_not_defaulted(self):
+ from plone.formblock.utils.email import addresses_from_block
+
+ # ``recipients`` defaults to the site address only when the key is
+ # absent; an explicit empty string is preserved as-is.
+ addresses = addresses_from_block({"recipients": ""}, {})
+ assert addresses.admin_recipients == ""