diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 84f02b688c..e2462b7b3a 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -842,6 +842,11 @@ def __setitem__(self, key, value): EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES.append(hostname) FROM_EMAIL = os.getenv("FROM_EMAIL", "no-reply@localhost") +# Align Django's built-in senders with the Baserow configured sender so that any +# code relying on Django defaults (e.g. the `Send email` action using instance +# SMTP) uses the configured FROM_EMAIL instead of `webmaster@localhost`. +DEFAULT_FROM_EMAIL = FROM_EMAIL +SERVER_EMAIL = FROM_EMAIL RESET_PASSWORD_TOKEN_MAX_AGE = 60 * 60 * 2 # 2 hours CHANGE_EMAIL_TOKEN_MAX_AGE = 60 * 60 * 12 # 12 hours diff --git a/backend/src/baserow/contrib/builder/api/data_sources/views.py b/backend/src/baserow/contrib/builder/api/data_sources/views.py index a053e66a69..6e10ef4fe5 100644 --- a/backend/src/baserow/contrib/builder/api/data_sources/views.py +++ b/backend/src/baserow/contrib/builder/api/data_sources/views.py @@ -61,12 +61,16 @@ DataSourceRefinementForbidden, ) from baserow.contrib.builder.data_sources.handler import DataSourceHandler +from baserow.contrib.builder.data_sources.operations import ( + DispatchDataSourceOperationType, +) from baserow.contrib.builder.data_sources.service import DataSourceService from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist from baserow.contrib.builder.pages.exceptions import PageDoesNotExist from baserow.contrib.builder.pages.handler import PageHandler from baserow.contrib.builder.pages.models import Page from baserow.core.exceptions import PermissionException +from baserow.core.handler import CoreHandler from baserow.core.services.exceptions import ( DoesNotExist, InvalidContextContentDispatchException, @@ -650,6 +654,14 @@ class GetRecordNamesView(APIView): def get(self, request, data_source_id: int): # Find the data source corresponding to the given id data_source = DataSourceHandler().get_data_source(data_source_id) + + CoreHandler().check_permissions( + request.user, + DispatchDataSourceOperationType.type, + workspace=data_source.page.builder.workspace, + context=data_source, + ) + service = data_source.service.specific service_type = service.get_type() diff --git a/backend/src/baserow/contrib/builder/data_sources/service.py b/backend/src/baserow/contrib/builder/data_sources/service.py index db8b178ab4..3c5b78a97d 100644 --- a/backend/src/baserow/contrib/builder/data_sources/service.py +++ b/backend/src/baserow/contrib/builder/data_sources/service.py @@ -321,6 +321,7 @@ def dispatch_data_sources( CoreHandler().check_multiple_permissions( checks, workspace=data_sources[0].page.builder.workspace, + raise_exception=True, ) results = self.handler.dispatch_data_sources(data_sources, dispatch_context) diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 2925c720b1..97a3936e91 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -870,7 +870,7 @@ def dispatch_data( using_instance_smtp = self._should_use_instance_smtp(service) if using_instance_smtp: - from_email = settings.DEFAULT_FROM_EMAIL + from_email = settings.FROM_EMAIL connection = get_connection( backend=settings.CELERY_EMAIL_BACKEND, timeout=SMTP_EMAIL_TIMEOUT, diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py index c72c8d4a43..dfbd9c563b 100755 --- a/backend/src/baserow/core/handler.py +++ b/backend/src/baserow/core/handler.py @@ -222,6 +222,7 @@ def check_multiple_permissions( workspace: Optional[Workspace] = None, include_trash: bool = False, return_permissions_exceptions: bool = False, + raise_exception: bool = False, ) -> Dict[PermissionCheck, Union[bool, PermissionException]]: """ Given a list of permission to check, returns True for each check for which the @@ -236,18 +237,27 @@ def check_multiple_permissions( a definitive answer. If None of the permission managers replied with a final answer for a check, - the operation is denied by default for this check. + the check is recorded as denied in the returned mapping. + + Use check_permissions(), or set raise_exception=True, if you want a denied + permission to raise a PermissionException. :param checks: The list of checks to do. Each check is a triplet of (actor, permission_name, scope). :param workspace: The optional workspace in which the operations take place. :param include_trash: If true, then also checks if the given workspace has been trashed instead of raising a DoesNotExist exception. - :param return_permissions_exceptions: Raise an exception when the permission is - disallowed when `True`. Return `False` instead when `False`. - `False` by default. + :param return_permissions_exceptions: When True, a denied check is recorded + in the returned mapping as the corresponding PermissionException instance. + Otherwise, a denied check is recorded as False. + :param raise_exception: When True, the first PermissionException encountered + among the denied checks is raised instead of being returned in the mapping. + :raises PermissionException: If `raise_exception` is True and at least one + check is denied. :return: A dictionary with one entry for each check of the parameter as key and - whether the operation is allowed or not as value. + whether the operation is allowed (True) or denied (False, or the + PermissionException instance when return_permissions_exceptions=True) + as value. """ result = {} @@ -274,9 +284,8 @@ def check_multiple_permissions( for check, check_result in manager_result.items(): if check_result is not None: - if ( - isinstance(check_result, PermissionException) - and not return_permissions_exceptions + if isinstance(check_result, PermissionException) and not ( + return_permissions_exceptions or raise_exception ): result[check] = False else: @@ -288,10 +297,15 @@ def check_multiple_permissions( for undetermined_check in undetermined_checks: result[undetermined_check] = ( PermissionDenied(undetermined_check.actor) - if return_permissions_exceptions + if (return_permissions_exceptions or raise_exception) else False ) + if raise_exception: + for check_result in result.values(): + if isinstance(check_result, PermissionException): + raise check_result + return result def check_permission_for_multiple_actors( diff --git a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py index f6ac0212ae..ff8b441d70 100644 --- a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py +++ b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py @@ -2054,6 +2054,64 @@ def test_get_record_names(api_client, data_fixture): ) +@pytest.mark.django_db +def test_dispatch_data_source_anonymous_unpublished_builder_is_denied( + api_client, data_fixture +): + user = data_fixture.create_user() + table, fields, rows = data_fixture.build_table( + user=user, + columns=[("Name", "text")], + rows=[["Ada"], ["Alan"]], + ) + builder = data_fixture.create_builder_application(user=user) + integration = data_fixture.create_local_baserow_integration( + user=user, application=builder + ) + page = data_fixture.create_builder_page(user=user, builder=builder) + data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( + user=user, page=page, integration=integration, table=table + ) + + url = reverse( + "api:builder:data_source:dispatch", kwargs={"data_source_id": data_source.id} + ) + # No authentication header: anonymous request. + response = api_client.post(url, {}, format="json") + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + +@pytest.mark.django_db +def test_get_record_names_anonymous_unpublished_builder_is_denied( + api_client, data_fixture +): + user = data_fixture.create_user() + builder = data_fixture.create_builder_application(user=user) + integration = data_fixture.create_local_baserow_integration( + user=user, application=builder + ) + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(name="Name", table=table, primary=True) + model = table.get_model(attribute_names=True) + row = model.objects.create(name="Ada") + + data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( + user=user, table=table, integration=integration + ) + + base_url = reverse( + "api:builder:data_source:record-names", + kwargs={"data_source_id": data_source.id}, + ) + # No authentication header: anonymous request. + response = api_client.get(f"{base_url}?record_ids={row.id}", format="json") + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + @pytest.mark.django_db def test_dispatch_data_sources_list_rows_with_elements( api_client, data_fixture, data_source_fixture diff --git a/backend/tests/baserow/contrib/builder/api/data_sources/test_public_data_source_views.py b/backend/tests/baserow/contrib/builder/api/data_sources/test_public_data_source_views.py index ea3cf8163c..89e3987903 100644 --- a/backend/tests/baserow/contrib/builder/api/data_sources/test_public_data_source_views.py +++ b/backend/tests/baserow/contrib/builder/api/data_sources/test_public_data_source_views.py @@ -525,7 +525,7 @@ def test_dispatch_data_sources_page_visibility_all_returns_elements( endpoint should return elements. """ - page = data_source_fixture["page"] + page = data_source_fixture["public_page"] page.visibility = Page.VISIBILITY_TYPES.ALL page.save() @@ -654,7 +654,7 @@ def test_dispatch_data_sources_page_visibility_logged_in_returns_no_elements_for endpoint should return zero elements. """ - page = data_source_fixture["page"] + page = data_source_fixture["public_page"] page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN page.save() diff --git a/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py index b6d25d4c9c..64918e9a7d 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py @@ -165,7 +165,7 @@ def test_send_smtp_email_with_integration_ignores_global_celery_email_backend( CELERY_EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend", EMAIL_HOST="instance.smtp.example.com", EMAIL_PORT=2525, - DEFAULT_FROM_EMAIL="instance@example.com", + FROM_EMAIL="My Database ", ) def test_send_smtp_email_uses_instance_smtp_settings(data_fixture): service = data_fixture.create_core_smtp_email_service( @@ -191,7 +191,7 @@ def test_send_smtp_email_uses_instance_smtp_settings(data_fixture): mock_email.assert_called_once_with( "Test Subject", "Hello, this is a test email!", - "instance@example.com", + "My Database ", ["recipient@example.com"], bcc=[], cc=[], @@ -200,6 +200,46 @@ def test_send_smtp_email_uses_instance_smtp_settings(data_fixture): assert result.data == {"success": True} +@pytest.mark.django_db +@override_settings( + INTEGRATION_ALLOW_SMTP_SERVICE_TO_USE_INSTANCE_SETTINGS=True, + CELERY_EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend", + EMAIL_HOST="instance.smtp.example.com", + EMAIL_PORT=2525, + FROM_EMAIL="My Database ", + DEFAULT_FROM_EMAIL="webmaster@localhost", +) +def test_send_smtp_email_instance_smtp_uses_from_email_not_default(data_fixture): + # Regression test: the instance SMTP path must use the configured + # FROM_EMAIL and not fall back to Django's DEFAULT_FROM_EMAIL, which + # defaults to `webmaster@localhost`. + service = data_fixture.create_core_smtp_email_service( + integration=None, + use_instance_smtp_settings=True, + from_email="''", + from_name="''", + to_emails="'recipient@example.com'", + subject="'Test Subject'", + body="'Hello, this is a test email!'", + body_type="plain", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with mock_django_email() as (mock_email, mock_connection): + service_type.dispatch(service, dispatch_context) + mock_email.assert_called_once_with( + "Test Subject", + "Hello, this is a test email!", + "My Database ", + ["recipient@example.com"], + bcc=[], + cc=[], + connection=mock_connection.return_value, + ) + + @pytest.mark.django_db def test_send_smtp_email_multiple_to_cc_and_bcc(data_fixture): smtp_integration = data_fixture.create_smtp_integration( diff --git a/changelog/entries/unreleased/bug/5640_fix_the_send_email_action_using_instance_smtp_sending_from_w.json b/changelog/entries/unreleased/bug/5640_fix_the_send_email_action_using_instance_smtp_sending_from_w.json new file mode 100644 index 0000000000..a735dbe8b3 --- /dev/null +++ b/changelog/entries/unreleased/bug/5640_fix_the_send_email_action_using_instance_smtp_sending_from_w.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix the Send email action using instance SMTP sending from webmaster@localhost instead of the configured FROM_EMAIL.", + "issue_origin": "github", + "issue_number": 5640, + "domain": "integration", + "bullet_points": [], + "created_at": "2026-07-08" +} diff --git a/changelog/entries/unreleased/bug/improved_anonymous_builder_dispatch.json b/changelog/entries/unreleased/bug/improved_anonymous_builder_dispatch.json new file mode 100644 index 0000000000..9f53079ee0 --- /dev/null +++ b/changelog/entries/unreleased/bug/improved_anonymous_builder_dispatch.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Improved anonymous builder dispatch.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-07-07" +}