From 5a5be199b922f3ca4edab2d5a66e483afd934db1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:08:47 +0000 Subject: [PATCH 01/38] feat(workflows): provision a managed sandbox email sender Co-Authored-By: Claude Opus 5.5 --- .../internal/workflows-email-sending-tiers.md | 12 ++ posthog/api/integration.py | 48 ++++- posthog/api/team/team_config.py | 5 + posthog/models/integration/__init__.py | 9 +- posthog/models/integration/email.py | 24 +++ posthog/settings/ses.py | 3 + .../integrations/frontend/generated/api.ts | 14 ++ products/workflows/backend/facade/api.py | 4 + .../workflows/backend/facade/contracts.py | 10 + .../services/data_suggestions/team_context.py | 6 +- .../backend/services/email_health.py | 3 +- .../backend/services/sandbox_sender.py | 69 +++++++ .../backend/services/setup_status.py | 6 +- .../services/transactional_email_workflows.py | 7 +- .../test/test_sandbox_sender_lifecycle.py | 46 +++++ .../test/test_team_workflows_config.py | 19 +- .../test_transactional_email_workflows.py | 17 +- .../test/test_workflow_setup_status.py | 13 +- .../tests/api/test_email_reputation.py | 44 ++++- .../backend/tests/api/test_sandbox_sender.py | 177 ++++++++++++++++++ .../api/test_workflow_data_suggestions.py | 9 + 21 files changed, 510 insertions(+), 35 deletions(-) create mode 100644 products/workflows/backend/services/sandbox_sender.py create mode 100644 products/workflows/backend/test/test_sandbox_sender_lifecycle.py create mode 100644 products/workflows/backend/tests/api/test_sandbox_sender.py diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 6914badeaa51..2d8d7faebd1a 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -16,6 +16,18 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook ## Configuration +### Sandbox sender provisioning + +The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. +The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. +Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN` and `SES_SANDBOX_FROM_ADDRESS` in Django; either empty value leaves the endpoint unavailable. +The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. +Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. +The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. +Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. + +### Sending tiers + Two sides read their own env vars because the worker never reads Django settings. Set them together; drift means the UI describes limits the worker does not apply, or the reverse. diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 1d836e2ae4fe..f2265c1dd6b2 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -20,11 +20,11 @@ from django_filters.rest_framework import DjangoFilterBackend from django_redis.cache import RedisCache from django_redis.exceptions import ConnectionInterrupted -from drf_spectacular.utils import extend_schema, extend_schema_field, extend_schema_serializer +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field, extend_schema_serializer from prometheus_client import Counter from redis.exceptions import RedisError from rest_framework import mixins, serializers, status, viewsets -from rest_framework.exceptions import APIException, PermissionDenied, Throttled, ValidationError +from rest_framework.exceptions import APIException, NotFound, PermissionDenied, Throttled, ValidationError from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -130,7 +130,11 @@ from products.slack_app.backend.services.slack_auth import SLACK_AUTH_FAILURE_CODES from products.tasks.backend.facade.api import get_in_progress_runs_for_github_integration from products.tasks.backend.facade.contracts import InProgressGithubRunsDTO -from products.workflows.backend.facade.api import get_active_workflows_using_integration +from products.workflows.backend.facade.api import ( + SandboxSenderUnavailable, + ensure_sandbox_email_sender, + get_active_workflows_using_integration, +) logger = structlog.get_logger(__name__) @@ -1499,6 +1503,7 @@ class IntegrationViewSet( "github_prepare_callback", "github_link_existing", "github_oauth_authorize", + "email_sandbox_sender", # Side-effecting POST (emails admins) — a read-only token must not be able to trigger it. "request_access", ] @@ -1537,7 +1542,13 @@ def dangerously_get_permissions(self): ] # Adding an integration only requires project membership. Every edit and removal uses the # viewset permission class, including the creator exception for Google account removal. - if self.action in ("create", "github_link_existing", "github_oauth_authorize", "request_access"): + if self.action in ( + "create", + "github_link_existing", + "github_oauth_authorize", + "request_access", + "email_sandbox_sender", + ): return base_permissions if self.action == "refresh_github_repos": return [*base_permissions, TeamMemberLightManagementPermission()] @@ -1549,6 +1560,9 @@ def get_throttles(self): return super().get_throttles() def perform_destroy(self, instance: Integration) -> None: + if instance.kind == "email": + EmailIntegration(instance).ensure_user_managed() + flows_using_integration = get_active_workflows_using_integration( team_id=instance.team_id, integration_id=instance.id ) @@ -2650,6 +2664,23 @@ def gitlab_members(self, request: Request, *args: Any, **kwargs: Any) -> Respons search = query_serializer.validated_data["search"] return assignees_response(lambda: GitLabIntegration(instance).list_assignees(search)) + @extend_schema( + request=None, + responses={200: IntegrationSerializer, 404: OpenApiResponse(description="No sandbox sender for this project.")}, + ) + @action(methods=["POST"], detail=False, url_path="email_sandbox_sender") + def email_sandbox_sender(self, request: Request, *args: Any, **kwargs: Any) -> Response: + try: + sandbox_sender = ensure_sandbox_email_sender(self.team_id) + except SandboxSenderUnavailable: + raise NotFound("The sandbox sender is not available for this project.") + if sandbox_sender.created: + report_user_action( + cast(User, request.user), "workflows sandbox sender provisioned", team=self.team, request=request + ) + integration = self.get_queryset().get(id=sandbox_sender.integration_id) + return Response(IntegrationSerializer(integration, context=self.get_serializer_context()).data) + @action(methods=["POST"], detail=True, url_path="email/verify") def email_verify(self, request, **kwargs): email = EmailIntegration(self.get_object()) @@ -2659,14 +2690,13 @@ def email_verify(self, request, **kwargs): @extend_schema(responses={200: IntegrationSerializer}) @action(methods=["PATCH"], detail=True, url_path="email") def email_update(self, request, **kwargs) -> Response: - instance = self.get_object() - config = request.data.get("config", {}) + email = EmailIntegration(self.get_object()) + email.ensure_user_managed() - serializer = NativeEmailIntegrationSerializer(data=config) + serializer = NativeEmailIntegrationSerializer(data=request.data.get("config", {})) serializer.is_valid(raise_exception=True) - email = EmailIntegration(instance) - email.update_native_integration(serializer.validated_data, instance.team_id) + email.update_native_integration(serializer.validated_data, email.integration.team_id) return Response(IntegrationSerializer(email.integration).data) diff --git a/posthog/api/team/team_config.py b/posthog/api/team/team_config.py index 4dc308f91d24..887ae690fd9e 100644 --- a/posthog/api/team/team_config.py +++ b/posthog/api/team/team_config.py @@ -7,6 +7,7 @@ from posthog.models import Team, TeamRevenueAnalyticsConfig from posthog.models.filters.utils import validate_group_type_index +from posthog.models.integration.email import SANDBOX_EMAIL_PROVIDER from posthog.models.integration.model import Integration from products.access_control.backend.presentation.access_control import UserAccessControlSerializerMixin @@ -417,6 +418,10 @@ def validate_default_email_integration_id(self, value: int | None) -> int | None ) if sender is None: raise serializers.ValidationError("Choose an email sender from this project.") + if sender.config.get("provider") == SANDBOX_EMAIL_PROVIDER: + raise serializers.ValidationError( + "The sandbox sender can't be the project default. Choose a sender on your own verified domain." + ) if not sender.config.get("verified"): raise serializers.ValidationError("Verify this sender's domain before making it the default.") return value diff --git a/posthog/models/integration/__init__.py b/posthog/models/integration/__init__.py index eb0922152a84..6f3bc600b289 100644 --- a/posthog/models/integration/__init__.py +++ b/posthog/models/integration/__init__.py @@ -38,7 +38,12 @@ dot_get, ) from .databricks import DatabricksIntegration, DatabricksIntegrationError -from .email import EmailIntegration, cleanup_ses_identity_on_integration_delete +from .email import ( + SANDBOX_EMAIL_INTEGRATION_ID, + SANDBOX_EMAIL_PROVIDER, + EmailIntegration, + cleanup_ses_identity_on_integration_delete, +) from .external_issues import ( SUPPORTED_EXTERNAL_ISSUE_PROVIDERS, external_issue_url, @@ -221,6 +226,8 @@ "ClickUpIntegration", "EmailIntegration", "cleanup_ses_identity_on_integration_delete", + "SANDBOX_EMAIL_INTEGRATION_ID", + "SANDBOX_EMAIL_PROVIDER", "LinearIntegration", "SUPPORTED_EXTERNAL_ISSUE_PROVIDERS", "external_issue_url", diff --git a/posthog/models/integration/email.py b/posthog/models/integration/email.py index 2dc30b43d6cc..06c942e27da1 100644 --- a/posthog/models/integration/email.py +++ b/posthog/models/integration/email.py @@ -20,6 +20,16 @@ from products.workflows.backend.facade.contracts import EmailDomainVerification +SANDBOX_EMAIL_PROVIDER = "sandbox" +SANDBOX_EMAIL_INTEGRATION_ID = "posthog-sandbox" +SANDBOX_SENDER_MANAGED_MESSAGE = "The sandbox sender is managed by PostHog." + + +def is_sandbox_sender_domain(domain: str) -> bool: + sandbox_domain = settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN + return bool(sandbox_domain) and (domain == sandbox_domain or domain.endswith(f".{sandbox_domain}")) + + class EmailIntegration: integration: model.Integration @@ -28,6 +38,14 @@ def __init__(self, integration: model.Integration) -> None: raise Exception("EmailIntegration init called with Integration with wrong 'kind'") self.integration = integration + @property + def is_sandbox_sender(self) -> bool: + return self.integration.config.get("provider") == SANDBOX_EMAIL_PROVIDER + + def ensure_user_managed(self) -> None: + if self.is_sandbox_sender: + raise ValidationError(SANDBOX_SENDER_MANAGED_MESSAGE) + @classmethod def create_native_integration( cls, config: dict, team_id: int, organization_id: str, created_by: User | None = None @@ -41,6 +59,11 @@ def create_native_integration( if domain in free_email_domains_list or domain in disposable_email_domains_list: raise ValidationError(f"Email domain {domain} is not supported. Please use a custom domain.") + if is_sandbox_sender_domain(domain): + raise ValidationError( + f"Email domain {domain} belongs to the PostHog sandbox sender. Please use a domain you own." + ) + # Check if any other integration already exists in a different team with the same domain, # if so, ensure this team is part of the same organization. If not, we block creation. same_domain_integrations = model.Integration.objects.filter(kind="email", config__domain=domain) @@ -126,6 +149,7 @@ def update_native_integration(self, config: dict, team_id: int) -> model.Integra return self.integration def verify(self) -> "EmailDomainVerification": + self.ensure_user_managed() domain = self.integration.config.get("domain") provider = self.integration.config.get("provider", "ses") mail_from_subdomain = self.integration.config.get("mail_from_subdomain", "feedback") diff --git a/posthog/settings/ses.py b/posthog/settings/ses.py index 6cd9beaff4c7..ee6db86140bb 100644 --- a/posthog/settings/ses.py +++ b/posthog/settings/ses.py @@ -29,6 +29,9 @@ if cs.strip() ] +WORKFLOWS_SANDBOX_SENDER_DOMAIN: str = os.getenv("WORKFLOWS_SANDBOX_SENDER_DOMAIN", "").strip().lower() +SES_SANDBOX_FROM_ADDRESS: str = os.getenv("SES_SANDBOX_FROM_ADDRESS", "").strip().lower() + # SNS topics allowed to deliver SES tenant reputation events (EventBridge -> SNS -> webhook). # Empty (the default) leaves the webhook inert: the SNS signature proves a message came from AWS, # but only the allowlist proves it came from *our* topic. diff --git a/products/integrations/frontend/generated/api.ts b/products/integrations/frontend/generated/api.ts index 54bdf2e0adc4..82d21118d721 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -849,6 +849,20 @@ export const integrationsDomainConnectCheckRetrieve = async ( }) } +export const getIntegrationsEmailSandboxSenderCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/integrations/email_sandbox_sender/` +} + +export const integrationsEmailSandboxSenderCreate = async ( + projectId: string, + options?: RequestInit +): Promise => { + return apiMutator(getIntegrationsEmailSandboxSenderCreateUrl(projectId), { + ...options, + method: 'POST', + }) +} + export const getIntegrationsGithubAvailableInstallationsRetrieveUrl = (projectId: string) => { return `/api/projects/${projectId}/integrations/github/available_installations/` } diff --git a/products/workflows/backend/facade/api.py b/products/workflows/backend/facade/api.py index 76fc797a34f0..55531f142b3e 100644 --- a/products/workflows/backend/facade/api.py +++ b/products/workflows/backend/facade/api.py @@ -13,6 +13,7 @@ EmailDomainDnsRecord, EmailDomainVerification, RecentWorkflow, + SandboxSenderUnavailable, TeamUtmDefaults, TierDecision, TwilioAccount, @@ -42,6 +43,7 @@ ) from products.workflows.backend.services.integration_usage import get_active_hog_flows_using_integration from products.workflows.backend.services.owned_workflow_health import list_owned_workflow_health +from products.workflows.backend.services.sandbox_sender import ensure_sandbox_email_sender from products.workflows.backend.services.template_input_usage import ( filter_hog_flow_references_by_access_level, get_hog_flows_referencing_template_input_keys, @@ -59,8 +61,10 @@ "WorkflowNotFound", "MIN_EMAIL_SENDING_TIER", "clear_default_email_sender", + "SandboxSenderUnavailable", "compute_next_occurrences", "create_batch_job", + "ensure_sandbox_email_sender", "ensure_workflows_config", "filter_hog_flow_references_by_access_level", "get_email_sending_state", diff --git a/products/workflows/backend/facade/contracts.py b/products/workflows/backend/facade/contracts.py index 5143ffb93f5d..e478559080a7 100644 --- a/products/workflows/backend/facade/contracts.py +++ b/products/workflows/backend/facade/contracts.py @@ -274,6 +274,16 @@ class EmailSendingTierLimits: max_batch_audience: int +@frozen +class SandboxEmailSender: + integration_id: int + created: bool + + +class SandboxSenderUnavailable(Exception): + pass + + @frozen class TierDecision: team_id: int diff --git a/products/workflows/backend/services/data_suggestions/team_context.py b/products/workflows/backend/services/data_suggestions/team_context.py index 3049253397b1..a959891b8c35 100644 --- a/products/workflows/backend/services/data_suggestions/team_context.py +++ b/products/workflows/backend/services/data_suggestions/team_context.py @@ -7,7 +7,7 @@ from django.db.models.functions import Coalesce from posthog.models import EventProperty, Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.api import EmailTemplateSummary, list_email_templates from products.workflows.backend.models.hog_flow.hog_flow import HogFlow @@ -100,7 +100,9 @@ def _verified_senders(team: Team) -> list[EmailSender]: return [ EmailSender(integration_id=integration.id, email=str(config["email"]), name=str(config.get("name") or "")) for integration in Integration.objects.filter(team_id=team.id, kind="email").order_by("created_at") - if (config := integration.config or {}).get("verified") is True and config.get("email") + if (config := integration.config or {}).get("verified") is True + and config.get("email") + and config.get("provider") != SANDBOX_EMAIL_PROVIDER ] diff --git a/products/workflows/backend/services/email_health.py b/products/workflows/backend/services/email_health.py index 5be6acff5ff5..35d86d68a037 100644 --- a/products/workflows/backend/services/email_health.py +++ b/products/workflows/backend/services/email_health.py @@ -12,7 +12,7 @@ import structlog from posthog.api.app_metrics2 import fetch_app_metric_totals_by_source, fetch_app_metric_totals_by_team_and_source -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.facade.contracts import ( EMAIL_HEALTH_METRIC_NAMES, @@ -216,6 +216,7 @@ def verified_email_domains(*, team_id: int) -> list[str]: dict.fromkeys( domain for domain in Integration.objects.filter(team_id=team_id, kind="email", config__verified=True) + .exclude(config__contains={"provider": SANDBOX_EMAIL_PROVIDER}) .order_by("id") .values_list("config__domain", flat=True) if domain diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py new file mode 100644 index 000000000000..4dc3d4cc94f9 --- /dev/null +++ b/products/workflows/backend/services/sandbox_sender.py @@ -0,0 +1,69 @@ +from django.conf import settings + +import structlog + +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration +from posthog.models.team import Team +from posthog.permissions import posthog_feature_flag_enabled +from posthog.plugins.plugin_server_api import reload_integrations_on_workers + +from products.workflows.backend.facade.contracts import SandboxEmailSender, SandboxSenderUnavailable + +logger = structlog.get_logger(__name__) + +SANDBOX_SENDER_FLAG = "workflows-sandbox-sender" +SANDBOX_SENDER_FALLBACK_NAME = "PostHog sandbox" +SANDBOX_SENDER_NAME_MAX_LENGTH = 40 +_SANDBOX_SENDER_NAME_PUNCTUATION = frozenset(" .,&'-") + + +def _sandbox_sender_display_name(organization_name: str) -> str: + kept = "".join(char for char in organization_name if char.isalnum() or char in _SANDBOX_SENDER_NAME_PUNCTUATION) + name = " ".join(kept.split())[:SANDBOX_SENDER_NAME_MAX_LENGTH].strip() + return f"{name} via PostHog" if name else SANDBOX_SENDER_FALLBACK_NAME + + +def _sandbox_sender_config(organization_name: str) -> dict[str, str | bool]: + return { + "provider": SANDBOX_EMAIL_PROVIDER, + "email": settings.SES_SANDBOX_FROM_ADDRESS, + "domain": settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN, + "name": _sandbox_sender_display_name(organization_name), + "verified": True, + } + + +def _sandbox_sender_configured() -> bool: + return bool(settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN and settings.SES_SANDBOX_FROM_ADDRESS) + + +def _sandbox_sender_enabled(team: Team) -> bool: + try: + return posthog_feature_flag_enabled( + SANDBOX_SENDER_FLAG, + str(team.uuid), + organization_id=team.organization_id, + team_id=team.id, + ) + except Exception: + logger.warning("workflows.sandbox_sender_flag_check_failed", team_id=team.id, exc_info=True) + return False + + +def ensure_sandbox_email_sender(team_id: int) -> SandboxEmailSender: + team = Team.objects.select_related("organization").get(id=team_id) + if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team): + raise SandboxSenderUnavailable() + + config = _sandbox_sender_config(team.organization.name) + integration, created = Integration.objects.get_or_create( + team_id=team_id, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + defaults={"config": config, "created_by": None}, + ) + if not created and integration.config != config: + integration.config = config + integration.save() + reload_integrations_on_workers(team_id, [integration.id]) + return SandboxEmailSender(integration_id=integration.id, created=created) diff --git a/products/workflows/backend/services/setup_status.py b/products/workflows/backend/services/setup_status.py index 64200b08626e..8f81060453d0 100644 --- a/products/workflows/backend/services/setup_status.py +++ b/products/workflows/backend/services/setup_status.py @@ -1,7 +1,7 @@ """Which messaging setup steps a project has done, in the order the setup wizard asks for them.""" from posthog.models import Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.api import list_email_templates from products.messaging.backend.facade.categories import list_categories @@ -33,7 +33,9 @@ def email_sending_paused(team: Team) -> bool: def get_setup_steps(team: Team) -> tuple[SetupStep, ...]: done = { - SetupStepKey.CHANNEL: Integration.objects.filter(team_id=team.id, kind="email").exists(), + SetupStepKey.CHANNEL: Integration.objects.filter(team_id=team.id, kind="email") + .exclude(config__contains={"provider": SANDBOX_EMAIL_PROVIDER}) + .exists(), SetupStepKey.DOMAIN: has_verified_email_sender(team), SetupStepKey.OPT_OUTS: len(list_categories(team.id)) > 0, SetupStepKey.SENDING_RULES: _frequency_cap_set(team), diff --git a/products/workflows/backend/services/transactional_email_workflows.py b/products/workflows/backend/services/transactional_email_workflows.py index 733ea49e5649..5f10f789b706 100644 --- a/products/workflows/backend/services/transactional_email_workflows.py +++ b/products/workflows/backend/services/transactional_email_workflows.py @@ -7,7 +7,7 @@ from django.db import transaction -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.scoping import with_team_scope from products.workflows.backend.facade.contracts import ( @@ -105,14 +105,15 @@ def _content_fields(content: TransactionalEmailContent, from_integration_id: int def resolve_transactional_email_sender(team_id: int, requested_id: int | None) -> int: - """The requested sender, or the project's default when none is requested. Raise when it isn't verified.""" + """The requested sender, or the project's default when none is requested. Raise when it isn't verified, + or when it is the sandbox sender, which only workflow email steps and test sends may use.""" sender_id = requested_id or ( TeamWorkflowsConfig.objects.filter(team_id=team_id) .values_list("default_email_integration_id", flat=True) .first() ) sender = Integration.objects.filter(team_id=team_id, kind="email", id=sender_id).first() if sender_id else None - if sender is None or not sender.config.get("verified"): + if sender is None or not sender.config.get("verified") or sender.config.get("provider") == SANDBOX_EMAIL_PROVIDER: raise TransactionalEmailSenderUnavailable() return sender.id diff --git a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py new file mode 100644 index 000000000000..e812993b80d9 --- /dev/null +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -0,0 +1,46 @@ +from posthog.test.base import BaseTest +from unittest.mock import MagicMock, patch + +from django.test import override_settings + +from posthog.management.commands.migrate_ses_tenants import migrate_ses_tenants +from posthog.models.integration import Integration + +from products.workflows.backend.facade.api import ensure_sandbox_email_sender +from products.workflows.backend.tasks.ses_tenant_state import reconcile_ses_tenant_states + + +@override_settings( + WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", + SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", +) +class TestSandboxSenderLifecycleExclusions(BaseTest): + def setUp(self) -> None: + super().setUp() + with patch("posthoganalytics.feature_enabled", return_value=True): + self.sandbox_row = Integration.objects.get( + team_id=self.team.id, id=ensure_sandbox_email_sender(self.team.id).integration_id + ) + + @patch("posthog.management.commands.migrate_ses_tenants.boto3.client") + def test_tenant_migration_skips_the_sandbox_sender(self, boto3_client: MagicMock) -> None: + counts = migrate_ses_tenants(team_ids=[self.team.id], domains=[]) + + assert counts.tenants == 0 + boto3_client.assert_not_called() + + @patch("products.workflows.backend.tasks.ses_tenant_state.SESProvider") + def test_tenant_state_sweep_skips_the_sandbox_sender(self, ses_provider: MagicMock) -> None: + reconcile_ses_tenant_states() + + ses_provider.return_value.get_tenant_reputation.assert_not_called() + + @patch("posthog.tasks.integrations.delete_ses_identity_if_unused") + def test_deleting_a_project_with_a_sandbox_sender_keeps_the_shared_identity( + self, delete_identity_task: MagicMock + ) -> None: + with self.captureOnCommitCallbacks(execute=True): + self.team.delete() + + assert not Integration.objects.filter(pk=self.sandbox_row.pk).exists() + delete_identity_task.delay.assert_not_called() diff --git a/products/workflows/backend/test/test_team_workflows_config.py b/products/workflows/backend/test/test_team_workflows_config.py index 2499e3807dd3..47e442da52a3 100644 --- a/products/workflows/backend/test/test_team_workflows_config.py +++ b/products/workflows/backend/test/test_team_workflows_config.py @@ -1,3 +1,5 @@ +from typing import Any + from posthog.test.base import APIBaseTest from django.contrib.admin import AdminSite @@ -8,7 +10,7 @@ from posthog.api.project import update_team_workflows_config from posthog.models import OrganizationMembership, Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.admin.team_workflows_config_admin import TeamWorkflowsConfigAdmin from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig @@ -156,16 +158,17 @@ def test_patch_ignores_unknown_keys_in_workflows_config(self) -> None: @parameterized.expand( [ - ("unverified_sender", "email", False, False), - ("other_projects_sender", "email", True, True), - ("not_an_email_sender", "slack", True, False), + ("unverified_sender", "email", {"verified": False}, False), + ("other_projects_sender", "email", {"verified": True}, True), + ("not_an_email_sender", "slack", {"verified": True}, False), + ("sandbox_sender", "email", {"verified": True, "provider": SANDBOX_EMAIL_PROVIDER}, False), ] ) - def test_patch_rejects_default_email_sender(self, _name: str, kind: str, verified: bool, other_team: bool) -> None: + def test_patch_rejects_default_email_sender( + self, _name: str, kind: str, config: dict[str, Any], other_team: bool + ) -> None: team = Team.objects.create(organization=self.organization, name="other") if other_team else self.team - sender = Integration.objects.create( - team=team, kind=kind, integration_id="hello@example.com", config={"verified": verified} - ) + sender = Integration.objects.create(team=team, kind=kind, integration_id="hello@example.com", config=config) response = self.client.patch(self.url, {"workflows_config": {"default_email_integration_id": sender.id}}) diff --git a/products/workflows/backend/test/test_transactional_email_workflows.py b/products/workflows/backend/test/test_transactional_email_workflows.py index bcdb89b99817..9bc094edc2ad 100644 --- a/products/workflows/backend/test/test_transactional_email_workflows.py +++ b/products/workflows/backend/test/test_transactional_email_workflows.py @@ -6,7 +6,7 @@ from parameterized import parameterized from posthog.models import Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.scoping import team_scope from products.cdp.backend.api.test.test_hog_function_templates import MOCK_NODE_TEMPLATES @@ -122,13 +122,26 @@ def test_the_stored_workflow_passes_the_workflows_api_validation(self): assert serializer.validated_data["trigger"] == flow.trigger == {"type": "api"} assert serializer.validated_data["billable_action_types"] == flow.billable_action_types - @parameterized.expand([("unverified_default", False, None), ("another_teams_sender", True, "other")]) + @parameterized.expand( + [ + ("unverified_default", False, None), + ("another_teams_sender", True, "other"), + ("sandbox_sender", True, "sandbox"), + ] + ) def test_a_new_transactional_email_needs_a_verified_sender_of_its_own(self, _name, verified, requested): self.sender.config = {"verified": verified} self.sender.save() requested_id = None if requested == "other": requested_id = self._other_teams_sender() + elif requested == "sandbox": + requested_id = Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ).id with self.assertRaises(TransactionalEmailSenderUnavailable): create_transactional_email_workflow( diff --git a/products/workflows/backend/test/test_workflow_setup_status.py b/products/workflows/backend/test/test_workflow_setup_status.py index 295020c053a0..703d272f6ed7 100644 --- a/products/workflows/backend/test/test_workflow_setup_status.py +++ b/products/workflows/backend/test/test_workflow_setup_status.py @@ -5,7 +5,7 @@ from parameterized import parameterized from rest_framework import status -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.testing import create_message_category_for_test, create_message_template_for_test from products.workflows.backend.models import HogFlow @@ -40,7 +40,16 @@ def _add_workflow(self, *, action_type: str, status: str = "draft", origin_produ actions=[{"id": "step", "type": action_type}], ) - def test_a_new_project_starts_at_the_channel(self) -> None: + @parameterized.expand([("no sender", False), ("only the sandbox sender", True)]) + def test_a_new_project_starts_at_the_channel(self, _name: str, has_sandbox_sender: bool) -> None: + if has_sandbox_sender: + Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ) + body = self._status() assert not any(self._done(body).values()) diff --git a/products/workflows/backend/tests/api/test_email_reputation.py b/products/workflows/backend/tests/api/test_email_reputation.py index 6f4a65ffa139..6d88e11e306d 100644 --- a/products/workflows/backend/tests/api/test_email_reputation.py +++ b/products/workflows/backend/tests/api/test_email_reputation.py @@ -11,7 +11,7 @@ from posthog.constants import AvailableFeature from posthog.models import Team from posthog.models.integration import Integration -from posthog.models.organization import OrganizationMembership +from posthog.models.organization import Organization, OrganizationMembership from posthog.models.user import User from products.access_control.backend.models.access_control import AccessControl @@ -356,14 +356,19 @@ def test_reputation_endpoint_returns_the_per_provider_breakdown(self): @parameterized.expand( [ - ("sibling verified on the same domain", True, ["mail.example.com"]), - ("sibling not verified yet", False, []), + ("sibling verified on the same domain", True, ["mail.example.com"], "ses"), + ("sibling not verified yet", False, [], "ses"), + ("legacy sender without a provider", True, ["mail.example.com"], None), ] ) def test_reputation_endpoint_names_domains_a_sibling_project_also_sends_from( - self, _name: str, sibling_verified: bool, expected: list[str] - ): + self, _name: str, sibling_verified: bool, expected: list[str], provider: str | None + ) -> None: self._verify_sending_domain() + if provider is None: + Integration.objects.filter(team=self.team, kind="email", integration_id="mail.example.com").update( + config={"domain": "mail.example.com", "verified": True} + ) sibling = Team.objects.create(organization=self.organization, name="Sibling") Integration.objects.create( team=sibling, @@ -376,6 +381,35 @@ def test_reputation_endpoint_names_domains_a_sibling_project_also_sends_from( assert body["isp_shared_domains"] == expected + def test_reputation_endpoint_leaves_the_sandbox_sender_out_of_the_breakdown(self) -> None: + other_team = Team.objects.create(organization=Organization.objects.create(name="Other"), name="Other") + for team in (self.team, other_team): + Integration.objects.create( + team=team, + kind="email", + integration_id="posthog-sandbox", + config={"domain": "sandbox.example.com", "provider": "sandbox", "verified": True}, + ) + + body = self._get_reputation( + {}, + isp_metrics=[ + IspSendingMetrics( + isp="Gmail", + emails_sent=900, + delivery_rate=0.97, + bounce_rate=0.01, + transient_bounce_rate=0.0, + complaint_rate=None, + complaint_base=0, + ) + ], + ) + + assert body["isps"] == [] + assert body["isp_shared_domains"] == [] + assert body["isp_withheld_domains"] == [] + def test_reputation_endpoint_omits_domains_only_this_project_sends_from(self): self._verify_sending_domain() self._verify_sending_domain("solo.example.com") diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py new file mode 100644 index 000000000000..aa9429216bc9 --- /dev/null +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -0,0 +1,177 @@ +from typing import TYPE_CHECKING, Any + +from posthog.test.base import APIBaseTest +from unittest.mock import MagicMock, _Call, patch + +from django.db.models import QuerySet +from django.test import override_settings + +from parameterized import parameterized +from rest_framework import status + +from posthog.models.integration import Integration +from posthog.models.organization import OrganizationMembership +from posthog.models.personal_api_key import PersonalAPIKey +from posthog.models.utils import hash_key_value + +if TYPE_CHECKING: + from rest_framework.response import _MonkeyPatchedResponse + +SANDBOX_SETTINGS = { + "WORKFLOWS_SANDBOX_SENDER_DOMAIN": "sandbox.example.com", + "SES_SANDBOX_FROM_ADDRESS": "hello@sandbox.example.com", +} + + +@override_settings(**SANDBOX_SETTINGS) +class TestSandboxSenderAPI(APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.boto3_client = self._patch("products.workflows.backend.providers.ses.boto3.client") + self.flag_enabled = self._patch("posthoganalytics.feature_enabled", return_value=True) + self.capture = self._patch("posthoganalytics.capture") + + def _patch(self, target: str, **kwargs: Any) -> MagicMock: + patcher = patch(target, **kwargs) + mock = patcher.start() + self.addCleanup(patcher.stop) + return mock + + def _ensure(self) -> "_MonkeyPatchedResponse": + return self.client.post(f"/api/projects/{self.team.id}/integrations/email_sandbox_sender/") + + def _sandbox_rows(self) -> QuerySet[Integration]: + return Integration.objects.filter(team=self.team, kind="email", integration_id="posthog-sandbox") + + def _provisioned_events(self) -> list[_Call]: + return [ + c for c in self.capture.call_args_list if c.kwargs.get("event") == "workflows sandbox sender provisioned" + ] + + def test_ensure_keeps_one_row_per_project_and_refreshes_its_name(self) -> None: + self.organization.name = "Acme" + self.organization.save() + + first = self._ensure() + assert first.status_code == status.HTTP_200_OK, first.json() + assert len(self._provisioned_events()) == 1 + self.organization.name = "Acme Rockets" + self.organization.save() + second = self._ensure() + + assert second.status_code == status.HTTP_200_OK, second.json() + row = self._sandbox_rows().get() + assert first.json()["id"] == second.json()["id"] == row.id + assert row.created_by is None + assert row.config == { + "provider": "sandbox", + "email": "hello@sandbox.example.com", + "domain": "sandbox.example.com", + "name": "Acme Rockets via PostHog", + "verified": True, + } + assert second.json()["config"]["name"] == "Acme Rockets via PostHog" + assert len(self._provisioned_events()) == 1 + self.boto3_client.assert_not_called() + + @parameterized.expand( + [ + ("symbols and markup dropped", "Acme ', + '

Hello

', + null, + ], + [ + '

Hello

', + '

Hello

', + null, + ], + ['', null], + ['Hello </body>', '<pre>Hello &lt;/body&gt;</pre>', null], + ['<body><p>Hello</p><template><plaintext>Example', '<p>Hello</p><template><pre>Example</pre></template>', null], + ['<head><template><plaintext>Example', '<head><template><pre>Example</pre></template></head>', null], + [ + '<body><p>Hello</p><noscript><style>p {color:blue}', + '<p>Hello</p><div><style>p {color:blue}</style></div>', + null, + ], + [ + '<body><p>Hello</p><noscript>&lt;plaintext&gt;Example</noscript></body>', + '<p>Hello</p><div>&lt;plaintext&gt;Example</div>', + null, + ], + [ + '<body><table><tbody><tr><td>Hello</td></tr></tbody></table></body>', + '<td>Hello</td>', + '<textarea>Preview text', + ], + ['<body><p>Hello</p><noscript><style></noscript><!--', '<p>Hello</p>', null], + [ + '<body><p>Hello</p><noscript><style>/* </noscript><plaintext> */ p {color:blue}</style></noscript>', + '<style>/* </noscript><plaintext> */ p {color:blue}</style>', + null, + ], + [ + '<body><p>Hello</p><noscript><p title="</noscript><style>">Fallback</p></noscript></body>', + '<p title="</noscript><style>">Fallback</p>', + null, + ], + [ + '<body><noscript><style></noscript><script></style></noscript>Hello', + '<style></noscript><script></style>', + null, + ], + ['<body style="background:#525252;color:white"><p>Hello</p></body>', '<p>Hello</p>', null], + ])('retains the footer outside template content: %s', async (html, expectedContent, preheader) => { + const result = await sender.withIdentificationFooter( + params(html, undefined, preheader ?? undefined), + 'Example organization via PostHog', + 1 + ) + expect(result.html).toContain(expectedContent) + for (const scriptingEnabled of [false, true]) { + const root = parse(result.html!, { scriptingEnabled }).childNodes.find(defaultTreeAdapter.isElementNode) + const body = root?.childNodes + .filter(defaultTreeAdapter.isElementNode) + .find((node) => node.tagName === 'body') + expect(body?.childNodes.at(-1)).toMatchObject({ + tagName: 'div', + childNodes: [{ nodeName: '#text', value: footer }], + }) + } + expect(result.preheader).toBeUndefined() + }) + it.each([ + ['<body><p>Hello</p><script><!--<script>', null, null], + ['<body><p>Hello</p><template><script><!--<script>', null, null], + ['', '', 'The sandbox email template must include HTML or text content. Update the template and try again.'], + ])('rejects content that cannot retain the footer: %s', async (html, text, error) => { + await expect( + sender.withIdentificationFooter(params(html, text ?? undefined), 'Example organization via PostHog', 1) + ).rejects.toThrow( + error ?? + 'The sandbox email template could not retain its identification footer. Update the template and try again.' + ) + }) +}) diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts new file mode 100644 index 000000000000..8a271c97538d --- /dev/null +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts @@ -0,0 +1,175 @@ +import { type DefaultTreeAdapterMap, defaultTreeAdapter, parse, parseFragment, serialize } from 'parse5' + +import { CyclotronInvocationQueueParametersEmailType } from '~/cdp/schema/cyclotron' +import { logger } from '~/common/utils/logger' +import { captureTeamEvent } from '~/common/utils/posthog' +import { TeamManager } from '~/common/utils/team-manager' + +import { maybeAddPreheaderToEmail } from './helpers/preheader' + +export interface SandboxEmailSenderConfig { + enabled: boolean + tenantName: string + configurationSetName: string + fromAddress: string +} + +const ROUTE_SETTING_NAMES = { + tenantName: 'SES_SANDBOX_TENANT_NAME', + configurationSetName: 'SES_SANDBOX_CONFIGURATION_SET', + fromAddress: 'SES_SANDBOX_FROM_ADDRESS', +} as const + +function emptyRouteSettings(config: SandboxEmailSenderConfig): string[] { + return (Object.keys(ROUTE_SETTING_NAMES) as (keyof typeof ROUTE_SETTING_NAMES)[]) + .filter((setting) => !config[setting].trim()) + .map((setting) => ROUTE_SETTING_NAMES[setting]) +} + +type SandboxEmailOutcome = + | { type: 'sent'; recipientCount: number } + | { type: 'blocked'; reason: 'switch_off'; blockedRecipientCount: number } + +function htmlBody(document: DefaultTreeAdapterMap['document']): DefaultTreeAdapterMap['element'] | undefined { + const root = document.childNodes.find(defaultTreeAdapter.isElementNode) + return root?.childNodes.find( + (node): node is DefaultTreeAdapterMap['element'] => + defaultTreeAdapter.isElementNode(node) && node.tagName === 'body' && node.namespaceURI === root.namespaceURI + ) +} + +function protectFooterAncestors( + document: DefaultTreeAdapterMap['document'], + body: DefaultTreeAdapterMap['element'] +): void { + const root = document.childNodes.find(defaultTreeAdapter.isElementNode)! + const visibleLayout = + 'display:block!important;position:static!important;overflow:visible!important;height:auto!important;max-height:none!important;clip:auto!important;clip-path:none!important;transform:none!important;opacity:1!important;visibility:visible!important;filter:none!important;contain:none!important;content-visibility:visible!important;zoom:1!important' + for (const ancestor of [root, body]) { + const style = ancestor.attrs.find((attribute) => attribute.name === 'style') + if (style) { + style.value = `${style.value};${visibleLayout}` + } else { + ancestor.attrs.push({ name: 'style', value: visibleLayout }) + } + } +} + +function appendHtmlFooter(html: string, footer: string, preheader?: string): string { + const preheaderText = parseFragment(preheader ?? '', { scriptingEnabled: false }) + const document = parse(maybeAddPreheaderToEmail(html, serialize(preheaderText, { scriptingEnabled: false })), { + scriptingEnabled: false, + }) + const body = htmlBody(document) + if (!body) { + throw new Error('The sandbox email template must have an HTML body. Update the template and try again.') + } + const nodes: DefaultTreeAdapterMap['childNode'][] = [...document.childNodes] + for (const node of nodes) { + if (defaultTreeAdapter.isElementNode(node)) { + if (node.tagName === 'plaintext' && node.namespaceURI === body.namespaceURI) { + node.tagName = 'pre' + } + if (node.tagName === 'noscript' && node.namespaceURI === body.namespaceURI) { + node.tagName = 'div' + } + nodes.push(...node.childNodes) + if (node.tagName === 'template' && node.namespaceURI === body.namespaceURI) { + nodes.push( + ...defaultTreeAdapter.getTemplateContent(node as DefaultTreeAdapterMap['template']).childNodes + ) + } + } + } + protectFooterAncestors(document, body) + const paragraph = defaultTreeAdapter.createElement('div', body.namespaceURI, [ + { + name: 'style', + value: 'all:initial!important;display:block!important;visibility:visible!important;opacity:1!important;font:12px sans-serif!important;color:#525252!important;background:#fff!important;padding:16px 0!important', + }, + ]) + defaultTreeAdapter.insertText(paragraph, footer) + defaultTreeAdapter.appendChild(body, paragraph) + const serialized = serialize(document, { scriptingEnabled: false }) + for (const scriptingEnabled of [false, true]) { + const deliveredFooter = htmlBody(parse(serialized, { scriptingEnabled }))?.childNodes.at(-1) + if ( + !deliveredFooter || + !defaultTreeAdapter.isElementNode(deliveredFooter) || + deliveredFooter.tagName !== 'div' || + deliveredFooter.namespaceURI !== body.namespaceURI || + deliveredFooter.attrs.find((attribute) => attribute.name === 'style')?.value !== paragraph.attrs[0].value || + deliveredFooter.childNodes.length !== 1 || + !defaultTreeAdapter.isTextNode(deliveredFooter.childNodes[0]) || + deliveredFooter.childNodes[0].value !== footer + ) { + throw new Error( + 'The sandbox email template could not retain its identification footer. Update the template and try again.' + ) + } + } + return serialized +} + +export class SandboxEmailSender { + public readonly canSend: boolean + + constructor( + public readonly config: SandboxEmailSenderConfig, + private teamManager: TeamManager + ) { + this.config = { + ...config, + tenantName: config.tenantName.trim(), + configurationSetName: config.configurationSetName.trim(), + fromAddress: config.fromAddress.trim(), + } + const emptySettings = emptyRouteSettings(config) + if (config.enabled && emptySettings.length) { + logger.error('The sandbox sender is switched on with empty route settings, so it skips every send', { + emptySettings, + }) + } + this.canSend = config.enabled && !emptySettings.length + } + + public async withIdentificationFooter( + params: CyclotronInvocationQueueParametersEmailType, + senderName: string, + teamId: number + ): Promise<CyclotronInvocationQueueParametersEmailType> { + if (!params.text && !params.html) { + throw new Error( + 'The sandbox email template must include HTML or text content. Update the template and try again.' + ) + } + const team = await this.teamManager.getTeam(teamId) + if (!team) { + throw new Error('Could not identify the organization sending this sandbox email. Try again.') + } + const footer = `This email was sent by ${senderName} with the PostHog sandbox sender. Organization ID: ${team.organization_id}.` + return { + ...params, + from: { ...params.from, name: senderName, email: this.config.fromAddress }, + replyTo: undefined, + preheader: undefined, + ...(params.text ? { text: `${params.text}\n\n${footer}` } : {}), + ...(params.html ? { html: appendHtmlFooter(params.html, footer, params.preheader) } : {}), + } + } + + public async capture(teamId: number, isTest: boolean, outcome: SandboxEmailOutcome): Promise<void> { + try { + const team = await this.teamManager.getTeam(teamId) + if (team) { + const properties = + outcome.type === 'sent' + ? { recipient_count: outcome.recipientCount, source: isTest ? 'test' : 'workflow' } + : { reason: outcome.reason, blocked_recipient_count: outcome.blockedRecipientCount } + captureTeamEvent(team, `workflows sandbox email ${outcome.type}`, { ...properties, is_test: isTest }) + } + } catch (error) { + logger.warn('Could not capture sandbox email event', { teamId, error }) + } + } +} diff --git a/nodejs/src/cdp/types.ts b/nodejs/src/cdp/types.ts index 25c0410db533..3610d04f787a 100644 --- a/nodejs/src/cdp/types.ts +++ b/nodejs/src/cdp/types.ts @@ -269,6 +269,7 @@ export type MinimalAppMetric = { | 'email_blocked' | 'email_unsubscribed' | 'email_untracked' + | 'email_sandbox_sent' | 'push_sent' | 'push_failed' | 'push_skipped' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86892802c719..86f88ee3cd84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1751,6 +1751,9 @@ importers: p-limit: specifier: 3.1.0 version: 3.1.0 + parse5: + specifier: 7.1.2 + version: 7.1.2 pg: specifier: ^8.6.0 version: 8.10.0 From f279cc9297141a855b4ab1dc82ab6cc8c7af0a95 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:27:11 +0000 Subject: [PATCH 03/38] feat(workflows): validate sandbox sender use at save time Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 1 + .../cdp/backend/presentation/serializers.py | 1 + products/cdp/backend/test/test_validation.py | 2 +- products/cdp/backend/validation.py | 112 +++++- .../backend/presentation/views/hog_flow.py | 41 ++- .../backend/services/broadcast_conversion.py | 55 ++- .../tests/api/test_broadcast_conversion.py | 46 +++ .../api/test_sandbox_sender_validation.py | 344 ++++++++++++++++++ 8 files changed, 569 insertions(+), 33 deletions(-) create mode 100644 products/workflows/backend/tests/api/test_sandbox_sender_validation.py diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 54be603427d3..fa14233c1387 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -25,6 +25,7 @@ The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Dj Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. +The sandbox sender is available only in workflow email steps and test sends. Broadcasts, destinations, sender rotation, custom From values, and Reply-To overrides are rejected. Unchanged stored sender settings remain editable after the flag turns off. ### Sending tiers diff --git a/products/cdp/backend/presentation/serializers.py b/products/cdp/backend/presentation/serializers.py index 78de717168e9..f1841ed412c5 100644 --- a/products/cdp/backend/presentation/serializers.py +++ b/products/cdp/backend/presentation/serializers.py @@ -8,4 +8,5 @@ InputsSchemaSerializer as InputsSchemaSerializer, InputsSerializer as InputsSerializer, require_viewer_access_to_added_actions as require_viewer_access_to_added_actions, + validate_sandbox_email_sender as validate_sandbox_email_sender, ) diff --git a/products/cdp/backend/test/test_validation.py b/products/cdp/backend/test/test_validation.py index fb45b2764a76..7a3f02513889 100644 --- a/products/cdp/backend/test/test_validation.py +++ b/products/cdp/backend/test/test_validation.py @@ -633,7 +633,7 @@ def _validate_email_from(self, from_value, existing_from=None, get_team=True, ca if get_team: context_extra["get_team"] = lambda: self.team if cache is not None: - context_extra["email_integration_domain_cache"] = cache + context_extra["email_integration_cache"] = cache return validate_inputs(inputs_schema, {"email": {"value": value}}, context_extra=context_extra) @parameterized.expand( diff --git a/products/cdp/backend/validation.py b/products/cdp/backend/validation.py index c0b8ad0de0ad..5e67bf77ae05 100644 --- a/products/cdp/backend/validation.py +++ b/products/cdp/backend/validation.py @@ -18,9 +18,10 @@ from posthog.hogql.parser import parse_program, parse_string_template from posthog.hogql.visitor import TraversingVisitor -from posthog.models.integration import POSTHOG_CONNECT_KIND, Integration +from posthog.models.integration import POSTHOG_CONNECT_KIND, SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.team.team import Team from posthog.models.user import User +from posthog.permissions import posthog_feature_flag_enabled from products.actions.backend.models.action import Action from products.cdp.backend.filters import ( @@ -81,13 +82,98 @@ def masked_secret_input_keys(stored_inputs: object) -> list[str]: def _sender_integration_ids(from_value: dict) -> set[int]: + rotation = from_value.get("integrationIds") return { - integration_id - for integration_id in [from_value.get("integrationId"), *(from_value.get("integrationIds") or [])] - if isinstance(integration_id, int) and not isinstance(integration_id, bool) + int(integration_id) + for integration_id in [from_value.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + if isinstance(integration_id, (int, float)) + and not isinstance(integration_id, bool) + and (not isinstance(integration_id, float) or integration_id.is_integer()) } +def _email_integrations(integration_ids: set[int], context: dict[str, Any]) -> dict[int, Integration | None]: + cache: dict[int, Integration | None] = context.setdefault("email_integration_cache", {}) + missing_ids = integration_ids - cache.keys() + if missing_ids: + integrations = { + integration.id: integration + for integration in Integration.objects.filter( + team_id=context["get_team"]().id, id__in=missing_ids, kind="email" + ) + } + cache.update({integration_id: integrations.get(integration_id) for integration_id in missing_ids}) + return cache + + +def validate_sandbox_email_sender(email_value: object, context: dict[str, Any]) -> None: + if not isinstance(email_value, dict) or not isinstance(email_value.get("from"), dict): + return + rotation = email_value["from"].get("integrationIds") + if isinstance(rotation, list) and len(rotation) > MAX_WORKFLOW_EMAIL_SENDERS: + raise serializers.ValidationError({"input": f"At most {MAX_WORKFLOW_EMAIL_SENDERS} email senders are allowed."}) + integration_ids = _sender_integration_ids(email_value["from"]) + get_team = context.get("get_team") + if not integration_ids or get_team is None: + return + integrations = _email_integrations(integration_ids, context) + if not any( + (integration := integrations[integration_id]) is not None + and (integration.config or {}).get("provider") == SANDBOX_EMAIL_PROVIDER + for integration_id in integration_ids + ): + return + if context.get("workflow_origin_product") == "broadcasts": + raise serializers.ValidationError( + {"input": "The sandbox sender cannot be used in broadcasts. Select a sender on your own verified domain."} + ) + is_test_send = getattr(context.get("view"), "action", None) == "invocations" + if context.get("workflow_action_type") != "function_email" and not is_test_send: + raise serializers.ValidationError( + { + "input": "The sandbox sender can only be used in workflow email steps and test sends. " + "Select a sender on your own verified domain for destinations." + } + ) + for stored in context.get("existing_email_values") or []: + if ( + isinstance(stored, dict) + and email_value["from"] == stored.get("from") + and (email_value.get("replyTo") or "") == (stored.get("replyTo") or "") + ): + return + if len(integration_ids) != 1: + raise serializers.ValidationError( + { + "input": "The sandbox sender must be the only sender. Remove the other senders " + "or select a sender on your own verified domain." + } + ) + if email_value["from"].get("email") or email_value["from"].get("name") or email_value.get("replyTo"): + raise serializers.ValidationError( + { + "input": "The sandbox sender uses a fixed From address and name and does not support Reply-To. " + "Remove these overrides or select a sender on your own verified domain." + } + ) + team = get_team() + flag_cache: dict[int, bool] = context.setdefault("sandbox_sender_enabled_cache", {}) + if team.id not in flag_cache: + try: + flag_cache[team.id] = posthog_feature_flag_enabled( + "workflows-sandbox-sender", str(team.uuid), organization_id=team.organization_id, team_id=team.id + ) + except Exception: + flag_cache[team.id] = False + if not flag_cache[team.id]: + raise serializers.ValidationError( + { + "input": "The sandbox sender is not available for this project. " + "Select a sender on your own verified domain." + } + ) + + def _validate_not_posthog_connection(integration_ids: list[int], context: dict) -> None: # A PostHog connection acts as the user who created it, so only that user may use it. A function # runs for the whole team and the runtime never resolves one, so reject it where the author sees why. @@ -146,20 +232,11 @@ def _validate_email_sender_override(from_value: dict, context: dict) -> None: return override_domain = override.split("@")[1].lower() - # An empty cached domain means the id resolved to no email integration for this team; the - # save is not blocked on it (there is no domain to compare), matching the uncached behavior. - shared_cache = context.get("email_integration_domain_cache") - domain_cache: dict[int, str] = shared_cache if isinstance(shared_cache, dict) else {} - missing_ids = [integration_id for integration_id in integration_ids if integration_id not in domain_cache] - if missing_ids: - for integration in Integration.objects.filter(team_id=get_team().id, id__in=missing_ids, kind="email"): - config = integration.config or {} - domain_cache[integration.id] = (config.get("domain") or (config.get("email") or "").split("@")[-1]).lower() - for integration_id in missing_ids: - domain_cache.setdefault(integration_id, "") - + integrations = _email_integrations(integration_ids, context) for integration_id in sorted(integration_ids): - integration_domain = domain_cache.get(integration_id) or "" + integration = integrations[integration_id] + config = (integration.config or {}) if integration is not None else {} + integration_domain = (config.get("domain") or (config.get("email") or "").split("@")[-1]).lower() if integration_domain and override_domain != integration_domain: raise serializers.ValidationError( { @@ -851,6 +928,7 @@ def validate(self, attrs): elif item_type == "email" or item_type == "native_email": if not isinstance(value, dict): raise serializers.ValidationError({"input": f"Value must be an email object."}) + validate_sandbox_email_sender(value, self.context) # Report every missing key in one error: these objects are typically authored # programmatically, and a one-at-a-time raise forces a round trip per missing key. missing = [f"'{key_}'" for key_ in ("from", "to", "subject") if not value.get(key_)] diff --git a/products/workflows/backend/presentation/views/hog_flow.py b/products/workflows/backend/presentation/views/hog_flow.py index aef760b3c2a6..217e1797efa7 100644 --- a/products/workflows/backend/presentation/views/hog_flow.py +++ b/products/workflows/backend/presentation/views/hog_flow.py @@ -128,6 +128,7 @@ InputsSchemaSerializer, InputsSerializer, require_viewer_access_to_added_actions, + validate_sandbox_email_sender, ) from products.cohorts.backend.facade.api import get_cohort_ref from products.messaging.backend.facade.categories import MessageCategoryMissing, get_category @@ -1074,14 +1075,14 @@ def _has_exact_string_filter(filters: dict, key: str) -> bool: return False -def _existing_email_from_by_action(instance: WorkflowEditState) -> dict[str, list[dict]]: - """Stored email sender overrides keyed by action id, live and draft variants both kept. +def _existing_email_values_by_action(instance: WorkflowEditState) -> dict[str, list[tuple[str | None, dict]]]: + """Stored email inputs keyed by action id, live and draft variants both kept. Which variant a save should be compared against depends on how the request routes (a builder save targets the draft, a raw API write targets live), so validation grandfathers a value matching either - both are values already stored on the workflow. """ - result: dict[str, list[dict]] = {} + result: dict[str, list[tuple[str | None, dict]]] = {} draft = instance.draft if isinstance(instance.draft, dict) else {} for actions in (instance.actions, draft.get("actions")): for stored_action in actions or []: @@ -1090,8 +1091,8 @@ def _existing_email_from_by_action(instance: WorkflowEditState) -> dict[str, lis email_input = ((stored_action.get("config") or {}).get("inputs") or {}).get("email") value = email_input.get("value") if isinstance(email_input, dict) else None from_value = value.get("from") if isinstance(value, dict) else None - if isinstance(from_value, dict): - result.setdefault(stored_action["id"], []).append(from_value) + if isinstance(value, dict) and isinstance(from_value, dict): + result.setdefault(stored_action["id"], []).append((stored_action.get("type"), value)) return result @@ -2081,6 +2082,23 @@ def _subscribes_to(event_id: str) -> bool: else: input_schema = template.inputs_schema inputs = data.get("config", {}).get("inputs", {}) + existing_emails = (self.context.get("existing_action_emails") or {}).get(data.get("id")) or [] + input_context = { + **self.context, + "workflow_action_type": data.get("type"), + "existing_email_values": [ + email for action_type, email in existing_emails if action_type == "function_email" + ], + # Request-scoped: a drip sequence's steps share senders, and the actions + # list validates one action at a time (mirrors _message_template_cache). + "email_integration_cache": self.context.setdefault("_email_integration_cache", {}), + "sandbox_sender_enabled_cache": self.context.setdefault("_sandbox_sender_enabled_cache", {}), + } + for schema in input_schema or []: + if schema.get("type") in {"email", "native_email"} and isinstance(inputs, dict): + email_input = inputs.get(schema["key"]) + if isinstance(email_input, dict): + validate_sandbox_email_sender(email_input.get("value"), input_context) function_config_serializer = HogFlowConfigFunctionInputsSerializer( data={ @@ -2088,6 +2106,7 @@ def _subscribes_to(event_id: str) -> bool: "inputs": inputs, }, context={ + **input_context, "function_type": template.type, "is_dwh_source": self.context.get("is_dwh_source", False), # The existing (decrypted) secret inputs for this action, so a resent @@ -2098,14 +2117,7 @@ def _subscribes_to(event_id: str) -> bool: # sender address is checked against the integration's verified domain at # save time, while an unchanged stored value is grandfathered. "get_team": self.context.get("get_team"), - "existing_email_from": (self.context.get("existing_action_email_from") or {}).get( - data.get("id") - ), - # Request-scoped: a drip sequence's steps share senders, and the actions - # list validates one action at a time (mirrors _message_template_cache). - "email_integration_domain_cache": self.context.setdefault( - "_email_integration_domain_cache", {} - ), + "existing_email_from": [email["from"] for _, email in existing_emails], }, ) @@ -3402,6 +3414,7 @@ def to_internal_value(self, data): # When used as a nested field (the `configuration` override on test invocations) DRF never # binds `self.instance`, so fall back to the flow passed in via context so recovery still works. instance = cast(Optional[WorkflowEditState], self.instance) or self.context.get("instance") + self.context["workflow_origin_product"] = instance.origin_product if instance else data.get("origin_product") # Who a "Create AI task" step runs as: the existing creator for an update, or the # requesting user for a brand-new flow (matches the `created_by` a create() actually @@ -3518,7 +3531,7 @@ def to_internal_value(self, data): # Stored sender overrides, keyed by action id, so child action validation only holds # newly written custom sender addresses to the verified-domain rule. Draft wins over # live for the same reason as secrets: it is the value the client last saw. - self.context["existing_action_email_from"] = _existing_email_from_by_action(instance) + self.context["existing_action_emails"] = _existing_email_values_by_action(instance) # Live "Run scout" notes, so only a new or changed note needs skill editor access. self.context["live_run_scout_notes"] = _live_run_scout_notes_by_action(instance) diff --git a/products/workflows/backend/services/broadcast_conversion.py b/products/workflows/backend/services/broadcast_conversion.py index 63d72ca384eb..0bb24b6db6d4 100644 --- a/products/workflows/backend/services/broadcast_conversion.py +++ b/products/workflows/backend/services/broadcast_conversion.py @@ -6,6 +6,8 @@ from django.db import models, transaction from django.utils import timezone +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration + from products.access_control.backend.facade.user_access_control import UserAccessControl from products.workflows.backend.facade.contracts import WorkflowActor, WorkflowSummary from products.workflows.backend.models.hog_flow.hog_flow import HogFlow @@ -37,9 +39,44 @@ def is_convertible_to_broadcast(flow: HogFlow) -> bool: config = trigger.get("config") or {} if config.get("type") != BROADCAST_TRIGGER_TYPE or (config.get("filters") or {}).get("audience_type") == "accounts": return False + if _sender_ids(_email_sender(email)) & _sandbox_sender_ids(flow.team_id): + return False return _joins_in_order(flow, trigger.get("id"), email.get("id"), exit_step.get("id")) +def _email_sender(email_step: dict) -> object: + """The step's stored `from` value. Drafts save leniently, so any level can hold another type.""" + value: object = email_step + for key in ("config", "inputs", "email", "value", "from"): + value = value.get(key) if isinstance(value, dict) else None + return value + + +def _sender_ids(sender: object) -> set[str]: + """The integrations a `from` value sends from, as strings, since stored ids can be ints, floats or strings.""" + if not isinstance(sender, dict): + return set() + rotation = sender.get("integrationIds") + ids = [sender.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + return {_integration_id_key(integration_id) for integration_id in ids if integration_id is not None} + + +def _integration_id_key(integration_id: object) -> str: + if isinstance(integration_id, float) and integration_id.is_integer(): + return str(int(integration_id)) + return str(integration_id) + + +def _sandbox_sender_ids(team_id: int) -> set[str]: + """Broadcasts can't send through the sandbox sender, so a workflow that uses it stays a workflow.""" + return { + str(integration_id) + for integration_id in Integration.objects.filter( + team_id=team_id, kind="email", config__provider=SANDBOX_EMAIL_PROVIDER + ).values_list("id", flat=True) + } + + def _joins_in_order(flow: HogFlow, trigger_id: object, email_id: object, exit_id: object) -> bool: """Whether the only edges run trigger to email and email to exit.""" paths = {(edge.get("from"), edge.get("to")) for edge in flow.edges or [] if isinstance(edge, dict)} @@ -60,6 +97,15 @@ def _step_count(step_type: str) -> models.Func: return models.Func(steps, function="jsonb_array_length", output_field=models.IntegerField()) +def _email_sender_value() -> models.Func: + return models.Func( + models.F("actions"), + _json_path('$[*] ? (@.type == "function_email").config.inputs.email.value.from'), + function="jsonb_path_query_first", + output_field=models.JSONField(), + ) + + def _step_id(step_type: str) -> models.Func: return models.Func( models.F("actions"), @@ -91,6 +137,7 @@ def list_broadcast_conversion_candidates( _exit_count=_step_count("exit"), _trigger_id=_step_id("trigger"), _email_id=_step_id("function_email"), + _email_sender=_email_sender_value(), _exit_id=_step_id("exit"), _account_audience=models.Func( models.F("actions"), @@ -111,6 +158,7 @@ def list_broadcast_conversion_candidates( .only("id", "team_id", "name", "status", "edges", "created_by") .order_by("-updated_at", "id") ) + sandbox_sender_ids = _sandbox_sender_ids(team_id) found: list[HogFlow] = [] offset = 0 while len(found) < limit: @@ -118,7 +166,12 @@ def list_broadcast_conversion_candidates( if not page: break offset += len(page) - page = [flow for flow in page if _joins_in_order(flow, flow._trigger_id, flow._email_id, flow._exit_id)] + page = [ + flow + for flow in page + if _joins_in_order(flow, flow._trigger_id, flow._email_id, flow._exit_id) + and not _sender_ids(flow._email_sender) & sandbox_sender_ids + ] if user_access_control is not None: # Converting needs editor access, so a workflow the caller can only view is not offered. user_access_control.preload_object_access_controls(cast("list[models.Model]", page)) diff --git a/products/workflows/backend/tests/api/test_broadcast_conversion.py b/products/workflows/backend/tests/api/test_broadcast_conversion.py index 54498d98385f..b6bb1bed62d1 100644 --- a/products/workflows/backend/tests/api/test_broadcast_conversion.py +++ b/products/workflows/backend/tests/api/test_broadcast_conversion.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from typing import Any from posthog.test.base import APIBaseTest @@ -7,6 +8,7 @@ from rest_framework import status from posthog.models.activity_logging.activity_log import ActivityLog +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.models import HogFlow @@ -68,6 +70,11 @@ def _convert(self, flow: HogFlow) -> Any: ), ("extra step", {"actions": [TRIGGER, EMAIL, DELAY, EXIT]}, False), ("edge that skips the email", {"edges": [*EDGES, {"from": "trigger_node", "to": "exit_node"}]}, False), + ( + "email step without a sender object", + {"actions": [TRIGGER, {**EMAIL, "config": {"inputs": {"email": {"value": "draft"}}}}, EXIT]}, + True, + ), ] ) def test_only_workflows_the_broadcast_editor_can_own_are_offered_and_converted( @@ -91,6 +98,45 @@ def test_only_workflows_the_broadcast_editor_can_own_are_offered_and_converted( assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() assert flow.origin_product == overrides.get("origin_product") + @parameterized.expand( + [ + ("sandbox sender", {"integrationId": "sandbox"}, int, False), + ("sandbox sender stored as a whole-number float", {"integrationId": "sandbox"}, float, False), + ("sandbox sender stored as a string", {"integrationId": "sandbox"}, str, False), + ("sandbox sender in a rotation", {"integrationIds": ["own", "sandbox"]}, int, False), + ("own sender", {"integrationId": "own"}, int, True), + ] + ) + def test_a_workflow_sending_through_the_sandbox_sender_does_not_become_a_broadcast( + self, _name: str, sender: dict[str, Any], stored_as: Callable[[int], Any], convertible: bool + ) -> None: + integration_ids = { + "sandbox": Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ).id, + "own": Integration.objects.create( + team=self.team, + kind="email", + integration_id="sender@example.com", + config={"provider": "ses", "domain": "example.com", "verified": True}, + ).id, + } + sender_from = { + key: [stored_as(integration_ids[name]) for name in value] + if isinstance(value, list) + else stored_as(integration_ids[value]) + for key, value in sender.items() + } + email = {**EMAIL, "config": {"inputs": {"email": {"value": {"from": sender_from}}}}} + flow = self._flow(actions=[TRIGGER, email, EXIT]) + + assert (str(flow.id) in self._candidate_ids()) is convertible + response = self._convert(flow) + assert (response.status_code == status.HTTP_200_OK) is convertible, response.json() + def test_candidates_stop_at_the_cap_and_say_more_exist(self) -> None: newest = self._flow(name="Newest") self._flow(name="Broken", edges=[{"from": "trigger_node", "to": "exit_node"}]) diff --git a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py new file mode 100644 index 000000000000..6b0abff0348c --- /dev/null +++ b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py @@ -0,0 +1,344 @@ +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.db import connection +from django.test import override_settings +from django.test.utils import CaptureQueriesContext + +from parameterized import parameterized + +from posthog.models.integration import Integration + +from products.cdp.backend.api.hog_function import HogFunctionInvocationSerializer +from products.cdp.backend.models.hog_function_template import HogFunctionTemplate +from products.workflows.backend.facade.api import ensure_sandbox_email_sender +from products.workflows.backend.facade.workflows import get_workflow_edit_state +from products.workflows.backend.models.hog_flow import HogFlow +from products.workflows.backend.presentation.views.hog_flow import HogFlowInvocationSerializer + + +@override_settings( + WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", + SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", +) +class TestSandboxSenderValidation(APIBaseTest): + def setUp(self) -> None: + super().setUp() + flag = patch("posthoganalytics.feature_enabled", return_value=True) + self.flag_enabled = flag.start() + self.addCleanup(flag.stop) + capture = patch("posthoganalytics.capture") + capture.start() + self.addCleanup(capture.stop) + self.sender = Integration.objects.get( + team_id=self.team.id, id=ensure_sandbox_email_sender(self.team.id).integration_id + ) + self.email_schema = [{"key": "email", "type": "native_email", "required": True, "templating": "liquid"}] + HogFunctionTemplate.objects.create( + template_id="template-email", + name="Email", + type="destination", + status="hidden", + code="return sendEmail(inputs.email);", + inputs_schema=self.email_schema, + ) + + def _workflow(self) -> dict[str, Any]: + return { + "name": "Sandbox workflow", + "actions": [ + { + "id": "trigger", + "type": "trigger", + "name": "Trigger", + "config": { + "type": "event", + "filters": {"events": [{"id": "$pageview", "type": "events", "order": 0}]}, + }, + }, + { + "id": "email", + "type": "function_email", + "name": "Send email", + "config": {"inputs": {"email": {"value": self._email()}}}, + }, + {"id": "exit", "type": "exit", "name": "Exit", "config": {}}, + ], + "edges": [ + {"from": "trigger", "to": "email", "type": "continue"}, + {"from": "email", "to": "exit", "type": "continue"}, + ], + } + + def _email(self) -> dict[str, Any]: + return { + "from": {"integrationId": self.sender.id}, + "to": "member@example.com", + "subject": "Hello", + "html": "<p>Hello</p>", + } + + def _function(self) -> dict[str, Any]: + return { + "name": "Sandbox destination", + "type": "destination", + "hog": "return 1;", + "inputs_schema": self.email_schema, + "inputs": {"email": {"value": self._email()}}, + } + + @parameterized.expand([("integer",), ("float",), ("malformed_rotation",)]) + def test_broadcast_cannot_select_the_sandbox_sender(self, sender_shape: str) -> None: + workflow = self._workflow() + workflow["origin_product"] = "broadcasts" + sender = workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"] + if sender_shape == "float": + sender["integrationId"] = float(self.sender.id) + elif sender_shape == "malformed_rotation": + sender["integrationIds"] = self.sender.id + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "sandbox sender" in response.json()["detail"].lower() + assert "broadcast" in response.json()["detail"].lower() + + def test_destination_cannot_select_the_sandbox_sender(self) -> None: + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + self._function(), + ) + + assert response.status_code == 400, response.json() + assert "sandbox sender" in response.json()["detail"].lower() + assert "workflow email steps and test sends" in response.json()["detail"].lower() + + def test_generic_workflow_step_cannot_select_the_sandbox_sender_with_a_legacy_email_schema(self) -> None: + template = HogFunctionTemplate.objects.get(template_id="template-email") + template.inputs_schema = [{**self.email_schema[0], "type": "email"}] + template.save() + workflow = self._workflow() + workflow["actions"][1]["type"] = "function" + workflow["actions"][1]["config"]["template_id"] = "template-email" + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "workflow email steps and test sends" in response.json()["detail"].lower() + + def test_workflow_can_select_the_sandbox_sender_alone(self) -> None: + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + + assert response.status_code == 201, response.json() + assert response.json()["actions"][1]["config"]["inputs"]["email"]["value"]["from"] == { + "integrationId": self.sender.id + } + + @parameterized.expand([("exit",), ("function",)]) + def test_inert_email_data_is_not_an_existing_sandbox_sender(self, action_type: str) -> None: + workflow = self._workflow() + action = workflow["actions"][1] + action["type"] = action_type + action["config"]["template_id"] = "missing-template" + email = action["config"]["inputs"]["email"]["value"] + email["from"]["name"] = "New sender" + email["replyTo"] = "reply@example.com" + created = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + assert created.status_code == 201, created.json() + action["type"] = "function_email" + action["config"].pop("template_id") + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{created.json()['id']}", + {"actions": workflow["actions"]}, + ) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + + def test_sandbox_sender_cannot_rotate_with_own_senders(self) -> None: + own_sender = Integration.objects.create( + team=self.team, + kind="email", + config={"provider": "ses", "domain": "example.com", "email": "sender@example.com", "verified": True}, + ) + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"] = { + "integrationIds": [self.sender.id, own_sender.id] + } + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "sandbox sender must be the only sender" in response.json()["detail"].lower() + + def test_oversized_sender_rotation_is_rejected_before_looking_up_integrations(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"]["integrationIds"] = list(range(100)) + + with CaptureQueriesContext(connection) as queries: + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "At most 10 email senders" in response.json()["detail"] + assert not [query for query in queries if 'FROM "posthog_integration"' in query["sql"]] + + @parameterized.expand( + [ + ("custom From address", "email", "custom@sandbox.example.com"), + ("templated From address", "email", "{{ person.properties.email }}"), + ("custom From name", "name", "Custom sender"), + ("Reply-To", "replyTo", "reply@example.com"), + ] + ) + def test_sandbox_sender_cannot_use_sender_overrides(self, _name: str, field: str, value: str) -> None: + workflow = self._workflow() + email = workflow["actions"][1]["config"]["inputs"]["email"]["value"] + if field == "replyTo": + email[field] = value + else: + email["from"][field] = value + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + assert "Reply-To" in response.json()["detail"] + + def test_workflow_cannot_select_the_sandbox_sender_after_the_flag_turns_off(self) -> None: + self.flag_enabled.return_value = False + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + + assert response.status_code == 400, response.json() + assert "sandbox sender is not available for this project" in response.json()["detail"].lower() + + def test_unchanged_sandbox_sender_keeps_saving_after_the_flag_turns_off(self) -> None: + created = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + assert created.status_code == 201, created.json() + actions = created.json()["actions"] + actions[1]["config"]["inputs"]["email"]["value"]["subject"] = "Updated subject" + self.flag_enabled.return_value = False + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{created.json()['id']}", + {"name": "Renamed workflow", "actions": actions}, + ) + + assert response.status_code == 200, response.json() + email = response.json()["actions"][1]["config"]["inputs"]["email"]["value"] + assert email["from"] == {"integrationId": self.sender.id} + assert email["subject"] == "Updated subject" + + def test_test_send_configuration_can_select_the_sandbox_sender(self) -> None: + serializer = HogFunctionInvocationSerializer( + data={"configuration": self._function()}, + context={"get_team": lambda: self.team, "view": SimpleNamespace(action="invocations")}, + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["configuration"]["inputs"]["email"]["value"]["from"] == { + "integrationId": self.sender.id + } + + @parameterized.expand([("missing",), ("cleared",)]) + def test_broadcast_test_cannot_clear_its_origin(self, origin: str) -> None: + workflow = self._workflow() + stored = HogFlow.objects.create( + team=self.team, + name="Broadcast", + origin_product="broadcasts", + actions=workflow["actions"], + edges=workflow["edges"], + ) + if origin == "cleared": + workflow["origin_product"] = None + serializer = HogFlowInvocationSerializer( + data={"configuration": workflow}, + context={ + "get_team": lambda: self.team, + "view": SimpleNamespace(action="invocations"), + "instance": get_workflow_edit_state( + team_id=self.team.id, workflow_id=stored.id, user_access_control=None, required_level=None + ), + }, + ) + + assert not serializer.is_valid(), serializer.validated_data + assert "broadcast" in str(serializer.errors).lower() + + @parameterized.expand([("live",), ("draft",)]) + def test_unchanged_legacy_sender_survives_unrelated_edits(self, variant: str) -> None: + live = self._workflow() + live_email = live["actions"][1]["config"]["inputs"]["email"]["value"] + live_email["from"].update({"email": "legacy@example.com", "name": "Legacy sender"}) + live_email["replyTo"] = "legacy-reply@example.com" + draft = deepcopy(live) + draft_email = draft["actions"][1]["config"]["inputs"]["email"]["value"] + draft_email["from"]["name"] = "Draft legacy sender" + draft_email["replyTo"] = "draft-reply@example.com" + stored = HogFlow.objects.create( + team=self.team, + name="Legacy workflow", + actions=live["actions"], + edges=live["edges"], + status=HogFlow.State.ACTIVE, + draft=draft, + ) + actions = deepcopy(live["actions"] if variant == "live" else draft["actions"]) + actions[1]["config"]["inputs"]["email"]["value"]["subject"] = "Updated legacy subject" + self.flag_enabled.return_value = False + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{stored.id}", + {"name": "Renamed legacy workflow", "actions": actions}, + ) + + assert response.status_code == 200, response.json() + + @parameterized.expand([("email",), ("name",), ("replyTo",)]) + def test_legacy_sender_overrides_cannot_be_changed(self, field: str) -> None: + workflow = self._workflow() + email = workflow["actions"][1]["config"]["inputs"]["email"]["value"] + email["from"].update({"email": "legacy@example.com", "name": "Legacy sender"}) + email["replyTo"] = "legacy-reply@example.com" + stored = HogFlow.objects.create( + team=self.team, + name="Legacy workflow", + actions=workflow["actions"], + edges=workflow["edges"], + status=HogFlow.State.ACTIVE, + ) + if field == "replyTo": + email[field] = "changed@example.com" + else: + email["from"][field] = "changed@example.com" if field == "email" else "Changed sender" + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{stored.id}", + {"actions": workflow["actions"]}, + ) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + + def test_malformed_sender_rotation_returns_a_validation_error(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"]["integrationIds"] = self.sender.id + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow, HTTP_X_POSTHOG_CLIENT="mcp") + + assert response.status_code == 400, response.json() + assert "list of Integration IDs" in response.json()["detail"] + + def test_null_email_inputs_return_a_validation_error(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"] = None + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow, HTTP_X_POSTHOG_CLIENT="mcp") + + assert response.status_code == 400, response.json() From 30fb28b0ec9edda26e831b55c5537a693a5091ad Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:28:41 +0000 Subject: [PATCH 04/38] feat(workflows): restrict sandbox email to verified org members Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 1 + nodejs/src/cdp/cdp-services.ts | 7 +- .../organization-members.service.test.ts | 74 ++ .../managers/organization-members.service.ts | 127 +++ .../services/messaging/email.service.test.ts | 758 +++++++++++++++++- .../cdp/services/messaging/email.service.ts | 280 ++++++- .../messaging/sandbox-email-sender.ts | 26 +- nodejs/src/cdp/utils.ts | 1 + 8 files changed, 1220 insertions(+), 54 deletions(-) create mode 100644 nodejs/src/cdp/services/managers/organization-members.service.test.ts create mode 100644 nodejs/src/cdp/services/managers/organization-members.service.ts diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index fa14233c1387..d1378b2726b2 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -85,5 +85,6 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent. Sandbox email uses a fixed SES tenant, configuration set, and From address. Set `SES_SANDBOX_TENANT_NAME`, `SES_SANDBOX_CONFIGURATION_SET`, and `SES_SANDBOX_FROM_ADDRESS` on the worker. Django reads the same `SES_SANDBOX_FROM_ADDRESS` for the address it stores and shows. The address must belong to the shared SES identity. Sandbox sends disable click tracking and unsubscribe headers because the complete stack restricts them to verified organization members for internal testing. Marketing delivery to external audiences requires a verified sender. +Every To, CC, and BCC address must belong to an active organization member with a verified email address. Membership snapshots expire within one minute, and the worker checks recipients again immediately before delivery. A failed or stale membership check skips the send and explains the blocked addresses in the run log. HTML is parsed and normalized before the identification footer is appended. Root clipping and visibility styles are overridden while template typography is preserved; malformed markup may render differently from an own-domain send. Preview text is parsed separately so escaped template values remain readable. The visible footer is best effort across email clients and arbitrary template CSS. Signed message tracking headers retain sender attribution independently of the template. diff --git a/nodejs/src/cdp/cdp-services.ts b/nodejs/src/cdp/cdp-services.ts index ae8961673f6a..3b60a0e40a33 100644 --- a/nodejs/src/cdp/cdp-services.ts +++ b/nodejs/src/cdp/cdp-services.ts @@ -31,6 +31,7 @@ import { InvocationResultsService } from './services/invocation-results.service' import { HogFunctionManagerService } from './services/managers/hog-function-manager.service' import { HogFunctionTemplateManagerService } from './services/managers/hog-function-template-manager.service' import { IntegrationManagerService } from './services/managers/integration-manager.service' +import { OrganizationMembersService } from './services/managers/organization-members.service' import { PersonsManagerService } from './services/managers/persons-manager.service' import { RecipientsManagerService } from './services/managers/recipients-manager.service' import { TeamWorkflowsConfigService } from './services/managers/team-workflows-config.service' @@ -486,8 +487,10 @@ export function createCdpCoreServices( configurationSetName: config.SES_SANDBOX_CONFIGURATION_SET, fromAddress: config.SES_SANDBOX_FROM_ADDRESS, }, - deps.teamManager - ) + deps.teamManager, + redis + ), + new OrganizationMembersService(deps.postgres) ) const recipientTokensService = new RecipientTokensService(config.ENCRYPTION_SALT_KEYS, config.SITE_URL) const hogInputsService = new HogInputsService(deps.integrationManager, recipientTokensService, deps.encryptedFields) diff --git a/nodejs/src/cdp/services/managers/organization-members.service.test.ts b/nodejs/src/cdp/services/managers/organization-members.service.test.ts new file mode 100644 index 000000000000..e52ebfceb53e --- /dev/null +++ b/nodejs/src/cdp/services/managers/organization-members.service.test.ts @@ -0,0 +1,74 @@ +import { PostgresRouter } from '~/common/utils/db/postgres' + +import { OrganizationMembersService } from './organization-members.service' + +describe('sandbox organization membership decisions', () => { + it('fails closed when a warmed organization mapping expires during a member refresh', async () => { + let now = -10_000 + const organizationId = '00000000-0000-0000-0000-000000000001' + const query = jest.fn((_use, _sql, params, operation) => { + if (operation === 'fetch-sandbox-team-organization') { + return Promise.resolve({ + rows: params[0].map((id: number) => ({ id, organization_id: organizationId })), + }) + } + if (now > 0) { + now = 63_000 + } + return Promise.resolve({ + rows: [{ organization_id: organizationId, email: 'member@example.com', is_email_verified: true }], + }) + }) + const service = new OrganizationMembersService({ query } as unknown as PostgresRouter, () => now) + expect(await service.getBlockedRecipients(2, ['member@example.com'])).toEqual([]) + now = 0 + await service.getOrganizationSnapshot(1) + now = 58_000 + + await expect(service.getBlockedRecipients(1, ['member@example.com'])).rejects.toThrow('expired') + }) + + it('rejects a member response that outlasts its one-minute decision', async () => { + let now = 0 + const organizationId = '00000000-0000-0000-0000-000000000001' + const query = jest + .fn() + .mockResolvedValueOnce({ rows: [{ id: 1, organization_id: organizationId }] }) + .mockImplementationOnce(() => { + now = 60_000 + return Promise.resolve({ + rows: [{ organization_id: organizationId, email: 'member@example.com', is_email_verified: true }], + }) + }) + const service = new OrganizationMembersService({ query } as unknown as PostgresRouter, () => now) + + await expect(service.getBlockedRecipients(1, ['member@example.com'])).rejects.toThrow('expired') + }) + + it('shares a bounded organization lookup across repeated sends and refreshes it after a minute', async () => { + let now = 0 + let organizationId = '00000000-0000-0000-0000-000000000001' + const query = jest.fn((_use, _sql, _params, operation) => + Promise.resolve({ + rows: + operation === 'fetch-sandbox-team-organization' + ? [{ id: 1, organization_id: organizationId }] + : [{ organization_id: organizationId, email: 'member@example.com', is_email_verified: true }], + }) + ) + const service = new OrganizationMembersService({ query } as unknown as PostgresRouter, () => now) + + expect(await service.getBlockedRecipients(1, ['member@example.com'])).toEqual([]) + expect(await service.getBlockedRecipients(1, ['member@example.com'])).toEqual([]) + expect( + query.mock.calls.filter(([, , , operation]) => operation === 'fetch-sandbox-team-organization') + ).toHaveLength(1) + + now = 60_001 + organizationId = '00000000-0000-0000-0000-000000000002' + expect(await service.getBlockedRecipients(1, ['member@example.com'])).toEqual([]) + expect( + query.mock.calls.filter(([, , , operation]) => operation === 'fetch-sandbox-team-organization') + ).toHaveLength(2) + }) +}) diff --git a/nodejs/src/cdp/services/managers/organization-members.service.ts b/nodejs/src/cdp/services/managers/organization-members.service.ts new file mode 100644 index 000000000000..14dd567233cf --- /dev/null +++ b/nodejs/src/cdp/services/managers/organization-members.service.ts @@ -0,0 +1,127 @@ +import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres' +import { LazyLoader } from '~/common/utils/lazy-loader' + +type OrganizationMemberSnapshot = { + verifiedEmails: ReadonlySet<string> + unverifiedEmails: ReadonlySet<string> + expiresAt: number +} + +export type BlockedRecipient = { readonly address: string; readonly unverifiedMember: boolean } + +export type TeamOrganizationSnapshot = { readonly organizationId: string; readonly expiresAt: number } + +const MAX_SNAPSHOT_AGE_MS = 60_000 +const REFRESH_AGE_MS = MAX_SNAPSHOT_AGE_MS - 1_000 + +export class OrganizationMembersService { + private snapshots: LazyLoader<OrganizationMemberSnapshot> + private organizations: LazyLoader<TeamOrganizationSnapshot> + + constructor( + postgres: PostgresRouter, + private now: () => number = Date.now + ) { + this.organizations = new LazyLoader({ + name: 'sandbox_team_organizations', + refreshAgeMs: REFRESH_AGE_MS, + refreshJitterMs: 0, + bufferMs: 0, + loader: async (teamIds) => { + const expiresAt = this.now() + MAX_SNAPSHOT_AGE_MS + const { rows } = await postgres.query<{ id: number; organization_id: string }>( + PostgresUse.COMMON_WRITE, + 'SELECT id, organization_id FROM posthog_team WHERE id = ANY($1::bigint[])', + [teamIds.map(Number)], + 'fetch-sandbox-team-organization' + ) + return Object.fromEntries( + rows.map((team) => [String(team.id), { organizationId: team.organization_id, expiresAt }]) + ) + }, + }) + this.snapshots = new LazyLoader({ + name: 'organization_email_members', + refreshAgeMs: REFRESH_AGE_MS, + refreshJitterMs: 0, + bufferMs: 0, + loader: async (organizationIds) => { + const expiresAt = this.now() + MAX_SNAPSHOT_AGE_MS + const { rows } = await postgres.query<{ + organization_id: string + email: string + is_email_verified: boolean | null + }>( + PostgresUse.COMMON_WRITE, + `SELECT membership.organization_id, users.email, users.is_email_verified + FROM posthog_organizationmembership AS membership + JOIN posthog_user AS users ON users.id = membership.user_id + WHERE membership.organization_id = ANY($1::uuid[]) AND users.is_active = true`, + [organizationIds], + 'fetch-organization-email-members' + ) + const members: Record<string, { verified: Set<string>; unverified: Set<string> }> = Object.fromEntries( + organizationIds.map((id) => [id, { verified: new Set<string>(), unverified: new Set<string>() }]) + ) + for (const row of rows) { + const { verified, unverified } = members[row.organization_id] + ;(row.is_email_verified === true ? verified : unverified).add(row.email.trim().toLowerCase()) + } + return Object.fromEntries( + Object.entries(members).map(([id, { verified, unverified }]) => [ + id, + { verifiedEmails: verified, unverifiedEmails: unverified, expiresAt }, + ]) + ) + }, + }) + } + + public async getOrganizationSnapshot(teamId: number): Promise<TeamOrganizationSnapshot> { + const key = String(teamId) + let snapshot = await this.organizations.get(key) + if (snapshot && this.now() >= snapshot.expiresAt) { + this.organizations.markForRefresh(key) + snapshot = await this.organizations.get(key) + } + if (!snapshot) { + throw new Error('Could not identify the organization for this team') + } + if (this.now() >= snapshot.expiresAt) { + this.organizations.markForRefresh(key) + throw new Error('The organization lookup expired') + } + return snapshot + } + + public async getBlockedRecipients( + teamId: number, + recipients: string[], + organizationSnapshot?: TeamOrganizationSnapshot + ): Promise<BlockedRecipient[]> { + const expiresAt = this.now() + MAX_SNAPSHOT_AGE_MS + const organization = organizationSnapshot ?? (await this.getOrganizationSnapshot(teamId)) + if (this.now() >= organization.expiresAt) { + throw new Error('The organization lookup expired') + } + const { organizationId } = organization + let snapshot = await this.snapshots.get(organizationId) + if (snapshot && this.now() >= snapshot.expiresAt && this.now() < Math.min(expiresAt, organization.expiresAt)) { + this.snapshots.markForRefresh(organizationId) + snapshot = await this.snapshots.get(organizationId) + } + if (!snapshot) { + throw new Error('Could not check organization members') + } + if (this.now() >= Math.min(expiresAt, snapshot.expiresAt, organization.expiresAt)) { + this.snapshots.markForRefresh(organizationId) + throw new Error('The organization member check expired') + } + return recipients + .filter((email) => !snapshot.verifiedEmails.has(email.trim().toLowerCase())) + .map((address) => ({ + address, + unverifiedMember: snapshot.unverifiedEmails.has(address.trim().toLowerCase()), + })) + } +} diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 657fffaa9d98..ea7408b4e358 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -20,12 +20,18 @@ import { defineLuaTokenBucketV2 } from '~/common/redis/redis-token-bucket-v2.lua import { defineLuaTokenBucketV3 } from '~/common/redis/redis-token-bucket-v3.lua' import { RedisClient, RedisClientPipeline, RedisV2, createRedisV2PoolFromConfig } from '~/common/redis/redis-v2' import { closeHub, createHub } from '~/common/utils/db/hub' -import { PostgresUse } from '~/common/utils/db/postgres' +import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres' import * as posthog from '~/common/utils/posthog' import { waitForExpect } from '~/tests/helpers/expectations' -import { createTestTeamFixture } from '~/tests/helpers/sql' +import { + createOrganization, + createOrganizationMembership, + createTestTeamFixture, + createUser, +} from '~/tests/helpers/sql' import { Hub, Team } from '../../../types' +import { OrganizationMembersService } from '../managers/organization-members.service' import { RecipientsManagerService } from '../managers/recipients-manager.service' import { TeamWorkflowsConfigService } from '../managers/team-workflows-config.service' import { RateLimiterService } from '../rate-limiter/rate-limiter.service' @@ -230,6 +236,31 @@ describe('EmailService', () => { }) describe('sandbox sender', () => { let capture: jest.SpyInstance + let memberId: number + let memberIds: number[] + let extraOrganizationIds: string[] + const memberEmail = (name = 'test'): string => `${name}-${team.id}@example.com` + const createSandboxParams = ( + params: Partial<CyclotronInvocationQueueParametersEmailType> = {} + ): CyclotronInvocationQueueParametersEmailType => + createEmailParams({ to: { email: memberEmail(), name: 'Example member' }, ...params }) + const createMember = async (email: string, organizationId = team.organization_id): Promise<number> => { + const id = await createUser(hub.postgres, email) + memberIds.push(id) + await createOrganizationMembership(hub.postgres, organizationId, id) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET email = $1, is_active = true, is_email_verified = true WHERE id = $2', + [email, id], + 'test:verify-sandbox-member' + ) + return id + } + const createOtherOrganization = async (): Promise<string> => { + const id = await createOrganization(hub.postgres) + extraOrganizationIds.push(id) + return id + } let sandboxRedisClient: Redis.Redis | undefined const createSandboxLimiter = async (name: string): Promise<RateLimiterService> => { sandboxRedisClient = await hub.redisPool.acquire() @@ -250,6 +281,7 @@ describe('EmailService', () => { enabled: boolean, tierLimiter: RateLimiterService | null = null, messageAssetsService?: MessageAssetsService, + membersPostgres: PostgresRouter = hub.postgres, routeSettings: Partial<SandboxEmailSenderConfig> = {} ): EmailService => { const sandboxService = new EmailService( @@ -282,8 +314,19 @@ describe('EmailService', () => { fromAddress: 'fixed-sandbox@example.com', ...routeSettings, }, - hub.teamManager - ) + hub.teamManager, + { + useClient: async (_options, callback) => { + const client = await hub.redisPool.acquire() + try { + return await callback(client as unknown as RedisClient) + } finally { + await hub.redisPool.release(client) + } + }, + } + ), + new OrganizationMembersService(membersPostgres) ) sendEmailSpy = jest.spyOn(sandboxService.sesV2Client!, 'send') as jest.SpyInstance sendEmailSpy.mockResolvedValue({ MessageId: 'sandbox-message-id' }) @@ -292,6 +335,11 @@ describe('EmailService', () => { beforeEach(async () => { capture = jest.spyOn(posthog, 'captureTeamEvent').mockImplementation(() => {}) + memberIds = [] + extraOrganizationIds = [] + memberId = await createMember(memberEmail()) + await createMember(memberEmail('cc')) + await createMember(memberEmail('bcc')) await insertIntegration(hub.postgres, team.id, { id: getIntegrationId(4), kind: 'email', @@ -303,11 +351,46 @@ describe('EmailService', () => { verified: true, }, }) - invocation.queueParameters = createEmailParams({ from: { integrationId: 4 } }) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + }) Object.assign(invocation, { hogFlow: { id: invocation.functionId, team_id: team.id } }) }) afterEach(async () => { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_messagesuppression WHERE team_id = $1', + [team.id], + 'test:delete-sandbox-suppressions' + ) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_organizationmembership WHERE user_id = ANY($1::integer[])', + [memberIds], + 'test:delete-sandbox-memberships' + ) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_user WHERE id = ANY($1::integer[])', + [memberIds], + 'test:delete-sandbox-members' + ) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_organization WHERE id = ANY($1::uuid[])', + [extraOrganizationIds], + 'test:delete-sandbox-organizations' + ) + const client = await hub.redisPool.acquire() + try { + const keys = await client.keys(`@posthog/workflows-sandbox-blocked/${team.id}/*`) + if (keys.length) { + await client.del(...keys) + } + } finally { + await hub.redisPool.release(client) + } capture.mockRestore() if (sandboxRedisClient) { await hub.redisPool.release(sandboxRedisClient) @@ -315,16 +398,28 @@ describe('EmailService', () => { } }) + it('skips a sandbox send to a person without an email address as a missing recipient', async () => { + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: '', name: 'Example member' }, + }) + + const result = await service.executeSendEmail(invocation) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.metrics.map((m) => m.metric_name)).toEqual(['email_missing_recipient']) + expect(capture).not.toHaveBeenCalled() + }) + it.each([false, true])( 'permits sandbox destination invocations only as explicit tests (%s)', async (isTest) => { service = createSandboxService(true) const destination = createExampleInvocation({ team_id: team.id, id: 'email-destination' }) destination.state.vmState = { stack: [] } as any - destination.queueParameters = createEmailParams({ from: { integrationId: 4 } }) - + destination.queueParameters = createSandboxParams({ from: { integrationId: 4 } }) const result = await service.executeSendEmail(destination, isTest) - expect(result.error).toBeUndefined() expect(result.skipped === true).toBe(!isTest) expect(sendEmailSpy).toHaveBeenCalledTimes(isTest ? 1 : 0) @@ -340,6 +435,627 @@ describe('EmailService', () => { } ) + it('deduplicates blocked analytics per reason and send kind across sender instances while preserving every skip log', async () => { + for (let attempt = 0; attempt < 2; attempt++) { + service = createSandboxService(true) + invocation.state.vmState = { stack: [] } as any + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: 'outside@example.com' }, + }) + const result = await service.executeSendEmail(invocation) + expect(result.skipped).toBe(true) + expect(result.logs).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('outside@example.com') }) + ) + } + expect(capture).toHaveBeenCalledTimes(1) + + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: 'outside@example.com' }, + }) + await service.executeSendEmail(invocation, true) + expect(capture).toHaveBeenCalledTimes(2) + expect(capture).toHaveBeenLastCalledWith( + expect.anything(), + 'workflows sandbox email blocked', + expect.objectContaining({ reason: 'recipient_not_member', is_test: true }) + ) + + service = createSandboxService(false) + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 } }) + await service.executeSendEmail(invocation) + expect(capture).toHaveBeenCalledTimes(3) + expect(capture).toHaveBeenLastCalledWith( + expect.anything(), + 'workflows sandbox email blocked', + expect.objectContaining({ reason: 'switch_off' }) + ) + }) + + it.each([false, true])('blocks a non-member To address (isTest=%s)', async (isTest) => { + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: 'outside@example.com' }, + }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: 'info', + message: + 'Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: outside@example.com. Verify your own domain to send to anyone.', + }), + ]) + ) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'recipient_not_member', is_test: isTest, blocked_recipient_count: 1 } + ) + }) + + it.each([false].flatMap((isTest) => [false, true].map((checkFailed) => [isTest, checkFailed] as const)))( + 'packs all blocked addresses and guidance into few long log rows (isTest=%s, checkFailed=%s)', + async (isTest, checkFailed) => { + const outside = Array.from( + { length: 200 }, + (_, index) => + `${'a'.repeat(60)}${index}@${'b'.repeat(48)}.${'c'.repeat(48)}.${'d'.repeat(48)}.example.com` + ) + const membersPostgres = checkFailed ? new PostgresRouter(hub) : hub.postgres + if (checkFailed) { + await membersPostgres.end() + } + service = createSandboxService(true, null, undefined, membersPostgres) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + cc: outside.slice(0, 100).join(', '), + bcc: outside.slice(100).join(', '), + }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + const messages = result.logs.map(({ message }) => message).join('\n') + for (const email of checkFailed ? [memberEmail(), ...outside] : outside) { + expect(messages).toContain(email) + } + expect( + result.logs.filter(({ message }) => outside.some((email) => message.includes(email))) + ).toHaveLength(5) + expect(messages.toLowerCase()).toContain('verify your own domain to send to anyone.') + expect(messages).not.toContain('(truncated)') + expect(capture).toHaveBeenCalledTimes(1) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { + reason: checkFailed ? 'check_failed' : 'recipient_not_member', + is_test: isTest, + blocked_recipient_count: checkFailed ? 201 : 200, + } + ) + } + ) + + it.each( + [false].flatMap((isTest) => + [ + 'cc', + 'bcc', + 'cc list', + 'bcc list', + 'multiple', + 'unverified', + 'legacy', + 'inactive', + 'other organization', + ].map((recipient) => [isTest, recipient] as const) + ) + )('blocks all delivery for %s / %s', async (isTest, recipient) => { + let blocked = ['outside@example.com'] + let params = createSandboxParams({ from: { integrationId: 4 } }) + if (recipient === 'cc' || recipient === 'bcc') { + params = { ...params, [recipient]: 'Outside member <OUTSIDE@example.com>' } + blocked = ['OUTSIDE@example.com'] + } else if (recipient === 'cc list' || recipient === 'bcc list') { + const field = recipient === 'cc list' ? 'cc' : 'bcc' + params = { ...params, [field]: `"Example, colleague" <${memberEmail(field)}>, outside@example.com` } + } else if (recipient === 'multiple') { + params = { ...params, cc: 'outside@example.com', bcc: 'another@example.com' } + blocked = ['outside@example.com', 'another@example.com'] + } else if (recipient === 'other organization') { + blocked = [memberEmail('outside')] + await createMember(blocked[0], await createOtherOrganization()) + params = { ...params, to: { email: blocked[0] } } + } else { + blocked = [memberEmail()] + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET is_active = $1, is_email_verified = $2 WHERE id = $3', + [recipient !== 'inactive', recipient === 'legacy' ? null : recipient === 'inactive', memberId], + 'test:change-sandbox-member-status' + ) + } + service = createSandboxService(true) + invocation.queueParameters = params + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.queueScheduledAt).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + const unverifiedMember = recipient === 'unverified' || recipient === 'legacy' + expect(result.logs).toContainEqual( + expect.objectContaining({ + level: 'info', + message: unverifiedMember + ? `Skipping send: the sandbox sender only sends to organization members with verified email addresses. Unverified addresses: ${blocked.join(', ')}. Verify your email address in PostHog, or ask these members to verify theirs.` + : `Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: ${blocked.join(', ')}. Verify your own domain to send to anyone.`, + }) + ) + expect(capture).toHaveBeenCalledTimes(1) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { + reason: unverifiedMember ? 'recipient_unverified' : 'recipient_not_member', + is_test: isTest, + blocked_recipient_count: blocked.length, + } + ) + expect(result.capturedPostHogEvents).toEqual([]) + }) + + it.each( + [false].flatMap((isTest) => + [ + 'Example colleague', + '"member@example.com"', + '"Example, colleague"', + '"Example" Colleague', + ].flatMap((name) => + [false, true].map((storedMixedCase) => [isTest, name, storedMixedCase] as const) + ) + ) + )( + 'matches bare addresses without case sensitivity (isTest=%s, name=%s, storedMixedCase=%s)', + async (isTest, name, storedMixedCase) => { + if (storedMixedCase) { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET email = initcap(email) WHERE id = ANY($1::integer[])', + [memberIds], + 'test:store-mixed-case-sandbox-members' + ) + } + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: memberEmail().toUpperCase() }, + cc: `${name} <${memberEmail('cc').toUpperCase()}>`, + bcc: ` ${name} <${memberEmail('bcc').toUpperCase()}> `, + }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(result).toMatchObject({ finished: true }) + expect(result.skipped).not.toBe(true) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: true }]) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect((sendEmailSpy.mock.calls[0][0] as SendEmailCommand).input.Destination).toEqual({ + ToAddresses: [memberEmail().toUpperCase()], + CcAddresses: [memberEmail('cc').toUpperCase()], + BccAddresses: [memberEmail('bcc').toUpperCase()], + }) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email sent', + { recipient_count: 3, source: isTest ? 'test' : 'workflow', is_test: isTest } + ) + } + ) + + it.each( + [false].flatMap((isTest) => + ['cc', 'bcc'].flatMap((field) => [false, true].map((list) => [isTest, field, list] as const)) + ) + )('accepts escaped quoted display names (isTest=%s, field=%s, list=%s)', async (isTest, field, list) => { + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + [field]: `"Example \\"colleague, teammate\\"" <${memberEmail(field)}>${list ? `, ${memberEmail(field === 'cc' ? 'bcc' : 'cc')}` : ''}`, + }) + const result = await service.executeSendEmail(invocation, isTest) + expect(result.error).toBeUndefined() + expect(result.skipped).not.toBe(true) + expect(result.invocation.state.vmState?.stack).toEqual([{ success: true }]) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + const destination = (sendEmailSpy.mock.calls[0][0] as SendEmailCommand).input.Destination + expect(field === 'cc' ? destination?.CcAddresses : destination?.BccAddresses).toEqual([ + memberEmail(field), + ...(list ? [memberEmail(field === 'cc' ? 'bcc' : 'cc')] : []), + ]) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email sent', + { recipient_count: list ? 3 : 2, source: isTest ? 'test' : 'workflow', is_test: isTest } + ) + }) + + it.each( + [false].flatMap((isTest) => + ['cc', 'bcc'].flatMap((field) => + [false, true].flatMap((suppressed) => + [false, true].map((wrapped) => [isTest, field, suppressed, wrapped] as const) + ) + ) + ) + )( + 'keeps a quoted mailbox intact (isTest=%s, field=%s, suppressed=%s, wrapped=%s)', + async (isTest, field, suppressed, wrapped) => { + const email = `"example,<${team.id}>"@example.com` + await createMember(email) + if (suppressed) { + await new EmailSuppressionService( + hub.postgres, + emailSuppressionConfigFromEnv() + ).recordHardBounces(team.id, [email]) + } + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + [field]: wrapped ? `Example colleague <${email}>` : email, + }) + const result = await service.executeSendEmail(invocation, isTest) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: !suppressed }]) + if (suppressed) { + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.logs).toContainEqual( + expect.objectContaining({ message: expect.stringContaining(email) }) + ) + expect(capture).not.toHaveBeenCalled() + } else { + expect(result.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + const destination = (sendEmailSpy.mock.calls[0][0] as SendEmailCommand).input.Destination + expect(field === 'cc' ? destination?.CcAddresses : destination?.BccAddresses).toEqual([email]) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email sent', + { recipient_count: 2, source: isTest ? 'test' : 'workflow', is_test: isTest } + ) + } + } + ) + + it.each([false])('rechecks membership after slow send preparation (isTest=%s)', async (isTest) => { + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + const preparation = jest.spyOn(EmailSuppressionService.prototype, 'isSuppressed') + const suppress = preparation.getMockImplementation()! + try { + service = createSandboxService(true) + const first = await service.executeSendEmail(invocation, isTest) + expect(first.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_organizationmembership WHERE user_id = $1', + [memberId], + 'test:revoke-sandbox-member-before-preparation' + ) + clock.mockReturnValue(now + 58_000) + preparation.mockImplementation(async function (this: EmailSuppressionService, ...args) { + const suppressed = await suppress.apply(this, args) + clock.mockReturnValue(now + 63_000) + return suppressed + }) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + cc: memberEmail('cc'), + }) + const result = await service.executeSendEmail(invocation, isTest) + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack.at(-1)).toEqual({ success: false }) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(capture).toHaveBeenLastCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'recipient_not_member', is_test: isTest, blocked_recipient_count: 1 } + ) + } finally { + clock.mockRestore() + preparation.mockRestore() + } + }) + + it.each([false].flatMap((isTest) => [false, true].map((warm) => [isTest, warm] as const)))( + 'fails closed when member SQL fails (isTest=%s, warm=%s)', + async (isTest, warm) => { + const membersPostgres = new PostgresRouter({ + DATABASE_URL: hub.DATABASE_URL, + POSTGRES_CONNECTION_POOL_SIZE: 1, + }) + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + try { + await membersPostgres.query( + PostgresUse.COMMON_WRITE, + 'CREATE TEMP TABLE posthog_team AS SELECT id, organization_id FROM public.posthog_team WHERE id = $1', + [team.id], + 'test:own-sandbox-team-lookup' + ) + service = createSandboxService(true, null, undefined, membersPostgres) + if (warm) { + const first = await service.executeSendEmail(invocation, isTest) + expect(first.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + clock.mockReturnValue(now + 61_000) + } + await membersPostgres.query( + PostgresUse.COMMON_WRITE, + 'SET search_path = pg_temp', + [], + 'test:fail-sandbox-member-lookup' + ) + const { rows } = await membersPostgres.query<{ organization_id: string }>( + PostgresUse.COMMON_WRITE, + 'SELECT organization_id FROM posthog_team WHERE id = $1', + [team.id], + 'test:confirm-sandbox-team-lookup' + ) + expect(rows).toEqual([{ organization_id: team.organization_id }]) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + cc: memberEmail('cc'), + bcc: memberEmail('bcc'), + }) + const result = await service.executeSendEmail(invocation, isTest) + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack.at(-1)).toEqual({ success: false }) + expect(sendEmailSpy).toHaveBeenCalledTimes(warm ? 1 : 0) + expect(result.logs).toContainEqual( + expect.objectContaining({ + message: `Skipping send: could not check organization members for these addresses: ${memberEmail()}, ${memberEmail('cc')}, ${memberEmail('bcc')}. Try again, or verify your own domain to send to anyone.`, + }) + ) + expect(capture).toHaveBeenLastCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'check_failed', is_test: isTest, blocked_recipient_count: 3 } + ) + } finally { + clock.mockRestore() + await membersPostgres.end() + } + } + ) + + it.each([false])( + 'blocks previous organization members after a project transfer (isTest=%s)', + async (isTest) => { + const otherOrganization = await createOtherOrganization() + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + try { + service = createSandboxService(true) + const first = await service.executeSendEmail(invocation, isTest) + expect(first.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_team SET organization_id = $1 WHERE id = $2', + [otherOrganization, team.id], + 'test:transfer-sandbox-team' + ) + clock.mockReturnValue(now + 61_000) + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 } }) + + const transferred = await service.executeSendEmail(invocation, isTest) + + expect(transferred).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(transferred.error).toBeUndefined() + expect(transferred.invocation.state.vmState?.stack.at(-1)).toEqual({ success: false }) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(transferred.logs).toContainEqual( + expect.objectContaining({ + message: `Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: ${memberEmail()}. Verify your own domain to send to anyone.`, + }) + ) + expect(capture).toHaveBeenLastCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'recipient_not_member', is_test: isTest, blocked_recipient_count: 1 } + ) + } finally { + clock.mockRestore() + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_team SET organization_id = $1 WHERE id = $2', + [team.organization_id, team.id], + 'test:restore-sandbox-team' + ) + } + } + ) + + it.each( + [{ failure: 'a closed member connection', closeConnection: true }].flatMap((failure) => + [false, true].map((isTest) => ({ ...failure, isTest })) + ) + )( + 'fails closed on $failure and bypasses it for own senders (isTest=$isTest)', + async ({ closeConnection, isTest }) => { + const membersPostgres = new PostgresRouter(hub) + if (closeConnection) { + await membersPostgres.end() + } + const memberQueries = jest.spyOn(membersPostgres, 'query') + try { + service = createSandboxService(true, null, undefined, membersPostgres) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toContainEqual( + expect.objectContaining({ + level: 'info', + message: `Skipping send: could not check organization members for these addresses: ${memberEmail()}. Try again, or verify your own domain to send to anyone.`, + }) + ) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'check_failed', is_test: isTest, blocked_recipient_count: 1 } + ) + + invocation.queueParameters = createSandboxParams({ from: { integrationId: 1 } }) + memberQueries.mockClear() + const ownSender = await service.executeSendEmail(invocation, isTest) + expect(ownSender.error).toBeUndefined() + expect(ownSender.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(memberQueries).not.toHaveBeenCalled() + } finally { + memberQueries.mockRestore() + if (!closeConnection) { + await membersPostgres.end() + } + } + } + ) + + it.each(['membership', 'verification', 'active status', 'lookup failure'])( + 'refreshes cached members within a minute after %s changes', + async (change) => { + const membersPostgres = new PostgresRouter(hub) + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + let databaseClosed = false + try { + service = createSandboxService(true, null, undefined, membersPostgres) + const params = createSandboxParams({ from: { integrationId: 4 } }) + const first = await service.executeSendEmail(invocation) + expect(first.error).toBeUndefined() + expect(first.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + if (change === 'membership') { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM posthog_organizationmembership WHERE user_id = $1 AND organization_id = $2', + [memberId, team.organization_id], + 'test:remove-sandbox-member' + ) + } else if (change !== 'lookup failure') { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET is_active = $1, is_email_verified = $2 WHERE id = $3', + [change !== 'active status', change === 'verification' ? null : true, memberId], + 'test:revoke-sandbox-member' + ) + } + clock.mockReturnValue(now + 30_000) + invocation.queueParameters = params + const cached = await service.executeSendEmail(invocation) + expect(cached.error).toBeUndefined() + expect(cached.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + + if (change === 'lookup failure') { + await membersPostgres.end() + databaseClosed = true + } + clock.mockReturnValue(now + 60_000) + invocation.queueParameters = params + const expired = await service.executeSendEmail(invocation) + expect(expired).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(expired.error).toBeUndefined() + expect(expired.invocation.state.vmState?.stack.at(-1)).toEqual({ success: false }) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + expect(capture).toHaveBeenLastCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { + reason: + change === 'lookup failure' + ? 'check_failed' + : change === 'verification' + ? 'recipient_unverified' + : 'recipient_not_member', + is_test: false, + blocked_recipient_count: 1, + } + ) + } finally { + clock.mockRestore() + if (!databaseClosed) { + await membersPostgres.end() + } + } + } + ) + + it.each(['cc', 'bcc'] as const)( + 'blocks ambiguous %s mailboxes rather than sending a partially checked list', + async (field) => { + service = createSandboxService(true) + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + [field]: `Example <${memberEmail('cc')}> <outside@example.com>`, + }) + const result = await service.executeSendEmail(invocation) + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true }) + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('outside@example.com'), + }) + ) + } + ) + + it.each([false, true])( + 'sends only the checked address when the To display name contains mailbox syntax (utm=%s)', + async (utmEnabled) => { + service = createSandboxService(true) + invocation.hogFunction.metadata = { utm_tags_enabled: utmEnabled } + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: memberEmail(), name: 'Example" <outside@example.com>, "Example' }, + }) + const result = await service.executeSendEmail(invocation) + expect(result.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect((sendEmailSpy.mock.calls[0][0] as SendEmailCommand).input.Destination?.ToAddresses).toEqual([ + `"Example outside@example.com, Example" <${memberEmail()}>`, + ]) + } + ) + it.each([ ['the switch is off', false, false, {}], ['the switch is off', true, false, {}], @@ -349,7 +1065,7 @@ describe('EmailService', () => { ] as const)( 'skips when the sandbox route is unavailable because %s (isTest=%s)', async (_reason, isTest, enabled: boolean, routeSettings: Partial<SandboxEmailSenderConfig>) => { - service = createSandboxService(enabled, null, undefined, routeSettings) + service = createSandboxService(enabled, null, undefined, hub.postgres, routeSettings) const result = await service.executeSendEmail(invocation, isTest) expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) @@ -389,11 +1105,11 @@ describe('EmailService', () => { } as unknown as IngestionOutputs<'message_assets'> service = createSandboxService(true, null, new MessageAssetsService(outputs)) invocation.state.actionId = 'send-email' - invocation.queueParameters = createEmailParams({ + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4, email: 'override@example.com', name: 'Custom sender' }, replyTo: 'reply@example.com', - cc: 'cc@example.com', - bcc: 'bcc@example.com', + cc: memberEmail('cc'), + bcc: memberEmail('bcc'), text: htmlOnly ? undefined : 'Hello there.', html, }) @@ -418,9 +1134,9 @@ describe('EmailService', () => { FromEmailAddress: '"Example organization via PostHog" <fixed-sandbox@example.com>', FeedbackForwardingEmailAddress: 'fixed-sandbox@example.com', Destination: { - ToAddresses: ['"Test User" <test@example.com>'], - CcAddresses: ['cc@example.com'], - BccAddresses: ['bcc@example.com'], + ToAddresses: [`"Example member" <${memberEmail()}>`], + CcAddresses: [memberEmail('cc')], + BccAddresses: [memberEmail('bcc')], }, Content: { Simple: { @@ -520,7 +1236,7 @@ describe('EmailService', () => { const limiter = await createSandboxLimiter('sandbox-rejected-html-budget-test') service = createSandboxService(true, limiter, new MessageAssetsService(outputs)) invocation.state.actionId = 'send-email' - const params = createEmailParams({ from: { integrationId: 4 }, text, html }) + const params = createSandboxParams({ from: { integrationId: 4 }, text, html }) invocation.queueParameters = params const result = await service.executeSendEmail(invocation, isTest) @@ -543,9 +1259,9 @@ describe('EmailService', () => { expect(result.capturedPostHogEvents.some((event) => event.event === '$workflows_email_sent')).toBe( false ) - expect(params).toEqual(createEmailParams({ from: { integrationId: 4 }, text, html })) + expect(params).toEqual(createSandboxParams({ from: { integrationId: 4 }, text, html })) - invocation.queueParameters = createEmailParams({ from: { integrationId: 1 } }) + invocation.queueParameters = createSandboxParams({ from: { integrationId: 1 } }) const ownSender = await service.executeSendEmail(invocation) expect(ownSender.error).toBeUndefined() expect(ownSender.finished).toBe(true) @@ -555,7 +1271,7 @@ describe('EmailService', () => { it('sends untracked with the fixed identity and organization footer for text-only content', async () => { service = createSandboxService(true) - invocation.queueParameters = createEmailParams({ + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 }, html: '', text: 'Hello there.', @@ -627,7 +1343,7 @@ describe('EmailService', () => { expect(sandbox.error).toBeUndefined() expect(sendEmailSpy).toHaveBeenCalledTimes(1) - invocation.queueParameters = createEmailParams({ from: { integrationId: 1 } }) + invocation.queueParameters = createSandboxParams({ from: { integrationId: 1 } }) const ownSender = await service.executeSendEmail(invocation) expect(ownSender).toMatchObject({ finished: true }) expect(ownSender.error).toBeUndefined() @@ -637,7 +1353,7 @@ describe('EmailService', () => { expect(exhausted.finished).toBe(false) expect(sendEmailSpy).toHaveBeenCalledTimes(2) - invocation.queueParameters = createEmailParams({ from: { integrationId: 4 } }) + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 } }) const afterExhaustion = await service.executeSendEmail(invocation) expect(afterExhaustion).toMatchObject({ finished: true }) expect(afterExhaustion.error).toBeUndefined() diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index aafcefff8fb3..2ff9f7e51116 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -12,11 +12,12 @@ import { IntegrationType, MessageAssetRow, } from '~/cdp/types' -import { createAddLogFunction, logEntry } from '~/cdp/utils' +import { MAX_UNTRUNCATED_LOG_LENGTH, createAddLogFunction, logEntry, sanitizeLogMessage } from '~/cdp/utils' import { createInvocationResult } from '~/cdp/utils/invocation-utils' import { logger } from '~/common/utils/logger' import { IntegrationManagerService } from '../managers/integration-manager.service' +import { OrganizationMembersService, TeamOrganizationSnapshot } from '../managers/organization-members.service' import { RecipientManagerRecipient, RecipientsManagerService } from '../managers/recipients-manager.service' import { TeamWorkflowsConfigService } from '../managers/team-workflows-config.service' import { RateLimiterService } from '../rate-limiter/rate-limiter.service' @@ -345,6 +346,115 @@ export function parseAddressList(value?: string): string[] | undefined { return result.length > 0 ? result : undefined } +type QuoteScan = { unquotedIndexes: number[]; balanced: boolean } + +function scanQuotes(text: string): QuoteScan { + const unquotedIndexes: number[] = [] + let quoted = false + let escaped = false + for (let index = 0; index < text.length; index++) { + const character = text[index] + if (escaped) { + escaped = false + } else if (quoted && character === '\\') { + escaped = true + } else if (character === '"') { + quoted = !quoted + } else if (!quoted) { + unquotedIndexes.push(index) + } + } + return { unquotedIndexes, balanced: !quoted } +} + +function splitOutsideQuotes(text: string): string[] { + const commas = scanQuotes(text).unquotedIndexes.filter((index) => text[index] === ',') + return [-1, ...commas].map((comma, position) => text.slice(comma + 1, commas[position] ?? text.length)) +} + +function angleAddress(entry: string): string | undefined { + const { unquotedIndexes, balanced } = scanQuotes(entry) + const opening = unquotedIndexes.find((index) => entry[index] === '<') + const closing = entry.length - 1 + if (!balanced || opening === undefined || entry[closing] !== '>' || /[\r\n]/.test(entry)) { + return undefined + } + const unquotedBetween = (start: number, end: number): string => + unquotedIndexes + .filter((index) => index >= start && index < end) + .map((index) => entry[index]) + .join('') + const mailbox = entry.slice(opening + 1, closing).trim() + const isFramed = !/[>@;]/.test(unquotedBetween(0, opening)) && !/[<>]/.test(unquotedBetween(opening + 1, closing)) + return isFramed && mailbox ? mailbox : undefined +} + +function sandboxAddressList(value?: string): string[] { + return splitOutsideQuotes(value ?? '') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => angleAddress(entry) ?? entry) +} + +type SandboxRecipientBlockReason = 'recipient_not_member' | 'recipient_unverified' | 'check_failed' + +const SANDBOX_RECIPIENT_BLOCK_COPY: Record< + SandboxRecipientBlockReason, + { explanation: string; label: string; guidance: string } +> = { + recipient_not_member: { + explanation: + 'Skipping send: the sandbox sender only sends to active organization members with verified email addresses', + label: '. Blocked addresses: ', + guidance: 'Verify your own domain to send to anyone.', + }, + recipient_unverified: { + explanation: + 'Skipping send: the sandbox sender only sends to organization members with verified email addresses', + label: '. Unverified addresses: ', + guidance: 'Verify your email address in PostHog, or ask these members to verify theirs.', + }, + check_failed: { + explanation: 'Skipping send: could not check organization members', + label: ' for these addresses: ', + guidance: 'Try again, or verify your own domain to send to anyone.', + }, +} + +const BLOCKED_ADDRESSES_LABEL = 'Blocked addresses: ' + +function splitByLength(text: string, maxLength: number): string[] { + const chunks = [''] + for (const character of text) { + if (chunks[chunks.length - 1].length + character.length > maxLength) { + chunks.push('') + } + chunks[chunks.length - 1] += character + } + return chunks +} + +function packAddressRows(addresses: string[], maxLength: number): string[] { + const rows: string[] = [] + let row = '' + for (const piece of addresses.flatMap((address) => splitByLength(address, maxLength))) { + const joined = row ? `${row}, ${piece}` : piece + if (row && joined.length > maxLength) { + rows.push(row) + row = piece + } else { + row = joined + } + } + return [...rows, row] +} + +function blockedAddressLogRows(addresses: string[]): string[] { + return packAddressRows(addresses, MAX_UNTRUNCATED_LOG_LENGTH - BLOCKED_ADDRESSES_LABEL.length).map( + (addressList) => `${BLOCKED_ADDRESSES_LABEL}${addressList}` + ) +} + export class EmailService { sesV2Client: SESv2Client | null @@ -363,7 +473,8 @@ export class EmailService { private messageAssetsService?: MessageAssetsService, private workflowEmailRateLimiter: RateLimiterService | null = null, private teamEmailRateLimiter: RateLimiterService | null = null, - private sandboxSender: SandboxEmailSender | null = null + private sandboxSender: SandboxEmailSender | null = null, + private organizationMembers: OrganizationMembersService | null = null ) { this.sesV2Client = this.sesConfig.sesRegion ? new SESv2Client({ @@ -407,6 +518,7 @@ export class EmailService { let assetRow: MessageAssetRow | null = null let trackingEnabled = true let deliveryParams = params + let sandboxRecipients: string[] | undefined try { // Team-level kill switches: staff suspend all workflow email for a team whose sender @@ -512,13 +624,6 @@ export class EmailService { } const from = this.resolveFromSender(integration, params.from, addLog) - if (isSandbox && (params.from.email || params.from.name || params.replyTo)) { - addLog( - 'info', - 'Ignoring custom sender and Reply-To settings: the sandbox sender uses a fixed identity.' - ) - } - // An empty `to` comes from the customer's data (a person without an email property, or no // person at all), not from a fault in the send path. SES rejects it as an invalid address, // so record a skip with its own metric instead of an `email_failed`. Otherwise one project @@ -543,11 +648,39 @@ export class EmailService { return result } + if (isSandbox) { + const cc = sandboxAddressList(params.cc) + const bcc = sandboxAddressList(params.bcc) + sandboxRecipients = [params.to.email, ...cc, ...bcc] + if (!(await this.checkSandboxRecipients(result, sandboxRecipients, isTest))) { + return result + } + deliveryParams = { + ...params, + to: { + email: params.to.email.trim(), + name: params.to.name ? sanitizeFromName(params.to.name) : undefined, + }, + cc: cc.join(', ') || undefined, + bcc: bcc.join(', ') || undefined, + } + } + if (isSandbox && (params.from.email || params.from.name || params.replyTo)) { + addLog( + 'info', + 'Ignoring custom sender and Reply-To settings: the sandbox sender uses a fixed identity.' + ) + } + // Single choke point for the suppression check — every send path lands here regardless // of whether the invocation came from a workflow action or an email destination hog // function. Checking here means callers can't bypass it by taking a different upstream // route. Covers `to`, `cc`, and `bcc`; a suppressed address anywhere blocks the send. - const skipReason = await this.buildSuppressionSkipReason(invocation.teamId, params) + const skipReason = await this.buildSuppressionSkipReason( + invocation.teamId, + deliveryParams, + sandboxRecipients + ) if (skipReason) { addLog('info', skipReason) if (!isTest) { @@ -616,7 +749,10 @@ export class EmailService { // Charged per recipient, not per send: SES counts every to/cc/bcc address against its // own quota, so a send with many copies must spend that many tokens. const capRecipients = - 1 + extractEmailsFromAddressList(params.cc).length + extractEmailsFromAddressList(params.bcc).length + sandboxRecipients?.length ?? + 1 + + extractEmailsFromAddressList(deliveryParams.cc).length + + extractEmailsFromAddressList(deliveryParams.bcc).length const capDelay = isSandbox ? null : await this.claimTeamSendingBudget(invocation, isTest, capRecipients) if (capDelay) { result.finished = false @@ -637,18 +773,18 @@ export class EmailService { // A reply-to set on the email step wins over the sender's default. const paramsWithReplyTo = - parseAddressList(params.replyTo) || !integration.config.reply_to - ? params - : { ...params, replyTo: integration.config.reply_to } + parseAddressList(deliveryParams.replyTo) || !integration.config.reply_to + ? deliveryParams + : { ...deliveryParams, replyTo: integration.config.reply_to } // Tagged before click tracking wraps the links, so the tags land on the destination URL. const metadata = invocation.hogFunction.metadata const sendParams = - metadata?.utm_tags_enabled === true && params.html + metadata?.utm_tags_enabled === true && paramsWithReplyTo.html ? { ...paramsWithReplyTo, html: addUtmTagsToEmail( - params.html, + paramsWithReplyTo.html, resolveUtmTags( { utm_source: 'posthog', @@ -682,7 +818,18 @@ export class EmailService { from.name, invocation.teamId ) - await this.sendEmailWithSES(result, deliveryParams, from, trackingEnabled, integration, isTest) + if ( + !(await this.sendEmailWithSES( + result, + deliveryParams, + from, + trackingEnabled, + integration, + isTest + )) + ) { + return result + } await this.sandboxSender!.capture(invocation.teamId, isTest, { type: 'sent', recipientCount: capRecipients, @@ -912,6 +1059,69 @@ export class EmailService { return null } + private async checkSandboxRecipients( + result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, + recipients: string[], + isTest: boolean, + organization?: TeamOrganizationSnapshot + ): Promise<boolean> { + const invocation = result.invocation + let blockedRecipients: string[] + let reason: SandboxRecipientBlockReason + try { + if (!this.organizationMembers) { + throw new Error('Organization member checks are not configured') + } + const blocked = await this.organizationMembers.getBlockedRecipients( + invocation.teamId, + recipients, + organization + ) + blockedRecipients = blocked.map(({ address }) => address) + reason = blocked.every(({ unverifiedMember }) => unverifiedMember) + ? 'recipient_unverified' + : 'recipient_not_member' + } catch (error) { + blockedRecipients = recipients + reason = 'check_failed' + logger.warn('Could not check sandbox email recipients', { + teamId: invocation.teamId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + if (!blockedRecipients.length) { + return true + } + this.logSandboxRecipientBlock(result, blockedRecipients, reason) + result.skipped = true + result.invocation.state.vmState?.stack.push({ success: false }) + await this.sandboxSender!.capture(invocation.teamId, isTest, { + type: 'blocked', + reason, + blockedRecipientCount: blockedRecipients.length, + }) + return false + } + + private logSandboxRecipientBlock( + result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, + blockedRecipients: string[], + reason: SandboxRecipientBlockReason + ): void { + const addLog = createAddLogFunction(result.logs) + const addresses = blockedRecipients.map((address) => address.trim()) + const { explanation, label, guidance } = SANDBOX_RECIPIENT_BLOCK_COPY[reason] + const message = `${explanation}${label}${addresses.join(', ')}. ${guidance}` + if (sanitizeLogMessage([message]) === message) { + addLog('info', message) + return + } + addLog('info', `${explanation}. ${guidance}`) + for (const row of blockedAddressLogRows(addresses)) { + addLog('info', row) + } + } + // Returns a human-readable log string when any destination address is suppressed for the team, // or null when the send should proceed. Scans to + cc + bcc — SES delivers to every list, so a // suppressed address anywhere blocks the whole send. `cc` and `bcc` can be comma-separated @@ -919,14 +1129,14 @@ export class EmailService { // matching against the normalized suppression identifier. private async buildSuppressionSkipReason( teamId: number, - params: CyclotronInvocationQueueParametersEmailType + params: CyclotronInvocationQueueParametersEmailType, + recipientEmails?: string[] ): Promise<string | null> { - const recipients: string[] = [] - if (params.to?.email && params.to.email.trim()) { - recipients.push(params.to.email.trim()) - } - recipients.push(...extractEmailsFromAddressList(params.cc)) - recipients.push(...extractEmailsFromAddressList(params.bcc)) + const recipients = recipientEmails ?? [ + ...(params.to?.email?.trim() ? [params.to.email.trim()] : []), + ...extractEmailsFromAddressList(params.cc), + ...extractEmailsFromAddressList(params.bcc), + ] if (recipients.length === 0) { return null } @@ -1163,7 +1373,7 @@ export class EmailService { trackingEnabled: boolean, integration: IntegrationType, isTest = false - ): Promise<void> { + ): Promise<boolean> { if (!this.sesV2Client) { throw new Error('SES is not configured - set SES_REGION and AWS credentials') } @@ -1259,19 +1469,30 @@ export class EmailService { } const replyToAddresses = parseAddressList(params.replyTo) - const ccAddresses = parseAddressList(params.cc) - const bccAddresses = parseAddressList(params.bcc) + const parseRecipients = integration.config.provider === 'sandbox' ? sandboxAddressList : parseAddressList + const ccAddresses = parseRecipients(params.cc) + const bccAddresses = parseRecipients(params.bcc) if (replyToAddresses) { sendEmailParams.ReplyToAddresses = replyToAddresses } - if (ccAddresses) { + if (ccAddresses?.length) { sendEmailParams.Destination!.CcAddresses = ccAddresses } - if (bccAddresses) { + if (bccAddresses?.length) { sendEmailParams.Destination!.BccAddresses = bccAddresses } + if ( + integration.config.provider === 'sandbox' && + !(await this.checkSandboxRecipients( + result, + [params.to.email, ...(ccAddresses ?? []), ...(bccAddresses ?? [])], + isTest + )) + ) { + return false + } try { const response = await this.sesV2Client.send(new SendEmailCommand(sendEmailParams)) if (!response.MessageId) { @@ -1285,6 +1506,7 @@ export class EmailService { const message = error instanceof Error ? error.message : String(error) throw new Error(`Failed to send email via SES: ${message}`) } + return true } private generateUnsubscribeHeaders( diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts index 8a271c97538d..0186bcff2c23 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts @@ -1,6 +1,7 @@ import { type DefaultTreeAdapterMap, defaultTreeAdapter, parse, parseFragment, serialize } from 'parse5' import { CyclotronInvocationQueueParametersEmailType } from '~/cdp/schema/cyclotron' +import { RedisV2 } from '~/common/redis/redis-v2' import { logger } from '~/common/utils/logger' import { captureTeamEvent } from '~/common/utils/posthog' import { TeamManager } from '~/common/utils/team-manager' @@ -28,7 +29,11 @@ function emptyRouteSettings(config: SandboxEmailSenderConfig): string[] { type SandboxEmailOutcome = | { type: 'sent'; recipientCount: number } - | { type: 'blocked'; reason: 'switch_off'; blockedRecipientCount: number } + | { + type: 'blocked' + reason: 'switch_off' | 'recipient_not_member' | 'recipient_unverified' | 'check_failed' + blockedRecipientCount: number + } function htmlBody(document: DefaultTreeAdapterMap['document']): DefaultTreeAdapterMap['element'] | undefined { const root = document.childNodes.find(defaultTreeAdapter.isElementNode) @@ -116,7 +121,8 @@ export class SandboxEmailSender { constructor( public readonly config: SandboxEmailSenderConfig, - private teamManager: TeamManager + private teamManager: TeamManager, + private blockedEventRedis: Pick<RedisV2, 'useClient'> | null = null ) { this.config = { ...config, @@ -160,6 +166,22 @@ export class SandboxEmailSender { public async capture(teamId: number, isTest: boolean, outcome: SandboxEmailOutcome): Promise<void> { try { + if (outcome.type === 'blocked') { + const firstBlock = await this.blockedEventRedis?.useClient( + { name: 'sandbox-blocked-event', timeout: 1000 }, + (client) => + client.set( + `@posthog/workflows-sandbox-blocked/${teamId}/${outcome.reason}/${isTest ? 'test' : 'live'}`, + '1', + 'EX', + 3600, + 'NX' + ) + ) + if (firstBlock !== 'OK') { + return + } + } const team = await this.teamManager.getTeam(teamId) if (team) { const properties = diff --git a/nodejs/src/cdp/utils.ts b/nodejs/src/cdp/utils.ts index 78401817b4b8..a28cd207380e 100644 --- a/nodejs/src/cdp/utils.ts +++ b/nodejs/src/cdp/utils.ts @@ -15,6 +15,7 @@ import { HogFunctionInvocationGlobals, HogFunctionType, LogEntry, LogEntrySerial export const CDP_TEST_ID = '[CDP-TEST-HIDDEN]' export const MAX_LOG_LENGTH = 10000 const TRUNCATION_SUFFIX = '... (truncated)' +export const MAX_UNTRUNCATED_LOG_LENGTH = MAX_LOG_LENGTH - TRUNCATION_SUFFIX.length const REDACTED = '***REDACTED***' // Sync with person.py and constants.tsx From 222ac4e5617c4aa7d1cfa0f612b6b894b386ae45 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:56:14 +0000 Subject: [PATCH 05/38] feat(workflows): stop sandbox email for deactivated organizations A deactivated organization now has no verified members, so the sandbox sender blocks every send within the one-minute member snapshot window. Only an explicit false deactivates, matching the web middleware. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../managers/organization-members.service.ts | 5 +++- .../services/messaging/email.service.test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/nodejs/src/cdp/services/managers/organization-members.service.ts b/nodejs/src/cdp/services/managers/organization-members.service.ts index 14dd567233cf..9c39ab4194ee 100644 --- a/nodejs/src/cdp/services/managers/organization-members.service.ts +++ b/nodejs/src/cdp/services/managers/organization-members.service.ts @@ -56,7 +56,10 @@ export class OrganizationMembersService { `SELECT membership.organization_id, users.email, users.is_email_verified FROM posthog_organizationmembership AS membership JOIN posthog_user AS users ON users.id = membership.user_id - WHERE membership.organization_id = ANY($1::uuid[]) AND users.is_active = true`, + JOIN posthog_organization AS organization ON organization.id = membership.organization_id + WHERE membership.organization_id = ANY($1::uuid[]) + AND users.is_active = true + AND organization.is_active IS NOT FALSE`, [organizationIds], 'fetch-organization-email-members' ) diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index ea7408b4e358..3ca0fce8c2f5 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -256,6 +256,14 @@ describe('EmailService', () => { ) return id } + const setOrganizationActive = async (isActive: boolean | null): Promise<void> => { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_organization SET is_active = $1 WHERE id = $2', + [isActive, team.organization_id], + 'test:set-sandbox-organization-active' + ) + } const createOtherOrganization = async (): Promise<string> => { const id = await createOrganization(hub.postgres) extraOrganizationIds.push(id) @@ -563,6 +571,7 @@ describe('EmailService', () => { 'legacy', 'inactive', 'other organization', + 'deactivated organization', ].map((recipient) => [isTest, recipient] as const) ) )('blocks all delivery for %s / %s', async (isTest, recipient) => { @@ -581,6 +590,9 @@ describe('EmailService', () => { blocked = [memberEmail('outside')] await createMember(blocked[0], await createOtherOrganization()) params = { ...params, to: { email: blocked[0] } } + } else if (recipient === 'deactivated organization') { + blocked = [memberEmail()] + await setOrganizationActive(false) } else { blocked = [memberEmail()] await hub.postgres.query( @@ -622,6 +634,20 @@ describe('EmailService', () => { expect(result.capturedPostHogEvents).toEqual([]) }) + it.each([null, true])( + 'sends to a verified member of an organization that was never deactivated (is_active=%s)', + async (isActive) => { + await setOrganizationActive(isActive) + service = createSandboxService(true) + + const result = await service.executeSendEmail(invocation) + + expect(result.error).toBeUndefined() + expect(result.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + } + ) + it.each( [false].flatMap((isTest) => [ From 3f2f72d811de78ada57f6be364adbf16c2a5eb2e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:39:53 +0000 Subject: [PATCH 06/38] feat(workflows): cap sandbox sends and refund ses throttles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 9 +- nodejs/src/cdp/cdp-services.ts | 24 +- nodejs/src/cdp/config.ts | 5 + .../services/messaging/email.service.test.ts | 373 ++++++++++++++++-- .../cdp/services/messaging/email.service.ts | 77 +++- .../messaging/sandbox-email-sender.test.ts | 2 + .../messaging/sandbox-email-sender.ts | 93 ++++- .../rate-limiter/rate-limiter.service.test.ts | 127 +++++- .../rate-limiter/rate-limiter.service.ts | 151 +++++++ nodejs/src/server.ts | 23 +- 10 files changed, 820 insertions(+), 64 deletions(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index d1378b2726b2..4efe19cd1105 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -70,7 +70,14 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent. - The batch audience cap is decided when the batch is dispatched. Adding an email step to the workflow while a batch is queued does not re-cap it; the send-time buckets still cap every email at execution. This is why enforcement requires the worker caps to be deployed (see the rollout order). - Test-panel sends bypass the team buckets on purpose, matching the per-workflow rate limit. -- Sandbox sender sends bypass the team buckets without spending their tokens. Sandbox limits run independently. Keep `WORKFLOWS_SANDBOX_SENDER_ENABLED` off until the recipient membership checks, sandbox caps and sandbox pause gate are deployed. +- Sandbox sender sends bypass the team buckets without spending their tokens. Sandbox limits run independently. + The worker holds every sandbox send, test sends included, to a daily cap per project (`WORKFLOWS_SANDBOX_DAILY_TEAM_CAP`, one token per recipient copy) and per recipient address within that organization (`WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP`). These are continuously refilling token buckets, not calendar-day counters; an idle bucket starts full and refills its capacity over 24 hours. + A reached cap skips the send instead of delaying it. A limiter error or an empty or invalid cap value skips every sandbox send. + Cap keys and the final membership check share one organization snapshot, valid for at most one minute. A stalled claim or member lookup that outlives that snapshot skips the send and returns the claim. + SES throttle responses return the sandbox claim before the existing delivery retry. Returns are deduplicated, and Valkey ignores a return command that arrives more than five minutes after it was issued. + Test sends run inline in the CDP API, so the API reads the same `SES_RATE_LIMITER_VALKEY_*` settings as the email worker. Without them, every sandbox test send is skipped. + CDP API opens the SES Valkey pool only when sandbox delivery is enabled; otherwise its MX validation uses the existing local cache and DNS. + Deploy membership checks, daily caps, and the shared pause gate first. Configure the SES identity and Valkey pool, enable `WORKFLOWS_SANDBOX_SENDER_ENABLED`, then enable the UI flag. - The buckets are token buckets: a full idle bucket plus refill allows up to roughly twice the stated cap in the very first period. The bucket TTLs exceed the refill periods so this does not recur from idling. - A denied send parks until every short bucket has refilled enough to cover it, instead of retrying on a fixed few-minute cadence. The computed wait is capped at one hour and then jittered 1x to 2x, so a parked send can wait just under two hours between attempts. diff --git a/nodejs/src/cdp/cdp-services.ts b/nodejs/src/cdp/cdp-services.ts index 3b60a0e40a33..41373d89b0f4 100644 --- a/nodejs/src/cdp/cdp-services.ts +++ b/nodejs/src/cdp/cdp-services.ts @@ -170,6 +170,8 @@ export type CdpCoreServicesConfig = Pick< | 'SES_TRACKED_CONFIGURATION_SET' | 'SES_UNTRACKED_CONFIGURATION_SET' | 'WORKFLOWS_SANDBOX_SENDER_ENABLED' + | 'WORKFLOWS_SANDBOX_DAILY_TEAM_CAP' + | 'WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP' | 'SES_SANDBOX_TENANT_NAME' | 'SES_SANDBOX_CONFIGURATION_SET' | 'SES_SANDBOX_FROM_ADDRESS' @@ -214,13 +216,15 @@ export interface CdpCoreServicesDeps { /** * SES Valkey pool shared with the SES rate limiter, opened only on pods whose * capabilities actually execute email actions (hogflow/email cyclotron workers). - * `null` on every other CDP consumer and on cdp-api so idle pods don't hold - * open connections against the SES Valkey instance. + * Sandbox-enabled cdp-api opens it too, for the sandbox daily caps of test sends. + * `null` on every other CDP consumer so idle pods don't hold open connections + * against the SES Valkey instance. */ emailValidationValkey: RedisV2 | null /** - * Dedicated frequency cap Valkey pool, opened only on the same pods as `emailValidationValkey`. - * `null` elsewhere, and when no host is configured, which turns the cap off. + * Dedicated frequency cap Valkey pool, opened only on the email workers. `null` elsewhere, + * cdp-api included because its test sends skip the cap, and when no host is configured, + * which turns the cap off. */ frequencyCapValkey: RedisV2 | null } @@ -458,6 +462,9 @@ export function createCdpCoreServices( const teamEmailRateLimiter = deps.emailValidationValkey ? new RateLimiterService(deps.emailValidationValkey, { name: 'team-email' }) : null + const sandboxEmailRateLimiter = deps.emailValidationValkey + ? new RateLimiterService(deps.emailValidationValkey, { name: 'sandbox-email' }) + : null const emailService = new EmailService( { sesAccessKeyId: config.SES_ACCESS_KEY_ID, @@ -486,8 +493,11 @@ export function createCdpCoreServices( tenantName: config.SES_SANDBOX_TENANT_NAME, configurationSetName: config.SES_SANDBOX_CONFIGURATION_SET, fromAddress: config.SES_SANDBOX_FROM_ADDRESS, + dailyTeamCap: config.WORKFLOWS_SANDBOX_DAILY_TEAM_CAP, + dailyRecipientCap: config.WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP, }, deps.teamManager, + sandboxEmailRateLimiter, redis ), new OrganizationMembersService(deps.postgres) @@ -556,9 +566,9 @@ export function createCdpCoreServices( : undefined ) // MX verdicts live on the dedicated SES Valkey (same instance as the SES rate - // limiter, separate pool). The pool is created by the server only on pods - // whose capabilities execute email actions; everywhere else this is null - // and EmailValidationService degrades to the local cache + DNS. + // limiter, separate pool). The server creates the pool only on pods whose + // capabilities execute email actions and on sandbox-enabled cdp-api; everywhere + // else this is null and EmailValidationService degrades to the local cache + DNS. const emailValidationService = new EmailValidationService(deps.emailValidationValkey) const cohortMembershipRepository = new PostgresCohortMembershipRepository(deps.postgres) // Observer writes to both stores; the read source decides which verdict drives the metric. diff --git a/nodejs/src/cdp/config.ts b/nodejs/src/cdp/config.ts index 383a6e6091a2..3e379233333d 100644 --- a/nodejs/src/cdp/config.ts +++ b/nodejs/src/cdp/config.ts @@ -167,6 +167,9 @@ export type CdpConfig = ClickhouseConfig & { // Empty means not provisioned: tracking-off sends fall back to the tracked set with a warning. SES_UNTRACKED_CONFIGURATION_SET: string WORKFLOWS_SANDBOX_SENDER_ENABLED: boolean + // An empty or invalid value blocks every sandbox send. + WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: string + WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: string SES_SANDBOX_TENANT_NAME: string SES_SANDBOX_CONFIGURATION_SET: string SES_SANDBOX_FROM_ADDRESS: string @@ -412,6 +415,8 @@ export function getDefaultCdpConfig(): CdpConfig { SES_TRACKED_CONFIGURATION_SET: 'posthog-messaging', SES_UNTRACKED_CONFIGURATION_SET: '', WORKFLOWS_SANDBOX_SENDER_ENABLED: false, + WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: '25', + WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: '5', SES_SANDBOX_TENANT_NAME: '', SES_SANDBOX_CONFIGURATION_SET: '', SES_SANDBOX_FROM_ADDRESS: '', diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 3ca0fce8c2f5..4343e576e8bf 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -14,7 +14,7 @@ import { CyclotronInvocationQueueParametersEmailSchema, CyclotronInvocationQueueParametersEmailType, } from '~/cdp/schema/cyclotron' -import { CyclotronJobInvocationHogFunction } from '~/cdp/types' +import { CyclotronJobInvocationHogFunction, CyclotronJobInvocationResult } from '~/cdp/types' import { IngestionOutputs } from '~/common/outputs/ingestion-outputs' import { defineLuaTokenBucketV2 } from '~/common/redis/redis-token-bucket-v2.lua' import { defineLuaTokenBucketV3 } from '~/common/redis/redis-token-bucket-v3.lua' @@ -269,10 +269,11 @@ describe('EmailService', () => { extraOrganizationIds.push(id) return id } - let sandboxRedisClient: Redis.Redis | undefined + let sandboxRedisClients: Redis.Redis[] + let dailyCapLimiter: RateLimiterService const createSandboxLimiter = async (name: string): Promise<RateLimiterService> => { - sandboxRedisClient = await hub.redisPool.acquire() - const client = sandboxRedisClient + const client = await hub.redisPool.acquire() + sandboxRedisClients.push(client) defineLuaTokenBucketV2(client) defineLuaTokenBucketV3(client) const redis: RedisV2 = { @@ -287,10 +288,23 @@ describe('EmailService', () => { } const createSandboxService = ( enabled: boolean, - tierLimiter: RateLimiterService | null = null, - messageAssetsService?: MessageAssetsService, - membersPostgres: PostgresRouter = hub.postgres, - routeSettings: Partial<SandboxEmailSenderConfig> = {} + { + tierLimiter = null, + messageAssetsService, + membersPostgres = hub.postgres, + capLimiter = dailyCapLimiter, + dailyTeamCap = '100', + dailyRecipientCap = '100', + routeSettings = {}, + }: { + tierLimiter?: RateLimiterService | null + messageAssetsService?: MessageAssetsService + membersPostgres?: PostgresRouter + capLimiter?: RateLimiterService | null + dailyTeamCap?: string + dailyRecipientCap?: string + routeSettings?: Partial<SandboxEmailSenderConfig> + } = {} ): EmailService => { const sandboxService = new EmailService( { @@ -320,18 +334,15 @@ describe('EmailService', () => { tenantName: 'sandbox-tenant', configurationSetName: 'sandbox-email', fromAddress: 'fixed-sandbox@example.com', + dailyTeamCap, + dailyRecipientCap, ...routeSettings, }, hub.teamManager, + capLimiter, { - useClient: async (_options, callback) => { - const client = await hub.redisPool.acquire() - try { - return await callback(client as unknown as RedisClient) - } finally { - await hub.redisPool.release(client) - } - }, + useClient: async (_options, callback) => + callback(sandboxRedisClients[0] as unknown as RedisClient), } ), new OrganizationMembersService(membersPostgres) @@ -343,6 +354,8 @@ describe('EmailService', () => { beforeEach(async () => { capture = jest.spyOn(posthog, 'captureTeamEvent').mockImplementation(() => {}) + sandboxRedisClients = [] + dailyCapLimiter = await createSandboxLimiter('sandbox-daily-cap-test') memberIds = [] extraOrganizationIds = [] memberId = await createMember(memberEmail()) @@ -390,6 +403,10 @@ describe('EmailService', () => { [extraOrganizationIds], 'test:delete-sandbox-organizations' ) + capture.mockRestore() + for (const client of sandboxRedisClients) { + await hub.redisPool.release(client) + } const client = await hub.redisPool.acquire() try { const keys = await client.keys(`@posthog/workflows-sandbox-blocked/${team.id}/*`) @@ -399,11 +416,6 @@ describe('EmailService', () => { } finally { await hub.redisPool.release(client) } - capture.mockRestore() - if (sandboxRedisClient) { - await hub.redisPool.release(sandboxRedisClient) - sandboxRedisClient = undefined - } }) it('skips a sandbox send to a person without an email address as a missing recipient', async () => { @@ -524,7 +536,7 @@ describe('EmailService', () => { if (checkFailed) { await membersPostgres.end() } - service = createSandboxService(true, null, undefined, membersPostgres) + service = createSandboxService(true, { membersPostgres }) invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 }, cc: outside.slice(0, 100).join(', '), @@ -778,7 +790,7 @@ describe('EmailService', () => { const preparation = jest.spyOn(EmailSuppressionService.prototype, 'isSuppressed') const suppress = preparation.getMockImplementation()! try { - service = createSandboxService(true) + service = createSandboxService(true, { dailyTeamCap: '3' }) const first = await service.executeSendEmail(invocation, isTest) expect(first.error).toBeUndefined() expect(sendEmailSpy).toHaveBeenCalledTimes(1) @@ -808,6 +820,16 @@ describe('EmailService', () => { 'workflows sandbox email blocked', { reason: 'recipient_not_member', is_test: isTest, blocked_recipient_count: 1 } ) + + invocation.queueParameters = createSandboxParams({ + from: { integrationId: 4 }, + to: { email: memberEmail('cc') }, + bcc: memberEmail('bcc'), + }) + const afterBlock = await service.executeSendEmail(invocation, isTest) + expect(afterBlock.error).toBeUndefined() + expect(afterBlock.skipped).not.toBe(true) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) } finally { clock.mockRestore() preparation.mockRestore() @@ -830,7 +852,7 @@ describe('EmailService', () => { [team.id], 'test:own-sandbox-team-lookup' ) - service = createSandboxService(true, null, undefined, membersPostgres) + service = createSandboxService(true, { membersPostgres }) if (warm) { const first = await service.executeSendEmail(invocation, isTest) expect(first.error).toBeUndefined() @@ -938,7 +960,7 @@ describe('EmailService', () => { } const memberQueries = jest.spyOn(membersPostgres, 'query') try { - service = createSandboxService(true, null, undefined, membersPostgres) + service = createSandboxService(true, { membersPostgres }) const result = await service.executeSendEmail(invocation, isTest) @@ -982,7 +1004,7 @@ describe('EmailService', () => { const clock = jest.spyOn(Date, 'now').mockReturnValue(now) let databaseClosed = false try { - service = createSandboxService(true, null, undefined, membersPostgres) + service = createSandboxService(true, { membersPostgres }) const params = createSandboxParams({ from: { integrationId: 4 } }) const first = await service.executeSendEmail(invocation) expect(first.error).toBeUndefined() @@ -1091,7 +1113,7 @@ describe('EmailService', () => { ] as const)( 'skips when the sandbox route is unavailable because %s (isTest=%s)', async (_reason, isTest, enabled: boolean, routeSettings: Partial<SandboxEmailSenderConfig>) => { - service = createSandboxService(enabled, null, undefined, hub.postgres, routeSettings) + service = createSandboxService(enabled, { routeSettings }) const result = await service.executeSendEmail(invocation, isTest) expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) @@ -1129,7 +1151,7 @@ describe('EmailService', () => { const outputs = { produce: jest.fn().mockResolvedValue(undefined), } as unknown as IngestionOutputs<'message_assets'> - service = createSandboxService(true, null, new MessageAssetsService(outputs)) + service = createSandboxService(true, { messageAssetsService: new MessageAssetsService(outputs) }) invocation.state.actionId = 'send-email' invocation.queueParameters = createSandboxParams({ from: { integrationId: 4, email: 'override@example.com', name: 'Custom sender' }, @@ -1260,7 +1282,10 @@ describe('EmailService', () => { produce: jest.fn().mockResolvedValue(undefined), } as unknown as IngestionOutputs<'message_assets'> const limiter = await createSandboxLimiter('sandbox-rejected-html-budget-test') - service = createSandboxService(true, limiter, new MessageAssetsService(outputs)) + service = createSandboxService(true, { + tierLimiter: limiter, + messageAssetsService: new MessageAssetsService(outputs), + }) invocation.state.actionId = 'send-email' const params = createSandboxParams({ from: { integrationId: 4 }, text, html }) invocation.queueParameters = params @@ -1363,7 +1388,7 @@ describe('EmailService', () => { it('bypasses an exhausted sending tier and leaves its budget for own senders', async () => { const limiter = await createSandboxLimiter('sandbox-tier-budget-test') - service = createSandboxService(true, limiter) + service = createSandboxService(true, { tierLimiter: limiter }) const sandbox = await service.executeSendEmail(invocation) expect(sandbox).toMatchObject({ finished: true }) expect(sandbox.error).toBeUndefined() @@ -1385,6 +1410,294 @@ describe('EmailService', () => { expect(afterExhaustion.error).toBeUndefined() expect(sendEmailSpy).toHaveBeenCalledTimes(3) }) + + describe('daily caps', () => { + it('skips and refunds when the organization snapshot expires during the cap claim', async () => { + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + const claim = dailyCapLimiter.claimAllOrNothing.bind(dailyCapLimiter) + const delayedClaim = jest.spyOn(dailyCapLimiter, 'claimAllOrNothing') + try { + service = createSandboxService(true, { dailyTeamCap: '2', dailyRecipientCap: '2' }) + expectSent(await send(true, {})) + delayedClaim.mockImplementationOnce(async (buckets) => { + const grant = await claim(buckets) + clock.mockReturnValue(now + 61_000) + return grant + }) + + const expired = await send(true, {}) + expect(expired).toMatchObject({ finished: true, skipped: true }) + expect(expired.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + expect(expired.logs).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('Skipping send: could not check organization members'), + }) + ) + + clock.mockReturnValue(now + 122_000) + expectSent(await send(true, {})) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + } finally { + delayedClaim.mockRestore() + clock.mockRestore() + } + }) + + it('uses the membership organization for caps when the team cache is older', async () => { + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + const nextOrganization = await createOtherOrganization() + try { + service = createSandboxService(true, { dailyRecipientCap: '1' }) + expectSent(await send(true, {})) + await createOrganizationMembership(hub.postgres, nextOrganization, memberId) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_team SET organization_id = $1 WHERE id = $2', + [nextOrganization, team.id], + 'test:transfer-sandbox-project' + ) + clock.mockReturnValue(now + 61_000) + + expectSent(await send(true, {})) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + } finally { + clock.mockRestore() + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_team SET organization_id = $1 WHERE id = $2', + [team.organization_id, team.id], + 'test:restore-sandbox-project-organization' + ) + } + }) + + const TEAM_CAP_REACHED = + "Skipping send: this email would go over the sandbox sender's daily limit for this project. Verify your own domain to send more." + const recipientCapReached = (addresses: string): string => + `Skipping send: these addresses reached the sandbox sender's daily limit per address: ${addresses}. Verify your own domain to send more.` + const CAP_CHECK_FAILED = + "Skipping send: could not check the sandbox sender's daily limit. Try again later, or verify your own domain to send more." + + const send = async ( + isTest: boolean, + params: Partial<CyclotronInvocationQueueParametersEmailType> + ): Promise<CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>> => { + invocation.state.vmState = { stack: [] } as any + invocation.queueParameters = createSandboxParams({ from: { integrationId: 4 }, ...params }) + return await service.executeSendEmail(invocation, isTest) + } + const expectSent = (result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>): void => { + expect(result.error).toBeUndefined() + expect(result.skipped).not.toBe(true) + expect(result.invocation.state.vmState?.stack).toEqual([{ success: true }]) + } + const expectSkipped = ( + result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, + isTest: boolean, + message: string, + blocked: { reason: 'cap_reached' | 'check_failed'; blocked_recipient_count: number } + ): void => { + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [], messageAssets: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.queueScheduledAt).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toContainEqual(expect.objectContaining({ level: 'info', message })) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + expect.objectContaining({ reason: blocked.reason, is_test: isTest }) + ) + } + + it.each([false, true])( + 'holds the project to its daily cap without charging denied or own-sender sends (isTest=%s)', + async (isTest) => { + service = createSandboxService(true, { dailyTeamCap: '3', dailyRecipientCap: '1' }) + + expectSent(await send(isTest, { from: { integrationId: 1 } })) + expectSent(await send(isTest, { to: { email: memberEmail() } })) + expectSkipped( + await send(isTest, { + to: { email: memberEmail() }, + cc: memberEmail('cc'), + bcc: memberEmail('bcc'), + }), + isTest, + TEAM_CAP_REACHED, + { reason: 'cap_reached', blocked_recipient_count: 3 } + ) + expectSent(await send(isTest, { to: { email: memberEmail('cc') } })) + expectSent(await send(isTest, { to: { email: memberEmail('bcc') } })) + expectSkipped(await send(isTest, { to: { email: memberEmail() } }), isTest, TEAM_CAP_REACHED, { + reason: 'cap_reached', + blocked_recipient_count: 1, + }) + expect(sendEmailSpy).toHaveBeenCalledTimes(4) + } + ) + + it.each([false, true])( + 'skips a send with recipients at the daily cap and leaves the others their budget (isTest=%s)', + async (isTest) => { + service = createSandboxService(true, { dailyTeamCap: '5', dailyRecipientCap: '1' }) + + expectSent(await send(isTest, { to: { email: memberEmail('cc') }, cc: memberEmail('bcc') })) + expectSkipped( + await send(isTest, { + to: { email: memberEmail() }, + cc: memberEmail('cc').toUpperCase(), + bcc: memberEmail('bcc'), + }), + isTest, + recipientCapReached(`${memberEmail('cc').toUpperCase()}, ${memberEmail('bcc')}`), + { reason: 'cap_reached', blocked_recipient_count: 2 } + ) + expectSent(await send(isTest, { to: { email: memberEmail() } })) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + } + ) + + it.each([false, true])( + 'charges a repeated address once per address and once per copy for the project (isTest=%s)', + async (isTest) => { + service = createSandboxService(true, { dailyTeamCap: '3', dailyRecipientCap: '1' }) + + expectSent( + await send(isTest, { + to: { email: memberEmail() }, + cc: memberEmail().toUpperCase(), + bcc: ` ${memberEmail()} `, + }) + ) + expectSkipped( + await send(isTest, { to: { email: memberEmail('cc') } }), + isTest, + TEAM_CAP_REACHED, + { reason: 'cap_reached', blocked_recipient_count: 1 } + ) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + } + ) + + it('refills a spent sandbox allowance over a day rather than seconds', async () => { + service = createSandboxService(true, { dailyTeamCap: '1', dailyRecipientCap: '1' }) + expectSent(await send(true, {})) + const client = sandboxRedisClients[0] + const keys = ( + await client.keys(`@posthog/workflows-sandbox-daily/{${team.organization_id}}/*`) + ).filter((key) => !key.includes('/claimed/')) + const [seconds, microseconds] = await client.time() + const redisNow = Number(seconds) * 1000 + Math.floor(Number(microseconds) / 1000) + for (const key of keys) { + await client.hset(key, 'ts', redisNow - 12 * 60 * 60 * 1000) + } + expectSkipped(await send(true, {}), true, TEAM_CAP_REACHED, { + reason: 'cap_reached', + blocked_recipient_count: 1, + }) + for (const key of keys) { + await client.hset(key, 'ts', redisNow - 24 * 60 * 60 * 1000 - 1000) + } + expectSent(await send(true, {})) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])( + 'spends the daily caps once for a send that SES throttles before accepting it (isTest=%s)', + async (isTest) => { + service = createSandboxService(true, { dailyTeamCap: '2', dailyRecipientCap: '1' }) + sendEmailSpy.mockRejectedValueOnce(new ThrottlingException('Rate exceeded')) + + const throttled = await send(isTest, { cc: memberEmail('cc') }) + expect(throttled.finished).toBe(false) + expectSent(await service.executeSendEmail(throttled.invocation, isTest)) + expectSkipped( + await send(isTest, { to: { email: memberEmail('bcc') } }), + isTest, + TEAM_CAP_REACHED, + { reason: 'cap_reached', blocked_recipient_count: 1 } + ) + expect(sendEmailSpy).toHaveBeenCalledTimes(2) + } + ) + + it.each([false, true])( + 'rechecks membership after a delayed cap claim and returns a blocked grant (isTest=%s)', + async (isTest) => { + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + service = createSandboxService(true, { dailyTeamCap: '1', dailyRecipientCap: '1' }) + const claim = dailyCapLimiter.claimAllOrNothing.bind(dailyCapLimiter) + const delayedClaim = jest + .spyOn(dailyCapLimiter, 'claimAllOrNothing') + .mockImplementationOnce(async (buckets) => { + const grant = await claim(buckets) + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET is_email_verified = false WHERE id = $1', + [memberId], + 'test:revoke-sandbox-verification-during-cap-claim' + ) + clock.mockReturnValue(now + 61_000) + return grant + }) + try { + const blocked = await send(isTest, {}) + expect(blocked).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(blocked.error).toBeUndefined() + expect(blocked.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(sendEmailSpy).not.toHaveBeenCalled() + + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'UPDATE posthog_user SET is_email_verified = true WHERE id = $1', + [memberId], + 'test:restore-sandbox-verification' + ) + clock.mockReturnValue(now + 122_000) + expectSent(await send(isTest, {})) + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + } finally { + delayedClaim.mockRestore() + clock.mockRestore() + } + } + ) + + it.each([ + [ + 'the limiter is unreachable', + false, + { + capLimiter: new RateLimiterService( + { + useClient: () => Promise.reject(new Error('Connection is closed.')), + usePipeline: () => Promise.reject(new Error('Connection is closed.')), + }, + { name: 'sandbox-unreachable-test' } + ), + }, + ], + ['no limiter is configured', true, { capLimiter: null }], + ['the project cap is empty', false, { dailyTeamCap: '' }], + ['the recipient cap is empty', true, { dailyRecipientCap: ' ' }], + ['the project cap is zero', true, { dailyTeamCap: '0' }], + ['the recipient cap is fractional', false, { dailyRecipientCap: '1.5' }], + ['the project cap has trailing text', true, { dailyTeamCap: '25 per day' }], + ['the recipient cap is negative', false, { dailyRecipientCap: '-5' }], + ] as const)('skips every send when %s (isTest=%s)', async (_name, isTest, options) => { + service = createSandboxService(true, options) + + expectSkipped(await send(isTest, { cc: memberEmail('cc') }), isTest, CAP_CHECK_FAILED, { + reason: 'check_failed', + blocked_recipient_count: 2, + }) + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + }) }) describe('integration validation', () => { beforeEach(async () => { diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index 2ff9f7e51116..8094da1eb54a 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -35,7 +35,7 @@ import { EmailTrackingCodeSigner, TRACKING_CODE_HEADER_NAME } from './helpers/tr import { addUtmTagsToEmail, renderUtmOverrides, resolveUtmTags } from './helpers/utm' import { MessageAssetsService } from './message-assets.service' import { RecipientTokensService } from './recipient-tokens.service' -import { SandboxEmailSender } from './sandbox-email-sender' +import { SandboxDailyCapGrant, SandboxEmailSender } from './sandbox-email-sender' const sesThrottleResponsesTotal = new Counter({ name: 'cdp_ses_throttle_responses_total', @@ -396,10 +396,10 @@ function sandboxAddressList(value?: string): string[] { .map((entry) => angleAddress(entry) ?? entry) } -type SandboxRecipientBlockReason = 'recipient_not_member' | 'recipient_unverified' | 'check_failed' +type SandboxAddressBlockReason = 'recipient_not_member' | 'recipient_unverified' | 'check_failed' | 'cap_reached' -const SANDBOX_RECIPIENT_BLOCK_COPY: Record< - SandboxRecipientBlockReason, +const SANDBOX_ADDRESS_BLOCK_COPY: Record< + SandboxAddressBlockReason, { explanation: string; label: string; guidance: string } > = { recipient_not_member: { @@ -419,8 +419,20 @@ const SANDBOX_RECIPIENT_BLOCK_COPY: Record< label: ' for these addresses: ', guidance: 'Try again, or verify your own domain to send to anyone.', }, + cap_reached: { + explanation: "Skipping send: these addresses reached the sandbox sender's daily limit per address", + label: ': ', + guidance: 'Verify your own domain to send more.', + }, } +const SANDBOX_CAP_SKIP_MESSAGES = { + project_cap_reached: + "Skipping send: this email would go over the sandbox sender's daily limit for this project. Verify your own domain to send more.", + check_failed: + "Skipping send: could not check the sandbox sender's daily limit. Try again later, or verify your own domain to send more.", +} as const + const BLOCKED_ADDRESSES_LABEL = 'Blocked addresses: ' function splitByLength(text: string, maxLength: number): string[] { @@ -1067,7 +1079,7 @@ export class EmailService { ): Promise<boolean> { const invocation = result.invocation let blockedRecipients: string[] - let reason: SandboxRecipientBlockReason + let reason: Exclude<SandboxAddressBlockReason, 'cap_reached'> try { if (!this.organizationMembers) { throw new Error('Organization member checks are not configured') @@ -1103,14 +1115,42 @@ export class EmailService { return false } + private async claimSandboxDailyCaps( + result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, + recipients: string[], + isTest: boolean + ): Promise<SandboxDailyCapGrant | null> { + const teamId = result.invocation.teamId + const organization = await this.organizationMembers?.getOrganizationSnapshot(teamId).catch(() => null) + const claim = organization + ? await this.sandboxSender!.claimDailyCaps(teamId, recipients, organization) + : { type: 'check_failed' as const } + if (claim.type === 'granted') { + return claim + } + result.skipped = true + result.invocation.state.vmState?.stack.push({ success: false }) + if (claim.type === 'recipient_cap_reached') { + this.logSandboxRecipientBlock(result, claim.addresses, 'cap_reached') + } else { + createAddLogFunction(result.logs)('info', SANDBOX_CAP_SKIP_MESSAGES[claim.type]) + } + await this.sandboxSender!.capture(teamId, isTest, { + type: 'blocked', + reason: claim.type === 'check_failed' ? 'check_failed' : 'cap_reached', + blockedRecipientCount: claim.type === 'recipient_cap_reached' ? claim.addresses.length : recipients.length, + }) + return null + } + private logSandboxRecipientBlock( result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, blockedRecipients: string[], - reason: SandboxRecipientBlockReason + reason: SandboxAddressBlockReason ): void { const addLog = createAddLogFunction(result.logs) const addresses = blockedRecipients.map((address) => address.trim()) - const { explanation, label, guidance } = SANDBOX_RECIPIENT_BLOCK_COPY[reason] + const { explanation, label, guidance } = SANDBOX_ADDRESS_BLOCK_COPY[reason] const message = `${explanation}${label}${addresses.join(', ')}. ${guidance}` if (sanitizeLogMessage([message]) === message) { addLog('info', message) @@ -1483,15 +1523,17 @@ export class EmailService { sendEmailParams.Destination!.BccAddresses = bccAddresses } - if ( - integration.config.provider === 'sandbox' && - !(await this.checkSandboxRecipients( - result, - [params.to.email, ...(ccAddresses ?? []), ...(bccAddresses ?? [])], - isTest - )) - ) { - return false + let dailyCapGrant: SandboxDailyCapGrant | null = null + if (integration.config.provider === 'sandbox') { + const sandboxRecipients = [params.to.email, ...(ccAddresses ?? []), ...(bccAddresses ?? [])] + dailyCapGrant = await this.claimSandboxDailyCaps(result, sandboxRecipients, isTest) + if (!dailyCapGrant) { + return false + } + if (!(await this.checkSandboxRecipients(result, sandboxRecipients, isTest, dailyCapGrant.organization))) { + await this.sandboxSender!.returnDailyCaps(dailyCapGrant) + return false + } } try { const response = await this.sesV2Client.send(new SendEmailCommand(sendEmailParams)) @@ -1501,6 +1543,9 @@ export class EmailService { } catch (error: unknown) { if (isSesThrottleError(error)) { sesThrottleResponsesTotal.inc({ error_code: error.name }) + if (dailyCapGrant) { + await this.sandboxSender!.returnDailyCaps(dailyCapGrant) + } throw new SESThrottleError(error.name, pickThrottleRetryDelayMs(), error.message) } const message = error instanceof Error ? error.message : String(error) diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts index e14c85849250..a8aa11e26af3 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts @@ -13,6 +13,8 @@ const sender = new SandboxEmailSender( tenantName: 'sandbox-tenant', configurationSetName: 'sandbox-email', fromAddress: 'fixed-sandbox@example.com', + dailyTeamCap: '25', + dailyRecipientCap: '5', }, { getTeam: jest.fn().mockResolvedValue({ organization_id: '00000000-0000-0000-0000-000000000001' }), diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts index 0186bcff2c23..c333dc97b603 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'crypto' import { type DefaultTreeAdapterMap, defaultTreeAdapter, parse, parseFragment, serialize } from 'parse5' import { CyclotronInvocationQueueParametersEmailType } from '~/cdp/schema/cyclotron' @@ -6,13 +7,20 @@ import { logger } from '~/common/utils/logger' import { captureTeamEvent } from '~/common/utils/posthog' import { TeamManager } from '~/common/utils/team-manager' +import { TeamOrganizationSnapshot } from '../managers/organization-members.service' +import { ClaimRequest, RateLimiterService } from '../rate-limiter/rate-limiter.service' import { maybeAddPreheaderToEmail } from './helpers/preheader' +const SECONDS_PER_DAY = 86400 +const DAILY_CAP_TTL_SECONDS = 2 * SECONDS_PER_DAY + export interface SandboxEmailSenderConfig { enabled: boolean tenantName: string configurationSetName: string fromAddress: string + dailyTeamCap: string + dailyRecipientCap: string } const ROUTE_SETTING_NAMES = { @@ -27,14 +35,57 @@ function emptyRouteSettings(config: SandboxEmailSenderConfig): string[] { .map((setting) => ROUTE_SETTING_NAMES[setting]) } +export type SandboxDailyCapGrant = { + type: 'granted' + claimId: string + buckets: ClaimRequest[] + organization: TeamOrganizationSnapshot +} + +export type SandboxDailyCapClaim = + | SandboxDailyCapGrant + | { type: 'project_cap_reached' } + | { type: 'recipient_cap_reached'; addresses: string[] } + | { type: 'check_failed' } + type SandboxEmailOutcome = | { type: 'sent'; recipientCount: number } | { type: 'blocked' - reason: 'switch_off' | 'recipient_not_member' | 'recipient_unverified' | 'check_failed' + reason: 'switch_off' | 'recipient_not_member' | 'recipient_unverified' | 'check_failed' | 'cap_reached' blockedRecipientCount: number } +function dailyBucket(key: string, requested: number, capacity: number): ClaimRequest { + return { key, requested, capacity, refillPerSecond: capacity / SECONDS_PER_DAY, ttlSeconds: DAILY_CAP_TTL_SECONDS } +} + +function parseDailyCap(value: string): number | null { + const cap = /^\d+$/.test(value) ? Number(value) : null + return cap !== null && Number.isSafeInteger(cap) && cap > 0 ? cap : null +} + +// The organization hash tag keeps shared recipient and project claims in one Valkey cluster slot. +function dailyCapKeyPrefix(organizationId: string): string { + return `@posthog/workflows-sandbox-daily/{${organizationId}}` +} + +function addressDigest(address: string): string { + return createHash('sha256').update(address.toLowerCase()).digest('hex') +} + +function distinctAddresses(recipients: string[]): string[] { + const addresses = new Map<string, string>() + for (const recipient of recipients) { + const trimmed = recipient.trim() + const normalized = trimmed.toLowerCase() + if (!addresses.has(normalized)) { + addresses.set(normalized, trimmed) + } + } + return [...addresses.values()] +} + function htmlBody(document: DefaultTreeAdapterMap['document']): DefaultTreeAdapterMap['element'] | undefined { const root = document.childNodes.find(defaultTreeAdapter.isElementNode) return root?.childNodes.find( @@ -122,6 +173,7 @@ export class SandboxEmailSender { constructor( public readonly config: SandboxEmailSenderConfig, private teamManager: TeamManager, + private dailyCapLimiter: RateLimiterService | null = null, private blockedEventRedis: Pick<RedisV2, 'useClient'> | null = null ) { this.config = { @@ -139,6 +191,45 @@ export class SandboxEmailSender { this.canSend = config.enabled && !emptySettings.length } + public async claimDailyCaps( + teamId: number, + recipients: string[], + organization: TeamOrganizationSnapshot + ): Promise<SandboxDailyCapClaim> { + const dailyTeamCap = parseDailyCap(this.config.dailyTeamCap) + const dailyRecipientCap = parseDailyCap(this.config.dailyRecipientCap) + if (!this.dailyCapLimiter || dailyTeamCap === null || dailyRecipientCap === null) { + logger.error('Sandbox email daily caps are not configured correctly', { teamId }) + return { type: 'check_failed' } + } + if (Date.now() >= organization.expiresAt) { + return { type: 'check_failed' } + } + const prefix = dailyCapKeyPrefix(organization.organizationId) + const addresses = distinctAddresses(recipients) + const buckets = [ + dailyBucket(`${prefix}/project/${teamId}`, recipients.length, dailyTeamCap), + ...addresses.map((address) => + dailyBucket(`${prefix}/recipient/${addressDigest(address)}`, 1, dailyRecipientCap) + ), + ] + const claim = await this.dailyCapLimiter.claimAllOrNothing(buckets) + if (claim.granted) { + return { type: 'granted', claimId: randomUUID(), buckets, organization } + } + if (claim.deniedIndexes === null) { + return { type: 'check_failed' } + } + if (claim.deniedIndexes.includes(0)) { + return { type: 'project_cap_reached' } + } + return { type: 'recipient_cap_reached', addresses: claim.deniedIndexes.map((index) => addresses[index - 1]) } + } + + public async returnDailyCaps(grant: SandboxDailyCapGrant): Promise<void> { + await this.dailyCapLimiter?.returnClaim(grant.claimId, grant.buckets) + } + public async withIdentificationFooter( params: CyclotronInvocationQueueParametersEmailType, senderName: string, diff --git a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts index 490f0fee49ef..1b9ef5e4d58d 100644 --- a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts +++ b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts @@ -2,10 +2,13 @@ import { randomUUID } from 'crypto' import { register } from 'prom-client' import { deleteKeysWithPrefix } from '~/common/redis/_tests/redis' -import { RedisV2, createRedisV2PoolFromConfig } from '~/common/redis/redis-v2' +import { RedisClient, RedisClientPipeline, RedisV2 } from '~/common/redis/redis-v2' import { closeHub, createHub } from '~/common/utils/db/hub' -import { Hub } from '~/types' +import { createRedisPoolFromConfig } from '~/common/utils/db/redis' +import { TeamManager } from '~/common/utils/team-manager' +import { Hub, RedisPool } from '~/types' +import { SandboxEmailSender } from '../messaging/sandbox-email-sender' import { RateLimiterService } from './rate-limiter.service' const KEY = `@posthog-test/ses-rate-limiter/${randomUUID()}/bucket` @@ -15,13 +18,14 @@ describe('RateLimiterService', () => { let hub: Hub let redis: RedisV2 + let redisPool: RedisPool let limiter: RateLimiterService beforeEach(async () => { hub = await createHub() // Reuse the local CDP Redis for tests — same wire protocol, same atomicity. // In production this points at the dedicated SES rate-limiter Valkey. - redis = createRedisV2PoolFromConfig({ + redisPool = createRedisPoolFromConfig({ connection: hub.CDP_REDIS_HOST ? { url: hub.CDP_REDIS_HOST, @@ -31,11 +35,29 @@ describe('RateLimiterService', () => { poolMinSize: hub.REDIS_POOL_MIN_SIZE, poolMaxSize: hub.REDIS_POOL_MAX_SIZE, }) + redis = { + useClient: async (_options, callback) => { + const client = await redisPool.acquire() + try { + return await callback(client as RedisClient) + } finally { + await redisPool.release(client) + } + }, + usePipeline: async (options, callback) => + redis.useClient(options, async (client) => { + const pipeline = client.pipeline() as RedisClientPipeline + callback(pipeline) + return pipeline.exec() + }), + } limiter = new RateLimiterService(redis, { name: 'ses-rate-limiter' }) await deleteKeysWithPrefix(redis, KEY) }) afterEach(async () => { + await redisPool.drain() + await redisPool.clear() await closeHub(hub) }) @@ -413,4 +435,103 @@ describe('RateLimiterService', () => { expect([first.reserved, second.reserved, third.reserved]).toEqual([true, false, false]) }) }) + + it('shares recipient allowances across projects in an organization while keeping project allowances separate', async () => { + const organizations = { 1: randomUUID(), 3: randomUUID() } + const manager = { + getTeam: jest.fn((id: number) => + Promise.resolve({ organization_id: id === 3 ? organizations[3] : organizations[1] }) + ), + } as unknown as TeamManager + const sender = new SandboxEmailSender( + { + enabled: true, + tenantName: 'sandbox', + configurationSetName: 'sandbox', + fromAddress: 'sender@example.com', + dailyTeamCap: '1', + dailyRecipientCap: '1', + }, + manager, + limiter + ) + + try { + const firstOrganization = { organizationId: organizations[1], expiresAt: Infinity } + const otherOrganization = { organizationId: organizations[3], expiresAt: Infinity } + expect(await sender.claimDailyCaps(1, ['member@example.com'], firstOrganization)).toMatchObject({ + type: 'granted', + }) + expect(await sender.claimDailyCaps(2, ['member@example.com'], firstOrganization)).toEqual({ + type: 'recipient_cap_reached', + addresses: ['member@example.com'], + }) + expect(await sender.claimDailyCaps(2, ['other@example.com'], firstOrganization)).toMatchObject({ + type: 'granted', + }) + expect(await sender.claimDailyCaps(3, ['member@example.com'], otherOrganization)).toMatchObject({ + type: 'granted', + }) + expect(await sender.claimDailyCaps(2, ['third@example.com'], firstOrganization)).toEqual({ + type: 'project_cap_reached', + }) + } finally { + for (const organizationId of Object.values(organizations)) { + await deleteKeysWithPrefix(redis, `@posthog/workflows-sandbox-daily/{${organizationId}}`) + } + } + }) + + it('spends an unanswered claim once when the same EVAL is replayed', async () => { + const bucket = { key: `${KEY}/claim-replay`, requested: 1, capacity: 2, refillPerSecond: 0 } + const replayingRedis: RedisV2 = { + ...redis, + useClient: async (options, callback) => + redis.useClient(options, async (client) => { + const evaluate = client.eval.bind(client) + const replay = jest.spyOn(client, 'eval').mockImplementation(async (...args) => { + await evaluate(...args) + return evaluate(...args) + }) + try { + return await callback(client) + } finally { + replay.mockRestore() + } + }), + } + const replayingLimiter = new RateLimiterService(replayingRedis, { name: 'ses-rate-limiter' }) + + expect(await replayingLimiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: false, deniedIndexes: [0] }) + }) + + describe('returnClaim', () => { + it('gives a claim back once, even when the return is replayed', async () => { + const bucket = { key: `${KEY}/return`, requested: 1, capacity: 2, refillPerSecond: 0 } + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + + await limiter.returnClaim('claim-1', [bucket]) + await limiter.returnClaim('claim-1', [bucket]) + + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: false, deniedIndexes: [0] }) + }) + + it('ignores a return that reaches Valkey after its window', async () => { + const bucket = { key: `${KEY}/late-return`, requested: 1, capacity: 1, refillPerSecond: 0 } + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: true }) + + const issuedLongAgo = jest.spyOn(Date, 'now').mockReturnValue(Date.now() - 60 * 60 * 1000) + try { + await limiter.returnClaim('claim-1', [bucket]) + } finally { + issuedLongAgo.mockRestore() + } + + expect(await limiter.claimAllOrNothing([bucket])).toEqual({ granted: false, deniedIndexes: [0] }) + }) + }) }) diff --git a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts index 1d4b178a0574..3e09da89b2cb 100644 --- a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts +++ b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto' import { Counter, Histogram } from 'prom-client' import { RedisV2 } from '~/common/redis/redis-v2' @@ -249,6 +250,77 @@ end return {1, 0, 0, 0} ` +const CLAIM_ALL_OR_NOTHING_LUA = ` +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local claimKey = KEYS[#KEYS] +local previous = redis.call('GET', claimKey) +if previous then + return cjson.decode(previous) +end +if now > tonumber(ARGV[#ARGV - 1]) then + return {0} +end +local replayTtlMs = tonumber(ARGV[#ARGV]) +local available = {} +local denied = {0} +for i = 1, #KEYS - 1 do + local offset = (i - 1) * 4 + local requested = tonumber(ARGV[offset + 1]) + local capacity = tonumber(ARGV[offset + 2]) + local refillPerSecond = tonumber(ARGV[offset + 3]) + local existing = redis.call('hmget', KEYS[i], 'ts', 'pool') + local avail + if existing[1] == false then + avail = capacity + else + local elapsedMs = math.max(0, now - tonumber(existing[1])) + local currentTokens = capacity + if existing[2] ~= false then + currentTokens = tonumber(existing[2]) + end + avail = math.min(capacity, currentTokens + (elapsedMs / 1000.0) * refillPerSecond) + end + available[i] = avail + if avail < requested then + table.insert(denied, i) + end +end +if #denied > 1 then + redis.call('SET', claimKey, cjson.encode(denied), 'NX', 'PX', replayTtlMs) + return denied +end +for i = 1, #KEYS - 1 do + local offset = (i - 1) * 4 + redis.call('hset', KEYS[i], 'ts', now, 'pool', available[i] - tonumber(ARGV[offset + 1])) + redis.call('expire', KEYS[i], tonumber(ARGV[offset + 4])) +end +local granted = {1} +redis.call('SET', claimKey, cjson.encode(granted), 'NX', 'PX', replayTtlMs) +return granted +` + +// ioredis can replay unanswered commands, so returns need deduplication and an expiry. +const RETURN_CLAIM_LUA = ` +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local buckets = #KEYS - 1 +if now > tonumber(ARGV[buckets + 1]) then + return 0 +end +if not redis.call('set', KEYS[#KEYS], 1, 'NX', 'PX', ARGV[buckets + 2]) then + return 0 +end +for i = 1, buckets do + if redis.call('hexists', KEYS[i], 'pool') == 1 then + redis.call('hincrbyfloat', KEYS[i], 'pool', ARGV[i]) + end +end +return 1 +` + +const RETURN_WINDOW_MS = 5 * 60 * 1000 + export interface RateLimiterConfig { /** Logical name for metrics/logging only (e.g. 'ses'). */ name: string @@ -373,6 +445,61 @@ export class RateLimiterService { } } + // All bucket keys must share a hash tag because clustered Valkey rejects cross-slot Lua calls. + public async claimAllOrNothing( + buckets: ClaimRequest[] + ): Promise<{ granted: true } | { granted: false; deniedIndexes: number[] | null }> { + const endTimer = claimLatency.startTimer({ limiter: this.config.name }) + const keys = buckets.map((bucket) => bucket.key) + const claimId = randomUUID() + const deadline = Date.now() + RETURN_WINDOW_MS + try { + const result = await this.valkey.useClient( + { name: `rate-limiter:${this.config.name}:claimAllOrNothing`, timeout: 1000 }, + (client) => + client.eval( + CLAIM_ALL_OR_NOTHING_LUA, + buckets.length + 1, + ...keys, + `${keys[0]}/claimed/${claimId}`, + ...buckets.flatMap((bucket) => + [bucket.requested, bucket.capacity, bucket.refillPerSecond, bucket.ttlSeconds ?? 3600].map( + String + ) + ), + String(deadline), + String(2 * RETURN_WINDOW_MS) + ) + ) + if (Array.isArray(result) && result.length === 1 && result[0] === 1) { + claimCounter.inc({ limiter: this.config.name, result: 'granted_full' }) + return { granted: true } + } + if ( + Array.isArray(result) && + result[0] === 0 && + result.length > 1 && + result.slice(1).every((index) => Number.isInteger(index) && index >= 1 && index <= buckets.length) && + new Set(result.slice(1)).size === result.length - 1 + ) { + claimCounter.inc({ limiter: this.config.name, result: 'denied' }) + return { granted: false, deniedIndexes: result.slice(1).map((index) => index - 1) } + } + logger.warn('🪙', `RateLimiterService(${this.config.name}) claim returned invalid result`, { + keys, + raw: result, + }) + claimCounter.inc({ limiter: this.config.name, result: 'valkey_error' }) + return { granted: false, deniedIndexes: null } + } catch (err) { + logger.warn('🪙', `RateLimiterService(${this.config.name}) claim threw`, { keys, error: String(err) }) + claimCounter.inc({ limiter: this.config.name, result: 'valkey_error' }) + return { granted: false, deniedIndexes: null } + } finally { + endTimer() + } + } + /** * Atomically claim `requested` tokens from BOTH buckets, or neither. A denial consumes no * tokens, so a caller that retries a multi-token claim cannot drain the buckets while never @@ -449,4 +576,28 @@ export class RateLimiterService { endTimer() } } + + public async returnClaim(claimId: string, buckets: Pick<ClaimRequest, 'key' | 'requested'>[]): Promise<void> { + const keys = buckets.map((bucket) => bucket.key) + try { + await this.valkey.useClient( + { name: `rate-limiter:${this.config.name}:returnClaim`, timeout: 1000 }, + (client) => + client.eval( + RETURN_CLAIM_LUA, + buckets.length + 1, + ...keys, + `${keys[0]}/returned/${claimId}`, + ...buckets.map((bucket) => String(bucket.requested)), + String(Date.now() + RETURN_WINDOW_MS), + String(2 * RETURN_WINDOW_MS) + ) + ) + } catch (err) { + logger.warn('🪙', `RateLimiterService(${this.config.name}) claim return threw`, { + keys, + error: String(err), + }) + } + } } diff --git a/nodejs/src/server.ts b/nodejs/src/server.ts index 81391973e04b..a95a45ab3c39 100644 --- a/nodejs/src/server.ts +++ b/nodejs/src/server.ts @@ -137,8 +137,9 @@ export class PluginServer implements NodeServer { // Build typed deps objects for consumers. `emailValidationValkey` and `frequencyCapValkey` // are null in the shared deps and set only by the cyclotron workers that run the hogflow - // email action (see `withEmailActionValkeys` at their loaders below) — no - // other consumer touches the SES or frequency cap Valkey. + // email action (see `withEmailActionValkeys` at their loaders below). Sandbox-enabled + // cdp-api gets only the SES pool, because its test sends skip the frequency cap. + // No other consumer touches the SES or frequency cap Valkey. const cdpDeps: CdpConsumerBaseDeps | undefined = needsCdp ? { postgres: this.postgres!, @@ -245,7 +246,18 @@ export class PluginServer implements NodeServer { : null const api = new CdpApi( this.config, - { ...cdpDeps!, idempotencyValkey: createIdempotencyValkeyPool(this.config) }, + { + ...cdpDeps!, + ...(this.config.WORKFLOWS_SANDBOX_SENDER_ENABLED + ? { + emailValidationValkey: createSesRateLimiterValkeyPool( + this.config, + 'email-mx-validation' + ), + } + : {}), + idempotencyValkey: createIdempotencyValkeyPool(this.config), + }, { hogQueue: kafkaQueue, hogflowQueue: postgresV2Queue }, batchResolverProducer ) @@ -456,9 +468,8 @@ export class PluginServer implements NodeServer { /** * Grants the SES Valkey pool that backs the shared MX-verdict cache to a worker's - * deps. Only the cyclotron workers that run the hogflow email action call this, so - * the pool is never opened on other CDP consumers or cdp-api — an idle Valkey sized - * for the SES rate limiter shouldn't hold connections from pods that never validate. + * deps. Only the email workers call this. Sandbox-enabled cdp-api opens the SES pool on its + * own, without the frequency cap pool, and the pool stays closed on other CDP consumers. * Null when no SES Valkey host is configured (local dev), in which case * EmailValidationService degrades to its local cache + DNS. * From 0d48e51f6be7d02151c77dd0e8bf89769ff78fd5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:41:10 +0000 Subject: [PATCH 07/38] feat(workflows): offer the sandbox sender in workflow email steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- common/hogvm/typescript/src/stl/print.ts | 2 +- .../CyclotronJob/CyclotronJobInputs.tsx | 11 + frontend/src/lib/constants.tsx | 1 + .../src/lib/integrations/IntegrationsList.tsx | 6 +- .../integrations/integrationsLogic.test.ts | 262 ++++++++++++++++ .../src/lib/integrations/integrationsLogic.ts | 241 +++++++++++++-- frontend/src/lib/integrations/utils.ts | 5 + .../EmailSenderSummary.test.tsx | 70 +++++ .../email-templater/EmailSenderSummary.tsx | 6 +- .../email-templater/EmailTemplater.test.tsx | 96 +++++- .../email-templater/EmailTemplater.tsx | 250 +--------------- .../email-templater/LiquidSupportedText.tsx | 59 ++++ .../NativeEmailIntegrationChoice.stories.tsx | 114 +++++++ .../NativeEmailIntegrationChoice.tsx | 263 ++++++++++++++++ .../emailTemplaterLogic.test.ts | 282 ++++++++++++++++++ .../email-templater/emailTemplaterLogic.tsx | 274 ++++++++++++++++- .../email-templater/selectSenders.test.ts | 49 +++ .../email-templater/selectSenders.ts | 38 +++ .../Broadcasts/BroadcastsEmptyState.tsx | 2 +- .../Broadcasts/steps/BroadcastContentStep.tsx | 4 +- .../frontend/Channels/ChannelCatalog.tsx | 44 ++- .../Channels/MessageChannels.stories.tsx | 63 ++++ .../frontend/Channels/MessageChannels.tsx | 30 +- .../Channels/SandboxEmailSenderRow.tsx | 51 ++++ .../Channels/channelCatalogLogic.test.ts | 102 +++++++ .../frontend/Channels/channelCatalogLogic.ts | 95 ++++-- .../frontend/Channels/channelSummary.test.ts | 3 + .../frontend/Channels/channelSummary.ts | 6 +- .../Channels/defaultEmailSender.test.ts | 10 + .../frontend/Channels/defaultEmailSender.ts | 5 +- .../frontend/Channels/emailSendingMessages.ts | 3 + .../messageAudienceWarnings.test.ts | 9 + .../messageAudienceWarnings.tsx | 16 +- .../HogFlowFunctionConfiguration.tsx | 1 + .../setupGuide/wizard/WizardChannelStep.tsx | 7 +- .../wizard/workflowsOnboardingWizardLogic.ts | 2 +- .../setupGuide/workflowsSetupGuideLogic.ts | 2 +- 37 files changed, 2133 insertions(+), 351 deletions(-) create mode 100644 frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.ts create mode 100644 products/workflows/frontend/Channels/MessageChannels.stories.tsx create mode 100644 products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx create mode 100644 products/workflows/frontend/Channels/channelCatalogLogic.test.ts diff --git a/common/hogvm/typescript/src/stl/print.ts b/common/hogvm/typescript/src/stl/print.ts index 0a53f8d6e906..744cf703fb3a 100644 --- a/common/hogvm/typescript/src/stl/print.ts +++ b/common/hogvm/typescript/src/stl/print.ts @@ -119,7 +119,7 @@ export function printHogStringOutput(obj: any): string { return printHogValue(obj) } -type ASTNode = Map<string, any> | null +export type ASTNode = Map<string, any> | null // Note: this printer currently is experimental and used for debugging/printing SQL only. // When making queries via run(query), we send the AST nodes directly to the server. diff --git a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx index 8639c256a962..66cf48e39d7f 100644 --- a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx +++ b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx @@ -140,6 +140,7 @@ export type CyclotronJobInputsProps = { // (the workflow builder's auto-save). Only the email input types read these. emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean parentConfiguration?: CyclotronJobInputConfiguration onInputSchemaChange?: (schema: CyclotronJobInputSchemaType[]) => void // Classes for the column the inputs are laid out in, so a host with height to spare can let @@ -160,6 +161,7 @@ export function CyclotronJobInputs({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, showSource, sampleGlobalsWithInputs, className, @@ -205,6 +207,7 @@ export function CyclotronJobInputs({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> ) })} @@ -308,6 +311,7 @@ function EmailTemplateField({ fieldErrors, liveChanges, saveIndicator, + sandboxSenderAllowed, }: { schema: CyclotronJobInputSchemaType value: any @@ -316,6 +320,7 @@ function EmailTemplateField({ fieldErrors?: EmailFieldErrors liveChanges?: boolean saveIndicator?: ReactNode + sandboxSenderAllowed?: boolean }): JSX.Element { return ( <EmailTemplater @@ -328,6 +333,7 @@ function EmailTemplateField({ fieldErrors={fieldErrors} liveChanges={liveChanges} saveIndicator={saveIndicator} + sandboxSenderAllowed={sandboxSenderAllowed} /> ) } @@ -717,6 +723,7 @@ type CyclotronJobInputProps = { emailFieldErrors?: EmailFieldErrors emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean } function NonFailureStatusCodesField({ @@ -772,6 +779,7 @@ function CyclotronJobInputRenderer({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputProps): JSX.Element { const templating = schema.templating ?? true @@ -917,6 +925,7 @@ function CyclotronJobInputRenderer({ fieldErrors={emailFieldErrors} liveChanges={emailLiveChanges} saveIndicator={emailSaveIndicator} + sandboxSenderAllowed={emailSandboxSenderAllowed} /> ) case 'non_failure_status_codes': @@ -1106,6 +1115,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputWithSchemaProps): JSX.Element | null { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key }) const [editing, setEditing] = useState(false) @@ -1276,6 +1286,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> )} {warning && !value?.secret ? ( diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index b2b1810e4a7c..df60d2f723f0 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -611,6 +611,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows WORKFLOWS_OVERVIEW_TAB: 'workflows-overview-tab', // owner: @dmarchuk #team-workflows, experimental Overview tab on a workflow WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows + WORKFLOWS_SANDBOX_SENDER: 'workflows-sandbox-sender', // owner: #team-workflows WORKFLOWS_SEND_CALENDAR: 'workflows-send-calendar', // owner: @meikelmosby #team-workflows, calendar of scheduled email sends in Broadcasts and Workflows WORKFLOWS_TILE_CANVAS: 'workflows-tile-canvas', // owner: #team-workflows WORKFLOWS_TRANSACTIONAL_EMAIL_API: 'workflows-transactional-email-api', // owner: @mayteio #team-workflows diff --git a/frontend/src/lib/integrations/IntegrationsList.tsx b/frontend/src/lib/integrations/IntegrationsList.tsx index 8e2ec973d3ee..cb224c510316 100644 --- a/frontend/src/lib/integrations/IntegrationsList.tsx +++ b/frontend/src/lib/integrations/IntegrationsList.tsx @@ -20,14 +20,14 @@ export function IntegrationsList({ /** Rendered once the list has loaded and nothing matches. */ emptyState?: JSX.Element }): JSX.Element { - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { userManagedIntegrations, integrationsLoading } = useValues(integrationsLogic) const { startPolling, stopPolling } = useActions(integrationsLogic) useOnMountEffect(() => { startPolling() return () => stopPolling() }) - const filteredIntegrations = integrations?.filter((integration) => { + const filteredIntegrations = userManagedIntegrations?.filter((integration) => { if (onlyKinds && !onlyKinds.includes(integration.kind)) { return false } @@ -46,7 +46,7 @@ export function IntegrationsList({ filteredIntegrations.map((integration) => ( <IntegrationView key={integration.id} integration={integration} /> )) - ) : integrationsLoading || integrations === null ? ( + ) : integrationsLoading || userManagedIntegrations === null ? ( <LemonSkeleton className="h-10" /> ) : ( emptyState diff --git a/frontend/src/lib/integrations/integrationsLogic.test.ts b/frontend/src/lib/integrations/integrationsLogic.test.ts index bd55fe125b36..11507a2825b9 100644 --- a/frontend/src/lib/integrations/integrationsLogic.test.ts +++ b/frontend/src/lib/integrations/integrationsLogic.test.ts @@ -7,6 +7,8 @@ import { expectLogic } from 'kea-test-utils' import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import apiReal, { ApiError } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { teamLogic } from 'scenes/teamLogic' import { useMocks } from '~/mocks/jest' @@ -78,6 +80,266 @@ describe('integrationsLogic', () => { jest.restoreAllMocks() }) + describe('sandbox email sender', () => { + const sandboxSender: IntegrationType = { + id: 9, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + icon_url: '', + config: { + provider: 'sandbox', + email: 'sandbox@example.com', + domain: 'example.com', + name: 'Acme via PostHog', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', + } + const ownSender: IntegrationType = { + id: 10, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + icon_url: '', + config: { provider: 'ses', email: 'hello@acme.example.com', domain: 'acme.example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', + } + let ensureCalls: number + let ensureStatus: number + + beforeEach(() => { + ensureCalls = 0 + ensureStatus = 200 + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + if (ensureStatus !== 200) { + return [ensureStatus, { detail: 'The sandbox sender is not available for this project.' }] + } + integrationsPayload = [...integrationsPayload, sandboxSender] + return [200, sandboxSender] + }, + }, + }) + }) + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + const enableFlag = (): void => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + + it('provisions the sandbox sender once when the flag is on and no row exists, then reloads', async () => { + enableFlag() + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }) + .toDispatchActions(['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + + expect(ensureCalls).toBe(1) + }) + + it('waits for the integrations list before deciding whether to provision', async () => { + enableFlag() + logic.actions.clearIntegrations() + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + + expect(ensureCalls).toBe(1) + }) + + it.each([ + { reason: 'the flag is off', flag: false, existing: [] as IntegrationType[] }, + { reason: 'a sandbox row already exists', flag: true, existing: [sandboxSender] }, + ])('does not call the endpoint when $reason', async ({ flag, existing }) => { + if (flag) { + enableFlag() + } + integrationsPayload = existing + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess']) + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + + expect(ensureCalls).toBe(0) + }) + + it.each([ + { outcome: 'a 404', status: 404 }, + { outcome: 'a server error', status: 500 }, + ])( + 'stops after $outcome from the endpoint, without a failure toast or a retry on reload', + async ({ status }) => { + enableFlag() + ensureStatus = status + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }) + .toDispatchActions(['provisionSandboxEmailSender', 'provisionSandboxEmailSenderSuccess']) + .toNotHaveDispatchedActions(['provisionSandboxEmailSenderFailure']) + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }) + .toDispatchActions(['loadIntegrationsSuccess']) + .toFinishAllListeners() + + expect(ensureCalls).toBe(1) + expect(logic.values.sandboxEmailSender).toBeNull() + expect(logic.values.sandboxEmailSenderProvisionError).toBe( + 'The sandbox sender is not available for this project.' + ) + if (status === 404) { + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toFinishAllListeners() + expect(ensureCalls).toBe(1) + } + } + ) + + it.each([ + [ + 'with an API detail', + { detail: 'Could not load senders. Try again.' }, + 'Could not load senders. Try again.', + ], + ['without an API detail', '<html>Bad gateway</html>', null], + ])( + 'retains a failed integrations load %s without treating the unresolved list as empty', + async (_name, body, expectedError) => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [502, body] } }) + logic.actions.clearIntegrations() + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsFailure', + ]) + expect(logic.values.integrations).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.integrationsLoadError).toBe(expectedError) + + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [] } } }) + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsSuccess', + ]) + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.integrationsLoadError).toBeNull() + } + ) + + it('retries a failed provision when a surface explicitly asks again', async () => { + enableFlag() + ensureStatus = 500 + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toDispatchActions([ + 'provisionSandboxEmailSenderSuccess', + ]) + + ensureStatus = 200 + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()) + .toDispatchActions(['provisionSandboxEmailSenderSuccess', 'loadIntegrationsSuccess']) + .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) + + expect(ensureCalls).toBe(2) + }) + + it('provisions once the flag arrives after a surface asked for the sandbox sender', async () => { + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + enableFlag() + }).toDispatchActions(['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + + expect(ensureCalls).toBe(1) + }) + + it('forgets an in-flight result when the project switches, and still provisions the new project when asked', async () => { + enableFlag() + let releaseEnsure: () => void = () => {} + const ensureGate = new Promise<void>((resolve) => (releaseEnsure = resolve)) + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + ensureCalls += 1 + await ensureGate + return [404, {}] + }, + }, + }) + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toDispatchActions(['provisionSandboxEmailSender']) + + await expectLogic(logic, () => { + teamLogic.actions.loadCurrentTeamSuccess({ ...teamLogic.values.currentTeam!, id: 999, project_id: 999 }) + }).toDispatchActions(['clearIntegrations', 'loadIntegrationsSuccess']) + logic.actions.ensureSandboxEmailSender() + expect(ensureCalls).toBe(1) + + releaseEnsure() + await expectLogic(logic).toDispatchActions([ + 'provisionSandboxEmailSenderSuccess', + 'provisionSandboxEmailSender', + 'provisionSandboxEmailSenderSuccess', + ]) + + expect(ensureCalls).toBe(2) + expect(logic.values.sandboxEmailSenderProvisionResult).toBe('unavailable') + }) + + it.each([ + { flagState: 'on', flag: true, expectedSandbox: expect.objectContaining({ id: sandboxSender.id }) }, + { flagState: 'off', flag: false, expectedSandbox: null }, + ])( + 'keeps the sandbox row out of the own-sender lists and exposes it only with the flag $flagState', + async ({ flag, expectedSandbox }) => { + if (flag) { + enableFlag() + } + integrationsPayload = [sandboxSender, ownSender] + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }) + .toDispatchActions(['loadIntegrationsSuccess']) + .toMatchValues({ + sandboxEmailSender: expectedSandbox, + ownEmailIntegrations: [expect.objectContaining({ id: ownSender.id })], + userManagedIntegrations: [expect.objectContaining({ id: ownSender.id })], + domainGroupedEmailIntegrations: [ + { + domain: 'acme.example.com', + integrations: [expect.objectContaining({ id: ownSender.id })], + }, + ], + }) + expect(logic.values.getIntegrationsByKind(['email'])).toEqual([ + expect.objectContaining({ id: ownSender.id }), + ]) + } + ) + }) + describe('GitHub discovery freshness', () => { const discovery = (id: string): GitHubAvailableInstallationsResponseApi => ({ discovery_id: id, diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 7a3dc2f0de55..0480cb243340 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -6,6 +6,7 @@ import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import api, { ApiError, getCookie } from 'lib/api' import { globalSetupLogic } from 'lib/components/ProductSetup' +import { FEATURE_FLAGS } from 'lib/constants' import { buildGithubDisconnectDescription } from 'lib/integrations/githubDisconnectCopy' import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors' import { describeScopeShortfall, getMissingScopes, requestedScopesForKind } from 'lib/integrations/integrationScopes' @@ -14,6 +15,7 @@ import { INTEGRATION_ERROR_PARAM, OAUTH_STATE_EXPIRED, } from 'lib/integrations/oauthCallbackErrors' +import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'lib/logic/preflightLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { isKeyOf } from 'lib/utils/guards' @@ -26,6 +28,7 @@ import { EmailIntegrationDomainGroupedType, IntegrationKind, IntegrationType } f import { integrationsCreate, + integrationsEmailSandboxSenderCreate, integrationsGithubAvailableInstallationsRetrieve, integrationsGithubReposRetrieve, integrationsGithubLinkExistingCreate, @@ -44,9 +47,28 @@ import { ChannelType } from 'products/workflows/frontend/Channels/MessageChannel import type { AvailableSetupTaskIdsEnumApi } from '../../generated/core/api.schemas' import type { PreflightStatus, UserBasicType } from '../../types' -import { ICONS, getIntegrationNameFromKind } from './utils' +import { ICONS, getIntegrationNameFromKind, isSandboxEmailSender } from './utils' + +export type SandboxEmailSenderProvisionResult = 'provisioned' | 'unavailable' | 'failed' const INTEGRATIONS_POLL_INTERVAL_MS = 30_000 +const SANDBOX_ENSURE_TIMEOUT_MS = 15_000 + +async function requestSandboxEmailSender( + projectId: string +): Promise<{ result: SandboxEmailSenderProvisionResult; error: string | null }> { + try { + await integrationsEmailSandboxSenderCreate(projectId, { + signal: AbortSignal.timeout(SANDBOX_ENSURE_TIMEOUT_MS), + }) + return { result: 'provisioned', error: null } + } catch (error) { + return { + result: error instanceof ApiError && error.status === 404 ? 'unavailable' : 'failed', + error: error instanceof ApiError ? error.detail : null, + } + } +} function toastApiError(e: unknown): void { const detail = e instanceof ApiError ? e.detail : null @@ -105,6 +127,7 @@ export function reconnectReturnUrl(pathname: string, search: string): string { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface integrationsLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic preflight: PreflightStatus | null // preflightLogic siteUrlMisconfigured: boolean // preflightLogic currentProjectId: number | string // teamLogic @@ -179,19 +202,29 @@ export interface integrationsLogicValues { githubRepositoriesTotal: Record<number, number> githubSuggestionSubscribers: number integrations: IntegrationType[] | null + integrationsLoadError: string | null + integrationsLoadFailed: boolean integrationsLoading: boolean linearIntegrations: IntegrationType[] linkedGithubInstallation: IntegrationConfigApi | null linkedGithubInstallationLoading: boolean newIntegrationModalId: string | null newIntegrationModalKind: IntegrationKind | null + ownEmailIntegrations: IntegrationType[] pollingSubscribers: number requestedAccessKinds: IntegrationKind[] + sandboxEmailSender: IntegrationType | null + sandboxEmailSenderEnabled: boolean + sandboxEmailSenderProvisionError: string | null + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null + sandboxEmailSenderProvisionResultLoading: boolean + sandboxEmailSenderSetupError: string | null selectedIntegration: IntegrationType | null setupModalOpen: boolean setupModalType: ChannelType | null slackAvailable: boolean | undefined slackIntegrations: IntegrationType[] | undefined + userManagedIntegrations: IntegrationType[] | null } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. @@ -210,6 +243,13 @@ export interface integrationsLogicActions { kind: string missingScopes: string[] } // eventUsageLogic + setFeatureFlags: ( + flags: string[], + variants: Record<string, boolean | string> + ) => { + flags: string[] + variants: Record<string, boolean | string> + } // featureFlagLogic markTaskAsCompleted: (taskIdOrIds: AvailableSetupTaskIdsEnumApi | AvailableSetupTaskIdsEnumApi[]) => { taskIdOrIds: AvailableSetupTaskIdsEnumApi | AvailableSetupTaskIdsEnumApi[] } // globalSetupLogic @@ -232,6 +272,9 @@ export interface integrationsLogicActions { deleteIntegration: (id: number) => { id: number } + ensureSandboxEmailSender: () => { + value: true + } handleOauthCallback: ( kind: IntegrationKind, searchParams: any @@ -552,6 +595,24 @@ export interface integrationsLogicActions { channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined integration: IntegrationType | undefined } + provisionMissingSandboxEmailSender: () => { + value: true + } + provisionSandboxEmailSender: () => any + provisionSandboxEmailSenderFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + provisionSandboxEmailSenderSuccess: ( + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null, + payload?: any + ) => { + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null + payload?: any + } requestIntegrationAccess: ({ kind }: { kind: IntegrationKind }) => { kind: IntegrationKind } @@ -665,6 +726,9 @@ export interface integrationsLogicActions { kind: IntegrationKind } } + retrySandboxEmailSender: () => { + value: true + } setAccessRequestReason: (reason: string) => { reason: string } @@ -680,6 +744,9 @@ export interface integrationsLogicActions { projectId: number | string response: GitHubAvailableInstallationsResponseApi | null } + setSandboxEmailSenderProvisionError: (error: string | null) => { + error: string | null + } startPolling: () => { value: true } @@ -709,7 +776,7 @@ export interface integrationsLogicMeta { githubAvailableInstallationsResponse: GitHubAvailableInstallationsResponseApi | null ) => boolean | null getIntegrationsByKind: ( - integrations: IntegrationType[] | null + userManagedIntegrations: IntegrationType[] | null ) => ( kinds: ( | 'apns' @@ -770,7 +837,19 @@ export interface integrationsLogicMeta { getGitHubRepositoriesTotal: ( githubRepositoriesTotal: Record<number, number> ) => (integrationId: number) => number | null - domainGroupedEmailIntegrations: (integrations: IntegrationType[] | null) => EmailIntegrationDomainGroupedType[] + sandboxEmailSenderEnabled: (featureFlags: FeatureFlagsSet) => boolean + sandboxEmailSenderSetupError: ( + sandboxEmailSenderEnabled: boolean, + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null, + sandboxEmailSenderProvisionError: string | null + ) => string | null + sandboxEmailSender: ( + integrations: IntegrationType[] | null, + sandboxEmailSenderEnabled: boolean + ) => IntegrationType | null + userManagedIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] | null + ownEmailIntegrations: (userManagedIntegrations: IntegrationType[] | null) => IntegrationType[] + domainGroupedEmailIntegrations: (ownEmailIntegrations: IntegrationType[]) => EmailIntegrationDomainGroupedType[] } } @@ -784,7 +863,14 @@ export type integrationsLogicType = MakeLogicType< export const integrationsLogic = kea<integrationsLogicType>([ path(['lib', 'integrations', 'integrationsLogic']), connect(() => ({ - values: [preflightLogic, ['siteUrlMisconfigured', 'preflight'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['siteUrlMisconfigured', 'preflight'], + teamLogic, + ['currentProjectId'], + featureFlagLogic, + ['featureFlags'], + ], actions: [ globalSetupLogic, ['markTaskAsCompleted'], @@ -792,6 +878,8 @@ export const integrationsLogic = kea<integrationsLogicType>([ ['reportIntegrationConnectRejected', 'reportIntegrationScopeShortfallShown'], teamLogic, ['loadCurrentTeamSuccess'], + featureFlagLogic, + ['setFeatureFlags'], ], })), @@ -803,6 +891,10 @@ export const integrationsLogic = kea<integrationsLogicType>([ callback, }), deleteIntegration: (id: number) => ({ id }), + ensureSandboxEmailSender: true, + retrySandboxEmailSender: true, + setSandboxEmailSenderProvisionError: (error: string | null) => ({ error }), + provisionMissingSandboxEmailSender: true, invalidateGithubSuggestions: true, setGithubDisconnecting: (disconnecting: boolean) => ({ disconnecting }), loadGithubAvailableInstallations: true, @@ -838,6 +930,42 @@ export const integrationsLogic = kea<integrationsLogicType>([ }), reducers({ integrations: [null as IntegrationType[] | null, { clearIntegrations: () => null }], + integrationsLoadError: [ + null as string | null, + { + loadIntegrations: () => null, + loadIntegrationsSuccess: () => null, + clearIntegrations: () => null, + loadIntegrationsFailure: (_, { errorObject }) => + errorObject instanceof ApiError && errorObject.detail ? errorObject.detail : null, + }, + ], + integrationsLoadFailed: [ + false, + { + loadIntegrations: () => false, + loadIntegrationsSuccess: () => false, + clearIntegrations: () => false, + loadIntegrationsFailure: () => true, + }, + ], + sandboxEmailSenderProvisionError: [ + null as string | null, + { + clearIntegrations: () => null, + provisionSandboxEmailSender: () => null, + retrySandboxEmailSender: () => null, + setSandboxEmailSenderProvisionError: (_, { error }) => error, + }, + ], + sandboxEmailSenderProvisionResult: [ + null as SandboxEmailSenderProvisionResult | null, + { + clearIntegrations: () => null, + ensureSandboxEmailSender: (state) => (state === 'failed' ? null : state), + retrySandboxEmailSender: () => null, + }, + ], githubDisconnecting: [false, { setGithubDisconnecting: (_, { disconnecting }) => disconnecting }], githubDiscoveryGeneration: [ 0, @@ -1100,6 +1228,23 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, }, ], + sandboxEmailSenderProvisionResult: [ + null as SandboxEmailSenderProvisionResult | null, + { + provisionSandboxEmailSender: async (): Promise<SandboxEmailSenderProvisionResult | null> => { + const projectId = values.currentProjectId + const result = await requestSandboxEmailSender(String(projectId)) + if (values.currentProjectId !== projectId) { + return values.sandboxEmailSenderProvisionResult + } + actions.setSandboxEmailSenderProvisionError(result.error) + if (result.result === 'provisioned') { + actions.loadIntegrations() + } + return result.result + }, + }, + ], accessRequest: [ null as IntegrationKind | null, { @@ -1128,6 +1273,7 @@ export const integrationsLogic = kea<integrationsLogicType>([ cache.githubProjectGeneration = (cache.githubProjectGeneration ?? 0) + 1 actions.linkExistingGithubInstallationSuccess(null) cache.githubConnectionFingerprint = undefined + cache.sandboxEmailSenderWanted = false // Clear only on a project switch: same-project team refreshes keep the connected integrations. actions.clearIntegrations() actions.invalidateGithubSuggestions() @@ -1153,7 +1299,38 @@ export const integrationsLogic = kea<integrationsLogicType>([ } } }, + retrySandboxEmailSender: () => actions.ensureSandboxEmailSender(), + ensureSandboxEmailSender: () => { + cache.sandboxEmailSenderWanted = true + if (values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, + provisionMissingSandboxEmailSender: () => { + if ( + !values.sandboxEmailSenderEnabled || + values.sandboxEmailSender || + values.sandboxEmailSenderProvisionResult || + values.sandboxEmailSenderProvisionResultLoading + ) { + return + } + actions.provisionSandboxEmailSender() + }, + setFeatureFlags: () => { + if (cache.sandboxEmailSenderWanted && values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, + provisionSandboxEmailSenderSuccess: () => { + if (cache.sandboxEmailSenderWanted && values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, loadIntegrationsSuccess: ({ integrations }) => { + if (cache.sandboxEmailSenderWanted) { + actions.provisionMissingSandboxEmailSender() + } const fingerprint = JSON.stringify( integrations ?.filter((row) => row.kind === 'github') @@ -1479,7 +1656,7 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, ], getIntegrationsByKind: [ - (s) => [s.integrations], + (s) => [s.userManagedIntegrations], (integrations: IntegrationType[] | null) => { return (kinds: IntegrationKind[]) => integrations?.filter((i) => kinds.includes(i.kind)) || [] }, @@ -1511,23 +1688,51 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, ], - domainGroupedEmailIntegrations: [ + sandboxEmailSenderEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + ], + sandboxEmailSenderSetupError: [ + (s) => [ + s.sandboxEmailSenderEnabled, + s.sandboxEmailSenderProvisionResult, + s.sandboxEmailSenderProvisionError, + ], + (enabled: boolean, result: string | null, provisionError: string | null): string | null => + enabled && (result === 'failed' || result === 'unavailable') + ? provisionError || 'Could not set up the sandbox sender.' + : null, + ], + sandboxEmailSender: [ + (s) => [s.integrations, s.sandboxEmailSenderEnabled], + (integrations: IntegrationType[] | null, sandboxEmailSenderEnabled: boolean): IntegrationType | null => + sandboxEmailSenderEnabled ? (integrations?.find(isSandboxEmailSender) ?? null) : null, + ], + userManagedIntegrations: [ (s) => [s.integrations], - (integrations: IntegrationType[] | null): EmailIntegrationDomainGroupedType[] => { + (integrations: IntegrationType[] | null): IntegrationType[] | null => + integrations?.filter((x) => !isSandboxEmailSender(x)) ?? null, + ], + ownEmailIntegrations: [ + (s) => [s.userManagedIntegrations], + (userManagedIntegrations: IntegrationType[] | null): IntegrationType[] => + userManagedIntegrations?.filter((x) => x.kind === 'email') ?? [], + ], + domainGroupedEmailIntegrations: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): EmailIntegrationDomainGroupedType[] => { const domainGroupedIntegrations: Record<string, EmailIntegrationDomainGroupedType> = {} - integrations - ?.filter((x) => x.kind === 'email') - .forEach((integration) => { - const domain = integration.config.domain - if (!domainGroupedIntegrations[domain]) { - domainGroupedIntegrations[domain] = { - domain, - integrations: [], - } + ownEmailIntegrations.forEach((integration) => { + const domain = integration.config.domain + if (!domainGroupedIntegrations[domain]) { + domainGroupedIntegrations[domain] = { + domain, + integrations: [], } - domainGroupedIntegrations[domain].integrations.push(integration) - }) + } + domainGroupedIntegrations[domain].integrations.push(integration) + }) return Object.values(domainGroupedIntegrations) }, diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 25a66c942229..1d9ef34222e7 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -139,6 +139,11 @@ export const ICONS: Record<IntegrationKind, any> = { // surface, so invert them in dark mode (`dark:invert`) wherever the integration icon is rendered. export const DARK_MODE_INVERT_ICON_KINDS = new Set<IntegrationKind>(['apns', 'apple-ads', 'github']) +export const isSandboxEmailSender = (integration: { kind: string; config?: unknown }): boolean => + integration.kind === 'email' && (integration.config as { provider?: unknown } | undefined)?.provider === 'sandbox' + +export const SANDBOX_EMAIL_SENDER_NOTE = 'Delivers only to verified members of your organization.' + export const getIntegrationNameFromKind = (kind: string): string => { switch (kind) { case 'google-pubsub': diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx new file mode 100644 index 000000000000..e0bbe575df94 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { EmailSenderSummary } from './EmailSenderSummary' +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' + +const OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { name: 'Acme', email: 'hello@acme.example.com', verified: true }, +} +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} + +describe('EmailSenderSummary', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + '/api/projects/:team_id/integrations/': { results: [OWN_SENDER, SANDBOX_SENDER] }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(cleanup) + + it.each([ + ['the own sender', OWN_SENDER.id, 'Acme'], + ['the sandbox sender', SANDBOX_SENDER.id, 'Choose who this email comes from'], + ])('shows a transactional template sending from %s', async (_name, integrationId, expectedText) => { + const props: EmailTemplaterLogicProps = { + type: 'native_email_transactional_template', + value: { subject: 'Hello', text: 'Hello', html: '<div>Hello</div>', from: { integrationId } } as any, + onChange: jest.fn(), + } + const logic = emailTemplaterLogic(props) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + render( + <BindLogic logic={emailTemplaterLogic} props={props}> + <EmailSenderSummary /> + </BindLogic> + ) + + expect(await screen.findByText(expectedText)).toBeInTheDocument() + expect(screen.queryByText('Acme via PostHog')).not.toBeInTheDocument() + logic.unmount() + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx index a59fe39c9031..e21998f57a1e 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx @@ -4,19 +4,15 @@ import { useState } from 'react' import { IconPencil } from '@posthog/icons' import { LemonButton, LemonDropdown, LemonLabel } from '@posthog/lemon-ui' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' - import { senderAddressError, senderFrom, senderIntegrationFor } from './emailSenderFields' import { EmailSenderForm } from './EmailSenderForm' import { emailTemplaterLogic } from './emailTemplaterLogic' export function EmailSenderSummary(): JSX.Element { - const { emailTemplate } = useValues(emailTemplaterLogic) + const { emailTemplate, senderIntegrations: senders } = useValues(emailTemplaterLogic) const { setEmailTemplateValue } = useActions(emailTemplaterLogic) - const { integrations } = useValues(integrationsLogic) const [isEditing, setIsEditing] = useState(false) - const senders = (integrations ?? []).filter((integration) => integration.kind === 'email') const from = senderFrom(emailTemplate) const sender = senderIntegrationFor(emailTemplate, senders) const name = from.name || sender?.config?.name diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index 4965bb411682..04148f4979ad 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom' import { cleanup, render, screen } from '@testing-library/react' import { useValues } from 'kea' -import { NativeEmailIntegrationChoice } from './EmailTemplater' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' let mockSenderRotationEnabled = false @@ -14,6 +14,7 @@ jest.mock('lib/hooks/useFeatureFlag', () => ({ jest.mock('kea', () => ({ ...jest.requireActual('kea'), useValues: jest.fn(), + useActions: () => ({ chooseSenders: jest.fn() }), })) jest.mock('@posthog/lemon-ui', () => ({ @@ -22,25 +23,31 @@ jest.mock('@posthog/lemon-ui', () => ({ LemonSelect: () => <div data-attr="single-email-sender-select" />, })) +const OWN_SENDERS = [ + { id: 1, kind: 'email', display_name: 'Alice <alice@example.com>' }, + { id: 2, kind: 'email', display_name: 'Bob <bob@example.com>' }, +] +const SANDBOX_SENDER = { id: 7, kind: 'email', display_name: 'Acme via PostHog <sandbox@example.com>' } + describe('NativeEmailIntegrationChoice', () => { afterEach(cleanup) - beforeEach(() => { + const mockValues = ( + sandboxEmailSender: typeof SANDBOX_SENDER | null, + overrides: Record<string, unknown> = {} + ): void => { jest.mocked(useValues).mockReturnValue({ - integrationsLoading: false, - integrations: [ - { - id: 1, - kind: 'email', - display_name: 'Alice <alice@example.com>', - }, - { - id: 2, - kind: 'email', - display_name: 'Bob <bob@example.com>', - }, - ], + senderIntegrationsLoading: false, + logicProps: {}, + senderIntegrations: sandboxEmailSender ? [...OWN_SENDERS, sandboxEmailSender] : OWN_SENDERS, + sandboxSenderId: sandboxEmailSender?.id, + isSandboxSenderSelected: false, + ...overrides, }) + } + + beforeEach(() => { + mockValues(null) }) it.each([ @@ -54,4 +61,63 @@ describe('NativeEmailIntegrationChoice', () => { expect(screen.getByTestId(expectedPicker)).toBeInTheDocument() expect(screen.getByText('Custom sender')).toBeInTheDocument() }) + + it('hides the custom sender controls and explains the recipient rule while the sandbox sender is selected', () => { + mockSenderRotationEnabled = true + mockValues(SANDBOX_SENDER, { isSandboxSenderSelected: true }) + + render( + <NativeEmailIntegrationChoice + label="From" + value={{ integrationId: 7, email: 'custom@example.com', name: 'Custom' }} + onChange={jest.fn()} + /> + ) + + expect(screen.queryByText('Custom sender')).not.toBeInTheDocument() + expect(screen.queryByText('Custom address')).not.toBeInTheDocument() + expect(screen.getByText('Delivers only to verified members of your organization.')).toBeInTheDocument() + }) + + it.each([ + { offered: false, selected: [1, 2], custom: false, warns: false }, + { offered: true, selected: [1, 2], custom: false, warns: true }, + { offered: true, selected: [1], custom: false, warns: false }, + { offered: true, selected: [1], custom: true, warns: true }, + ])( + 'mentions sandbox selection changes for $selected with custom values $custom and option offered $offered', + ({ offered, selected, custom, warns }) => { + mockSenderRotationEnabled = true + mockValues(offered ? SANDBOX_SENDER : null, { hasCustomSenderDetails: custom }) + + render( + <NativeEmailIntegrationChoice + label="From" + value={{ integrationId: 1, integrationIds: selected }} + onChange={jest.fn()} + /> + ) + + expect(screen.queryByText(/Choosing (the )?sandbox/)).toEqual(warns ? expect.anything() : null) + } + ) + + it('shows the picker, not the empty state, while the sandbox sender is still being created', () => { + mockSenderRotationEnabled = false + mockValues(null, { senderIntegrations: [], senderIntegrationsLoading: true }) + + render(<NativeEmailIntegrationChoice label="From" value={{}} onChange={jest.fn()} />) + + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByTestId('single-email-sender-select')).toBeInTheDocument() + }) + + it('keeps the connect action available when loading senders fails', () => { + mockValues(null, { senderIntegrations: [], senderIntegrationsError: 'Could not load your senders.' }) + + render(<NativeEmailIntegrationChoice label="From" value={{}} onChange={jest.fn()} />) + + expect(screen.getByText('Could not load your senders.')).toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute('href', '/workflows/channels') + }) }) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx index e990f2d12462..fcc5ac871399 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -5,16 +5,8 @@ import { ReactNode, useEffect, useState } from 'react' import { createPortal } from 'react-dom' import EmailEditor, { EditorRef } from 'react-email-editor' -import { IconCollapse, IconExpand, IconExternal, IconPlus, IconX } from '@posthog/icons' -import { - LemonButton, - LemonCard, - LemonInputSelect, - LemonLabel, - LemonModal, - LemonSegmentedButton, - LemonSelect, -} from '@posthog/lemon-ui' +import { IconCollapse, IconExpand, IconPlus, IconX } from '@posthog/icons' +import { LemonButton, LemonCard, LemonLabel, LemonModal, LemonSegmentedButton } from '@posthog/lemon-ui' import { CyclotronJobTemplateSuggestionsButton, @@ -24,15 +16,12 @@ import { insertTemplateReference, templateReferenceForOption, } from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' -import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea' import { CodeEditorInline } from 'lib/monaco/CodeEditorInline' import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable' import { sceneAgentPanelLogic } from 'scenes/max/sceneAgentPanelLogic' -import { urls } from 'scenes/urls' import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic' @@ -44,7 +33,9 @@ import { previewLinkTargetCustomJs } from './custom-tools/previewLinkTarget' import { unsubscribeLinkToolCustomJs } from './custom-tools/unsubscribeLinkTool' import { EmailSenderSummary } from './EmailSenderSummary' import { EMAIL_TYPE_SUPPORTED_FIELDS, EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' -import { EmailFieldErrors, EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' +import { LiquidSupportedText } from './LiquidSupportedText' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' +import { EmailFieldErrors } from './types' export type EmailEditorMode = 'full' | 'preview' @@ -266,237 +257,6 @@ function DestinationEmailTemplaterForm({ ) } -export function NativeEmailIntegrationChoice({ - label, - onChange, - value, -}: { - /** Rendered inside the sender row so it aligns with the select rather than spanning the override rows. */ - label: ReactNode - onChange: (value: EmailTemplateFrom) => void - value?: EmailTemplateFrom -}): JSX.Element { - const { integrationsLoading, integrations } = useValues(integrationsLogic) - const { logicProps } = useValues(emailTemplaterLogic) - const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') - const integrationsOfKind = integrations?.filter((x) => x.kind === 'email') - const selectedIntegrationIds = value?.integrationIds?.length - ? value.integrationIds - : value?.integrationId - ? [value.integrationId] - : [] - - // Presence of the override keys is what reveals the inputs, so a saved override is - // visible again on reopen without any separate reveal state. - const overridesVisible = value?.email !== undefined || value?.name !== undefined - - const onChangeIntegration = (integrationId: number): void => { - if (integrationId === -1) { - window.open(urls.workflows('channels'), '_blank') - return - } - onChange({ ...value, integrationId, integrationIds: undefined }) - } - - const onChangeIntegrations = (integrationIds: number[]): void => { - if (integrationIds.length > MAX_WORKFLOW_EMAIL_SENDERS) { - return - } - onChange({ - ...value, - integrationId: integrationIds[0], - integrationIds: integrationIds.length > 1 ? integrationIds : undefined, - }) - } - - if (!integrationsLoading && integrationsOfKind?.length === 0) { - return ( - <div className="flex gap-2 justify-between items-center"> - {label} - <div className="flex gap-2 items-center"> - <span className="text-muted">No email senders configured yet</span> - <LemonButton - size="small" - type="tertiary" - to={urls.workflows('channels')} - targetBlank - className="m-1" - icon={<IconExternal />} - > - Connect email sender - </LemonButton> - </div> - </div> - ) - } - - return ( - <div className="flex flex-col"> - <div className="flex gap-2 items-center"> - {label} - {senderRotationEnabled ? ( - <div className="flex flex-col flex-1"> - <LemonInputSelect<number> - className="m-1 flex-1" - mode="multiple" - placeholder="Choose email senders" - loading={integrationsLoading} - options={(integrationsOfKind || []).map((integration) => ({ - key: String(integration.id), - label: integration.display_name, - value: integration.id, - }))} - value={selectedIntegrationIds} - size="small" - fullWidth - autoWidth={false} - onChange={onChangeIntegrations} - data-attr="workflow-email-sender-select" - action={{ - children: 'Add new email sender', - icon: <IconExternal />, - onClick: () => window.open(urls.workflows('channels'), '_blank'), - }} - /> - <span className="px-2 pb-1 text-xs text-muted"> - Choose up to {MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from - this list. - </span> - </div> - ) : ( - <LemonSelect - className="m-1 flex-1" - type="tertiary" - placeholder="Choose email sender" - loading={integrationsLoading} - options={[ - { - title: 'Email senders', - options: (integrationsOfKind || []).map((integration) => ({ - label: integration.display_name, - value: integration.id, - })), - }, - { - options: [ - { - label: 'Add new email sender', - icon: <IconExternal />, - value: -1, - }, - ], - }, - ]} - value={value?.integrationId} - size="small" - fullWidth - onChange={onChangeIntegration} - data-attr="workflow-email-sender-select" - /> - )} - {!overridesVisible && ( - <LemonButton - size="xsmall" - type="secondary" - icon={<IconPlus />} - className="mr-2 shrink-0" - data-attr="email-from-custom-sender" - onClick={() => onChange({ ...value, email: '', name: '' })} - tooltip="Set a custom sender address or name, with template support" - > - Custom sender - </LemonButton> - )} - </div> - {overridesVisible && ( - <> - <div className="flex gap-2 items-center border-t"> - <LemonLabel - className="min-w-30 shrink-0" - info="The address to send from. Templates are supported, so the address can come from the triggering event. It must be on every selected sender's verified domain. Leave empty to use the sender's address." - > - Custom address - </LemonLabel> - <LiquidSupportedText - value={value?.email ?? ''} - onChange={(email) => onChange({ ...value, email })} - globals={logicProps.variables} - /> - <LemonButton - size="xsmall" - type="tertiary" - icon={<IconX />} - className="mr-2" - data-attr="email-from-custom-sender-remove" - onClick={() => onChange({ ...value, email: undefined, name: undefined })} - tooltip="Remove custom sender" - /> - </div> - <div className="flex gap-2 items-center border-t"> - <LemonLabel - className="min-w-30 shrink-0" - info="The sender name shown in the recipient's inbox. Templates are supported. Leave empty to use the sender's name." - > - Custom name - </LemonLabel> - <LiquidSupportedText - value={value?.name ?? ''} - onChange={(name) => onChange({ ...value, name })} - globals={logicProps.variables} - /> - </div> - </> - )} - </div> - ) -} - -function LiquidSupportedText({ - value, - onChange, - globals, -}: { - value: string - onChange: (value?: string) => void - globals: any -}): JSX.Element { - const { templatingEngine } = useValues(emailTemplaterLogic) - const { setTemplatingEngine } = useActions(emailTemplaterLogic) - const { onEditorMount, cursorOffset } = useTemplateEditorCursor() - - const templating = templatingEngine ?? 'hog' - - return ( - <span className="flex grow group relative justify-between"> - <span className="absolute top-0 right-2 z-20 p-px opacity-0 transition-opacity group-hover:opacity-100"> - <CyclotronJobTemplateSuggestionsButton - templating={templatingEngine} - setTemplatingEngine={setTemplatingEngine} - value={value} - onOptionSelect={(option) => { - onChange?.( - insertTemplateReference( - value || '', - templateReferenceForOption(option, templating), - cursorOffset() - ) - ) - }} - /> - </span> - <CodeEditorInline - embedded - className="flex-1" - globals={globals} - value={value} - language={templating === 'hog' ? 'hogTemplate' : 'liquid'} - onChange={onChange} - onMount={onEditorMount} - /> - </span> - ) -} - export function TemplatePickerModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }): JSX.Element { const { templates } = useValues(emailTemplaterLogic) const { applyTemplate } = useActions(emailTemplaterLogic) diff --git a/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx b/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx new file mode 100644 index 000000000000..573651adf4e6 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx @@ -0,0 +1,59 @@ +import { useActions, useValues } from 'kea' + +import { + CyclotronJobTemplateSuggestionsButton, + useTemplateEditorCursor, +} from 'lib/components/CyclotronJob/CyclotronJobTemplateSuggestions' +import { + insertTemplateReference, + templateReferenceForOption, +} from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' +import { CodeEditorInline } from 'lib/monaco/CodeEditorInline' + +import { emailTemplaterLogic } from './emailTemplaterLogic' + +export function LiquidSupportedText({ + value, + onChange, + globals, +}: { + value: string + onChange: (value?: string) => void + globals: any +}): JSX.Element { + const { templatingEngine } = useValues(emailTemplaterLogic) + const { setTemplatingEngine } = useActions(emailTemplaterLogic) + const { onEditorMount, cursorOffset } = useTemplateEditorCursor() + + const templating = templatingEngine ?? 'hog' + + return ( + <span className="flex grow group relative justify-between"> + <span className="absolute top-0 right-2 z-20 p-px opacity-0 transition-opacity group-hover:opacity-100"> + <CyclotronJobTemplateSuggestionsButton + templating={templatingEngine} + setTemplatingEngine={setTemplatingEngine} + value={value} + onOptionSelect={(option) => { + onChange?.( + insertTemplateReference( + value || '', + templateReferenceForOption(option, templating), + cursorOffset() + ) + ) + }} + /> + </span> + <CodeEditorInline + embedded + className="flex-1" + globals={globals} + value={value} + language={templating === 'hog' ? 'hogTemplate' : 'liquid'} + onChange={onChange} + onMount={onEditorMount} + /> + </span> + ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx new file mode 100644 index 000000000000..20d8b9d989ae --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx @@ -0,0 +1,114 @@ +import { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' +import { useState } from 'react' + +import { LemonLabel } from '@posthog/lemon-ui' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' +import { EmailTemplate, EmailTemplateFrom } from './types' + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +type StoryArgs = { + from: EmailTemplateFrom + sandboxSenderAllowed: boolean + narrow?: boolean +} + +const EMPTY_EMAIL: EmailTemplate = { design: null, html: '', subject: '', text: '', from: {}, to: '' } + +const meta: Meta<StoryArgs> = { + title: 'Scenes-App/HogFunctions/Email sender picker', + parameters: { layout: 'padded', viewMode: 'story' }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ from, sandboxSenderAllowed, narrow }) => { + useStorybookMocks({ + get: { + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] }, + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER }, + }) + const [template, setTemplate] = useState<EmailTemplate>({ ...EMPTY_EMAIL, from }) + const logicProps: EmailTemplaterLogicProps = { + value: template, + onChange: setTemplate, + type: 'native_email', + liveChanges: true, + sandboxSenderAllowed, + } + return ( + <BindLogic logic={emailTemplaterLogic} props={logicProps}> + <div className={narrow ? 'w-[520px] border rounded' : 'max-w-2xl border rounded'}> + <NativeEmailIntegrationChoice + label={<LemonLabel className="min-w-30 shrink-0 pl-2">From</LemonLabel>} + value={template.from as EmailTemplateFrom} + onChange={(from) => setTemplate({ ...template, from })} + /> + </div> + </BindLogic> + ) +} + +const sandboxFlag = (rotation: boolean): string[] => + rotation + ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER, FEATURE_FLAGS.WORKFLOWS_EMAIL_SENDER_ROTATION] + : [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] + +export const OwnSenderSelected: StoryFn<StoryArgs> = Template.bind({}) +OwnSenderSelected.args = { from: { integrationId: OWN_SENDER.id }, sandboxSenderAllowed: true } +OwnSenderSelected.parameters = { featureFlags: sandboxFlag(false) } + +export const SandboxSenderSelected: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderSelected.args = { from: { integrationId: SANDBOX_SENDER.id }, sandboxSenderAllowed: true } +SandboxSenderSelected.parameters = { featureFlags: sandboxFlag(false) } + +export const SandboxSenderSelectedInRotation: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderSelectedInRotation.args = { + from: { integrationId: SANDBOX_SENDER.id }, + sandboxSenderAllowed: true, +} +SandboxSenderSelectedInRotation.parameters = { featureFlags: sandboxFlag(true) } + +export const SandboxSenderSelectedInRotationNarrow: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderSelectedInRotationNarrow.args = { + from: { integrationId: SANDBOX_SENDER.id }, + sandboxSenderAllowed: true, + narrow: true, +} +SandboxSenderSelectedInRotationNarrow.parameters = { featureFlags: sandboxFlag(true) } + +export const SandboxSenderHiddenOnBroadcast: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderHiddenOnBroadcast.args = { + from: { integrationId: OWN_SENDER.id }, + sandboxSenderAllowed: false, +} +SandboxSenderHiddenOnBroadcast.parameters = { featureFlags: sandboxFlag(false) } diff --git a/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx new file mode 100644 index 000000000000..d4282178d8a8 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx @@ -0,0 +1,263 @@ +import { useActions, useValues } from 'kea' +import { ReactNode } from 'react' + +import { IconExternal, IconPlus, IconX } from '@posthog/icons' +import { LemonBanner, LemonButton, LemonInputSelect, LemonLabel, LemonSelect, LemonTag } from '@posthog/lemon-ui' + +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' +import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils' +import { urls } from 'scenes/urls' + +import { IntegrationType } from '~/types' + +import { emailTemplaterLogic } from './emailTemplaterLogic' +import { LiquidSupportedText } from './LiquidSupportedText' +import { selectedSenderIds } from './selectSenders' +import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' + +function rotationSenderHint({ + sandboxSenderAvailable, + hasCustomSenderDetails, + selectedSenderCount, +}: { + sandboxSenderAvailable: boolean + hasCustomSenderDetails: boolean + selectedSenderCount: number +}): string { + const hints = [`Choose up to ${MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender.`] + if (sandboxSenderAvailable && (hasCustomSenderDetails || selectedSenderCount > 1)) { + hints.push('Choosing the sandbox sender replaces the selection.') + if (hasCustomSenderDetails) { + hints.push('It also removes custom sender and Reply-To.') + } + } + return hints.join(' ') +} + +export function NativeEmailIntegrationChoice({ + label, + onChange, + value, +}: { + /** Rendered inside the sender row so it aligns with the select rather than spanning the override rows. */ + label: ReactNode + onChange: (value: EmailTemplateFrom) => void + value?: EmailTemplateFrom +}): JSX.Element { + const { + logicProps, + senderIntegrations, + sandboxSenderId, + isSandboxSenderSelected, + senderIntegrationsLoading, + senderIntegrationsError, + hasCustomSenderDetails, + } = useValues(emailTemplaterLogic) + const { chooseSenders, retrySenderIntegrations } = useActions(emailTemplaterLogic) + const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') + + // Presence of the override keys is what reveals the inputs, so a saved override is + // visible again on reopen without any separate reveal state. + const overridesVisible = !isSandboxSenderSelected && (value?.email !== undefined || value?.name !== undefined) + + const onChangeIntegration = (integrationId: number): void => { + if (integrationId === -1) { + window.open(urls.workflows('channels'), '_blank') + return + } + chooseSenders([integrationId]) + } + + const senderLabel = (integration: IntegrationType): JSX.Element => ( + <> + <span translate="no">{integration.display_name}</span> + {integration.id === sandboxSenderId && ( + <LemonTag type="highlight" className="ml-2 align-middle"> + Sandbox + </LemonTag> + )} + </> + ) + + const errorBanner = senderIntegrationsError ? ( + <LemonBanner + type="error" + className="break-words" + action={{ children: 'Try again', onClick: retrySenderIntegrations, loading: senderIntegrationsLoading }} + > + {senderIntegrationsError} + </LemonBanner> + ) : null + + const connectSender = ( + <LemonButton + size="small" + type="tertiary" + to={urls.workflows('channels')} + targetBlank + className="m-1 self-start" + icon={<IconExternal />} + > + Connect email sender + </LemonButton> + ) + + if (!senderIntegrationsLoading && senderIntegrations.length === 0 && errorBanner) { + return ( + <div className="flex flex-col gap-2"> + {label} + {errorBanner} + {connectSender} + </div> + ) + } + if (!senderIntegrationsLoading && senderIntegrations.length === 0) { + return ( + <div className="flex gap-2 justify-between items-center"> + {label} + <div className="flex gap-2 items-center"> + <span className="text-muted">No email senders configured yet</span> + {connectSender} + </div> + </div> + ) + } + + return ( + <div className="flex flex-col"> + {errorBanner} + <div className="flex gap-2 items-center"> + {label} + {senderRotationEnabled ? ( + <div className="flex flex-col flex-1 min-w-0"> + <LemonInputSelect<number> + className="m-1 flex-1" + mode="multiple" + placeholder="Choose email senders" + loading={senderIntegrationsLoading} + options={senderIntegrations.map((integration) => ({ + key: String(integration.id), + label: integration.display_name, + labelComponent: senderLabel(integration), + value: integration.id, + }))} + value={selectedSenderIds(value)} + size="small" + fullWidth + autoWidth={false} + onChange={chooseSenders} + data-attr="workflow-email-sender-select" + action={{ + children: 'Add new email sender', + icon: <IconExternal />, + onClick: () => window.open(urls.workflows('channels'), '_blank'), + }} + /> + <span className="px-2 pb-1 text-xs text-muted"> + {isSandboxSenderSelected + ? SANDBOX_EMAIL_SENDER_NOTE + : rotationSenderHint({ + sandboxSenderAvailable: sandboxSenderId !== undefined, + hasCustomSenderDetails, + selectedSenderCount: selectedSenderIds(value).length, + })} + </span> + </div> + ) : ( + <div className="flex flex-col flex-1 min-w-0"> + <LemonSelect + className="m-1 flex-1" + type="tertiary" + placeholder="Choose email sender" + loading={senderIntegrationsLoading} + options={[ + { + title: 'Email senders', + options: senderIntegrations.map((integration) => ({ + label: integration.display_name, + labelInMenu: senderLabel(integration), + value: integration.id, + })), + }, + { + options: [ + { + label: 'Add new email sender', + icon: <IconExternal />, + value: -1, + }, + ], + }, + ]} + value={value?.integrationId} + size="small" + fullWidth + onChange={onChangeIntegration} + data-attr="workflow-email-sender-select" + /> + {isSandboxSenderSelected && ( + <span className="px-2 pb-1 text-xs text-muted">{SANDBOX_EMAIL_SENDER_NOTE}</span> + )} + {!isSandboxSenderSelected && sandboxSenderId !== undefined && hasCustomSenderDetails && ( + <span className="px-2 pb-1 text-xs text-muted"> + Choosing the sandbox sender removes custom sender and Reply-To. + </span> + )} + </div> + )} + {!overridesVisible && !isSandboxSenderSelected && ( + <LemonButton + size="xsmall" + type="secondary" + icon={<IconPlus />} + className="mr-2 shrink-0" + data-attr="email-from-custom-sender" + onClick={() => onChange({ ...value, email: '', name: '' })} + tooltip="Set a custom sender address or name, with template support" + > + Custom sender + </LemonButton> + )} + </div> + {overridesVisible && ( + <> + <div className="flex gap-2 items-center border-t"> + <LemonLabel + className="min-w-30 shrink-0" + info="The address to send from. Templates are supported, so the address can come from the triggering event. It must be on every selected sender's verified domain. Leave empty to use the sender's address." + > + Custom address + </LemonLabel> + <LiquidSupportedText + value={value?.email ?? ''} + onChange={(email) => onChange({ ...value, email })} + globals={logicProps.variables} + /> + <LemonButton + size="xsmall" + type="tertiary" + icon={<IconX />} + className="mr-2" + data-attr="email-from-custom-sender-remove" + onClick={() => onChange({ ...value, email: undefined, name: undefined })} + tooltip="Remove custom sender" + /> + </div> + <div className="flex gap-2 items-center border-t"> + <LemonLabel + className="min-w-30 shrink-0" + info="The sender name shown in the recipient's inbox. Templates are supported. Leave empty to use the sender's name." + > + Custom name + </LemonLabel> + <LiquidSupportedText + value={value?.name ?? ''} + onChange={(name) => onChange({ ...value, name })} + globals={logicProps.variables} + /> + </div> + </> + )} + </div> + ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts index 2dc5302a5b56..23ffe8f40dc8 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts @@ -1,7 +1,10 @@ import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { LemonDialog } from '@posthog/lemon-ui' + import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -56,9 +59,288 @@ describe('emailTemplaterLogic', () => { afterEach(() => { logic?.unmount() + jest.restoreAllMocks() jest.useRealTimers() }) + describe('sender picker', () => { + const OWN_SENDER = { id: 5, kind: 'email', display_name: 'Acme <hello@acme.example.com>', config: {} } + const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', verified: true }, + } + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + it.each([ + { surface: 'a workflow email step', sandboxSenderAllowed: true, flag: true, expectedIds: [5, 7] }, + { + surface: 'a workflow email step with the flag off', + sandboxSenderAllowed: true, + flag: false, + expectedIds: [5], + }, + { surface: 'a broadcast or destination', sandboxSenderAllowed: false, flag: true, expectedIds: [5] }, + ])('lists the sandbox sender last only on $surface', async ({ sandboxSenderAllowed, flag, expectedIds }) => { + if (flag) { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed })) + logic.mount() + + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual(expectedIds) + }) + + it('creates a missing sandbox sender once on mount and lists it after the reload', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + let integrationsPayload: Record<string, any>[] = [OWN_SENDER] + let ensureCalls = 0 + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + integrationsPayload = [...integrationsPayload, SANDBOX_SENDER] + return [200, SANDBOX_SENDER] + }, + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed: true })) + logic.mount() + + await expectLogic(integrationsLogic).toDispatchActions([ + 'loadIntegrationsSuccess', + 'provisionSandboxEmailSender', + 'loadIntegrationsSuccess', + ]) + + expect(ensureCalls).toBe(1) + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5, 7]) + }) + + it.each([{ ownSenders: [] }, { ownSenders: [OWN_SENDER] }])( + 'keeps the saved sandbox restrictions after flag rollback with own senders $ownSenders', + async ({ ownSenders }) => { + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] } }, + }) + const onChange = jest.fn() + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 } }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + expect(logic.values.senderIntegrations).toContainEqual(expect.objectContaining({ id: 7 })) + expect(logic.values.isSandboxSenderSelected).toBe(true) + expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo') + if (ownSenders.length) { + logic.actions.chooseSenders([7, 5]) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ from: { integrationId: 5, integrationIds: undefined } }) + ) + expect(logic.values.senderIntegrations).not.toContainEqual(expect.objectContaining({ id: 7 })) + } + } + ) + + it.each([ + { + surface: 'a workflow email step with no own sender yet', + sandboxSenderAllowed: true, + ownSenders: [], + expectedLoading: true, + }, + { + surface: 'a workflow email step that already lists an own sender', + sandboxSenderAllowed: true, + ownSenders: [OWN_SENDER], + expectedLoading: false, + }, + { + surface: 'a broadcast or destination', + sandboxSenderAllowed: false, + ownSenders: [], + expectedLoading: false, + }, + ])( + 'waits for sandbox provisioning only on $surface', + async ({ sandboxSenderAllowed, ownSenders, expectedLoading }) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + let releaseEnsure: () => void = () => {} + const ensureGate = new Promise<void>((resolve) => (releaseEnsure = resolve)) + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: ownSenders } }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + await ensureGate + return [200, SANDBOX_SENDER] + }, + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed })) + logic.mount() + await expectLogic(integrationsLogic, () => { + integrationsLogic.actions.ensureSandboxEmailSender() + }).toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSender']) + + expect(logic.values.senderIntegrationsLoading).toBe(expectedLoading) + releaseEnsure() + await expectLogic(integrationsLogic).toDispatchActions(['provisionSandboxEmailSenderSuccess']) + } + ) + + it.each([ + { + field: 'custom from address', + from: { integrationId: 5, email: 'hello@example.com' }, + replyTo: '', + confirm: true, + }, + { field: 'sender name', from: { integrationId: 5, name: 'Custom' }, replyTo: '', confirm: true }, + { field: 'Reply-To', from: { integrationId: 5 }, replyTo: 'replies@example.com', confirm: true }, + { field: 'no custom values', from: { integrationId: 5 }, replyTo: '', confirm: false }, + { + field: 'a rotation selection', + from: { integrationId: 5, integrationIds: [5, 6] }, + replyTo: '', + confirm: true, + }, + ])('switches with $field after any required confirmation', async ({ from, replyTo, confirm }) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { + results: [ + SANDBOX_SENDER, + OWN_SENDER, + { ...OWN_SENDER, id: 6, display_name: 'Second <second@example.com>' }, + ], + }, + }, + }) + const onChange = jest.fn() + const dialog = jest.spyOn(LemonDialog, 'open').mockImplementation(jest.fn()) + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + onChange.mockClear() + logic.actions.chooseSenders([7]) + expect(dialog).toHaveBeenCalledTimes(confirm ? 1 : 0) + if (confirm) { + expect(onChange).not.toHaveBeenCalled() + if (from.integrationIds) { + expect(dialog.mock.calls[0][0].description).toContain('Acme <hello@acme.example.com>') + expect(dialog.mock.calls[0][0].description).toContain('Second <second@example.com>') + } + emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo, subject: 'Changed during confirmation' }, + }) + ) + const confirmSwitch = dialog.mock.calls[0][0].primaryButton?.onClick as (() => void) | undefined + confirmSwitch?.() + } + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + replyTo: '', + subject: confirm ? 'Changed during confirmation' : DEFAULT_EMAIL_TEMPLATE.subject, + }) + ) + expect(logic.values.isSandboxSenderSelected).toBe(true) + expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo') + }) + + it('exposes a load error to the picker instead of treating the null list as empty', async () => { + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [ + 500, + { detail: 'Could not load senders. Try again.' }, + ], + }, + }) + logic = emailTemplaterLogic(makeProps()) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure']) + expect(logic.values.senderIntegrationsError).toBe('Could not load senders. Try again.') + expect(logic.values.senderIntegrationsLoading).toBe(false) + }) + + it.each([404, 500])('exposes a sandbox setup failure (%s) beside available own senders', async (status) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: [OWN_SENDER] } }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => [ + status, + { detail: 'Sandbox setup is unavailable. Try again.' }, + ], + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed: true })) + logic.mount() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.senderIntegrationsError).toBe('Sandbox setup is unavailable. Try again.') + expect(logic.values.senderIntegrations.map((sender) => sender.id)).toEqual([5]) + }) + + it('offers no Reply-To while the sandbox sender is selected, since the sandbox sender does not support it', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } }) + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 }, replyTo: 'replies@example.com' }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + await expectLogic(logic).toMatchValues({ isSandboxSenderSelected: true }) + expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo') + expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo') + + logic.actions.setEmailTemplateValue('from', { integrationId: 8 }) + await expectLogic(logic).toMatchValues({ isSandboxSenderSelected: false }) + expect(logic.values.visibleFields.map((field) => field.key)).toContain('replyTo') + }) + }) + describe('advanced fields', () => { it('hides advanced fields by default', async () => { logic = emailTemplaterLogic(makeProps()) diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx index 761790c09b16..6c8244ae7546 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx @@ -22,17 +22,21 @@ import { LemonDialog } from '@posthog/lemon-ui' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { lemonToast } from 'lib/lemon-ui/LemonToast' import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' import { objectsEqual } from 'lib/utils/objects' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { teamLogic } from 'scenes/teamLogic' -import { PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' +import { IntegrationType, PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' import { MessageTemplate } from 'products/workflows/frontend/TemplateLibrary/types' +import type { SandboxEmailSenderProvisionResult } from '../../../lib/integrations/integrationsLogic' import { registerEmailMediaLibrary } from './emailMediaLibrary' +import { selectSenders, selectedSenderIds } from './selectSenders' import type { EmailFieldErrors, EmailTemplate } from './types' export type { EmailTemplate } @@ -215,6 +219,7 @@ export interface EmailTemplaterLogicProps { // not compute these itself; a caller that validates the email step (e.g. the workflow builder) // decides what and when to show. fieldErrors?: EmailFieldErrors + sandboxSenderAllowed?: boolean } function autoRevealAdvancedFields( @@ -237,6 +242,17 @@ function autoRevealAdvancedFields( // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic + integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic + integrationsLoading: boolean // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic preflight: PreflightStatus | null // preflightLogic currentProjectId: number | string // teamLogic activeContentTab: 'plaintext' | 'visual' @@ -252,11 +268,13 @@ export interface emailTemplaterLogicValues { emailTemplateTouched: boolean emailTemplateTouches: Record<string, boolean> emailTemplateValidationErrors: DeepPartialMap<EmailTemplate, ValidationErrorType> + hasCustomSenderDetails: boolean hiddenAdvancedFields: EmailMetaField[] isEmailEditorReady: boolean isEmailTemplateSubmitting: boolean isEmailTemplateValid: boolean isModalOpen: boolean + isSandboxSenderSelected: boolean isSaveTemplateModalOpen: boolean isTemplatePickerOpen: boolean logicProps: EmailTemplaterLogicProps @@ -264,7 +282,13 @@ export interface emailTemplaterLogicValues { personPropertyDefinitions: PropertyDefinition[] personPropertyDefinitionsLoading: boolean revealedAdvancedFields: EmailMetaFieldKey[] + sandboxSenderId: number | undefined + selectedSandboxSender: IntegrationType | null + senderIntegrations: IntegrationType[] + senderIntegrationsError: string | null + senderIntegrationsLoading: boolean showEmailTemplateErrors: boolean + supportedFields: EmailMetaField[] templates: MessageTemplate[] templatesLoading: boolean templatingEngine: 'hog' | 'liquid' @@ -279,9 +303,19 @@ export interface emailTemplaterLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicActions { + ensureSandboxEmailSender: () => { + value: true + } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic applyTemplate: (template: MessageTemplate) => { template: MessageTemplate } + chooseSenders: (integrationIds: number[]) => { + integrationIds: number[] + } closeWithConfirmation: () => { value: true } @@ -330,6 +364,9 @@ export interface emailTemplaterLogicActions { resetEmailTemplate: (values?: EmailTemplate) => { values?: EmailTemplate } + retrySenderIntegrations: () => { + value: true + } revealAdvancedField: (key: EmailMetaFieldKey) => { key: EmailMetaFieldKey } @@ -395,6 +432,12 @@ export interface emailTemplaterLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicMeta { __keaTypeGenInternalSelectorTypes: { + senderIntegrationsError: ( + integrationsLoadError: string | null, + integrationsLoadFailed: any, + sandboxEmailSenderSetupError: any, + arg: boolean + ) => string | null logicProps: (arg: any) => EmailTemplaterLogicProps mergeTags: (personPropertyDefinitions: PropertyDefinition[]) => UnlayerMergeTags unlayerEditorProjectId: (preflight: PreflightStatus | null) => 275430 | undefined @@ -411,8 +454,33 @@ export interface emailTemplaterLogicMeta { id: string } | undefined - visibleFields: (arg: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]) => EmailMetaField[] - hiddenAdvancedFields: (arg: EmailTemplaterType, visibleFields: EmailMetaField[]) => EmailMetaField[] + senderIntegrationsLoading: ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: any, + sandboxEmailSenderProvisionResultLoading: boolean, + ownEmailIntegrations: IntegrationType[], + arg: boolean + ) => boolean + selectedSandboxSender: ( + integrations: IntegrationType[] | null, + emailTemplate: EmailTemplate + ) => IntegrationType | null + senderIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + arg: boolean + ) => IntegrationType[] + supportedFields: (arg: EmailTemplaterType, isSandboxSenderSelected: boolean) => EmailMetaField[] + visibleFields: ( + supportedFields: EmailMetaField[], + revealedAdvancedFields: EmailMetaFieldKey[] + ) => EmailMetaField[] + hiddenAdvancedFields: (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]) => EmailMetaField[] + sandboxSenderId: (senderIntegrations: IntegrationType[]) => number | undefined + hasCustomSenderDetails: (emailTemplate: EmailTemplate) => boolean + isSandboxSenderSelected: (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined) => boolean } } @@ -427,9 +495,32 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ props({} as EmailTemplaterLogicProps), path(['scenes', 'hog-functions', 'email-templater', 'emailTemplaterLogic']), connect(() => ({ - values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['preflight'], + integrationsLogic, + [ + 'integrations', + 'ownEmailIntegrations', + 'sandboxEmailSender', + 'integrationsLoading', + 'sandboxEmailSenderProvisionResultLoading', + 'sandboxEmailSenderProvisionResult', + 'sandboxEmailSenderProvisionError', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'sandboxEmailSenderSetupError', + 'sandboxEmailSenderEnabled', + ], + featureFlagLogic, + ['featureFlags'], + teamLogic, + ['currentProjectId'], + ], + actions: [integrationsLogic, ['ensureSandboxEmailSender', 'retrySandboxEmailSender', 'loadIntegrations']], })), actions({ + retrySenderIntegrations: true, setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }), onEmailEditorReady: true, setIsModalOpen: (isModalOpen: boolean) => ({ isModalOpen }), @@ -444,6 +535,7 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ setActiveContentTab: (tab: 'visual' | 'plaintext') => ({ tab }), revealAdvancedField: (key: EmailMetaFieldKey) => ({ key }), hideAdvancedField: (key: EmailMetaFieldKey) => ({ key }), + chooseSenders: (integrationIds: number[]) => ({ integrationIds }), }), reducers({ emailEditorRef: [ @@ -538,6 +630,23 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ })), selectors({ + senderIntegrationsError: [ + (s) => [ + s.integrationsLoadError, + s.integrationsLoadFailed, + s.sandboxEmailSenderSetupError, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + loadError: string | null, + loadFailed: boolean, + sandboxSetupError: string | null, + sandboxAllowed: boolean + ): string | null => + loadError || + (loadFailed ? 'Could not load email senders.' : null) || + (sandboxAllowed ? sandboxSetupError : null), + ], logicProps: [() => [(_, props) => props], (props: EmailTemplaterLogicProps) => props], mergeTags: [ (s) => [s.personPropertyDefinitions], @@ -590,17 +699,26 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ // shared by the whole project, so the project is the user it sees. emailMediaLibraryEnabled ? { id: `project-${currentProjectId}` } : undefined, ], - visibleFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.revealedAdvancedFields], - (type: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( - (field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key) - ), - ], - hiddenAdvancedFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.visibleFields], - (type: EmailTemplaterType, visibleFields: EmailMetaField[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter((f) => f.isAdvancedField && !visibleFields.includes(f)), + senderIntegrationsLoading: [ + (s) => [ + s.integrationsLoading, + s.integrations, + s.integrationsLoadFailed, + s.sandboxEmailSenderProvisionResultLoading, + s.ownEmailIntegrations, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderProvisionResultLoading: boolean, + ownEmailIntegrations: IntegrationType[], + sandboxSenderAllowed: boolean + ): boolean => + integrationsLoading || + (integrations === null && !integrationsLoadFailed) || + (sandboxSenderAllowed && sandboxEmailSenderProvisionResultLoading && ownEmailIntegrations.length === 0), ], }), @@ -657,7 +775,130 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ }, })), + selectors({ + selectedSandboxSender: [ + (s) => [s.integrations, s.emailTemplate], + (integrations: IntegrationType[] | null, emailTemplate: EmailTemplate): IntegrationType | null => { + const from = emailTemplate?.from + const ids = selectedSenderIds(typeof from === 'object' ? from : undefined) + return ( + integrations?.find( + (integration) => isSandboxEmailSender(integration) && ids.includes(integration.id) + ) ?? null + ) + }, + ], + senderIntegrations: [ + (s) => [ + s.ownEmailIntegrations, + s.sandboxEmailSender, + s.selectedSandboxSender, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + sandboxSenderAllowed: boolean + ): IntegrationType[] => { + const sandboxSender = sandboxEmailSender ?? selectedSandboxSender + return sandboxSenderAllowed && sandboxSender + ? [...ownEmailIntegrations, sandboxSender] + : ownEmailIntegrations + }, + ], + supportedFields: [ + (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.isSandboxSenderSelected], + (type: EmailTemplaterType, isSandboxSenderSelected: boolean): EmailMetaField[] => + EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( + (field) => !isSandboxSenderSelected || field.key !== EMAIL_META_FIELDS.REPLY_TO.key + ), + ], + visibleFields: [ + (s) => [s.supportedFields, s.revealedAdvancedFields], + (supportedFields: EmailMetaField[], revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => + supportedFields.filter((field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key)), + ], + hiddenAdvancedFields: [ + (s) => [s.supportedFields, s.visibleFields], + (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]): EmailMetaField[] => + supportedFields.filter((f) => f.isAdvancedField && !visibleFields.includes(f)), + ], + sandboxSenderId: [ + (s) => [s.senderIntegrations], + (senderIntegrations: IntegrationType[]): number | undefined => + senderIntegrations.find(isSandboxEmailSender)?.id, + ], + hasCustomSenderDetails: [ + (s) => [s.emailTemplate], + (template: EmailTemplate): boolean => { + const from = typeof template?.from === 'object' ? template.from : undefined + return !!(from?.email || from?.name || template?.replyTo) + }, + ], + isSandboxSenderSelected: [ + (s) => [s.emailTemplate, s.sandboxSenderId], + (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined): boolean => { + const from = emailTemplate?.from + return ( + sandboxSenderId !== undefined && + selectedSenderIds(typeof from === 'object' ? from : undefined).includes(sandboxSenderId) + ) + }, + ], + }), + listeners(({ props, values, actions, cache }) => ({ + retrySenderIntegrations: () => { + if (values.senderIntegrationsLoading) { + return + } + actions.loadIntegrations() + if (props.sandboxSenderAllowed) { + actions.retrySandboxEmailSender() + } + }, + chooseSenders: ({ integrationIds }) => { + const from = values.emailTemplate?.from + const { sandboxSenderId } = values + const next = selectSenders(typeof from === 'object' ? from : undefined, integrationIds, sandboxSenderId) + if (!next) { + return + } + if (sandboxSenderId !== undefined && next.integrationId === sandboxSenderId) { + const selectedIds = selectedSenderIds(typeof from === 'object' ? from : undefined) + const selectSandbox = (): void => { + actions.setEmailTemplateValues({ from: next, replyTo: '' }) + actions.hideAdvancedField('replyTo') + } + if (values.hasCustomSenderDetails || selectedIds.length > 1) { + const selectedNames = selectedIds.map( + (id) => + values.senderIntegrations.find((sender) => sender.id === id)?.display_name || `sender ${id}` + ) + LemonDialog.open({ + title: 'Switch to the sandbox sender?', + description: [ + selectedIds.length > 1 + ? `This replaces ${selectedNames.join(' and ')} with the sandbox sender.` + : '', + values.hasCustomSenderDetails + ? 'This removes your custom sender address, sender name, and Reply-To.' + : '', + 'The sandbox sender uses its own identity.', + ] + .filter(Boolean) + .join(' '), + primaryButton: { children: 'Switch sender', onClick: selectSandbox }, + secondaryButton: { children: 'Cancel' }, + }) + } else { + selectSandbox() + } + return + } + actions.setEmailTemplateValue('from', next) + }, onEmailEditorReady: () => { const editor = values.emailEditorRef?.editor if (editor && values.emailMediaLibraryEnabled) { @@ -976,6 +1217,9 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ actions.loadTemplates() actions.loadPersonPropertyDefinitions() + if (props.sandboxSenderAllowed) { + actions.ensureSandboxEmailSender() + } }), beforeUnmount(({ props, values }) => { diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts new file mode 100644 index 000000000000..18ad53cd434c --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts @@ -0,0 +1,49 @@ +import { selectSenders } from './selectSenders' + +describe('selectSenders', () => { + const current = { integrationId: 1, email: 'custom@example.com', name: 'Custom' } + + it.each([ + { + case: 'picking the sandbox sender keeps only it and drops the custom sender values', + ids: [1, 2, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + }, + { + case: 'adding an own sender while the sandbox sender is selected drops the sandbox sender', + value: { integrationId: 7 }, + ids: [7, 1], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: undefined }, + }, + { + case: 'own senders rotate and keep the custom sender values', + ids: [1, 2], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: [1, 2], email: 'custom@example.com', name: 'Custom' }, + }, + { + case: 'picking the sandbox sender over a full rotation still works', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined }, + }, + { + case: 'a surface without the sandbox sender treats its id like any other', + ids: [1, 7], + sandboxId: undefined, + expected: { integrationId: 1, integrationIds: [1, 7] }, + }, + { + case: 'more senders than the limit are refused', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12], + sandboxId: 7, + expected: null, + }, + ])('$case', ({ value = current, ids, sandboxId, expected }) => { + expect(selectSenders(value, ids, sandboxId)).toEqual( + expected === null ? null : expect.objectContaining(expected) + ) + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts new file mode 100644 index 000000000000..d822a83691cd --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts @@ -0,0 +1,38 @@ +import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' + +export function selectedSenderIds(value: EmailTemplateFrom | undefined): number[] { + if (value?.integrationIds?.length) { + return value.integrationIds + } + return value?.integrationId ? [value.integrationId] : [] +} + +function exclusiveSandboxSenders( + integrationIds: number[], + sandboxSenderId: number | undefined, + sandboxSelected: boolean +): number[] { + if (sandboxSenderId === undefined || !integrationIds.includes(sandboxSenderId)) { + return integrationIds + } + return sandboxSelected ? integrationIds.filter((id) => id !== sandboxSenderId) : [sandboxSenderId] +} + +export function selectSenders( + value: EmailTemplateFrom | undefined, + integrationIds: number[], + sandboxSenderId: number | undefined +): EmailTemplateFrom | null { + const sandboxSelected = sandboxSenderId !== undefined && selectedSenderIds(value).includes(sandboxSenderId) + const senders = exclusiveSandboxSenders(integrationIds, sandboxSenderId, sandboxSelected) + if (senders.length > MAX_WORKFLOW_EMAIL_SENDERS) { + return null + } + const sandboxChosen = sandboxSenderId !== undefined && senders.includes(sandboxSenderId) + return { + ...value, + integrationId: senders[0], + integrationIds: senders.length > 1 ? senders : undefined, + ...(sandboxChosen ? { email: undefined, name: undefined } : {}), + } +} diff --git a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx index e2ad5442a586..5a7ee8c20c5a 100644 --- a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx +++ b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx @@ -14,7 +14,7 @@ import { newBroadcastAgentLogic } from './newBroadcastAgentLogic' /** The Broadcasts tab before the first broadcast: set up a verified sender first, then write one. */ export function BroadcastsEmptyState(): JSX.Element { - const { integrations } = useValues(integrationsLogic) + const { userManagedIntegrations: integrations } = useValues(integrationsLogic) const { startNewBroadcast } = useActions(newBroadcastAgentLogic) const editorDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.Workflow, diff --git a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx index b8d9c58abd41..fc5bf6e01788 100644 --- a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx +++ b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx @@ -27,13 +27,13 @@ export function BroadcastContentStep(): JSX.Element { const { broadcast, email, name, stepValidationErrors, selectedSender, emailSettings, aiEmailDraftStatus } = useValues(broadcastWizardLogic) const { setEmail, setEmailSettings } = useActions(broadcastWizardLogic) - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { integrations, integrationsLoading, ownEmailIntegrations } = useValues(integrationsLogic) const { loadIntegrations } = useActions(integrationsLogic) const [templatePickerOpen, setTemplatePickerOpen] = useState(false) // null: closed. 'new': set up a sender. An integration: finish verifying that one. const [senderSetup, setSenderSetup] = useState<'new' | IntegrationType | null>(null) - const hasSenders = !!integrations?.some((integration) => integration.kind === 'email') + const hasSenders = ownEmailIntegrations.length > 0 const senderUnverified = !!selectedSender && selectedSender.config?.verified !== true // A deleted sender would otherwise show as its bare id, so the picker leaves it out. const missingSenderIds = integrations ? getMissingSenderIds(email.from, integrations) : [] diff --git a/products/workflows/frontend/Channels/ChannelCatalog.tsx b/products/workflows/frontend/Channels/ChannelCatalog.tsx index 4dfa0205512a..53ab31dbd122 100644 --- a/products/workflows/frontend/Channels/ChannelCatalog.tsx +++ b/products/workflows/frontend/Channels/ChannelCatalog.tsx @@ -1,6 +1,6 @@ -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' -import { LemonBanner, LemonSkeleton } from '@posthog/lemon-ui' +import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui' import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' import { TeamMembershipLevel } from 'lib/constants' @@ -13,7 +13,9 @@ const GRID_CLASS = 'grid gap-2 grid-cols-1 @min-[44rem]/main-content:grid-cols-2 /** Every channel a workflow can send through, with whether this project has it set up. */ export function ChannelCatalog(): JSX.Element { - const { channels, activeCount, integrationsLoadFailed } = useValues(channelCatalogLogic) + const { channels, activeCount, integrationsLoadFailed, integrationsLoading, integrationsLoadError } = + useValues(channelCatalogLogic) + const { loadIntegrations } = useActions(channelCatalogLogic) const restrictedReason = useRestrictedArea({ scope: RestrictionScope.Project, minimumAccessLevel: TeamMembershipLevel.Admin, @@ -28,13 +30,37 @@ export function ChannelCatalog(): JSX.Element { )} </div> {channels ? ( - <div className={GRID_CLASS}> - {channels.map((channel) => ( - <ChannelCatalogCard key={channel.kind} channel={channel} disabledReason={restrictedReason} /> - ))} - </div> + <> + {integrationsLoadFailed && ( + <div className="flex flex-wrap items-center gap-2 text-xs text-secondary"> + <span>Couldn't refresh your channels. Showing the last loaded connections.</span> + <LemonButton + size="xsmall" + type="tertiary" + onClick={loadIntegrations} + loading={integrationsLoading} + > + Try again + </LemonButton> + </div> + )} + <div className={GRID_CLASS}> + {channels.map((channel) => ( + <ChannelCatalogCard + key={channel.kind} + channel={channel} + disabledReason={restrictedReason} + /> + ))} + </div> + </> ) : integrationsLoadFailed ? ( - <LemonBanner type="error">Couldn't load your channels. Refresh the page to try again.</LemonBanner> + <LemonBanner + type="error" + action={{ children: 'Try again', onClick: loadIntegrations, loading: integrationsLoading }} + > + {integrationsLoadError || "Couldn't load your channels."} + </LemonBanner> ) : ( <div className={GRID_CLASS}> {CATALOG_CHANNELS.map((kind) => ( diff --git a/products/workflows/frontend/Channels/MessageChannels.stories.tsx b/products/workflows/frontend/Channels/MessageChannels.stories.tsx new file mode 100644 index 000000000000..6f527664481f --- /dev/null +++ b/products/workflows/frontend/Channels/MessageChannels.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { MessageChannels } from './MessageChannels' + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +type StoryArgs = { integrations: Record<string, unknown>[]; narrow?: boolean } + +const meta: Meta<StoryArgs> = { + title: 'Products/Workflows/Channels', + component: MessageChannels, + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ integrations, narrow }) => { + useStorybookMocks({ + get: { '/api/projects/:team_id/integrations/': { results: integrations } }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER }, + }) + return ( + <div className={narrow ? 'w-[520px]' : undefined}> + <MessageChannels /> + </div> + ) +} + +export const SandboxSenderOnly: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderOnly.args = { integrations: [SANDBOX_SENDER] } + +export const SandboxAndOwnSender: StoryFn<StoryArgs> = Template.bind({}) +SandboxAndOwnSender.args = { integrations: [SANDBOX_SENDER, OWN_SENDER] } + +export const SandboxAndOwnSenderNarrow: StoryFn<StoryArgs> = Template.bind({}) +SandboxAndOwnSenderNarrow.args = { integrations: [SANDBOX_SENDER, OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Channels/MessageChannels.tsx b/products/workflows/frontend/Channels/MessageChannels.tsx index 7c51179f101b..ccbd62a829c3 100644 --- a/products/workflows/frontend/Channels/MessageChannels.tsx +++ b/products/workflows/frontend/Channels/MessageChannels.tsx @@ -1,22 +1,31 @@ import { useActions, useValues } from 'kea' +import { LemonBanner } from '@posthog/lemon-ui' + import { SetupTaskId } from 'lib/components/ProductSetup' import { EmailIntegrationsList } from 'lib/integrations/EmailIntegrationsList' import { IntegrationsList } from 'lib/integrations/IntegrationsList' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' import { ChannelSetupModal } from './ChannelSetupModal' +import { SandboxEmailSenderRow } from './SandboxEmailSenderRow' const MESSAGING_CHANNEL_TYPES = ['email', 'slack', 'twilio', 'firebase', 'apns'] as const export type ChannelType = (typeof MESSAGING_CHANNEL_TYPES)[number] export function MessageChannels(): JSX.Element { - const { setupModalOpen, integrations, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { setupModalOpen, userManagedIntegrations, setupModalType, selectedIntegration, sandboxEmailSender } = + useValues(integrationsLogic) const { closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) + const { sandboxSetupError, sandboxSetupLoading } = useValues(channelCatalogLogic) + const { retrySandboxSenderSetup } = useActions(channelCatalogLogic) - const allWorkflowIntegrations = - integrations?.filter((integration) => MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType)) ?? [] + const ownWorkflowIntegrations = + userManagedIntegrations?.filter((integration) => + MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType) + ) ?? [] return ( <> @@ -33,7 +42,20 @@ export function MessageChannels(): JSX.Element { <div className="flex flex-col gap-4" data-attr="message-channels"> <ChannelCatalog /> - {allWorkflowIntegrations.length > 0 && ( + {sandboxSetupError && ( + <LemonBanner + type="error" + action={{ + children: 'Try again', + onClick: retrySandboxSenderSetup, + loading: sandboxSetupLoading, + }} + > + {sandboxSetupError} + </LemonBanner> + )} + {sandboxEmailSender && <SandboxEmailSenderRow integration={sandboxEmailSender} />} + {ownWorkflowIntegrations.length > 0 && ( <h3 className="mb-0 text-base font-semibold">Your connections</h3> )} <EmailIntegrationsList /> diff --git a/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx new file mode 100644 index 000000000000..3f02a3149fad --- /dev/null +++ b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx @@ -0,0 +1,51 @@ +import { useActions } from 'kea' + +import { IconLetter, IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' +import { TeamMembershipLevel } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' + +import { IntegrationType } from '~/types' + +export function SandboxEmailSenderRow({ integration }: { integration: IntegrationType }): JSX.Element { + const { openSetupModal } = useActions(integrationsLogic) + const restrictedReason = useRestrictedArea({ + scope: RestrictionScope.Project, + minimumAccessLevel: TeamMembershipLevel.Admin, + }) + + return ( + <div className="rounded border bg-surface-primary p-2 @container" data-attr="sandbox-email-sender"> + <div className="flex flex-col gap-3 ml-2 @lg:flex-row @lg:items-center"> + <div className="flex flex-1 gap-4 items-start min-w-0"> + <IconLetter className="w-8 h-8 shrink-0" /> + <div className="flex flex-col gap-1 min-w-0"> + <div className="flex flex-wrap gap-2 items-center"> + <strong>Sandbox sender</strong> + <span className="text-xs text-secondary truncate" translate="no"> + {`${integration.config.name} <${integration.config.email}>`} + </span> + </div> + <span className="text-sm text-secondary"> + Sends from a PostHog address, so it works before you verify a domain. It delivers only to + verified members of your organization. Add your own sender to email anyone. + </span> + </div> + </div> + <LemonButton + type="secondary" + size="small" + icon={<IconPlus />} + className="shrink-0 self-start @lg:self-center" + onClick={() => openSetupModal(undefined, 'email')} + disabledReason={restrictedReason} + data-attr="sandbox-email-sender-add-own" + > + Add your own sender + </LemonButton> + </div> + </div> + ) +} diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts new file mode 100644 index 000000000000..0d20a05d44ff --- /dev/null +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { expectLogic } from 'kea-test-utils' +import { createElement } from 'react' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' + +describe('sandbox setup in the channel catalog', () => { + let logic: ReturnType<typeof channelCatalogLogic.build> + + beforeEach(() => initKeaTests()) + afterEach(() => { + cleanup() + logic?.unmount() + }) + + it.each([404, 500])('starts setup on mount and exposes a %s request for retry', async (failureStatus) => { + let status = failureStatus + let requests = 0 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return status === 200 + ? [200, sandbox] + : [status, { detail: 'Could not set up your sandbox sender.' }] + }, + }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await waitFor(() => expect(requests).toBe(1)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBe('Could not set up your sandbox sender.') + + status = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await waitFor(() => expect(requests).toBe(2)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBeNull() + }) + + it('keeps a channel load failure separate from sandbox setup and reloads on retry', async () => { + let loadStatus = 500 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => + loadStatus === 200 + ? [200, { results: [sandbox] }] + : [500, { detail: 'Could not load your channels.' }], + }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': [200, sandbox] }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure']) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.channels).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.sandboxSetupError).toBeNull() + + render(createElement(ChannelCatalog)) + expect(screen.getByText('Could not load your channels.')).toBeInTheDocument() + cleanup() + + loadStatus = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.channels).not.toBeNull() + expect(logic.values.sandboxSetupError).toBeNull() + render(createElement(ChannelCatalog)) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + + loadStatus = 500 + await act(async () => { + await expectLogic(integrationsLogic, () => + integrationsLogic.actions.loadIntegrations() + ).toFinishAllListeners() + }) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + expect(screen.getByText(/Couldn't refresh your channels/)).toBeInTheDocument() + }) +}) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts index 842e323f374c..a038b8213f1c 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts @@ -1,4 +1,4 @@ -import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, selectors } from 'kea' import { router } from 'kea-router' import posthog from 'posthog-js' @@ -8,6 +8,7 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { removeProjectIdIfPresent } from 'lib/utils/kea-router' +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' import type { FeatureFlagsSet } from '../../../../frontend/src/lib/logic/featureFlagLogic' import type { IntegrationType } from '../../../../frontend/src/types' import { CATALOG_CHANNELS, ChannelSummary, PUSH_CHANNELS, summarizeChannel } from './channelSummary' @@ -17,27 +18,33 @@ import type { ChannelType } from './MessageChannels' export interface channelCatalogLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic + integrationsLoading: boolean // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic activeCount: number channels: ChannelSummary[] | null - integrationsLoadFailed: boolean + sandboxSetupError: string | null + sandboxSetupLoading: boolean } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicActions { - loadIntegrationsFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any + ensureSandboxEmailSender: () => { + value: true } // integrationsLogic - loadIntegrationsSuccess: ( - integrations: IntegrationType[], + loadCurrentTeamSuccess: ( + currentTeam: null | import('../../../../frontend/src/types').TeamPublicType, payload?: any ) => { - integrations: IntegrationType[] + currentTeam: null | import('../../../../frontend/src/types').TeamPublicType payload?: any } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic openSetupModal: ( integration?: IntegrationType | undefined, channelType?: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined @@ -45,6 +52,12 @@ export interface channelCatalogLogicActions { channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined integration: IntegrationType | undefined } // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic + retrySandboxSenderSetup: () => { + value: true + } startChannelSetup: (kind: ChannelType) => { kind: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' } @@ -53,6 +66,11 @@ export interface channelCatalogLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicMeta { __keaTypeGenInternalSelectorTypes: { + sandboxSetupLoading: ( + integrationsLoading: boolean, + sandboxEmailSenderProvisionResultLoading: boolean + ) => boolean + sandboxSetupError: (integrationsLoadFailed: boolean, sandboxEmailSenderSetupError: any) => string | null channels: (integrations: IntegrationType[] | null, featureFlags: FeatureFlagsSet) => ChannelSummary[] | null activeCount: (channels: ChannelSummary[] | null) => number } @@ -68,22 +86,46 @@ export type channelCatalogLogicType = MakeLogicType< export const channelCatalogLogic = kea<channelCatalogLogicType>([ path(['products', 'workflows', 'frontend', 'Channels', 'channelCatalogLogic']), connect(() => ({ - values: [integrationsLogic, ['integrations'], featureFlagLogic, ['featureFlags']], - actions: [integrationsLogic, ['openSetupModal', 'loadIntegrationsFailure', 'loadIntegrationsSuccess']], + values: [ + integrationsLogic, + [ + 'integrations', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'sandboxEmailSenderSetupError', + 'integrationsLoading', + 'sandboxEmailSenderEnabled', + 'sandboxEmailSenderProvisionResult', + 'sandboxEmailSenderProvisionError', + 'sandboxEmailSenderProvisionResultLoading', + ], + featureFlagLogic, + ['featureFlags'], + ], + actions: [ + integrationsLogic, + [ + 'openSetupModal', + 'ensureSandboxEmailSender', + 'retrySandboxEmailSender', + 'loadCurrentTeamSuccess', + 'loadIntegrations', + ], + ], })), actions({ startChannelSetup: (kind: ChannelType) => ({ kind }), - }), - reducers({ - integrationsLoadFailed: [ - false, - { - loadIntegrationsFailure: () => true, - loadIntegrationsSuccess: () => false, - }, - ], + retrySandboxSenderSetup: true, }), selectors({ + sandboxSetupLoading: [ + (s) => [s.integrationsLoading, s.sandboxEmailSenderProvisionResultLoading], + (loading: boolean, provisioning: boolean): boolean => loading || provisioning, + ], + sandboxSetupError: [ + (s) => [s.integrationsLoadFailed, s.sandboxEmailSenderSetupError], + (loadFailed: boolean, setupError: string | null): string | null => (loadFailed ? null : setupError), + ], /** `null` until the integrations load, so the catalog never shows a channel as not set up by mistake. */ channels: [ (s) => [s.integrations, s.featureFlags], @@ -103,6 +145,14 @@ export const channelCatalogLogic = kea<channelCatalogLogicType>([ ], }), listeners(({ actions, values }) => ({ + loadCurrentTeamSuccess: () => actions.ensureSandboxEmailSender(), + retrySandboxSenderSetup: () => { + if (values.sandboxSetupLoading) { + return + } + actions.loadIntegrations() + actions.retrySandboxEmailSender() + }, startChannelSetup: ({ kind }) => { // pinned: analytics event name - renaming breaks dashboards posthog.capture('workflows channel setup started', { @@ -120,4 +170,5 @@ export const channelCatalogLogic = kea<channelCatalogLogicType>([ actions.openSetupModal(undefined, kind) }, })), + afterMount(({ actions }) => actions.ensureSandboxEmailSender()), ]) diff --git a/products/workflows/frontend/Channels/channelSummary.test.ts b/products/workflows/frontend/Channels/channelSummary.test.ts index da3118f48f50..9fc451b7f5cc 100644 --- a/products/workflows/frontend/Channels/channelSummary.test.ts +++ b/products/workflows/frontend/Channels/channelSummary.test.ts @@ -4,10 +4,13 @@ import type { ChannelType } from './MessageChannels' const email = (verified: boolean): any => ({ kind: 'email', config: { verified } }) const slack = (errors?: string): any => ({ kind: 'slack', config: {}, errors }) const github: any = { kind: 'github', config: {} } +const sandbox: any = { kind: 'email', config: { provider: 'sandbox', verified: true } } describe('summarizeChannel', () => { test.each<[string, ChannelType, any[], ChannelStatus, number]>([ ['nothing connected', 'email', [], 'not-set-up', 0], + ['sandbox is separate from own email setup', 'email', [sandbox], 'not-set-up', 0], + ['sandbox does not verify an own sender', 'email', [sandbox, email(false)], 'needs-verification', 1], ['other kinds do not count', 'slack', [github, email(true)], 'not-set-up', 0], ['an unverified email address needs verification', 'email', [email(false)], 'needs-verification', 1], ['one verified address makes email active', 'email', [email(false), email(true)], 'active', 2], diff --git a/products/workflows/frontend/Channels/channelSummary.ts b/products/workflows/frontend/Channels/channelSummary.ts index 1b32d0d360f9..ca139d53f1df 100644 --- a/products/workflows/frontend/Channels/channelSummary.ts +++ b/products/workflows/frontend/Channels/channelSummary.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import type { IntegrationType } from '~/types' import type { ChannelType } from './MessageChannels' @@ -31,7 +33,9 @@ export function summarizeChannel( kind: ChannelType, integrations: Pick<IntegrationType, 'kind' | 'config' | 'errors'>[] ): ChannelSummary { - const connected = integrations.filter((integration) => integration.kind === kind) + const connected = integrations.filter( + (integration) => integration.kind === kind && !isSandboxEmailSender(integration) + ) const count = connected.length if (count === 0) { return { kind, status: 'not-set-up', count } diff --git a/products/workflows/frontend/Channels/defaultEmailSender.test.ts b/products/workflows/frontend/Channels/defaultEmailSender.test.ts index ce1b0d280f58..c5c5999cc209 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.test.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.test.ts @@ -5,8 +5,18 @@ import { resolveDefaultEmailSender } from './defaultEmailSender' const sender = (id: number, verified: boolean, kind = 'email'): IntegrationType => ({ id, kind, config: { verified } }) as unknown as IntegrationType +const sandboxSender = { ...sender(7, true), config: { verified: true, provider: 'sandbox' } } + describe('resolveDefaultEmailSender', () => { it.each([ + { case: 'nothing when only sandbox exists', integrations: [sandboxSender], defaultId: null, expected: null }, + { + case: 'the own sender beside sandbox', + integrations: [sandboxSender, sender(1, true)], + defaultId: null, + expected: 1, + }, + { case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null }, { case: 'the project default', integrations: [sender(1, true), sender(2, true)], defaultId: 2, expected: 2 }, { case: 'the only verified sender', diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 82f05e42c8f8..063d45406654 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import { IntegrationType } from '~/types' export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' @@ -12,7 +14,8 @@ export function resolveDefaultEmailSender( defaultIntegrationId: number | null | undefined ): DefaultEmailSender | null { const verified = (integrations ?? []).filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true + (integration) => + integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true ) const projectDefault = verified.find((integration) => integration.id === defaultIntegrationId) if (projectDefault) { diff --git a/products/workflows/frontend/Channels/emailSendingMessages.ts b/products/workflows/frontend/Channels/emailSendingMessages.ts index 1573829036cf..52fb836d018e 100644 --- a/products/workflows/frontend/Channels/emailSendingMessages.ts +++ b/products/workflows/frontend/Channels/emailSendingMessages.ts @@ -4,3 +4,6 @@ export const EMAIL_SENDING_SUSPENDED_MESSAGE = export const UNVERIFIED_SENDER_MESSAGE = "Verify the sender's domain before sending" export const NO_EMAIL_SENDER_MESSAGE = 'Set up an email sender before sending' + +export const SANDBOX_ONLY_SENDER_MESSAGE = + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.' diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts index 5ac223a19584..7c892e04bdef 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts @@ -17,6 +17,7 @@ const LIST_MATCHING = '"Signups" is still matching people. You can launch when i const verified = { kind: 'email', config: { verified: true } } const unverified = { kind: 'email', config: { verified: false } } +const sandbox = { kind: 'email', config: { provider: 'sandbox', verified: true } } const matchingList = { name: 'Signups', is_static: true, is_calculating: true, last_calculation: null } const audience: MessageAudience = { source: 'cohort', @@ -51,6 +52,14 @@ describe('loadMessageAudienceWarnings', () => { senders: [unverified], expected: ["Verify the sender's domain before sending"], }, + ...(['broadcast', 'workflow'] as const).map((destination) => ({ + case: `only the sandbox sender (${destination})`, + destination, + senders: [sandbox], + expected: [ + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.', + ], + })), { case: 'unverified senders on the first of several pages', destination: 'broadcast', diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx index 03758588a586..205e7c21312c 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx @@ -3,6 +3,7 @@ import { router } from 'kea-router' import { LemonDialog } from '@posthog/lemon-ui' import { ApiConfig } from 'lib/api' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { urls } from 'scenes/urls' import { cohortsRetrieve } from 'products/cohorts/frontend/generated/api' @@ -14,14 +15,16 @@ import { audienceCohortIds, audienceCohortLaunchError, toAudienceCohort } from ' import { EMAIL_SENDING_SUSPENDED_MESSAGE, NO_EMAIL_SENDER_MESSAGE, + SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE, } from '../Channels/emailSendingMessages' import { MessageAudience, MessageAudienceDestination, messageAudienceUrl } from './messageAudience' export function emailSenderWarning(integrations: Pick<IntegrationConfigApi, 'kind' | 'config'>[]): string | null { - const senders = integrations.filter((integration) => integration.kind === 'email') + const emailIntegrations = integrations.filter((integration) => integration.kind === 'email') + const senders = emailIntegrations.filter((integration) => !isSandboxEmailSender(integration)) if (senders.length === 0) { - return NO_EMAIL_SENDER_MESSAGE + return emailIntegrations.length > 0 ? SANDBOX_ONLY_SENDER_MESSAGE : NO_EMAIL_SENDER_MESSAGE } const verified = senders.some( (integration) => (integration.config as { verified?: boolean } | undefined)?.verified === true @@ -111,10 +114,11 @@ export async function openMessageAudience( onClick: navigate, 'data-attr': `message-audience-${audience.source}-continue`, }, - secondaryButton: - warnings.includes(NO_EMAIL_SENDER_MESSAGE) || warnings.includes(UNVERIFIED_SENDER_MESSAGE) - ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } - : { children: 'Cancel' }, + secondaryButton: [NO_EMAIL_SENDER_MESSAGE, SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE].some( + (message) => warnings.includes(message) + ) + ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } + : { children: 'Cancel' }, }) } finally { opening = false diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index 7cb2788dbe9c..c16d7b727ba1 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -269,6 +269,7 @@ export function HogFlowFunctionConfiguration({ // (into the staged draft on active workflows), so the editor needs no save step. emailLiveChanges emailSaveIndicator={<WorkflowAutoSaveIndicator />} + emailSandboxSenderAllowed={isEmailStep && workflow?.origin_product !== 'broadcasts'} configuration={{ inputs: inputs as Record<string, CyclotronJobInputType>, inputs_schema: schema }} showSource={false} sampleGlobalsWithInputs={sampleGlobals} diff --git a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx index 59b20a30429c..bce67c310fba 100644 --- a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx +++ b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx @@ -10,7 +10,12 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelSetupModal } from '../../Channels/ChannelSetupModal' export function WizardChannelStep(): JSX.Element { - const { integrations, setupModalOpen, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { + userManagedIntegrations: integrations, + setupModalOpen, + setupModalType, + selectedIntegration, + } = useValues(integrationsLogic) const { openSetupModal, closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) const hasEmailChannel = !!integrations?.some((integration) => integration.kind === 'email') diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts index 09b48e850579..429ffc206fff 100644 --- a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts +++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts @@ -257,7 +257,7 @@ export const workflowsOnboardingWizardLogic = kea<workflowsOnboardingWizardLogic workflowsSetupGuideLogic, ['steps as guideSteps'], integrationsLogic, - ['integrations'], + ['userManagedIntegrations as integrations'], featureFlagLogic, ['featureFlags'], workflowTemplatesLogic, diff --git a/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts b/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts index ce490206e701..1f6be4602b5a 100644 --- a/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts +++ b/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts @@ -143,7 +143,7 @@ export const workflowsSetupGuideLogic = kea<workflowsSetupGuideLogicType>([ projectLogic, ['currentProjectId'], integrationsLogic, - ['integrations'], + ['userManagedIntegrations as integrations'], optOutCategoriesLogic, ['categories', 'categoriesLoading'], messageTemplatesLogic, From f1e44aa71164b5e2be31b73c7ecafdf2786da049 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:43:11 +0000 Subject: [PATCH 08/38] feat(workflows): test templates with the sandbox sender Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- nodejs/src/cdp/cdp-api.serial.test.ts | 38 +++ nodejs/src/cdp/cdp-api.ts | 2 +- .../services/hogflows/actions/hog_function.ts | 4 + .../services/messaging/email.service.test.ts | 27 +- .../cdp/services/messaging/email.service.ts | 6 + .../SendTestEmailModal.stories.tsx | 84 +++++ .../SendTestEmailModal.test.tsx | 166 ++++++++++ .../TemplateLibrary/SendTestEmailModal.tsx | 151 +++++++-- .../messageTemplateTestSendLogic.test.ts | 305 +++++++++++++++++- .../messageTemplateTestSendLogic.ts | 303 +++++++++++++---- .../HogFlowEditorNotificationPanelTest.tsx | 7 +- .../panel/testing/HogFlowEditorPanelTest.tsx | 7 +- .../getHogFlowTestResultMessage.test.ts | 130 ++++++++ .../testing/getHogFlowTestResultMessage.ts | 26 ++ 14 files changed, 1154 insertions(+), 102 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx create mode 100644 products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts create mode 100644 products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts diff --git a/nodejs/src/cdp/cdp-api.serial.test.ts b/nodejs/src/cdp/cdp-api.serial.test.ts index 6a5914f4fcc1..a48bd30a8240 100644 --- a/nodejs/src/cdp/cdp-api.serial.test.ts +++ b/nodejs/src/cdp/cdp-api.serial.test.ts @@ -4,6 +4,7 @@ import { mockFetch } from '../../tests/helpers/mocks/request.mock' import { Server } from 'http' import jwt from 'jsonwebtoken' +import { DateTime } from 'luxon' import supertest from 'supertest' import express from 'ultimate-express' @@ -2721,6 +2722,43 @@ describe('CDP API', () => { emailSpy.mockRestore() }) + it('reports a skipped email delivery and preserves step-through continuation', async () => { + emailSpy.mockImplementation((invocation: any) => + Promise.resolve({ + invocation, + finished: true, + skipped: true, + logs: [ + { + level: 'warn', + timestamp: DateTime.now(), + message: 'Skipping send: Sandbox daily cap reached.', + }, + ], + metrics: [], + capturedPostHogEvents: [], + warehouseWebhookPayloads: [], + messageAssets: [], + conversionWatchers: [], + }) + ) + + const res = await supertest(app).post(`/api/projects/${team.id}/hog_flows/${hogFlowId}/invocations`).send({ + globals, + configuration: {}, + current_action_id: 'email_1', + }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('skipped') + expect(res.body.nextActionId).toBe('exit') + expect(res.body.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('Sandbox daily cap reached.') }), + ]) + ) + }) + it('sends the email inline via EmailService instead of routing to the email queue', async () => { const res = await supertest(app).post(`/api/projects/${team.id}/hog_flows/${hogFlowId}/invocations`).send({ globals, diff --git a/nodejs/src/cdp/cdp-api.ts b/nodejs/src/cdp/cdp-api.ts index 5868cd1dcb56..abc282c22f5b 100644 --- a/nodejs/src/cdp/cdp-api.ts +++ b/nodejs/src/cdp/cdp-api.ts @@ -930,7 +930,7 @@ export class CdpApi { const result = await this.hogFlowExecutor.executeCurrentAction(invocation, { hogExecutorOptions: options }) res.json({ - nextActionId: result.skipped ? null : result.invocation.state.currentAction?.id, + nextActionId: result.skipped && result.finished ? null : result.invocation.state.currentAction?.id, status: result.error ? 'error' : result.skipped ? 'skipped' : 'success', errors: result.error ? [result.error] : [], logs: [...result.logs, ...logs], diff --git a/nodejs/src/cdp/services/hogflows/actions/hog_function.ts b/nodejs/src/cdp/services/hogflows/actions/hog_function.ts index 931326f1882f..7d1efd7418bf 100644 --- a/nodejs/src/cdp/services/hogflows/actions/hog_function.ts +++ b/nodejs/src/cdp/services/hogflows/actions/hog_function.ts @@ -241,6 +241,10 @@ export class HogFunctionHandler implements ActionHandler { ) } + if (hogExecutorOptions?.isTest && functionResult.skipped) { + result.skipped = true + } + // Add billable_invocation metric only if the function actually executed (not skipped) if (!functionResult.skipped) { // A step that does not send leaves this undefined and bills as before. diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 4343e576e8bf..8d4d5d9f773e 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -765,6 +765,7 @@ describe('EmailService', () => { expect(result.error).toBeUndefined() expect(result.invocation.state.vmState?.stack).toEqual([{ success: !suppressed }]) if (suppressed) { + expect(result.skipped).toBe(true) expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.logs).toContainEqual( expect.objectContaining({ message: expect.stringContaining(email) }) @@ -2588,6 +2589,18 @@ describe('EmailService', () => { expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics.map((m) => m.metric_name)).toContain('email_suppressed') expect(result.metrics.map((m) => m.metric_name)).not.toContain('email_sent') + expect(result.skipped).not.toBe(true) + }) + + it('reports editor test sends to a suppressed recipient as skipped without recording metrics', async () => { + jest.spyOn(service['emailSuppressionService'], 'isSuppressed').mockResolvedValue(true) + sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) + + const result = await service.executeSendEmail(invocation, true) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.metrics).toEqual([]) + expect(result.skipped).toBe(true) }) it('calls SES when the recipient is not suppressed', async () => { @@ -2663,18 +2676,27 @@ describe('EmailService', () => { ) } - it('does not call SES while the team is suspended and records email_suspended', async () => { + it.each([false, true])('does not call SES while the team is suspended (sandbox=%s)', async (isSandbox) => { await suspendTeam() + if (isSandbox) { + await insertIntegration(hub.postgres, team.id, { + id: getIntegrationId(4), + kind: 'email', + config: { provider: 'sandbox', verified: true }, + }) + invocation.queueParameters = createEmailParams({ from: { integrationId: 4 } }) + } sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) const result = await service.executeSendEmail(invocation) expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics.map((m) => m.metric_name)).toEqual(['email_suspended']) + expect(result.skipped === true).toBe(isSandbox) expect(invocation.state.vmState?.stack).toEqual([{ success: false }]) }) - it('blocks editor test sends while suspended without recording metrics', async () => { + it('reports editor test sends as skipped while suspended without recording metrics', async () => { await suspendTeam() sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) @@ -2682,6 +2704,7 @@ describe('EmailService', () => { expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics).toEqual([]) + expect(result.skipped).toBe(true) }) // A per-workflow pause holds one workflow's email while the rest of the project keeps diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index 8094da1eb54a..39a05cdf3288 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -541,6 +541,9 @@ export class EmailService { // gating here turns wasted invocations into one clear skip. const suspensionCause = await this.teamWorkflowsConfigService.getEmailSendingSuspension(invocation.teamId) if (suspensionCause) { + if (isSandbox || isTest) { + result.skipped = true + } addLog( 'warn', suspensionCause === 'staff' @@ -694,6 +697,9 @@ export class EmailService { sandboxRecipients ) if (skipReason) { + if (isSandbox || isTest) { + result.skipped = true + } addLog('info', skipReason) if (!isTest) { result.metrics.push({ diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx new file mode 100644 index 000000000000..0b989b5768fb --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx @@ -0,0 +1,84 @@ +import { Meta, StoryFn } from '@storybook/react' +import { useActions } from 'kea' +import { useEffect } from 'react' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { messageTemplateLogic } from './messageTemplateLogic' +import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic' +import { SendTestEmailModal } from './SendTestEmailModal' + +const LOGIC_PROPS = { id: 'new' } + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const MEMBER_EMAIL = 'teammate@example.com' + +const SANDBOX_BLOCK_LOG = `Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: ${MEMBER_EMAIL}. Verify your own domain to send to anyone.` + +type StoryArgs = { + recipient?: string + sendResult?: Record<string, unknown> +} + +const meta: Meta<StoryArgs> = { + title: 'Products/Workflows/Send test email modal', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ recipient, sendResult }) => { + useStorybookMocks({ + get: { + '/api/organizations/:organization_id/members/': { + results: [{ id: 'sandbox-teammate', user: { id: 42, email: MEMBER_EMAIL, is_email_verified: true } }], + }, + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER] }, + '/api/environments/:team_id/messaging_templates/': { results: [] }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER, + '/api/environments/:team_id/hog_flows/:id/invocations/': sendResult ?? { status: 'success' }, + }, + }) + const { setTemplateValue } = useActions(messageTemplateLogic(LOGIC_PROPS)) + const { setModalOpen, setRecipientEmail, sendTestEmail } = useActions(messageTemplateTestSendLogic(LOGIC_PROPS)) + + useEffect(() => { + setTemplateValue('content.email.subject', 'Welcome to Acme') + setTemplateValue('content.email.text', 'Hello from the team.') + setModalOpen(true) + if (recipient) { + setRecipientEmail(recipient) + } + if (sendResult) { + sendTestEmail() + } + }, [recipient, sendResult, setTemplateValue, setModalOpen, setRecipientEmail, sendTestEmail]) + + return <SendTestEmailModal {...LOGIC_PROPS} isOpen /> +} + +export const SandboxSenderPreselected: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderPreselected.args = { recipient: MEMBER_EMAIL } + +export const RecipientOutsideOrganization: StoryFn<StoryArgs> = Template.bind({}) +RecipientOutsideOrganization.args = { recipient: 'outsider@example.com' } + +export const SendBlockedByServer: StoryFn<StoryArgs> = Template.bind({}) +SendBlockedByServer.args = { + recipient: MEMBER_EMAIL, + sendResult: { status: 'skipped', nextActionId: null, logs: [{ level: 'info', message: SANDBOX_BLOCK_LOG }] }, +} diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx new file mode 100644 index 000000000000..592f91724334 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx @@ -0,0 +1,166 @@ +import { MOCK_DEFAULT_USER } from 'lib/api.mock' + +import '@testing-library/jest-dom' + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { expectLogic } from 'kea-test-utils' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { userLogic } from 'scenes/userLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { messageTemplateLogic } from './messageTemplateLogic' +import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic' +import { SendTestEmailModal } from './SendTestEmailModal' + +const sandbox = { + id: 9, + kind: 'email', + display_name: 'Sandbox <sender@example.com>', + config: { provider: 'sandbox', verified: true }, +} + +describe('SendTestEmailModal', () => { + let logic: ReturnType<typeof messageTemplateTestSendLogic.build> + let template: ReturnType<typeof messageTemplateLogic.build> + + beforeEach(() => { + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + afterEach(() => { + cleanup() + logic?.unmount() + template?.unmount() + }) + + const openModal = (): void => { + template = messageTemplateLogic({ id: 'new' }) + template.mount() + template.actions.setTemplateValue('content.email.subject', 'Welcome') + logic = messageTemplateTestSendLogic({ id: 'new' }) + logic.mount() + logic.actions.setModalOpen(true) + render(<SendTestEmailModal id="new" isOpen />) + } + + it.each([ + [false, 'Verify your email address to send test emails to yourself.'], + [true, null], + ])('asks a signed-in user with verified=%s to verify their own address', async (verified, expected) => { + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { results: [sandbox] }, + '/api/organizations/:organization_id/members/': { results: [] }, + }, + }) + userLogic.actions.loadUserSuccess({ ...MOCK_DEFAULT_USER, is_email_verified: verified }) + openModal() + await expectLogic(logic).toFinishAllListeners() + logic.actions.setRecipientEmail(MOCK_DEFAULT_USER.email.toUpperCase()) + + await waitFor(() => + expect(screen.queryByText(/isn't among the verified members you can see/) !== null).toBe(verified) + ) + expect( + screen.queryByText('Verify your email address to send test emails to yourself.')?.textContent ?? null + ).toBe(expected) + }) + + it.each([true, false])('distinguishes member loading from an empty list (%s)', async (loading) => { + let releaseMembers: () => void = () => {} + const gate = new Promise<void>((resolve) => { + releaseMembers = resolve + }) + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { results: [sandbox] }, + '/api/organizations/:organization_id/members/': async () => { + if (loading) { + await gate + } + return [200, { results: [] }] + }, + }, + }) + openModal() + if (!loading) { + await expectLogic(logic).toFinishAllListeners() + } + fireEvent.focus(screen.getByPlaceholderText('you@example.com')) + expect(document.querySelector('.LemonSkeleton') !== null).toBe(loading) + expect(screen.queryByText('Type an email address and press Enter') !== null).toBe(!loading) + releaseMembers() + await expectLogic(logic).toFinishAllListeners() + }) + + it.each([404, 500])( + 'shows a %s setup error detail and retries through the integrations API', + async (failureStatus) => { + let requests = 0 + let status = failureStatus + const detail = 'Sandbox sending is not configured. Try again.' + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }], + '/api/organizations/:organization_id/members/': { results: [] }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return status === 200 ? [200, sandbox] : [status, { detail }] + }, + }, + }) + openModal() + await expectLogic(integrationsLogic).toFinishAllListeners() + await waitFor(() => expect(screen.getByText(detail)).toBeInTheDocument()) + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute( + 'href', + '/project/997/workflows/channels' + ) + status = 200 + fireEvent.click(screen.getAllByText('Try again')[0]) + await waitFor(() => expect(requests).toBe(2)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(screen.queryByText(detail)).not.toBeInTheDocument() + logic.actions.setRecipientEmail('outside@example.com') + await waitFor(() => + expect( + screen.getByText( + /isn't among the verified members you can see\. The sandbox sender may not deliver to it\. Add your own sender to email anyone\./ + ) + ).toBeInTheDocument() + ) + } + ) + it('shows an integrations load failure with a retry instead of an empty sender list', async () => { + let status = 500 + const detail = 'Could not load your senders. Try again.' + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => + status === 200 ? [200, { results: [sandbox] }] : [status, { detail }], + '/api/organizations/:organization_id/members/': { results: [] }, + }, + }) + openModal() + await waitFor(() => expect(screen.getByText(detail)).toBeInTheDocument()) + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute( + 'href', + '/project/997/workflows/channels' + ) + status = 200 + fireEvent.click(screen.getAllByText('Try again')[0]) + await expectLogic(integrationsLogic).toFinishAllListeners() + await waitFor(() => expect(screen.queryByText(detail)).not.toBeInTheDocument()) + }) +}) diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx index 22671911dbed..7303ed078ffd 100644 --- a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx @@ -1,27 +1,72 @@ import { useActions, useValues } from 'kea' import { IconExternal } from '@posthog/icons' -import { LemonBanner, LemonButton, LemonInput, LemonLabel, LemonSelect, Spinner } from '@posthog/lemon-ui' +import { + LemonBanner, + LemonButton, + LemonInput, + LemonInputSelect, + LemonLabel, + LemonSelect, + LemonTag, + Spinner, +} from '@posthog/lemon-ui' +import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils' import { LemonModal } from 'lib/lemon-ui/LemonModal' import { urls } from 'scenes/urls' +import { IntegrationType } from '~/types' + import { MessageTemplateLogicProps } from './messageTemplateLogic' import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic' +function senderOption( + integration: IntegrationType, + sandboxEmailSender: IntegrationType | null +): { + label: string + value: number + labelInMenu?: JSX.Element +} { + const option = { label: integration.display_name, value: integration.id } + if (integration.id !== sandboxEmailSender?.id) { + return option + } + return { + ...option, + labelInMenu: ( + <span className="flex items-center gap-2"> + <span translate="no">{integration.display_name}</span> + <LemonTag type="highlight">Sandbox</LemonTag> + </span> + ), + } +} + export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen: boolean }): JSX.Element { const { isOpen, ...logicProps } = props const logic = messageTemplateTestSendLogic(logicProps) const { recipientEmail, + recipientSuggestions, + recipientIsUnverifiedSelf, + sandboxEmailSenderEnabled, + recipientNotSuggested, senderIntegrationId, + sandboxEmailSender, + isSandboxSenderSelected, emailIntegrations, - integrationsLoading, + emailIntegrationsLoading, + emailSendersError, sendDisabledReason, testSendResult, testSendResultLoading, + testSendSkipMessage, + membersLoading, } = useValues(logic) - const { setModalOpen, setRecipientEmail, setSenderIntegrationId, sendTestEmail } = useActions(logic) + const { setModalOpen, setRecipientEmail, setSenderIntegrationId, sendTestEmail, retryEmailSenders } = + useActions(logic) return ( <LemonModal @@ -48,32 +93,86 @@ export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen: <div className="flex flex-col gap-4 min-w-100"> <div className="flex flex-col gap-1"> <LemonLabel>Send to</LemonLabel> - <LemonInput - type="email" - value={recipientEmail} - onChange={setRecipientEmail} - placeholder="you@example.com" - data-attr="send-test-email-recipient" - /> + {sandboxEmailSenderEnabled ? ( + <LemonInputSelect + mode="single" + allowCustomValues + disabled={testSendResultLoading} + value={recipientEmail ? [recipientEmail] : []} + onChange={(values) => setRecipientEmail(values[0] ?? '')} + options={recipientSuggestions.map((email) => ({ key: email, label: email }))} + loading={membersLoading} + placeholder="you@example.com" + emptyStateComponent="Type an email address and press Enter" + data-attr="send-test-email-recipient" + /> + ) : ( + <LemonInput + type="email" + disabled={testSendResultLoading} + value={recipientEmail} + onChange={setRecipientEmail} + placeholder="you@example.com" + data-attr="send-test-email-recipient" + /> + )} + {recipientIsUnverifiedSelf ? ( + <LemonBanner type="warning"> + Verify your email address to send test emails to yourself. + </LemonBanner> + ) : recipientNotSuggested ? ( + <LemonBanner + type="warning" + action={{ + children: 'Add your own sender', + to: urls.workflows('channels'), + targetBlank: true, + }} + > + <span translate="no" className="break-all"> + {recipientEmail} + </span>{' '} + isn't among the verified members you can see. The sandbox sender may not deliver to it. Add + your own sender to email anyone. + </LemonBanner> + ) : null} </div> <div className="flex flex-col gap-1"> <LemonLabel>From</LemonLabel> - {integrationsLoading && emailIntegrations.length === 0 ? ( + {emailSendersError && ( + <LemonBanner + type="error" + action={{ + children: 'Try again', + onClick: retryEmailSenders, + loading: emailIntegrationsLoading, + disabledReason: testSendResultLoading ? 'Wait for the test send to finish' : undefined, + }} + > + {emailSendersError} + </LemonBanner> + )} + {emailIntegrationsLoading && emailIntegrations.length === 0 ? ( <Spinner /> ) : emailIntegrations.length > 0 ? ( - <LemonSelect - value={senderIntegrationId} - onChange={(id) => setSenderIntegrationId(id)} - options={emailIntegrations.map((integration) => ({ - label: integration.display_name, - value: integration.id, - }))} - data-attr="send-test-email-sender" - fullWidth - /> + <> + <LemonSelect + disabled={testSendResultLoading} + value={senderIntegrationId} + onChange={(id) => setSenderIntegrationId(id)} + options={emailIntegrations.map((integration) => + senderOption(integration, sandboxEmailSender) + )} + data-attr="send-test-email-sender" + fullWidth + /> + {isSandboxSenderSelected && ( + <span className="text-xs text-secondary">{SANDBOX_EMAIL_SENDER_NOTE}</span> + )} + </> ) : ( - <div className="flex gap-2 items-center"> - <span className="text-muted">No email senders configured yet</span> + <div className="flex flex-wrap gap-2 items-center"> + {!emailSendersError && <span className="text-muted">No email senders configured yet</span>} <LemonButton size="small" type="secondary" @@ -86,10 +185,10 @@ export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen: </div> )} </div> - {testSendResult && testSendResult.status === 'skipped' ? ( + {testSendSkipMessage ? ( <LemonBanner type="warning"> - {testSendResult.logs?.map((log) => log.message).join(' ') || - 'The send was skipped for this recipient.'} + <div className="font-semibold">This test email was not sent</div> + <div className="break-words">{testSendSkipMessage}</div> </LemonBanner> ) : testSendResult && testSendResult.status === 'error' ? ( <LemonBanner type="error"> diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts index 81a4245849aa..c7328a89ef3c 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts @@ -1,6 +1,11 @@ +import { MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_SECOND_BASIC_USER, MOCK_SECOND_ORGANIZATION_MEMBER } from 'lib/api.mock' + import { expectLogic } from 'kea-test-utils' +import { FEATURE_FLAGS } from 'lib/constants' import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { membersLogic } from 'scenes/organization/membersLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' @@ -18,6 +23,11 @@ jest.mock('lib/lemon-ui/LemonToast', () => ({ const mockToast = require('lib/lemon-ui/LemonToast').lemonToast +const VERIFIED_MEMBERS = [MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_SECOND_ORGANIZATION_MEMBER].map((member) => ({ + ...member, + user: { ...member.user, is_email_verified: true }, +})) + describe('messageTemplateTestSendLogic', () => { let logic: ReturnType<typeof messageTemplateTestSendLogic.build> let templateLogic: ReturnType<typeof messageTemplateLogic.build> @@ -29,6 +39,7 @@ describe('messageTemplateTestSendLogic', () => { useMocks({ get: { + '/api/organizations/:organization_id/members/': { results: VERIFIED_MEMBERS }, '/api/projects/:team_id/integrations/': { results: [ { @@ -85,6 +96,22 @@ describe('messageTemplateTestSendLogic', () => { }).toMatchValues({ recipientEmail: 'john.doe@posthog.com' }) }) + it('leaves the recipient empty with the sandbox flag on, so the member suggestions show with the user first', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + try { + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ recipientEmail: '' }) + expect(logic.values.recipientSuggestions[0]).toBe('john.doe@posthog.com') + } finally { + featureFlagLogic.actions.setFeatureFlags([], {}) + } + }) + it('defaults the sender to the first verified email integration, excluding unverified and non-email kinds', async () => { await expectLogic(logic).toMatchValues({ senderIntegrationId: 5, @@ -129,7 +156,13 @@ describe('messageTemplateTestSendLogic', () => { post: { '/api/environments/:team_id/hog_flows/:id/invocations/': () => [ 200, - { status: 'skipped', logs: [{ message: 'Recipient has opted out.' }] }, + { + status: 'skipped', + logs: [ + { message: 'Executing action [Action:send_test_email]' }, + { message: '[Action:send_test_email] Recipient has opted out, skipping message delivery.' }, + ], + }, ], }, }) @@ -145,6 +178,276 @@ describe('messageTemplateTestSendLogic', () => { expect(mockToast.error).not.toHaveBeenCalled() expect(logic.values.isModalOpen).toBe(true) expect(logic.values.testSendResult?.status).toBe('skipped') + expect(logic.values.testSendSkipMessage).toBe('Recipient has opted out, skipping message delivery.') + }) + + it.each(['recipient', 'sender'])('clears the previous send outcome when changing the %s', async (field) => { + logic.actions.sendTestEmailSuccess({ + status: 'skipped', + logs: [], + nextActionId: null, + errors: [], + variables: {}, + }) + expect(logic.values.testSendResult?.status).toBe('skipped') + + if (field === 'recipient') { + logic.actions.setRecipientEmail('teammate@example.com') + } else { + logic.actions.setSenderIntegrationId(5) + } + + await expectLogic(logic).toMatchValues({ testSendResult: null }) + }) + + it.each(['recipient', 'sender'])('keeps the send in flight when the %s changes', async (field) => { + let releaseSend: () => void = () => {} + const pendingSend = new Promise<void>((resolve) => { + releaseSend = resolve + }) + useMocks({ + post: { + '/api/environments/:team_id/hog_flows/:id/invocations/': async () => { + await pendingSend + return [200, { status: 'success', nextActionId: null }] + }, + }, + }) + logic.actions.setRecipientEmail('recipient@example.com') + logic.actions.sendTestEmail() + try { + await expectLogic(logic).toMatchValues({ testSendResultLoading: true }) + if (field === 'recipient') { + logic.actions.setRecipientEmail('teammate@example.com') + } else { + logic.actions.setSenderIntegrationId(5) + } + await expectLogic(logic).toMatchValues({ testSendResultLoading: true }) + } finally { + releaseSend() + await expectLogic(logic).toFinishAllListeners() + } + }) + + describe('sandbox sender', () => { + const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + } + const UNVERIFIED_OWN_SENDER = { + id: 4, + kind: 'email', + display_name: 'Unverified sender <unverified@example.com>', + config: { verified: false }, + } + const VERIFIED_OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Sender <sender@example.com>', + config: { verified: true }, + } + let ensureCalls: number + let integrationsPayload: Record<string, any>[] + + const loadIntegrations = async (integrations: Record<string, any>[]): Promise<void> => { + integrationsPayload = integrations + await expectLogic(integrationsLogic, () => { + integrationsLogic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess']) + } + + beforeEach(() => { + ensureCalls = 0 + integrationsPayload = [] + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + integrationsPayload = [...integrationsPayload, SANDBOX_SENDER] + return [200, SANDBOX_SENDER] + }, + }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + it.each([ + { + description: 'preselects the sandbox sender when the project has no verified own sender', + integrations: [UNVERIFIED_OWN_SENDER, SANDBOX_SENDER], + expectedSenderId: 7, + expectedSenderIds: [7], + }, + { + description: 'prefers a verified own sender and lists the sandbox sender last', + integrations: [SANDBOX_SENDER, VERIFIED_OWN_SENDER], + expectedSenderId: 5, + expectedSenderIds: [5, 7], + }, + ])('$description', async ({ integrations, expectedSenderId, expectedSenderIds }) => { + await loadIntegrations(integrations) + + await expectLogic(logic).toMatchValues({ senderIntegrationId: expectedSenderId }) + expect(logic.values.emailIntegrations.map((integration) => integration.id)).toEqual(expectedSenderIds) + }) + + it('lists the sandbox sender only while the flag is on, and drops a selection that is no longer listed', async () => { + await loadIntegrations([SANDBOX_SENDER, VERIFIED_OWN_SENDER]) + logic.actions.setSenderIntegrationId(7) + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + featureFlagLogic.actions.setFeatureFlags([], {}) + + await expectLogic(logic).toMatchValues({ + senderIntegrationId: 5, + emailIntegrations: [expect.objectContaining({ id: 5 })], + }) + }) + + it('opening the modal creates a missing sandbox sender once and preselects it', async () => { + await loadIntegrations([UNVERIFIED_OWN_SENDER]) + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toDispatchActions(integrationsLogic, ['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + logic.actions.setModalOpen(false) + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + + expect(ensureCalls).toBe(1) + }) + + it('does not create the sandbox sender while the flag is off, and catches up when the flag lands on an open modal', async () => { + featureFlagLogic.actions.setFeatureFlags([], {}) + await loadIntegrations([UNVERIFIED_OWN_SENDER]) + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }).toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + await expectLogic(integrationsLogic).toFinishAllListeners() + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + expect(ensureCalls).toBe(1) + }) + + it('sends a sandbox test without the template Reply-To, which the sandbox sender does not support', async () => { + await loadIntegrations([SANDBOX_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + templateLogic.actions.setTemplateValue('content.email.replyTo', 'replies@example.com') + logic.actions.setRecipientEmail('rose.dawson@posthog.com') + + await expectLogic(logic, () => { + logic.actions.sendTestEmail() + }).toDispatchActions(['sendTestEmailSuccess']) + + const emailAction = capturedBody.configuration.actions.find((a: any) => a.type === 'function_email') + expect(emailAction.config.inputs.email.value).toMatchObject({ from: { integrationId: 7 }, replyTo: '' }) + }) + + it('warns about addresses absent from visible verified suggestions without blocking server authorization', async () => { + useMocks({ + get: { + '/api/organizations/:organization_id/members/': { + results: [ + ...VERIFIED_MEMBERS, + { + ...MOCK_SECOND_ORGANIZATION_MEMBER, + id: 'unverified-member', + user: { + ...MOCK_SECOND_BASIC_USER, + id: 303, + email: 'unverified@posthog.com', + is_email_verified: false, + }, + }, + { + ...MOCK_SECOND_ORGANIZATION_MEMBER, + id: 'verification-unknown-member', + user: { + ...MOCK_SECOND_BASIC_USER, + id: 304, + email: 'unknown@example.com', + is_email_verified: undefined, + }, + }, + ], + }, + }, + }) + await loadIntegrations([SANDBOX_SENDER, VERIFIED_OWN_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + logic.actions.setSenderIntegrationId(7) + logic.actions.setRecipientEmail('outsider@example.com') + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ + recipientNotSuggested: true, + sendDisabledReason: undefined, + recipientSuggestions: expect.arrayContaining(['rose.dawson@posthog.com']), + }) + + logic.actions.setRecipientEmail('Rose.Dawson@posthog.com') + await expectLogic(logic).toMatchValues({ + recipientNotSuggested: false, + sendDisabledReason: undefined, + }) + + logic.actions.setRecipientEmail('unverified@posthog.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) + expect(logic.values.recipientSuggestions).not.toContain('unverified@posthog.com') + + logic.actions.setRecipientEmail('unknown@example.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) + expect(logic.values.recipientSuggestions).not.toContain('unknown@example.com') + + logic.actions.setRecipientEmail('outsider@example.com') + logic.actions.setSenderIntegrationId(5) + await expectLogic(logic).toMatchValues({ + recipientNotSuggested: false, + sendDisabledReason: undefined, + }) + }) + + it('holds the send while the member list loads, and does not warn before it has loaded', async () => { + await loadIntegrations([SANDBOX_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + logic.actions.setRecipientEmail('outsider@example.com') + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }) + .toDispatchActions(membersLogic, ['loadAllMembers']) + .toMatchValues({ + recipientNotSuggested: false, + sendDisabledReason: 'Checking your organization members', + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ recipientNotSuggested: true }) + }) }) it('keeps the modal open and surfaces the error when the send fails', async () => { diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index dc1d9315665d..f381bc3ebaf7 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -4,16 +4,20 @@ import { loaders } from 'kea-loaders' import api from 'lib/api' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isEmail } from 'lib/utils/url' +import { membersLogic } from 'scenes/organization/membersLogic' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' -import { IntegrationType, UserType } from '~/types' +import { IntegrationType, OrganizationMemberType, UserType } from '~/types' +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' import { senderFrom, senderIntegrationFor, } from '../../../../frontend/src/scenes/hog-functions/email-templater/emailSenderFields' +import { skippedSendReasons } from '../Workflows/hogflows/panel/testing/getHogFlowTestResultMessage' import { HogflowTestResult } from '../Workflows/hogflows/steps/types' import { createExampleEvent } from '../Workflows/hogflows/testEventFactory' import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types' @@ -21,49 +25,69 @@ import { EXIT_NODE_ID, NEW_WORKFLOW, TRIGGER_NODE_ID } from '../Workflows/workfl import { MessageTemplateLogicProps, messageTemplateLogic } from './messageTemplateLogic' import { MessageTemplate } from './types' -// Fixed id for the single email action in the synthetic test flow - referenced by the edges -// below and passed as `current_action_id` so the CDP worker executes just this one node. +// The edges and current_action_id must identify the same synthetic email action. const TEST_EMAIL_ACTION_ID = 'send_test_email' // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicValues { integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic integrationsLoading: boolean // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic + meFirstMembers: OrganizationMemberType[] // membersLogic + members: OrganizationMemberType[] | null // membersLogic + membersLoading: boolean // membersLogic template: MessageTemplate // messageTemplateLogic currentTeamId: number | null // teamLogic user: UserType | null // userLogic emailIntegrations: IntegrationType[] + emailIntegrationsLoading: boolean + emailSendersError: string | null isModalOpen: boolean + isSandboxSenderSelected: boolean recipientEmail: string + recipientIsUnverifiedSelf: boolean + recipientNotSuggested: boolean + recipientSuggestions: string[] sendDisabledReason: string | undefined senderIntegrationId: number | null senderIntegrationIdOverride: number | null testSendResult: HogflowTestResult | null testSendResultLoading: boolean + testSendSkipMessage: string | null } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicActions { + setFeatureFlags: ( + flags: string[], + variants: Record<string, boolean | string> + ) => { + flags: string[] + variants: Record<string, boolean | string> + } // featureFlagLogic + ensureSandboxEmailSender: () => { + value: true + } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic + loadAllMembers: () => { + value: true + } // membersLogic clearTestSendResult: () => { value: true } - clearTestSendResultFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any - } - clearTestSendResultSuccess: ( - testSendResult: null, - payload?: { - value: true - } - ) => { - testSendResult: null - payload?: { - value: true - } + retryEmailSenders: () => { + value: true } sendTestEmail: () => any sendTestEmailFailure: ( @@ -95,17 +119,50 @@ export interface messageTemplateTestSendLogicActions { export interface messageTemplateTestSendLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { - emailIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] + emailSendersError: ( + integrationsLoadError: string | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderSetupError: string | null + ) => string | null + emailIntegrationsLoading: ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderProvisionResultLoading: boolean + ) => boolean + emailIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ) => IntegrationType[] senderIntegrationId: ( senderIntegrationIdOverride: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate ) => number | null + isSandboxSenderSelected: ( + senderIntegrationId: number | null, + sandboxEmailSender: IntegrationType | null + ) => boolean + recipientSuggestions: (meFirstMembers: OrganizationMemberType[]) => string[] + recipientIsUnverifiedSelf: ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + user: UserType | null + ) => boolean + recipientNotSuggested: ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + members: OrganizationMemberType[] | null, + recipientSuggestions: string[] + ) => boolean sendDisabledReason: ( template: MessageTemplate, recipientEmail: string, - senderIntegrationId: number | null + senderIntegrationId: number | null, + isSandboxSenderSelected: boolean, + membersLoading: boolean ) => string | undefined + testSendSkipMessage: (testSendResult: HogflowTestResult | null) => string | null } } @@ -129,7 +186,29 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType teamLogic, ['currentTeamId'], integrationsLogic, - ['integrations', 'integrationsLoading'], + [ + 'ownEmailIntegrations', + 'sandboxEmailSender', + 'sandboxEmailSenderEnabled', + 'integrationsLoading', + 'integrations', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'sandboxEmailSenderSetupError', + 'sandboxEmailSenderProvisionError', + 'sandboxEmailSenderProvisionResultLoading', + 'sandboxEmailSenderProvisionResult', + ], + membersLogic, + ['members', 'membersLoading', 'meFirstMembers'], + ], + actions: [ + integrationsLogic, + ['ensureSandboxEmailSender', 'retrySandboxEmailSender', 'loadIntegrations'], + membersLogic, + ['loadAllMembers'], + featureFlagLogic, + ['setFeatureFlags'], ], })), actions({ @@ -137,19 +216,24 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType setRecipientEmail: (email: string) => ({ email }), setSenderIntegrationId: (id: number | null) => ({ id }), clearTestSendResult: true, + retryEmailSenders: true, }), reducers({ isModalOpen: [false, { setModalOpen: (_, { open }) => open }], recipientEmail: ['', { setRecipientEmail: (_, { email }) => email }], senderIntegrationIdOverride: [null as number | null, { setSenderIntegrationId: (_, { id }) => id }], + testSendResult: [ + null as HogflowTestResult | null, + { clearTestSendResult: () => null, setRecipientEmail: () => null, setSenderIntegrationId: () => null }, + ], }), loaders(({ values }) => ({ testSendResult: [ null as HogflowTestResult | null, { - clearTestSendResult: () => null, sendTestEmail: async (): Promise<HogflowTestResult> => { - const { template, recipientEmail, senderIntegrationId, currentTeamId } = values + const { template, recipientEmail, senderIntegrationId, currentTeamId, isSandboxSenderSelected } = + values const emailAction: HogFlowAction = { id: TEST_EMAIL_ACTION_ID, @@ -164,16 +248,19 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType email: { value: { ...template.content.email, - // A test send has exactly one visible recipient - never carry over a - // cc/bcc that happened to be on the copied source content. + // Test sends must never deliver to CC/BCC copied from the source template. cc: '', bcc: '', + ...(isSandboxSenderSelected ? { replyTo: '' } : {}), // A custom name and address carry over, so the test shows what a send shows. - from: { - ...senderFrom(template.content.email ?? {}), - integrationIds: undefined, - integrationId: senderIntegrationId ?? undefined, - }, + // The sandbox sender ignores them and always sends from its fixed identity. + from: isSandboxSenderSelected + ? { integrationId: senderIntegrationId ?? undefined } + : { + ...senderFrom(template.content.email ?? {}), + integrationIds: undefined, + integrationId: senderIntegrationId ?? undefined, + }, to: { email: recipientEmail, name: '' }, }, templating: template.content.templating === 'hog' ? 'hog' : 'liquid', @@ -211,27 +298,88 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType ], })), selectors({ + emailSendersError: [ + (s) => [s.integrationsLoadError, s.integrationsLoadFailed, s.sandboxEmailSenderSetupError], + (loadError: string | null, loadFailed: boolean, sandboxSetupError: string | null): string | null => + loadError || (loadFailed ? 'Could not load email senders.' : null) || sandboxSetupError, + ], + emailIntegrationsLoading: [ + (s) => [ + s.integrationsLoading, + s.integrations, + s.integrationsLoadFailed, + s.sandboxEmailSenderProvisionResultLoading, + ], + ( + loading: boolean, + integrations: IntegrationType[] | null, + loadFailed: boolean, + provisioning: boolean + ): boolean => loading || (integrations === null && !loadFailed) || provisioning, + ], emailIntegrations: [ - (s) => [s.integrations], - (integrations: IntegrationType[] | null): IntegrationType[] => - integrations?.filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true - ) ?? [], + (s) => [s.ownEmailIntegrations, s.sandboxEmailSender], + ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ): IntegrationType[] => { + const verifiedOwnSenders = ownEmailIntegrations.filter( + (integration) => integration.config?.verified === true + ) + return sandboxEmailSender ? [...verifiedOwnSenders, sandboxEmailSender] : verifiedOwnSenders + }, ], senderIntegrationId: [ (s) => [s.senderIntegrationIdOverride, s.emailIntegrations, s.template], (override: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate): number | null => - override ?? + emailIntegrations.find((integration) => integration.id === override)?.id ?? senderIntegrationFor(template.content.email ?? {}, emailIntegrations)?.id ?? emailIntegrations[0]?.id ?? null, ], + isSandboxSenderSelected: [ + (s) => [s.senderIntegrationId, s.sandboxEmailSender], + (senderIntegrationId: number | null, sandboxEmailSender: IntegrationType | null): boolean => + sandboxEmailSender !== null && senderIntegrationId === sandboxEmailSender.id, + ], + recipientSuggestions: [ + (s) => [s.meFirstMembers], + (meFirstMembers: OrganizationMemberType[]): string[] => + meFirstMembers + .filter((member) => member.user.is_email_verified === true) + .map((member) => member.user.email), + ], + recipientIsUnverifiedSelf: [ + (s) => [s.isSandboxSenderSelected, s.recipientEmail, s.user], + (isSandboxSenderSelected: boolean, recipientEmail: string, user: UserType | null): boolean => + isSandboxSenderSelected && + !!user && + user.is_email_verified !== true && + user.email.toLowerCase() === recipientEmail.trim().toLowerCase(), + ], + recipientNotSuggested: [ + (s) => [s.isSandboxSenderSelected, s.recipientEmail, s.members, s.recipientSuggestions], + ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + members: OrganizationMemberType[] | null, + recipientSuggestions: string[] + ): boolean => + isSandboxSenderSelected && + members !== null && + isEmail(recipientEmail) && + !recipientSuggestions.some( + (suggestion) => suggestion.toLowerCase() === recipientEmail.trim().toLowerCase() + ), + ], sendDisabledReason: [ - (s) => [s.template, s.recipientEmail, s.senderIntegrationId], + (s) => [s.template, s.recipientEmail, s.senderIntegrationId, s.isSandboxSenderSelected, s.membersLoading], ( template: MessageTemplate, recipientEmail: string, - senderIntegrationId: number | null + senderIntegrationId: number | null, + isSandboxSenderSelected: boolean, + membersLoading: boolean ): string | undefined => { if (!template.content.email?.subject) { return 'Add a subject first' @@ -245,33 +393,64 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType if (!senderIntegrationId) { return 'Connect an email sender first' } + if (isSandboxSenderSelected && membersLoading) { + return 'Checking your organization members' + } return undefined }, ], + testSendSkipMessage: [ + (s) => [s.testSendResult], + (testSendResult: HogflowTestResult | null): string | null => { + if (testSendResult?.status !== 'skipped') { + return null + } + return skippedSendReasons(testSendResult).join(' ') || 'The send was skipped for this recipient.' + }, + ], }), - listeners(({ actions, values }) => ({ - setModalOpen: ({ open }) => { - if (!open) { - return + listeners(({ actions, values }) => { + const prepareSandboxSender = (): void => { + if (values.isModalOpen && values.sandboxEmailSenderEnabled) { + actions.ensureSandboxEmailSender() + actions.loadAllMembers() } - actions.clearTestSendResult() - if (!values.recipientEmail && values.user?.email) { - actions.setRecipientEmail(values.user.email) - } - }, - sendTestEmailSuccess: ({ testSendResult }) => { - if (testSendResult.status === 'success') { - lemonToast.success(`Test email sent to ${values.recipientEmail}`) - actions.setModalOpen(false) + } + return { + retryEmailSenders: () => { + if (values.emailIntegrationsLoading || values.testSendResultLoading) { + return + } + actions.loadIntegrations() + if (values.sandboxEmailSenderEnabled) { + actions.retrySandboxEmailSender() + } + }, + setModalOpen: ({ open }) => { + if (!open) { + return + } actions.clearTestSendResult() - } else if (testSendResult.status === 'skipped') { - lemonToast.warning('Test email skipped, see details below') - } else { + if (!values.recipientEmail && values.user?.email && !values.sandboxEmailSenderEnabled) { + actions.setRecipientEmail(values.user.email) + } + prepareSandboxSender() + }, + setFeatureFlags: prepareSandboxSender, + sendTestEmailSuccess: ({ testSendResult }) => { + if (testSendResult.status === 'success') { + lemonToast.success(`Test email sent to ${values.recipientEmail}`) + actions.setModalOpen(false) + actions.clearTestSendResult() + } else if (testSendResult.status === 'skipped') { + lemonToast.warning('Test email skipped, see details below') + } else { + lemonToast.error('Failed to send test email') + } + }, + sendTestEmailFailure: () => { lemonToast.error('Failed to send test email') - } - }, - sendTestEmailFailure: () => { - lemonToast.error('Failed to send test email') - }, - })), + }, + } + }), ]) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx index 6c5c34ceda8e..c9c13dca1109 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx @@ -29,6 +29,7 @@ import { asDisplay } from 'products/persons/frontend/person-utils' import { renderWorkflowLogMessage } from '../../../logs/log-utils' import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic' import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' import { hogFlowEditorNotificationTestLogic } from './hogFlowEditorNotificationTestLogic' import { reorderGlobalsForEmailAction } from './hogFlowEditorNotificationTestLogic' @@ -368,11 +369,7 @@ export function EmailActionTestContent(): JSX.Element | null { : 'error' } > - {testResult.status === 'success' - ? 'Success' - : testResult.status === 'skipped' - ? 'Workflow was skipped because the event did not match the filter criteria' - : 'Error: ' + testResult.errors?.join(', ')} + {getHogFlowTestResultMessage(testResult)} </LemonBanner> <div className="flex flex-col gap-2"> diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx index 923aeaf0cd93..f53bedb1dd5b 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx @@ -11,6 +11,7 @@ import { LogsViewerTable } from 'scenes/hog-functions/logs/LogsViewer' import { renderWorkflowLogMessage } from '../../../logs/log-utils' import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic' import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' import { HogFlowEditorTestEvent } from './HogFlowEditorTestEvent' import { hogFlowEditorTestLogic } from './hogFlowEditorTestLogic' @@ -157,11 +158,7 @@ export function HogFlowEditorPanelTest(): JSX.Element | null { : 'error' } > - {testResult.status === 'success' - ? 'Success' - : testResult.status === 'skipped' - ? 'Workflow was skipped because the event did not match the filter criteria' - : 'Error: ' + testResult.errors?.join(', ')} + {getHogFlowTestResultMessage(testResult)} </LemonBanner> <div className="flex flex-col gap-2"> diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts new file mode 100644 index 000000000000..761d744d966d --- /dev/null +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts @@ -0,0 +1,130 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' +import { createElement } from 'react' + +import { dayjs } from 'lib/dayjs' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { workflowLogic } from '../../../workflowLogic' +import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { HogflowTestResult } from '../../steps/types' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' +import { EmailActionTestContent } from './HogFlowEditorNotificationPanelTest' +import { hogFlowEditorNotificationTestLogic } from './hogFlowEditorNotificationTestLogic' + +jest.mock('scenes/hog-functions/configuration/HogFunctionTest', () => ({ HogFunctionTestEditor: () => null })) + +describe('rendered workflow test result message', () => { + let workflow: ReturnType<typeof workflowLogic.build> + let editor: ReturnType<typeof hogFlowEditorLogic.build> + let logic: ReturnType<typeof hogFlowEditorNotificationTestLogic.build> + + beforeEach(async () => { + initKeaTests() + useMocks({ + get: { + '/api/environments/:team_id/persons/': { results: [] }, + '/api/environments/@current/messaging_categories': { results: [] }, + '/api/projects/:team_id/hog_flows/test-workflow-id/': { + id: 'test-workflow-id', + team_id: 997, + name: 'Test workflow', + status: 'draft', + actions: [ + { + id: 'send-email', + type: 'function_email', + name: 'Send email', + config: { template_id: 'template-email', inputs: {} }, + }, + ], + edges: [], + }, + }, + }) + workflow = workflowLogic({ id: 'test-workflow-id' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']).toFinishAllListeners() + editor = hogFlowEditorLogic({ id: 'test-workflow-id' }) + editor.mount() + editor.actions.setSelectedNodeId('send-email') + logic = hogFlowEditorNotificationTestLogic({ id: 'test-workflow-id' }) + logic.mount() + await expectLogic(logic).toFinishAllListeners() + }) + + afterEach(() => { + cleanup() + logic?.unmount() + editor?.unmount() + workflow?.unmount() + }) + + it.each([ + [ + "Skipping send: this email would go over the sandbox sender's daily limit for this project. Verify your own domain to send more.", + 'next-step', + ], + [ + 'Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: outsider@example.com.', + 'next-step', + ], + [ + 'Skipping send: could not check organization members for these addresses: member@example.com. Try again, or verify your own domain to send to anyone.', + null, + ], + ['Skipping send: recipient is on the suppression list.', 'next-step'], + ['Recipient has opted out, skipping message delivery.', 'next-step'], + ['Skipping send: the sandbox sender is paused. Try again later.', 'next-step'], + ])('shows the action-prefixed delivery skip reason above the logs: %s', (reason, nextActionId) => { + const result: HogflowTestResult = { + status: 'skipped', + nextActionId, + logs: [ + 'Sandbox sender selected.', + reason, + 'Email recipients: sandbox-user@example.com', + 'Function finished.', + ].map((message) => ({ + instanceId: 'test-run', + timestamp: dayjs('2026-05-01T00:00:00Z'), + rawTimestamp: '2026-05-01T00:00:00Z', + level: 'INFO', + message: `[Action:send-email] ${message}`, + })), + } + const { container } = render( + createElement(BindLogic, { + logic: workflowLogic, + props: { id: 'test-workflow-id' }, + children: createElement(BindLogic, { + logic: hogFlowEditorLogic, + props: { id: 'test-workflow-id' }, + children: createElement(EmailActionTestContent), + }), + }) + ) + act(() => logic.actions.setTestResult(result)) + expect(container.querySelector('.LemonBanner')).toHaveTextContent(reason) + expect(container.querySelector('.LemonBanner')).not.toHaveTextContent('sandbox-user@example.com') + }) +}) + +describe('workflow test result message', () => { + it.each([ + [{ status: 'success', nextActionId: 'next-step' }, 'Success'], + [{ status: 'error', nextActionId: null, errors: ['Invalid input'] }, 'Error: Invalid input'], + [{ status: 'skipped', nextActionId: 'next-step' }, 'This step was skipped. See the logs for details.'], + [ + { status: 'skipped', nextActionId: null }, + 'Workflow was skipped because the event did not match the filter criteria', + ], + ] as [HogflowTestResult, string][])('preserves the existing outcome message for %j', (result, expected) => { + expect(getHogFlowTestResultMessage(result)).toBe(expected) + }) +}) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts new file mode 100644 index 000000000000..eb46b3fc6b97 --- /dev/null +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts @@ -0,0 +1,26 @@ +import { HogflowTestResult } from '../../steps/types' + +export function skippedSendReasons(result: HogflowTestResult): string[] { + return ( + result.logs + ?.map((log) => log.message.replace(/^\[Action:[^\]]+\]\s*/, '')) + .filter( + (message) => message.startsWith('Skipping send:') || message.includes('skipping message delivery.') + ) ?? [] + ) +} + +export function getHogFlowTestResultMessage(result: HogflowTestResult): string { + if (result.status === 'success') { + return 'Success' + } + if (result.status === 'error') { + return 'Error: ' + result.errors?.join(', ') + } + return ( + skippedSendReasons(result).at(-1) ?? + (result.nextActionId + ? 'This step was skipped. See the logs for details.' + : 'Workflow was skipped because the event did not match the filter criteria') + ) +} From 74e68e076cfa852b0fe685a568dace5cf0c2e8f8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:43:46 +0000 Subject: [PATCH 09/38] feat(workflows): store shared sandbox tenant state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/scripts/check-idor-model-coverage.py | 1 + .../0037_sandbox_sender_tenant_state.py | 32 +++++++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- products/workflows/backend/models/__init__.py | 2 ++ .../models/sandbox_sender_tenant_state.py | 9 ++++++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 products/workflows/backend/migrations/0037_sandbox_sender_tenant_state.py create mode 100644 products/workflows/backend/models/sandbox_sender_tenant_state.py diff --git a/.github/scripts/check-idor-model-coverage.py b/.github/scripts/check-idor-model-coverage.py index ffe2c08f1681..aa7d0b87aa9c 100644 --- a/.github/scripts/check-idor-model-coverage.py +++ b/.github/scripts/check-idor-model-coverage.py @@ -324,6 +324,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st "DevStackImageTarget", "DevStackImageAttempt", "License", + "SandboxSenderTenantState", # instance-global mirror of the shared sandbox SES tenant state # --- Deprecated --- "Prompt", "PromptSequence", diff --git a/products/workflows/backend/migrations/0037_sandbox_sender_tenant_state.py b/products/workflows/backend/migrations/0037_sandbox_sender_tenant_state.py new file mode 100644 index 000000000000..1ded7276c510 --- /dev/null +++ b/products/workflows/backend/migrations/0037_sandbox_sender_tenant_state.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.18 on 2026-10-12 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workflows", "0036_teamworkflowsconfig_email_utm_defaults"), + ] + + operations = [ + migrations.CreateModel( + name="SandboxSenderTenantState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("tenant_name", models.CharField(max_length=64, unique=True)), + ( + "sending_status", + models.CharField(blank=True, db_default="", default="", max_length=32), + ), + ("synced_at", models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index d122755b7922..67cf914e224f 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0036_teamworkflowsconfig_email_utm_defaults +0037_sandbox_sender_tenant_state diff --git a/products/workflows/backend/models/__init__.py b/products/workflows/backend/models/__init__.py index bbc6b3a22a6c..fdf755e421f6 100644 --- a/products/workflows/backend/models/__init__.py +++ b/products/workflows/backend/models/__init__.py @@ -4,6 +4,7 @@ from .hog_flow_optimization import HogFlowOptimization from .hog_flow_revision import HogFlowRevision from .hog_flow_schedule.hog_flow_schedule import HogFlowSchedule +from .sandbox_sender_tenant_state import SandboxSenderTenantState from .team_workflows_config import TeamWorkflowsConfig from .workflow_idea import WorkflowIdea from .workflow_proposal import WorkflowProposal @@ -15,6 +16,7 @@ "HogFlowRevision", "HogFlowSchedule", "HogFlowTemplate", + "SandboxSenderTenantState", "TeamWorkflowsConfig", "WorkflowIdea", "WorkflowProposal", diff --git a/products/workflows/backend/models/sandbox_sender_tenant_state.py b/products/workflows/backend/models/sandbox_sender_tenant_state.py new file mode 100644 index 000000000000..f6b9172850bc --- /dev/null +++ b/products/workflows/backend/models/sandbox_sender_tenant_state.py @@ -0,0 +1,9 @@ +from django.db import models + + +class SandboxSenderTenantState(models.Model): + """Instance-level mirror of the shared sandbox SES tenant; platform state, not tenant data, so no team_id.""" + + tenant_name = models.CharField(max_length=64, unique=True) + sending_status = models.CharField(max_length=32, blank=True, default="", db_default="") + synced_at = models.DateTimeField(null=True, blank=True) From b2c31312b7a005863606847649a994b098530139 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:44:11 +0000 Subject: [PATCH 10/38] feat(workflows): honor shared sandbox tenant pauses Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 8 +- nodejs/src/cdp/cdp-services.ts | 4 +- .../sandbox-sender-state.service.test.ts | 24 ++ .../managers/sandbox-sender-state.service.ts | 52 +++++ .../services/messaging/email.service.test.ts | 215 +++++++++++++++++- .../cdp/services/messaging/email.service.ts | 33 ++- .../messaging/sandbox-email-sender.test.ts | 10 +- .../messaging/sandbox-email-sender.ts | 23 +- .../rate-limiter/rate-limiter.service.test.ts | 4 +- posthog/plugins/plugin_server_api.py | 5 + posthog/settings/ses.py | 1 + products/workflows/backend/providers/ses.py | 77 ++++--- .../backend/services/sandbox_sender.py | 6 +- .../backend/services/ses_tenant_events.py | 34 ++- .../backend/services/ses_tenant_state.py | 49 +++- .../backend/tasks/ses_tenant_state.py | 19 +- .../test/test_sandbox_sender_lifecycle.py | 1 + .../backend/test/test_ses_tenant_events.py | 29 +++ .../backend/test/test_ses_tenant_state.py | 123 ++++++++++ .../backend/tests/api/test_sandbox_sender.py | 2 + .../api/test_sandbox_sender_validation.py | 1 + 21 files changed, 667 insertions(+), 53 deletions(-) create mode 100644 nodejs/src/cdp/services/managers/sandbox-sender-state.service.test.ts create mode 100644 nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 4efe19cd1105..c1c81d1f8f33 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -20,7 +20,7 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. -Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN` and `SES_SANDBOX_FROM_ADDRESS` in Django; either empty value leaves the endpoint unavailable. +Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable. The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. @@ -75,6 +75,10 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent. A reached cap skips the send instead of delaying it. A limiter error or an empty or invalid cap value skips every sandbox send. Cap keys and the final membership check share one organization snapshot, valid for at most one minute. A stalled claim or member lookup that outlives that snapshot skips the send and returns the claim. SES throttle responses return the sandbox claim before the existing delivery retry. Returns are deduplicated, and Valkey ignores a return command that arrives more than five minutes after it was issued. + The SES tenant-state webhook and daily reconciliation mirror `SES_SANDBOX_TENANT_NAME` into `SandboxSenderTenantState` without emailing project admins. + Every sandbox send, including test sends, skips when that tenant's mirrored sending status is `DISABLED` or the pause lookup fails. A missing row allows sending, with SES enforcing its own state. + The worker rechecks pauses after the daily-cap claim and returns the claim when that recheck blocks delivery. + A committed state change reaches the workers through the `reload-sandbox-sender-state` message. Own-sender sends do not read this state. Test sends run inline in the CDP API, so the API reads the same `SES_RATE_LIMITER_VALKEY_*` settings as the email worker. Without them, every sandbox test send is skipped. CDP API opens the SES Valkey pool only when sandbox delivery is enabled; otherwise its MX validation uses the existing local cache and DNS. Deploy membership checks, daily caps, and the shared pause gate first. Configure the SES identity and Valkey pool, enable `WORKFLOWS_SANDBOX_SENDER_ENABLED`, then enable the UI flag. @@ -90,7 +94,7 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent. ### Sandbox delivery -Sandbox email uses a fixed SES tenant, configuration set, and From address. Set `SES_SANDBOX_TENANT_NAME`, `SES_SANDBOX_CONFIGURATION_SET`, and `SES_SANDBOX_FROM_ADDRESS` on the worker. Django reads the same `SES_SANDBOX_FROM_ADDRESS` for the address it stores and shows. The address must belong to the shared SES identity. +Sandbox email uses a fixed SES tenant, configuration set, and From address. Set `SES_SANDBOX_TENANT_NAME`, `SES_SANDBOX_CONFIGURATION_SET`, and `SES_SANDBOX_FROM_ADDRESS` on the worker. Set the same `SES_SANDBOX_TENANT_NAME` and `SES_SANDBOX_FROM_ADDRESS` on Django web and Celery so webhooks and reconciliation mirror the shared tenant; while either is empty, the provisioning endpoint stays unavailable. Django stores and shows that From address, and it must belong to the shared SES identity. Sandbox sends disable click tracking and unsubscribe headers because the complete stack restricts them to verified organization members for internal testing. Marketing delivery to external audiences requires a verified sender. Every To, CC, and BCC address must belong to an active organization member with a verified email address. Membership snapshots expire within one minute, and the worker checks recipients again immediately before delivery. A failed or stale membership check skips the send and explains the blocked addresses in the run log. HTML is parsed and normalized before the identification footer is appended. Root clipping and visibility styles are overridden while template typography is preserved; malformed markup may render differently from an own-domain send. Preview text is parsed separately so escaped template values remain readable. diff --git a/nodejs/src/cdp/cdp-services.ts b/nodejs/src/cdp/cdp-services.ts index 41373d89b0f4..6770b009f0ba 100644 --- a/nodejs/src/cdp/cdp-services.ts +++ b/nodejs/src/cdp/cdp-services.ts @@ -34,6 +34,7 @@ import { IntegrationManagerService } from './services/managers/integration-manag import { OrganizationMembersService } from './services/managers/organization-members.service' import { PersonsManagerService } from './services/managers/persons-manager.service' import { RecipientsManagerService } from './services/managers/recipients-manager.service' +import { SandboxSenderStateService } from './services/managers/sandbox-sender-state.service' import { TeamWorkflowsConfigService } from './services/managers/team-workflows-config.service' import { EmailSuppressionService } from './services/messaging/email-suppression.service' import { EmailValidationService } from './services/messaging/email-validation.service' @@ -498,7 +499,8 @@ export function createCdpCoreServices( }, deps.teamManager, sandboxEmailRateLimiter, - redis + redis, + new SandboxSenderStateService(deps.postgres, deps.pubSub) ), new OrganizationMembersService(deps.postgres) ) diff --git a/nodejs/src/cdp/services/managers/sandbox-sender-state.service.test.ts b/nodejs/src/cdp/services/managers/sandbox-sender-state.service.test.ts new file mode 100644 index 000000000000..6bdde7cfa59e --- /dev/null +++ b/nodejs/src/cdp/services/managers/sandbox-sender-state.service.test.ts @@ -0,0 +1,24 @@ +import { DependencyUnavailableError } from '~/common/utils/db/error' +import { PostgresRouter } from '~/common/utils/db/postgres' +import { PubSub } from '~/common/utils/pubsub' + +import { SandboxSenderStateService } from './sandbox-sender-state.service' + +describe('SandboxSenderStateService', () => { + it('rides out a transient database error instead of failing the sandbox send', async () => { + const query = jest + .fn() + .mockRejectedValueOnce( + new DependencyUnavailableError('server closed the connection unexpectedly', 'Postgres', new Error()) + ) + .mockResolvedValueOnce({ rows: [{ tenant_name: 'sandbox-tenant', sending_status: 'DISABLED' }] }) + const service = new SandboxSenderStateService( + { query } as unknown as PostgresRouter, + { + on: jest.fn(), + } as unknown as PubSub + ) + + await expect(service.isPaused('sandbox-tenant')).resolves.toBe(true) + }) +}) diff --git a/nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts b/nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts new file mode 100644 index 000000000000..3a82caf91e31 --- /dev/null +++ b/nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts @@ -0,0 +1,52 @@ +import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres' +import { DEFAULT_LOADER_RETRY, LazyLoader } from '~/common/utils/lazy-loader' +import { PubSub } from '~/common/utils/pubsub' + +type SandboxSenderState = { sendingStatus: string | undefined; generation: number } + +export class SandboxSenderStateService { + private lazyLoader: LazyLoader<SandboxSenderState> + private generation = 0 + + constructor( + private postgres: PostgresRouter, + pubSub: PubSub + ) { + this.lazyLoader = new LazyLoader({ + name: 'sandbox_sender_state', + refreshAgeMs: 30_000, + refreshJitterMs: 5_000, + loader: async (tenantNames) => await this.fetchStates(tenantNames), + loaderRetry: DEFAULT_LOADER_RETRY, + }) + pubSub.on<{ tenantName: string }>('reload-sandbox-sender-state', ({ tenantName }): void => { + this.generation++ + this.lazyLoader.markForRefresh(tenantName) + }) + } + + public async isPaused(tenantName: string): Promise<boolean> { + for (let attempt = 0; attempt < 2; attempt++) { + const state = await this.lazyLoader.get(tenantName) + if (state?.generation === this.generation) { + // No row means never synced; SES stays the final word. + return state.sendingStatus === 'DISABLED' + } + this.lazyLoader.markForRefresh(tenantName) + } + throw new Error('Sandbox sender state changed during lookup') + } + + private async fetchStates(tenantNames: string[]): Promise<Record<string, SandboxSenderState>> { + const generation = this.generation + const { rows } = await this.postgres.query<{ tenant_name: string; sending_status: string }>( + // The reload message fires right after the commit, so a lagging replica would cache the old state again. + PostgresUse.COMMON_WRITE, + 'SELECT tenant_name, sending_status FROM workflows_sandboxsendertenantstate WHERE tenant_name = ANY($1)', + [tenantNames], + 'fetch-sandbox-sender-state' + ) + const statuses = new Map(rows.map((row) => [row.tenant_name, row.sending_status])) + return Object.fromEntries(tenantNames.map((name) => [name, { sendingStatus: statuses.get(name), generation }])) + } +} diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 8d4d5d9f773e..b5a365b0e39e 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -33,6 +33,7 @@ import { import { Hub, Team } from '../../../types' import { OrganizationMembersService } from '../managers/organization-members.service' import { RecipientsManagerService } from '../managers/recipients-manager.service' +import { SandboxSenderStateService } from '../managers/sandbox-sender-state.service' import { TeamWorkflowsConfigService } from '../managers/team-workflows-config.service' import { RateLimiterService } from '../rate-limiter/rate-limiter.service' import { selectEmailSenderIntegrationId } from './email-sender-selection' @@ -296,6 +297,8 @@ describe('EmailService', () => { dailyTeamCap = '100', dailyRecipientCap = '100', routeSettings = {}, + tenantName = 'sandbox-tenant', + senderState = new SandboxSenderStateService(hub.postgres, hub.pubSub), }: { tierLimiter?: RateLimiterService | null messageAssetsService?: MessageAssetsService @@ -304,6 +307,8 @@ describe('EmailService', () => { dailyTeamCap?: string dailyRecipientCap?: string routeSettings?: Partial<SandboxEmailSenderConfig> + tenantName?: string + senderState?: SandboxSenderStateService } = {} ): EmailService => { const sandboxService = new EmailService( @@ -331,7 +336,7 @@ describe('EmailService', () => { new SandboxEmailSender( { enabled, - tenantName: 'sandbox-tenant', + tenantName, configurationSetName: 'sandbox-email', fromAddress: 'fixed-sandbox@example.com', dailyTeamCap, @@ -343,7 +348,8 @@ describe('EmailService', () => { { useClient: async (_options, callback) => callback(sandboxRedisClients[0] as unknown as RedisClient), - } + }, + senderState ), new OrganizationMembersService(membersPostgres) ) @@ -1412,6 +1418,211 @@ describe('EmailService', () => { expect(sendEmailSpy).toHaveBeenCalledTimes(3) }) + describe('pause gate', () => { + const pausedMessage = + 'Skipping send: the sandbox sender is paused right now. Verify your own domain to keep sending.' + const tenantName = (): string => `sandbox-tenant-${team.id}` + const setTenantStatus = async (status: string): Promise<void> => { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + `INSERT INTO workflows_sandboxsendertenantstate (tenant_name, sending_status, synced_at) + VALUES ($1, $2, now()) + ON CONFLICT (tenant_name) DO UPDATE SET sending_status = $2`, + [tenantName(), status], + 'test:set-sandbox-tenant-status' + ) + } + + afterEach(async () => { + await hub.postgres.query( + PostgresUse.COMMON_WRITE, + 'DELETE FROM workflows_sandboxsendertenantstate WHERE tenant_name = $1', + [tenantName()], + 'test:delete-sandbox-tenant-status' + ) + }) + + it.each([false, true])('skips while the sandbox tenant is paused (isTest=%s)', async (isTest) => { + await setTenantStatus('DISABLED') + service = createSandboxService(true, { tenantName: tenantName() }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(result.error).toBeUndefined() + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toEqual( + expect.arrayContaining([expect.objectContaining({ level: 'info', message: pausedMessage })]) + ) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'paused', is_test: isTest, blocked_recipient_count: 0 } + ) + }) + + it.each(['ENABLED', 'REINSTATED'])('sends while the sandbox tenant is %s', async (status) => { + await setTenantStatus(status) + service = createSandboxService(true, { tenantName: tenantName() }) + + const result = await service.executeSendEmail(invocation) + + expect(result.error).toBeUndefined() + expect(result.skipped).toBeFalsy() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + }) + + it('skips when the pause state cannot be read', async () => { + const brokenPostgres = new PostgresRouter(hub) + await brokenPostgres.end() + service = createSandboxService(true, { + tenantName: tenantName(), + senderState: new SandboxSenderStateService(brokenPostgres, hub.pubSub), + }) + + const result = await service.executeSendEmail(invocation) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(result.invocation.state.vmState?.stack).toEqual([{ success: false }]) + expect(result.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: 'info', + message: + 'Skipping send: could not check whether the sandbox sender is paused. Try again later, or verify your own domain to keep sending.', + }), + ]) + ) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email blocked', + { reason: 'check_failed', is_test: false, blocked_recipient_count: 0 } + ) + }) + + it('leaves own-sender sends alone while the sandbox tenant is paused', async () => { + await setTenantStatus('DISABLED') + service = createSandboxService(true, { tenantName: tenantName() }) + invocation.queueParameters = createEmailParams({ from: { integrationId: 1 } }) + + const result = await service.executeSendEmail(invocation) + + expect(result.error).toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + }) + + it('rechecks a pause after a delayed cap claim and returns the grant', async () => { + await setTenantStatus('ENABLED') + const now = Date.now() + const clock = jest.spyOn(Date, 'now').mockReturnValue(now) + service = createSandboxService(true, { + tenantName: tenantName(), + dailyTeamCap: '1', + dailyRecipientCap: '1', + }) + const claim = dailyCapLimiter.claimAllOrNothing.bind(dailyCapLimiter) + const delayedClaim = jest + .spyOn(dailyCapLimiter, 'claimAllOrNothing') + .mockImplementationOnce(async (buckets) => { + const grant = await claim(buckets) + await setTenantStatus('DISABLED') + clock.mockReturnValue(now + 40_000) + return grant + }) + try { + const blocked = await service.executeSendEmail(invocation) + expect(blocked).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(blocked.error).toBeUndefined() + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(blocked.logs).toContainEqual(expect.objectContaining({ message: pausedMessage })) + + await setTenantStatus('ENABLED') + clock.mockReturnValue(now + 80_000) + invocation.state.vmState = { stack: [] } as any + const resumed = await service.executeSendEmail(invocation) + expect(resumed.error).toBeUndefined() + expect(resumed.skipped).toBeFalsy() + expect(sendEmailSpy).toHaveBeenCalledTimes(1) + } finally { + delayedClaim.mockRestore() + clock.mockRestore() + } + }) + + it('skips when a pause invalidates a pending state read', async () => { + await setTenantStatus('ENABLED') + service = createSandboxService(true, { tenantName: tenantName() }) + let snapshotLoaded!: () => void + let releaseRead!: () => void + const snapshot = new Promise<void>((resolve) => { + snapshotLoaded = resolve + }) + const heldRead = new Promise<void>((resolve) => { + releaseRead = resolve + }) + let acknowledgeReload!: () => void + const reload = new Promise<void>((resolve) => { + acknowledgeReload = resolve + }) + hub.pubSub.on<{ tenantName: string }>('reload-sandbox-sender-state', ({ tenantName: name }) => { + if (name === tenantName()) { + acknowledgeReload() + } + }) + const query = hub.postgres.query.bind(hub.postgres) + let held = false + const delayedRead = jest.spyOn(hub.postgres, 'query').mockImplementation(async (...args) => { + const result = await query(...args) + if (args[3] === 'fetch-sandbox-sender-state' && !held) { + held = true + snapshotLoaded() + await heldRead + } + return result + }) + try { + const pendingSend = service.executeSendEmail(invocation) + await snapshot + await setTenantStatus('DISABLED') + await hub.pubSub.publish( + 'reload-sandbox-sender-state', + JSON.stringify({ tenantName: tenantName() }) + ) + await reload + releaseRead() + const result = await pendingSend + expect(result).toMatchObject({ finished: true, skipped: true, metrics: [] }) + expect(result.error).toBeUndefined() + expect(result.logs).toContainEqual(expect.objectContaining({ message: pausedMessage })) + expect(sendEmailSpy).not.toHaveBeenCalled() + } finally { + releaseRead() + delayedRead.mockRestore() + } + }) + + it('picks up a pause announced while the state is cached', async () => { + await setTenantStatus('ENABLED') + service = createSandboxService(true, { tenantName: tenantName() }) + expect((await service.executeSendEmail(invocation)).skipped).toBeFalsy() + + await setTenantStatus('DISABLED') + + await waitForExpect(async () => { + await hub.pubSub.publish( + 'reload-sandbox-sender-state', + JSON.stringify({ tenantName: tenantName() }) + ) + const result = await service.executeSendEmail(invocation) + expect(result.logs).toEqual( + expect.arrayContaining([expect.objectContaining({ message: pausedMessage })]) + ) + }, 3000) + }) + }) + describe('daily caps', () => { it('skips and refunds when the organization snapshot expires during the cap claim', async () => { const now = Date.now() diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index 39a05cdf3288..89bfb9edbf95 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -426,6 +426,12 @@ const SANDBOX_ADDRESS_BLOCK_COPY: Record< }, } +const SANDBOX_PAUSE_SKIP_MESSAGES = { + paused: 'Skipping send: the sandbox sender is paused right now. Verify your own domain to keep sending.', + check_failed: + 'Skipping send: could not check whether the sandbox sender is paused. Try again later, or verify your own domain to keep sending.', +} as const + const SANDBOX_CAP_SKIP_MESSAGES = { project_cap_reached: "Skipping send: this email would go over the sandbox sender's daily limit for this project. Verify your own domain to send more.", @@ -664,6 +670,9 @@ export class EmailService { } if (isSandbox) { + if (!(await this.passesSandboxPauseGate(result, isTest))) { + return result + } const cc = sandboxAddressList(params.cc) const bcc = sandboxAddressList(params.bcc) sandboxRecipients = [params.to.email, ...cc, ...bcc] @@ -1149,6 +1158,25 @@ export class EmailService { return null } + private async passesSandboxPauseGate( + result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, + isTest: boolean + ): Promise<boolean> { + const gate = await this.sandboxSender!.pauseGate() + if (gate === 'open') { + return true + } + createAddLogFunction(result.logs)('info', SANDBOX_PAUSE_SKIP_MESSAGES[gate]) + result.skipped = true + result.invocation.state.vmState?.stack.push({ success: false }) + await this.sandboxSender!.capture(result.invocation.teamId, isTest, { + type: 'blocked', + reason: gate, + blockedRecipientCount: 0, + }) + return false + } + private logSandboxRecipientBlock( result: CyclotronJobInvocationResult<CyclotronJobInvocationHogFunction>, blockedRecipients: string[], @@ -1536,7 +1564,10 @@ export class EmailService { if (!dailyCapGrant) { return false } - if (!(await this.checkSandboxRecipients(result, sandboxRecipients, isTest, dailyCapGrant.organization))) { + if ( + !(await this.passesSandboxPauseGate(result, isTest)) || + !(await this.checkSandboxRecipients(result, sandboxRecipients, isTest, dailyCapGrant.organization)) + ) { await this.sandboxSender!.returnDailyCaps(dailyCapGrant) return false } diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts index a8aa11e26af3..b5a5c2c92301 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.test.ts @@ -18,7 +18,10 @@ const sender = new SandboxEmailSender( }, { getTeam: jest.fn().mockResolvedValue({ organization_id: '00000000-0000-0000-0000-000000000001' }), - } as unknown as TeamManager + } as unknown as TeamManager, + null, + null, + { isPaused: jest.fn().mockResolvedValue(false) } ) const params = (html: string, text?: string, preheader?: string): CyclotronInvocationQueueParametersEmailType => ({ type: 'email', @@ -68,7 +71,10 @@ describe('sandbox email content', () => { }, { getTeam: jest.fn().mockResolvedValue({ organization_id: '00000000-0000-0000-0000-000000000001' }), - } as unknown as TeamManager + } as unknown as TeamManager, + null, + null, + { isPaused: jest.fn().mockResolvedValue(false) } ) const result = await paddedSender.withIdentificationFooter( params('<p>Hello</p>'), diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts index c333dc97b603..d165c1a972ab 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts @@ -8,6 +8,7 @@ import { captureTeamEvent } from '~/common/utils/posthog' import { TeamManager } from '~/common/utils/team-manager' import { TeamOrganizationSnapshot } from '../managers/organization-members.service' +import { SandboxSenderStateService } from '../managers/sandbox-sender-state.service' import { ClaimRequest, RateLimiterService } from '../rate-limiter/rate-limiter.service' import { maybeAddPreheaderToEmail } from './helpers/preheader' @@ -52,7 +53,13 @@ type SandboxEmailOutcome = | { type: 'sent'; recipientCount: number } | { type: 'blocked' - reason: 'switch_off' | 'recipient_not_member' | 'recipient_unverified' | 'check_failed' | 'cap_reached' + reason: + | 'switch_off' + | 'recipient_not_member' + | 'recipient_unverified' + | 'check_failed' + | 'cap_reached' + | 'paused' blockedRecipientCount: number } @@ -173,8 +180,9 @@ export class SandboxEmailSender { constructor( public readonly config: SandboxEmailSenderConfig, private teamManager: TeamManager, - private dailyCapLimiter: RateLimiterService | null = null, - private blockedEventRedis: Pick<RedisV2, 'useClient'> | null = null + private dailyCapLimiter: RateLimiterService | null, + private blockedEventRedis: Pick<RedisV2, 'useClient'> | null, + private senderState: Pick<SandboxSenderStateService, 'isPaused'> ) { this.config = { ...config, @@ -191,6 +199,15 @@ export class SandboxEmailSender { this.canSend = config.enabled && !emptySettings.length } + public async pauseGate(): Promise<'open' | 'paused' | 'check_failed'> { + try { + return (await this.senderState.isPaused(this.config.tenantName)) ? 'paused' : 'open' + } catch (error) { + logger.warn('Could not check the sandbox sender pause state', { error }) + return 'check_failed' + } + } + public async claimDailyCaps( teamId: number, recipients: string[], diff --git a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts index 1b9ef5e4d58d..387ba0b030e5 100644 --- a/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts +++ b/nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts @@ -453,7 +453,9 @@ describe('RateLimiterService', () => { dailyRecipientCap: '1', }, manager, - limiter + limiter, + null, + { isPaused: jest.fn().mockResolvedValue(false) } ) try { diff --git a/posthog/plugins/plugin_server_api.py b/posthog/plugins/plugin_server_api.py index 4176b4f87395..7cbb333e652b 100644 --- a/posthog/plugins/plugin_server_api.py +++ b/posthog/plugins/plugin_server_api.py @@ -82,6 +82,11 @@ def reload_team_workflows_config_on_workers(team_id: int): publish_message("reload-team-workflows-config", {"teamId": team_id}) +def reload_sandbox_sender_state_on_workers(tenant_name: str) -> None: + logger.info(f"Reloading sandbox sender state for tenant {tenant_name} on workers") + publish_message("reload-sandbox-sender-state", {"tenantName": tenant_name}) + + def populate_plugin_capabilities_on_workers(plugin_id: str): logger.info(f"Populating plugin capabilities for plugin {plugin_id} on workers") publish_message("populate-plugin-capabilities", {"pluginId": plugin_id}) diff --git a/posthog/settings/ses.py b/posthog/settings/ses.py index ee6db86140bb..74a23a5699cd 100644 --- a/posthog/settings/ses.py +++ b/posthog/settings/ses.py @@ -31,6 +31,7 @@ WORKFLOWS_SANDBOX_SENDER_DOMAIN: str = os.getenv("WORKFLOWS_SANDBOX_SENDER_DOMAIN", "").strip().lower() SES_SANDBOX_FROM_ADDRESS: str = os.getenv("SES_SANDBOX_FROM_ADDRESS", "").strip().lower() +SES_SANDBOX_TENANT_NAME: str = os.getenv("SES_SANDBOX_TENANT_NAME", "").strip() # SNS topics allowed to deliver SES tenant reputation events (EventBridge -> SNS -> webhook). # Empty (the default) leaves the webhook inert: the SNS signature proves a message came from AWS, diff --git a/products/workflows/backend/providers/ses.py b/products/workflows/backend/providers/ses.py index 5173b5324559..ca0c8ceeef8b 100644 --- a/products/workflows/backend/providers/ses.py +++ b/products/workflows/backend/providers/ses.py @@ -276,6 +276,13 @@ def remainder(metric: str) -> dict[str, int]: ) +@frozen +class TenantSendingState: + sending_status: str + reputation_impact: str | None + tenant_arn: str | None + + class SESProvider: ses_client: "SESClient" ses_v2_client: "SESV2Client" @@ -333,7 +340,17 @@ def get_tenant_reputation(self, team_id: int) -> dict[str, Any] | None: provider feedback loops, third-party listings), so this is the authoritative health source; our own app metrics only provide the per-workflow diagnosis. """ - tenant_name = self._tenant_name_for_team(team_id) + state = self.get_tenant_sending_state(self._tenant_name_for_team(team_id)) + if state is None: + return None + return { + "sending_status": state.sending_status, + "reputation_impact": state.reputation_impact, + "findings": self._open_findings(state.tenant_arn) if state.tenant_arn else [], + } + + def get_tenant_sending_state(self, tenant_name: str) -> TenantSendingState | None: + """Sending status and reputation impact of the named SES tenant, or None when it doesn't exist.""" try: tenant = self.ses_v2_client.get_tenant(TenantName=tenant_name)["Tenant"] except ClientError as e: @@ -343,40 +360,38 @@ def get_tenant_reputation(self, team_id: int) -> dict[str, Any] | None: sending_status: str = tenant.get("SendingStatus", "ENABLED") tenant_arn = tenant.get("TenantArn") - reputation_impact: str | None = None - findings: list[dict[str, Any]] = [] + if not tenant_arn: + return TenantSendingState(sending_status=sending_status, reputation_impact=None, tenant_arn=None) - if tenant_arn: - try: - entity = self.ses_v2_client.get_reputation_entity( - ReputationEntityReference=tenant_arn, ReputationEntityType="RESOURCE" - )["ReputationEntity"] - except ClientError as e: - # A tenant with no attributed sends yet has no reputation entity. - if e.response["Error"]["Code"] != "NotFoundException": - raise - entity = {} - reputation_impact = entity.get("ReputationImpact") + entity = self._reputation_entity(tenant_arn) + return TenantSendingState( # The aggregate folds in both AWS-managed and customer-managed pauses. - sending_status = entity.get("SendingStatusAggregate", sending_status) + sending_status=entity.get("SendingStatusAggregate", sending_status), + reputation_impact=entity.get("ReputationImpact"), + tenant_arn=tenant_arn, + ) - # RESOURCE_ARN is the only filter key AWS documents as usable on its own with this - # scoping (see _iter_open_recommendations for why STATUS is filtered locally). - findings.extend( - { - "finding_type": recommendation.get("Type", ""), - "impact": recommendation.get("Impact", "LOW"), - "description": recommendation.get("Description", ""), - "last_updated_at": recommendation.get("LastUpdatedTimestamp"), - } - for recommendation in self._iter_open_recommendations({"RESOURCE_ARN": tenant_arn}) - ) + def _reputation_entity(self, tenant_arn: str) -> Mapping[str, Any]: + try: + return self.ses_v2_client.get_reputation_entity( + ReputationEntityReference=tenant_arn, ReputationEntityType="RESOURCE" + )["ReputationEntity"] + except ClientError as e: + # A tenant with no attributed sends yet has no reputation entity. + if e.response["Error"]["Code"] != "NotFoundException": + raise + return {} - return { - "sending_status": sending_status, - "reputation_impact": reputation_impact, - "findings": findings, - } + def _open_findings(self, tenant_arn: str) -> list[dict[str, Any]]: + return [ + { + "finding_type": recommendation.get("Type", ""), + "impact": recommendation.get("Impact", "LOW"), + "description": recommendation.get("Description", ""), + "last_updated_at": recommendation.get("LastUpdatedTimestamp"), + } + for recommendation in self._iter_open_recommendations({"RESOURCE_ARN": tenant_arn}) + ] def _iter_open_recommendations( self, finding_filter: dict[Any, str] | None, client: "SESV2Client | None" = None diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py index 4dc3d4cc94f9..2da94cd73d1f 100644 --- a/products/workflows/backend/services/sandbox_sender.py +++ b/products/workflows/backend/services/sandbox_sender.py @@ -34,7 +34,11 @@ def _sandbox_sender_config(organization_name: str) -> dict[str, str | bool]: def _sandbox_sender_configured() -> bool: - return bool(settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN and settings.SES_SANDBOX_FROM_ADDRESS) + return bool( + settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN + and settings.SES_SANDBOX_FROM_ADDRESS + and settings.SES_SANDBOX_TENANT_NAME + ) def _sandbox_sender_enabled(team: Team) -> bool: diff --git a/products/workflows/backend/services/ses_tenant_events.py b/products/workflows/backend/services/ses_tenant_events.py index 77672ddc31cf..423cd5837aa5 100644 --- a/products/workflows/backend/services/ses_tenant_events.py +++ b/products/workflows/backend/services/ses_tenant_events.py @@ -15,21 +15,22 @@ from collections.abc import Mapping from typing import Any +from django.conf import settings + import requests import structlog from posthog.ingress.contracts import WebhookDelivery from posthog.ingress.verify.sns_signature import is_valid_sns_url -from products.workflows.backend.tasks.ses_tenant_state import sync_ses_tenant_state_task +from products.workflows.backend.tasks.ses_tenant_state import sync_sandbox_tenant_state_task, sync_ses_tenant_state_task logger = structlog.get_logger(__name__) _TENANT_TEAM_RE = re.compile(r"\bteam-(\d+)\b") -def _extract_team_id(event: dict[str, Any]) -> int | None: - """Pull the `team-<id>` tenant name out of an EventBridge event, wherever AWS put it.""" +def _tenant_candidates(event: dict[str, Any]) -> list[str]: detail = event.get("detail") or {} candidates: list[Any] = [ detail.get("tenantName"), @@ -39,8 +40,24 @@ def _extract_team_id(event: dict[str, Any]) -> int | None: resources = event.get("resources") if isinstance(resources, list): candidates.extend(resources) - for candidate in candidates: - if isinstance(candidate, str) and (match := _TENANT_TEAM_RE.search(candidate)): + return [candidate for candidate in candidates if isinstance(candidate, str)] + + +def _names_sandbox_tenant(event: dict[str, Any]) -> bool: + tenant_name = settings.SES_SANDBOX_TENANT_NAME + if not tenant_name: + return False + tenant_path = re.compile(rf"(?:/|:)tenant/{re.escape(tenant_name)}(?:/|$)") + return any( + candidate == tenant_name or (candidate.startswith("arn:") and tenant_path.search(candidate) is not None) + for candidate in _tenant_candidates(event) + ) + + +def _extract_team_id(event: dict[str, Any]) -> int | None: + """Pull the `team-<id>` tenant name out of an EventBridge event, wherever AWS put it.""" + for candidate in _tenant_candidates(event): + if match := _TENANT_TEAM_RE.search(candidate): return int(match.group(1)) return None @@ -61,7 +78,7 @@ def _confirm_subscription(message: Mapping[str, Any]) -> None: def _handle_notification(message: Mapping[str, Any]) -> None: - """Enqueue a tenant-state sync for the team the EventBridge event names. + """Enqueue a tenant-state sync for the sandbox tenant or the team the EventBridge event names. Every unusable payload is dropped rather than raised, because a redelivery would carry the same unusable payload. @@ -73,6 +90,11 @@ def _handle_notification(message: Mapping[str, Any]) -> None: if not isinstance(event, dict) or event.get("source") != "aws.ses": return + if _names_sandbox_tenant(event): + logger.info("ses_tenant_events_webhook_accepted", tenant="sandbox", detail_type=event.get("detail-type")) + sync_sandbox_tenant_state_task.delay() + return + team_id = _extract_team_id(event) if team_id is None: logger.warning("ses_tenant_events_webhook_no_tenant", detail_type=event.get("detail-type")) diff --git a/products/workflows/backend/services/ses_tenant_state.py b/products/workflows/backend/services/ses_tenant_state.py index 99f9f5c3c3de..82da157a6dc6 100644 --- a/products/workflows/backend/services/ses_tenant_state.py +++ b/products/workflows/backend/services/ses_tenant_state.py @@ -1,21 +1,26 @@ from collections.abc import Callable from typing import Any +from django.conf import settings from django.db import transaction from django.utils import timezone import structlog from posthog.models.team import Team -from posthog.plugins.plugin_server_api import reload_team_workflows_config_on_workers +from posthog.plugins.plugin_server_api import ( + reload_sandbox_sender_state_on_workers, + reload_team_workflows_config_on_workers, +) from posthog.tasks.email import ( send_email_sending_reputation_finding, send_email_sending_suspended, send_email_sending_unsuspended, ) +from products.workflows.backend.models.sandbox_sender_tenant_state import SandboxSenderTenantState from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig -from products.workflows.backend.providers.ses import SESProvider +from products.workflows.backend.providers.ses import SESProvider, TenantSendingState logger = structlog.get_logger(__name__) @@ -58,6 +63,46 @@ def _announce_state_change(team_id: int) -> None: logger.exception("Failed to announce SES tenant state change to workers", team_id=team_id) +def _announce_sandbox_state_change(tenant_name: str) -> None: + try: + reload_sandbox_sender_state_on_workers(tenant_name) + except Exception: + logger.exception("Failed to announce sandbox SES tenant state change to workers", tenant_name=tenant_name) + + +def _apply_sandbox_tenant_state(state: SandboxSenderTenantState, tenant: TenantSendingState) -> None: + previous_status = state.sending_status + state.synced_at = timezone.now() + if previous_status == tenant.sending_status: + state.save(update_fields=["synced_at"]) + logger.info("Sandbox SES tenant state unchanged", tenant_name=state.tenant_name, status=previous_status) + return + + state.sending_status = tenant.sending_status + state.save(update_fields=["sending_status", "synced_at"]) + logger.info( + "Sandbox SES tenant state changed", + tenant_name=state.tenant_name, + from_status=previous_status, + to_status=tenant.sending_status, + ) + tenant_name = state.tenant_name + transaction.on_commit(lambda: _announce_sandbox_state_change(tenant_name)) + + +def sync_sandbox_tenant_state(provider: SESProvider | None = None) -> None: + # Serialize SES reads so a stale sweep cannot overwrite a newer pause. + tenant_name = settings.SES_SANDBOX_TENANT_NAME + if not tenant_name: + return + SandboxSenderTenantState.objects.get_or_create(tenant_name=tenant_name) + with transaction.atomic(): + state = SandboxSenderTenantState.objects.select_for_update().get(tenant_name=tenant_name) + tenant = (provider or SESProvider()).get_tenant_sending_state(tenant_name) + if tenant is not None: + _apply_sandbox_tenant_state(state, tenant) + + def sync_ses_tenant_state(team_id: int, provider: SESProvider | None = None, *, verify_team: bool = True) -> None: """ Fetch the authoritative AWS SES tenant state for a team and apply it. Called from the diff --git a/products/workflows/backend/tasks/ses_tenant_state.py b/products/workflows/backend/tasks/ses_tenant_state.py index 55b41d59978f..d8c01d8c355f 100644 --- a/products/workflows/backend/tasks/ses_tenant_state.py +++ b/products/workflows/backend/tasks/ses_tenant_state.py @@ -6,7 +6,7 @@ from posthog.tasks.utils import CeleryQueue from products.workflows.backend.providers.ses import SESProvider -from products.workflows.backend.services.ses_tenant_state import sync_ses_tenant_state +from products.workflows.backend.services.ses_tenant_state import sync_sandbox_tenant_state, sync_ses_tenant_state logger = get_logger(__name__) @@ -27,6 +27,19 @@ def sync_ses_tenant_state_task(team_id: int) -> None: sync_ses_tenant_state(team_id) +@shared_task( + ignore_result=True, + queue=CeleryQueue.DEFAULT.value, + autoretry_for=(Exception,), + retry_backoff=60, + retry_backoff_max=600, + max_retries=5, + retry_jitter=True, +) +def sync_sandbox_tenant_state_task() -> None: + sync_sandbox_tenant_state() + + @shared_task(ignore_result=True, queue=CeleryQueue.LONG_RUNNING.value) @skip_team_scope_audit def reconcile_ses_tenant_states() -> None: @@ -44,6 +57,10 @@ def reconcile_ses_tenant_states() -> None: # One provider for the whole sweep: a fresh SESProvider per team would rebuild its boto3 # clients (and re-resolve credentials) thousands of times. provider = SESProvider() + try: + sync_sandbox_tenant_state(provider=provider) + except Exception: + logger.exception("SES sandbox tenant reconciliation failed") synced = 0 failed = 0 for team_id in team_ids.iterator(chunk_size=500): diff --git a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py index e812993b80d9..d293e7ef49cb 100644 --- a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -13,6 +13,7 @@ @override_settings( WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", + SES_SANDBOX_TENANT_NAME="posthog-sandbox", ) class TestSandboxSenderLifecycleExclusions(BaseTest): def setUp(self) -> None: diff --git a/products/workflows/backend/test/test_ses_tenant_events.py b/products/workflows/backend/test/test_ses_tenant_events.py index 8b4f7aa2d9e8..fb4fe7f6aa6a 100644 --- a/products/workflows/backend/test/test_ses_tenant_events.py +++ b/products/workflows/backend/test/test_ses_tenant_events.py @@ -23,6 +23,7 @@ from products.workflows.backend.facade.api import accept_ses_event TOPIC = "arn:aws:sns:us-east-1:123456789012:ses-tenant-events" +SANDBOX_TENANT = "sandbox-team-1" WEBHOOK_PATH = "/webhooks/workflows/ses-events" SUBSCRIBE_URL = "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=tok" _CERT_URL = "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-01d088a6f77103d0fe307c0069e40ed6.pem" @@ -106,6 +107,9 @@ def setUp(self) -> None: sync = patch("products.workflows.backend.services.ses_tenant_events.sync_ses_tenant_state_task") self.sync_mock = sync.start() self.addCleanup(sync.stop) + sandbox_sync = patch("products.workflows.backend.services.ses_tenant_events.sync_sandbox_tenant_state_task") + self.sandbox_sync_mock = sandbox_sync.start() + self.addCleanup(sandbox_sync.stop) def test_enqueues_a_sync_for_the_tenant_named_in_the_event(self) -> None: accept_ses_event(_delivery(_notification(_eventbridge_event()))) @@ -119,6 +123,31 @@ def test_finds_the_tenant_in_resource_arns_when_detail_has_no_name(self) -> None self.sync_mock.delay.assert_called_once_with(7) + @parameterized.expand( + [ + ("sandbox_tenant_by_name", {"tenantName": SANDBOX_TENANT}, [], "sandbox"), + ( + "sandbox_tenant_by_arn", + {}, + [f"arn:aws:ses:us-east-1:123456789012:tenant/{SANDBOX_TENANT}/abc"], + "sandbox", + ), + ("team_tenant", {"tenantName": "team-42"}, [], "team"), + ] + ) + @override_settings(SES_SANDBOX_TENANT_NAME=SANDBOX_TENANT) + def test_routes_the_sandbox_tenant_to_its_own_sync( + self, _name: str, detail: dict[str, Any], resources: list[str], expected_sync: str + ) -> None: + accept_ses_event(_delivery(_notification(_eventbridge_event(detail=detail, resources=resources)))) + + if expected_sync == "sandbox": + self.sandbox_sync_mock.delay.assert_called_once_with() + self.sync_mock.delay.assert_not_called() + else: + self.sync_mock.delay.assert_called_once_with(42) + self.sandbox_sync_mock.delay.assert_not_called() + def test_ignores_events_from_other_sources(self) -> None: accept_ses_event(_delivery(_notification(_eventbridge_event(source="aws.health")))) diff --git a/products/workflows/backend/test/test_ses_tenant_state.py b/products/workflows/backend/test/test_ses_tenant_state.py index e00b17a1df33..214aa4f1f8fb 100644 --- a/products/workflows/backend/test/test_ses_tenant_state.py +++ b/products/workflows/backend/test/test_ses_tenant_state.py @@ -1,10 +1,18 @@ +from datetime import UTC, datetime + +import time_machine from posthog.test.base import BaseTest from unittest.mock import MagicMock, patch +from django.test import override_settings from django.utils import timezone +from botocore.exceptions import ClientError from parameterized import parameterized +from posthog.models.integration import Integration + +from products.workflows.backend.models.sandbox_sender_tenant_state import SandboxSenderTenantState from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig from products.workflows.backend.services.ses_tenant_state import ( PROVIDER_PAUSE_REASON, @@ -12,6 +20,10 @@ apply_ses_tenant_state, sync_ses_tenant_state, ) +from products.workflows.backend.tasks.ses_tenant_state import ( + reconcile_ses_tenant_states, + sync_sandbox_tenant_state_task, +) class TestApplySesTenantState(BaseTest): @@ -183,3 +195,114 @@ def test_unknown_team_is_skipped_without_calling_ses(self) -> None: provider.get_tenant_reputation.assert_not_called() assert not TeamWorkflowsConfig.objects.filter(team_id=self.team.id + 99_999).exists() + + +SANDBOX_TENANT = "workflows-sandbox" +SANDBOX_TENANT_ARN = f"arn:aws:ses:us-east-1:123456789012:tenant/{SANDBOX_TENANT}/abc" + + +@override_settings(SES_SANDBOX_TENANT_NAME=SANDBOX_TENANT) +class TestSandboxTenantStateSync(BaseTest): + def setUp(self) -> None: + super().setUp() + boto3_client = patch("products.workflows.backend.providers.ses.boto3.client") + self.ses = boto3_client.start().return_value + self.addCleanup(boto3_client.stop) + publish = patch("posthog.plugins.plugin_server_api.publish_message") + self.publish = publish.start() + self.addCleanup(publish.stop) + self.ses.list_recommendations.return_value = {"Recommendations": []} + + def _aws_tenant(self, sending_status: str, impact: str) -> None: + self.ses.get_tenant.return_value = {"Tenant": {"TenantName": SANDBOX_TENANT, "TenantArn": SANDBOX_TENANT_ARN}} + self.ses.get_reputation_entity.return_value = { + "ReputationEntity": {"ReputationImpact": impact, "SendingStatusAggregate": sending_status} + } + + def _sync(self) -> None: + with self.captureOnCommitCallbacks(execute=True): + sync_sandbox_tenant_state_task() + + def test_a_changed_state_is_stored_and_announced_after_commit(self) -> None: + self._aws_tenant("ENABLED", "NONE") + self._sync() + self.publish.reset_mock() + self._aws_tenant("DISABLED", "HIGH") + + with self.captureOnCommitCallbacks(execute=True): + sync_sandbox_tenant_state_task() + self.publish.assert_not_called() + + state = SandboxSenderTenantState.objects.get(tenant_name=SANDBOX_TENANT) + assert state.sending_status == "DISABLED" + self.ses.get_tenant.assert_called_with(TenantName=SANDBOX_TENANT) + self.publish.assert_called_once_with("reload-sandbox-sender-state", {"tenantName": SANDBOX_TENANT}) + + @parameterized.expand([("same_impact", "HIGH"), ("only_the_impact_changed", "LOW")]) + def test_an_unchanged_sending_status_refreshes_synced_at_without_announcing(self, _name: str, impact: str) -> None: + self._aws_tenant("DISABLED", "HIGH") + with time_machine.travel("2026-10-05T10:00:00Z", tick=False): + self._sync() + self.publish.reset_mock() + self._aws_tenant("DISABLED", impact) + + with time_machine.travel("2026-10-05T11:00:00Z", tick=False): + self._sync() + + state = SandboxSenderTenantState.objects.get(tenant_name=SANDBOX_TENANT) + assert state.sending_status == "DISABLED" + assert state.synced_at == datetime(2026, 10, 5, 11, tzinfo=UTC) + self.publish.assert_not_called() + + def test_a_pause_is_stored_even_when_findings_cannot_be_listed(self) -> None: + self._aws_tenant("DISABLED", "HIGH") + self.ses.list_recommendations.side_effect = ClientError( + {"Error": {"Code": "TooManyRequestsException"}}, "ListRecommendations" + ) + + self._sync() + + assert SandboxSenderTenantState.objects.get(tenant_name=SANDBOX_TENANT).sending_status == "DISABLED" + + def test_an_unknown_sandbox_tenant_is_never_paused(self) -> None: + self.ses.get_tenant.side_effect = ClientError({"Error": {"Code": "NotFoundException"}}, "GetTenant") + + self._sync() + + state = SandboxSenderTenantState.objects.get(tenant_name=SANDBOX_TENANT) + assert (state.sending_status, state.synced_at) == ("", None) + self.publish.assert_not_called() + + def test_the_daily_sweep_syncs_the_sandbox_tenant_once_beside_team_tenants(self) -> None: + Integration.objects.create(team=self.team, kind="email", config={"provider": "ses"}) + self._aws_tenant("ENABLED", "LOW") + + with self.captureOnCommitCallbacks(execute=True): + reconcile_ses_tenant_states() + + tenant_names = [call.kwargs["TenantName"] for call in self.ses.get_tenant.call_args_list] + assert sorted(tenant_names) == sorted([SANDBOX_TENANT, f"team-{self.team.id}"]) + assert SandboxSenderTenantState.objects.get(tenant_name=SANDBOX_TENANT).sending_status == "ENABLED" + + def test_a_failed_sandbox_sync_does_not_stop_the_team_sweep(self) -> None: + Integration.objects.create(team=self.team, kind="email", config={"provider": "ses"}) + + def get_tenant(TenantName: str) -> dict: + if TenantName == SANDBOX_TENANT: + raise ClientError({"Error": {"Code": "TooManyRequestsException"}}, "GetTenant") + return {"Tenant": {"TenantName": TenantName, "SendingStatus": "ENABLED"}} + + self.ses.get_tenant.side_effect = get_tenant + + with self.captureOnCommitCallbacks(execute=True): + reconcile_ses_tenant_states() + + assert TeamWorkflowsConfig.objects.get(team=self.team).ses_tenant_sending_status == "ENABLED" + + @override_settings(SES_SANDBOX_TENANT_NAME="") + def test_nothing_is_synced_while_no_sandbox_tenant_is_configured(self) -> None: + sync_sandbox_tenant_state_task() + reconcile_ses_tenant_states() + + self.ses.get_tenant.assert_not_called() + assert not SandboxSenderTenantState.objects.exists() diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index aa9429216bc9..bf48ee499933 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -20,6 +20,7 @@ SANDBOX_SETTINGS = { "WORKFLOWS_SANDBOX_SENDER_DOMAIN": "sandbox.example.com", "SES_SANDBOX_FROM_ADDRESS": "hello@sandbox.example.com", + "SES_SANDBOX_TENANT_NAME": "posthog-sandbox", } @@ -98,6 +99,7 @@ def test_ensure_builds_the_display_name_from_the_organization_name( ("flag off", {}, False), ("domain unset", {"WORKFLOWS_SANDBOX_SENDER_DOMAIN": ""}, True), ("from address unset", {"SES_SANDBOX_FROM_ADDRESS": ""}, True), + ("tenant name unset", {"SES_SANDBOX_TENANT_NAME": ""}, True), ] ) def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( diff --git a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py index 6b0abff0348c..41fa33bbf220 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py @@ -24,6 +24,7 @@ @override_settings( WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", + SES_SANDBOX_TENANT_NAME="posthog-sandbox", ) class TestSandboxSenderValidation(APIBaseTest): def setUp(self) -> None: From 637101b3a2851adfe0211b39fd4f218100309a6a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:44:16 +0000 Subject: [PATCH 11/38] feat(workflows): separate sandbox delivery history Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 2 + nodejs/src/cdp/cdp-api.ts | 3 +- nodejs/src/cdp/cdp-services.ts | 31 ++--- .../messaging/email-tracking.service.test.ts | 114 ++++++++++++++++++ .../messaging/email-tracking.service.ts | 24 +++- .../src/cdp/services/messaging/helpers/ses.ts | 20 ++- .../messaging/sandbox-email-sender.ts | 21 +++- .../backend/services/email_health.py | 5 +- .../backend/services/email_sending_tier.py | 12 +- .../backend/test/test_email_sending_tier.py | 97 ++++++++++++++- .../tests/api/test_email_reputation.py | 22 ++++ 11 files changed, 322 insertions(+), 29 deletions(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index c1c81d1f8f33..420cfec8fdd0 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -52,6 +52,7 @@ None of them are wired there yet; the charts change is part of turning the rollo In order: staff suspension or an AWS-paused tenant drops to tier 0; a dirty 7-day window, a workflow auto-pause, or a HIGH tenant reputation impact demotes one tier (at most once per cooldown, anchored on the last rate demotion); a tier above 0 with zero sends for the decay period drops one step per period; promotion needs the dwell served, real use of the tier (at least the use ratio of the daily cap on the minimum number of separate days, counted only after the tier anchor), a clean 30-day window, and a clean tenant reputation. Rates only count on a meaningful denominator; below the complaint floor, the absolute complaint backstop still applies, including in windows with no sends. +Sandbox sender sends never count as sends: the sweep subtracts `email_sandbox_sent` from `email_sent` per day, floored at zero. Their bounces and complaints still count. Pinned teams never move automatically. While the tier is enforced, the sweep notifies teams it moved: a rate demotion sends an in-app notification plus an email to project admins, and an earned promotion sends an in-app notification only. @@ -81,6 +82,7 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent. A committed state change reaches the workers through the `reload-sandbox-sender-state` message. Own-sender sends do not read this state. Test sends run inline in the CDP API, so the API reads the same `SES_RATE_LIMITER_VALKEY_*` settings as the email worker. Without them, every sandbox test send is skipped. CDP API opens the SES Valkey pool only when sandbox delivery is enabled; otherwise its MX validation uses the existing local cache and DNS. + The CDP API's SES webhook captures `workflows sandbox email delivered` for a delivery on `SES_SANDBOX_CONFIGURATION_SET`, test sends included. Without that setting on the CDP API, the event never fires. Deploy membership checks, daily caps, and the shared pause gate first. Configure the SES identity and Valkey pool, enable `WORKFLOWS_SANDBOX_SENDER_ENABLED`, then enable the UI flag. - The buckets are token buckets: a full idle bucket plus refill allows up to roughly twice the stated cap in the very first period. The bucket TTLs exceed the refill periods so this does not recur from idling. - A denied send parks until every short bucket has refilled enough to cover it, instead of retrying on a fixed few-minute cadence. diff --git a/nodejs/src/cdp/cdp-api.ts b/nodejs/src/cdp/cdp-api.ts index abc282c22f5b..528a0212bc03 100644 --- a/nodejs/src/cdp/cdp-api.ts +++ b/nodejs/src/cdp/cdp-api.ts @@ -240,7 +240,8 @@ export class CdpApi { services.capturedEventsService, services.teamWorkflowsConfigService, new EmailTrackingCodeSigner(config.ENCRYPTION_SALT_KEYS, config.CDP_EMAIL_TRACKING_URL), - services.emailSuppressionService + services.emailSuppressionService, + services.sandboxEmailSender ) this.groupsManager = new GroupsManagerService(deps.teamManager, deps.groupRepository) this.batchExportHogFunctionService = new BatchExportHogFunctionService( diff --git a/nodejs/src/cdp/cdp-services.ts b/nodejs/src/cdp/cdp-services.ts index 6770b009f0ba..5f867ebe435e 100644 --- a/nodejs/src/cdp/cdp-services.ts +++ b/nodejs/src/cdp/cdp-services.ts @@ -118,6 +118,7 @@ export interface CdpCoreServices { /** Resolved outputs shared across every CDP service/consumer. */ outputs: CdpOutputs emailService: EmailService + sandboxEmailSender: SandboxEmailSender /** Buffers rendered-email asset rows and bulk-flushes them at the batch boundary. */ messageAssetsService: MessageAssetsService } @@ -466,6 +467,20 @@ export function createCdpCoreServices( const sandboxEmailRateLimiter = deps.emailValidationValkey ? new RateLimiterService(deps.emailValidationValkey, { name: 'sandbox-email' }) : null + const sandboxEmailSender = new SandboxEmailSender( + { + enabled: config.WORKFLOWS_SANDBOX_SENDER_ENABLED, + tenantName: config.SES_SANDBOX_TENANT_NAME, + configurationSetName: config.SES_SANDBOX_CONFIGURATION_SET, + fromAddress: config.SES_SANDBOX_FROM_ADDRESS, + dailyTeamCap: config.WORKFLOWS_SANDBOX_DAILY_TEAM_CAP, + dailyRecipientCap: config.WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP, + }, + deps.teamManager, + sandboxEmailRateLimiter, + redis, + new SandboxSenderStateService(deps.postgres, deps.pubSub) + ) const emailService = new EmailService( { sesAccessKeyId: config.SES_ACCESS_KEY_ID, @@ -488,20 +503,7 @@ export function createCdpCoreServices( messageAssetsService, workflowEmailRateLimiter, teamEmailRateLimiter, - new SandboxEmailSender( - { - enabled: config.WORKFLOWS_SANDBOX_SENDER_ENABLED, - tenantName: config.SES_SANDBOX_TENANT_NAME, - configurationSetName: config.SES_SANDBOX_CONFIGURATION_SET, - fromAddress: config.SES_SANDBOX_FROM_ADDRESS, - dailyTeamCap: config.WORKFLOWS_SANDBOX_DAILY_TEAM_CAP, - dailyRecipientCap: config.WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP, - }, - deps.teamManager, - sandboxEmailRateLimiter, - redis, - new SandboxSenderStateService(deps.postgres, deps.pubSub) - ), + sandboxEmailSender, new OrganizationMembersService(deps.postgres) ) const recipientTokensService = new RecipientTokensService(config.ENCRYPTION_SALT_KEYS, config.SITE_URL) @@ -638,6 +640,7 @@ export function createCdpCoreServices( recipientTokensService, outputs, emailService, + sandboxEmailSender, messageAssetsService, } } diff --git a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts index c76983c6f596..1b373db67e52 100644 --- a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts @@ -17,6 +17,7 @@ import { KAFKA_APP_METRICS_2, KAFKA_LOG_ENTRIES } from '~/common/config/kafka-to import { closeHub, createHub } from '~/common/utils/db/hub' import { PostgresUse } from '~/common/utils/db/postgres' import * as envUtils from '~/common/utils/env-utils' +import * as posthog from '~/common/utils/posthog' import { createCdpConsumerDeps } from '~/tests/helpers/cdp' import { waitForExpect } from '~/tests/helpers/expectations' import { createTestTeamFixture } from '~/tests/helpers/sql' @@ -819,6 +820,119 @@ describe('EmailTrackingService', () => { }) }) + describe('SES webhook records sandbox deliveries', () => { + const sandboxConfigurationSet = 'posthog-messaging-sandbox' + let app: express.Application + let server: Server + let verifySignatureSpy: jest.SpyInstance + let capture: jest.SpyInstance + + beforeEach(() => { + hub.SES_SANDBOX_CONFIGURATION_SET = sandboxConfigurationSet + const api = new CdpApi(hub, createCdpConsumerDeps(hub), { + hogQueue: createMockJobQueue(), + hogflowQueue: createMockJobQueue(), + }) + app = setupExpressApp() + app.use('/', api.router()) + server = app.listen(0, () => {}) + + verifySignatureSpy = jest + .spyOn(SesWebhookHandler.prototype as any, 'verifySnsSignature') + .mockResolvedValue(true) + capture = jest.spyOn(posthog, 'captureTeamEvent').mockImplementation(() => {}) + }) + + afterEach(() => { + server.close() + verifySignatureSpy.mockRestore() + capture.mockRestore() + }) + + const postDelivery = async ( + configurationSet: string, + isTest: boolean, + signedCode = true, + recipients: string[] | null = ['member@example.com'] + ): Promise<supertest.Response> => { + const signedTrackingCode = signer.generate( + { functionId: 'function-id', id: 'invocation-id', teamId: team.id }, + isTest + ) + const [unsignedTrackingCode] = signedTrackingCode.split('.') + const sesRecord = { + eventType: 'Delivery', + mail: { + timestamp: '2024-01-01T00:00:00.000Z', + source: 'sandbox@example.com', + messageId: 'ses-message-id', + destination: ['member@example.com'], + headers: signedCode ? [{ name: TRACKING_CODE_HEADER_NAME, value: signedTrackingCode }] : [], + tags: { + 'ses:configuration-set': [configurationSet], + ...(signedCode ? {} : { ph_id: [unsignedTrackingCode] }), + }, + }, + delivery: { + timestamp: '2024-01-01T00:00:01.000Z', + ...(recipients ? { recipients } : {}), + }, + } + const envelope = { + Type: 'Notification', + MessageId: 'sns-message-id', + TopicArn: 'arn:aws:sns:us-east-1:123456789012:ses-events', + Message: JSON.stringify(sesRecord), + Timestamp: '2024-01-01T00:00:01.000Z', + SignatureVersion: '1', + Signature: 'stubbed', + SigningCertURL: 'https://sns.us-east-1.amazonaws.com/cert.pem', + } + return await supertest(app) + .post('/public/m/ses_webhook') + .set('Content-Type', 'text/plain') + .send(JSON.stringify(envelope)) + } + + it.each([false, true])( + 'captures one delivered event for a delivery on the sandbox set (test send: %s)', + async (isTest) => { + const res = await postDelivery(sandboxConfigurationSet, isTest) + + expect(res.status).toBe(200) + expect(capture).toHaveBeenCalledTimes(1) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id, organization_id: team.organization_id }), + 'workflows sandbox email delivered', + { is_test: isTest, recipient_count: 1 } + ) + } + ) + + it.each([ + ['a delivery on another configuration set', 'posthog-messaging-tracked', true], + ['a sandbox delivery whose tracking code is not signed', sandboxConfigurationSet, false], + ])('captures no delivered event for %s', async (_name, configurationSet, signedCode) => { + const res = await postDelivery(configurationSet, false, signedCode) + + expect(res.status).toBe(200) + expect(capture).not.toHaveBeenCalled() + }) + + it.each([ + { recipients: ['member@example.com', 'another@example.com'], count: 2 }, + { recipients: null, count: 0 }, + ])('counts recipient copies in a delivery notification ($count)', async ({ recipients, count }) => { + const res = await postDelivery(sandboxConfigurationSet, false, true, recipients) + expect(res.status).toBe(200) + expect(capture).toHaveBeenCalledWith( + expect.objectContaining({ id: team.id }), + 'workflows sandbox email delivered', + { is_test: false, recipient_count: count } + ) + }) + }) + describe('resolveEmailEngagementDistinctId', () => { const buildInvocation = ( globals: Partial<NonNullable<CyclotronJobInvocationHogFunction['state']['globals']>> diff --git a/nodejs/src/cdp/services/messaging/email-tracking.service.ts b/nodejs/src/cdp/services/messaging/email-tracking.service.ts index 645866c70885..6a8c60e881d6 100644 --- a/nodejs/src/cdp/services/messaging/email-tracking.service.ts +++ b/nodejs/src/cdp/services/messaging/email-tracking.service.ts @@ -24,6 +24,7 @@ import { EmailSuppressionService } from './email-suppression.service' import { escapeHtml } from './helpers/escape-html' import { SES_LINK_INDEX_TAG, SesWebhookHandler } from './helpers/ses' import { EmailTrackingCodeSigner, trackingCodeFormatCounter } from './helpers/tracking-code' +import { SandboxEmailSender } from './sandbox-email-sender' export const PIXEL_GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64') const LINK_REGEX = @@ -280,10 +281,15 @@ export class EmailTrackingService { private capturedEventsService: CapturedEventsService, private teamWorkflowsConfigService: TeamWorkflowsConfigService, private trackingCodeSigner: EmailTrackingCodeSigner, - private emailSuppressionService: EmailSuppressionService + private emailSuppressionService: EmailSuppressionService, + private sandboxEmailSender: SandboxEmailSender ) { const allowedTopicArns = (process.env.SES_ALLOWED_SNS_TOPIC_ARNS ?? '').split(',') - this.sesWebhookHandler = new SesWebhookHandler(this.trackingCodeSigner, allowedTopicArns) + this.sesWebhookHandler = new SesWebhookHandler( + this.trackingCodeSigner, + allowedTopicArns, + this.sandboxEmailSender.config.configurationSetName + ) } public async trackMetric({ @@ -493,6 +499,7 @@ export class EmailTrackingService { hardBounceRecipients, complainedRecipients, deliveredRecipients, + sandboxDeliveries, } = await this.sesWebhookHandler.handleWebhook({ body: parseJSON(req.body), headers: req.headers, @@ -577,6 +584,8 @@ export class EmailTrackingService { emailTrackingErrorsCounter.inc({ error_type: 'suppression_update_failed', source: 'ses' }) } + await this.captureSandboxDeliveries(sandboxDeliveries || []) + return { status, message: body as string } } catch (error) { emailTrackingErrorsCounter.inc({ error_type: error.name || 'unknown' }) @@ -585,6 +594,17 @@ export class EmailTrackingService { } } + private async captureSandboxDeliveries( + deliveries: { teamId: string; isTest: boolean; recipientCount: number }[] + ): Promise<void> { + for (const { teamId, isTest, recipientCount } of deliveries) { + const parsedTeamId = Number(teamId) + if (Number.isSafeInteger(parsedTeamId) && parsedTeamId > 0) { + await this.sandboxEmailSender.capture(parsedTeamId, isTest, { type: 'delivered', recipientCount }) + } + } + } + private parseTrackingParams(query: Record<string, any>): { functionId?: string invocationId?: string diff --git a/nodejs/src/cdp/services/messaging/helpers/ses.ts b/nodejs/src/cdp/services/messaging/helpers/ses.ts index 04a6ced57eac..e98fedec8b24 100644 --- a/nodejs/src/cdp/services/messaging/helpers/ses.ts +++ b/nodejs/src/cdp/services/messaging/helpers/ses.ts @@ -396,7 +396,8 @@ export class SesWebhookHandler { private trackingCodeSigner: EmailTrackingCodeSigner, // Empty set means no restriction (dev/test); prod is expected to configure the workflow SES // topic ARN. - allowedTopicArns: string[] = [] + allowedTopicArns: string[] = [], + private sandboxConfigurationSetName = '' ) { this.allowedTopicArns = new Set(allowedTopicArns.map((arn) => arn.trim()).filter(Boolean)) } @@ -612,6 +613,7 @@ export class SesWebhookHandler { emailAddresses: string[] timestamp?: string }[] + sandboxDeliveries?: { teamId: string; isTest: boolean; recipientCount: number }[] }> { // Payloads carry recipients and clicked links, including reset links from sensitive emails, // so the logs keep identifiers only. @@ -714,6 +716,7 @@ export class SesWebhookHandler { emailAddresses: string[] timestamp?: string }[] = [] + const sandboxDeliveries: { teamId: string; isTest: boolean; recipientCount: number }[] = [] for (const rec of records) { logger.info('[SesWebhookHandler] processing record', { @@ -877,6 +880,20 @@ export class SesWebhookHandler { // carrier is only used for engagement metrics/log entries above. const codeIsTrusted = parsedCode?.format === 'signed' + if ( + rec.eventType === 'Delivery' && + codeIsTrusted && + teamId && + this.sandboxConfigurationSetName && + rec.mail.tags?.['ses:configuration-set']?.[0] === this.sandboxConfigurationSetName + ) { + sandboxDeliveries.push({ + teamId, + isTest: isTest ?? false, + recipientCount: rec.delivery.recipients?.length ?? 0, + }) + } + // Suppression writes (below) skip test sends — editor "Run test" traffic must not be // able to perturb production suppression state by targeting a bad recipient. const suppressionAllowed = teamId && codeIsTrusted && !isTest @@ -937,6 +954,7 @@ export class SesWebhookHandler { hardBounceRecipients, complainedRecipients, deliveredRecipients, + sandboxDeliveries, } } } diff --git a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts index d165c1a972ab..234bcae3a756 100644 --- a/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts +++ b/nodejs/src/cdp/services/messaging/sandbox-email-sender.ts @@ -51,6 +51,7 @@ export type SandboxDailyCapClaim = type SandboxEmailOutcome = | { type: 'sent'; recipientCount: number } + | { type: 'delivered'; recipientCount: number } | { type: 'blocked' reason: @@ -63,6 +64,17 @@ type SandboxEmailOutcome = blockedRecipientCount: number } +function outcomeProperties(outcome: SandboxEmailOutcome, isTest: boolean): Record<string, unknown> { + switch (outcome.type) { + case 'sent': + return { recipient_count: outcome.recipientCount, source: isTest ? 'test' : 'workflow' } + case 'blocked': + return { reason: outcome.reason, blocked_recipient_count: outcome.blockedRecipientCount } + case 'delivered': + return { recipient_count: outcome.recipientCount } + } +} + function dailyBucket(key: string, requested: number, capacity: number): ClaimRequest { return { key, requested, capacity, refillPerSecond: capacity / SECONDS_PER_DAY, ttlSeconds: DAILY_CAP_TTL_SECONDS } } @@ -292,11 +304,10 @@ export class SandboxEmailSender { } const team = await this.teamManager.getTeam(teamId) if (team) { - const properties = - outcome.type === 'sent' - ? { recipient_count: outcome.recipientCount, source: isTest ? 'test' : 'workflow' } - : { reason: outcome.reason, blocked_recipient_count: outcome.blockedRecipientCount } - captureTeamEvent(team, `workflows sandbox email ${outcome.type}`, { ...properties, is_test: isTest }) + captureTeamEvent(team, `workflows sandbox email ${outcome.type}`, { + ...outcomeProperties(outcome, isTest), + is_test: isTest, + }) } } catch (error) { logger.warn('Could not capture sandbox email event', { teamId, error }) diff --git a/products/workflows/backend/services/email_health.py b/products/workflows/backend/services/email_health.py index 35d86d68a037..4988f1516735 100644 --- a/products/workflows/backend/services/email_health.py +++ b/products/workflows/backend/services/email_health.py @@ -22,6 +22,7 @@ from products.workflows.backend.models.hog_flow.hog_flow import HogFlow from products.workflows.backend.providers.ses import SESProvider from products.workflows.backend.services.email_sending_attribution import fold_email_totals_by_flow +from products.workflows.backend.services.email_sending_tier import SANDBOX_SENT_METRIC, SENT_METRIC, own_sends from products.workflows.backend.utils.email_sending_tiers import max_email_sending_tier, resolve_team_email_sending_tier logger = structlog.get_logger(__name__) @@ -89,9 +90,9 @@ def team_email_sending_allowance(team_id: int) -> EmailSendingAllowance: def _team_email_sends_since(team_id: int, after: datetime) -> int: totals = fetch_app_metric_totals_by_team_and_source( - app_source="hog_flow", name=["email_sent"], after=after, team_ids=[team_id] + app_source="hog_flow", name=[SENT_METRIC, SANDBOX_SENT_METRIC], after=after, team_ids=[team_id] ) - return sum(counts.get("email_sent", 0) for counts in totals.get(team_id, {}).values()) + return sum(own_sends(counts) for counts in totals.get(team_id, {}).values()) AWS_TENANT_REPUTATION_CACHE_SECONDS = 5 * 60 diff --git a/products/workflows/backend/services/email_sending_tier.py b/products/workflows/backend/services/email_sending_tier.py index 4f3b83feae6b..ee8a3126eac4 100644 --- a/products/workflows/backend/services/email_sending_tier.py +++ b/products/workflows/backend/services/email_sending_tier.py @@ -25,6 +25,7 @@ APP_SOURCE = "hog_flow" SENT_METRIC = "email_sent" +SANDBOX_SENT_METRIC = "email_sandbox_sent" HARD_BOUNCE_METRIC = "email_bounced_hard" # SES complaint events are recorded under this name (see the SES webhook handler in the email # worker), so a complaint rate reads `email_blocked`, not a metric called "complaint". @@ -330,7 +331,7 @@ def _fetch_daily_metrics( workload: Workload = Workload.DEFAULT, ) -> dict[int, dict[str, dict[str, int]]]: auto_pause_metrics = [name for name in settings.WORKFLOWS_EMAIL_TIER_AUTO_PAUSE_METRIC_NAMES if name] - metric_names = [SENT_METRIC, HARD_BOUNCE_METRIC, COMPLAINT_METRIC, *auto_pause_metrics] + metric_names = [SENT_METRIC, SANDBOX_SENT_METRIC, HARD_BOUNCE_METRIC, COMPLAINT_METRIC, *auto_pause_metrics] # Tag the query so it is attributable in query-cost analysis and does not trip the untagged-query # guard in local dev. Celery adds only task identity, not a product or feature. This derives tier # state in the background, so it is enrichment, not a customer-facing query. One context here @@ -346,17 +347,22 @@ def _fetch_daily_metrics( ) +def own_sends(counts: dict[str, int]) -> int: + return max(0, counts.get(SENT_METRIC, 0) - counts.get(SANDBOX_SENT_METRIC, 0)) + + def _history_from_daily(team_id: int, days: dict[str, dict[str, int]]) -> TeamSendingHistory: # The tier decision only reads team totals, so metrics recorded under a batch job id and under # its parent workflow id sum the same either way and need no per-source resolution. auto_pause_metrics = [name for name in settings.WORKFLOWS_EMAIL_TIER_AUTO_PAUSE_METRIC_NAMES if name] + daily_sends = {day: sent for day, counts in days.items() if (sent := own_sends(counts)) > 0} return TeamSendingHistory( team_id=team_id, - sent=sum(counts.get(SENT_METRIC, 0) for counts in days.values()), + sent=sum(daily_sends.values()), hard_bounced=sum(counts.get(HARD_BOUNCE_METRIC, 0) for counts in days.values()), complained=sum(counts.get(COMPLAINT_METRIC, 0) for counts in days.values()), auto_paused=any(counts.get(metric, 0) > 0 for counts in days.values() for metric in auto_pause_metrics), - daily_sends={day: counts.get(SENT_METRIC, 0) for day, counts in days.items() if counts.get(SENT_METRIC, 0)}, + daily_sends=daily_sends, ) diff --git a/products/workflows/backend/test/test_email_sending_tier.py b/products/workflows/backend/test/test_email_sending_tier.py index 6509c8d0b0d4..dc564e1824d1 100644 --- a/products/workflows/backend/test/test_email_sending_tier.py +++ b/products/workflows/backend/test/test_email_sending_tier.py @@ -1,6 +1,7 @@ from datetime import timedelta -from posthog.test.base import BaseTest +import time_machine +from posthog.test.base import BaseTest, ClickhouseTestMixin from unittest.mock import patch from django.test import SimpleTestCase, override_settings @@ -8,6 +9,8 @@ from parameterized import parameterized +from posthog.test.fixtures import create_app_metric2 + from products.workflows.backend.management.commands.backfill_workflows_email_sending_tiers import ( Command as BackfillCommand, ) @@ -18,8 +21,10 @@ TeamSendingHistory, TierDecision, apply_tier_decision, + build_sending_histories, decide_tier, highest_qualifying_tier, + recompute_email_sending_tier_for_team, recompute_email_sending_tiers, ) from products.workflows.backend.utils.email_sending_tiers import get_email_sending_tier_limits @@ -446,6 +451,96 @@ def test_pinned_team_never_moves(self, _name: str, tier: int) -> None: assert TeamWorkflowsConfig.objects.get(team=self.team).email_sending_tier == tier +@time_machine.travel("2026-10-05T12:00:00Z", tick=False) +@override_settings(**TIER_SETTINGS) +class TestSandboxSendsInTierHistory(ClickhouseTestMixin, BaseTest): + def _record_metrics(self, *, days_ago: int, **counts: int) -> None: + for metric_name, count in counts.items(): + if count: + create_app_metric2( + team_id=self.team.pk, + app_source="hog_flow", + metric_kind="email", + metric_name=metric_name, + count=count, + timestamp=timezone.now() - timedelta(days=days_ago), + ) + + def _record_sends(self, *, days_ago: int, sent: int, sandbox_sent: int) -> None: + self._record_metrics(days_ago=days_ago, email_sent=sent, email_sandbox_sent=sandbox_sent) + + @parameterized.expand( + [ + ("no sends decay", 1, 0, 0, 0, "inactive"), + ("only sandbox sends decay like no sends", 1, 600, 600, 0, "inactive"), + ("sandbox sends do not lift own sends over the use bar", 0, 600, 300, 0, "tier_not_used_enough"), + ("own sends over the use bar still promote", 0, 900, 300, 1, "clean_and_used"), + ] + ) + def test_only_own_sends_count_toward_tier_history( + self, _name: str, current_tier: int, sent: int, sandbox_sent: int, expected_tier: int, expected_reason: str + ) -> None: + TeamWorkflowsConfig.objects.update_or_create( + team=self.team, + defaults={ + "email_sending_tier": current_tier, + "email_sending_tier_updated_at": timezone.now() - timedelta(days=40), + }, + ) + for days_ago in (1, 2): + self._record_sends(days_ago=days_ago, sent=sent, sandbox_sent=sandbox_sent) + + decision = recompute_email_sending_tier_for_team(self.team.id) + + assert decision is not None + assert (decision.new_tier, decision.reason) == (expected_tier, expected_reason) + assert TeamWorkflowsConfig.objects.get(team=self.team).email_sending_tier == expected_tier + + @parameterized.expand( + [ + ( + "sandbox excess on one day does not erase own sends of another day", + { + 1: {"email_sent": 100, "email_sandbox_sent": 300}, + 2: {"email_sent": 200, "email_sandbox_sent": 50}, + }, + {2: 150}, + 0, + 0, + ), + ( + "bounces and complaints of sandbox sends still count", + {1: {"email_sent": 50, "email_sandbox_sent": 50, "email_bounced_hard": 2, "email_blocked": 1}}, + {}, + 2, + 1, + ), + ] + ) + def test_sandbox_subtraction_is_per_day_and_keeps_feedback( + self, + _name: str, + metrics_by_days_ago: dict[int, dict[str, int]], + expected_daily_sends_by_days_ago: dict[int, int], + expected_hard_bounced: int, + expected_complained: int, + ) -> None: + for days_ago, counts in metrics_by_days_ago.items(): + self._record_metrics(days_ago=days_ago, **counts) + + history = build_sending_histories(after=timezone.now() - timedelta(days=30), team_ids=[self.team.pk])[ + self.team.pk + ] + + expected_daily_sends = { + (timezone.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d"): count + for days_ago, count in expected_daily_sends_by_days_ago.items() + } + assert history.daily_sends == expected_daily_sends + assert history.sent == sum(expected_daily_sends.values()) + assert (history.hard_bounced, history.complained) == (expected_hard_bounced, expected_complained) + + @override_settings(**TIER_SETTINGS) class TestBackfillEmailSendingTiers(BaseTest): def test_suspended_team_is_not_promoted_despite_qualifying_history(self) -> None: diff --git a/products/workflows/backend/tests/api/test_email_reputation.py b/products/workflows/backend/tests/api/test_email_reputation.py index 6d88e11e306d..d9e373088c6a 100644 --- a/products/workflows/backend/tests/api/test_email_reputation.py +++ b/products/workflows/backend/tests/api/test_email_reputation.py @@ -91,6 +91,28 @@ def test_reputation_endpoint_returns_empty_shape_when_nothing_was_sent(self): "email_sending_suspension_reason": "", } + def test_sending_allowance_leaves_out_sandbox_sends(self): + sends_by_source = { + self.team.id: {"flow-a": {"email_sent": 5, "email_sandbox_sent": 3}, "flow-b": {"email_sent": 2}} + } + + def requested_metrics(*, name: list[str], **_kwargs: object) -> dict[int, dict[str, dict[str, int]]]: + return { + team_id: { + source: {metric: count for metric, count in counts.items() if metric in name} + for source, counts in sources.items() + } + for team_id, sources in sends_by_source.items() + } + + with patch( + "products.workflows.backend.services.email_health.fetch_app_metric_totals_by_team_and_source", + side_effect=requested_metrics, + ): + allowance = self._get_reputation({})["sending_allowance"] + assert allowance["emails_sent_last_hour"] == 4 + assert allowance["emails_sent_last_day"] == 4 + @parameterized.expand( [ ("no_findings", "ENABLED", None, "healthy"), From 0c8ff866903c183eb0ca5a68f17b9730de4d0b28 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:44:17 +0000 Subject: [PATCH 12/38] feat(workflows): resolve the first-run email sender Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../integrations/integrationsLogic.test.ts | 15 +- .../src/lib/integrations/integrationsLogic.ts | 32 +- .../frontend/Channels/defaultEmailSender.ts | 2 +- .../frontend/Workflows/WorkflowActions.tsx | 28 +- .../tile-canvas/tileCanvasLogic.test.ts | 22 +- .../editor/tile-canvas/tileCanvasLogic.ts | 21 +- .../hogflows/hogFlowEditorLogic.test.ts | 4 +- .../Workflows/hogflows/hogFlowEditorLogic.tsx | 123 ++++- .../panel/HogFlowEditorPanelBuild.tsx | 9 +- .../hogflows/steps/components/StepView.tsx | 7 + .../hogflows/tree/HogFlowTreeDropzone.tsx | 12 +- .../hogflows/tree/HogFlowTreeStep.tsx | 5 +- .../Workflows/workflowEmailPrefill.test.ts | 12 +- .../Workflows/workflowEmailPrefill.ts | 23 - .../workflowLogic.firstRunSender.test.ts | 504 ++++++++++++++++++ .../frontend/Workflows/workflowLogic.ts | 56 +- .../frontend/firstRun/firstRunSenderLogic.ts | 145 +++++ .../frontend/firstRun/withFirstRunSender.ts | 45 ++ 18 files changed, 959 insertions(+), 106 deletions(-) create mode 100644 products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts create mode 100644 products/workflows/frontend/firstRun/firstRunSenderLogic.ts create mode 100644 products/workflows/frontend/firstRun/withFirstRunSender.ts diff --git a/frontend/src/lib/integrations/integrationsLogic.test.ts b/frontend/src/lib/integrations/integrationsLogic.test.ts index 11507a2825b9..7519ec77041c 100644 --- a/frontend/src/lib/integrations/integrationsLogic.test.ts +++ b/frontend/src/lib/integrations/integrationsLogic.test.ts @@ -254,12 +254,25 @@ describe('integrationsLogic', () => { ensureStatus = 200 await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()) - .toDispatchActions(['provisionSandboxEmailSenderSuccess', 'loadIntegrationsSuccess']) + .toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSenderSuccess']) .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) expect(ensureCalls).toBe(2) }) + it('reports provisioning failure when the refreshed sender list cannot load', async () => { + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [500, { detail: 'Sender list unavailable' }], + }, + }) + enableFlag() + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toFinishAllListeners() + + expect(logic.values.sandboxEmailSenderProvisionResult).toBe('failed') + expect(ensureCalls).toBe(1) + }) + it('provisions once the flag arrives after a surface asked for the sandbox sender', async () => { await expectLogic(logic, () => { logic.actions.ensureSandboxEmailSender() diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 0480cb243340..a0a247a47e6a 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -1105,7 +1105,7 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, ], }), - loaders(({ actions, values, cache }) => ({ + loaders(({ actions, asyncActions, values, cache }) => ({ integrations: [ null as IntegrationType[] | null, { @@ -1233,14 +1233,36 @@ export const integrationsLogic = kea<integrationsLogicType>([ { provisionSandboxEmailSender: async (): Promise<SandboxEmailSenderProvisionResult | null> => { const projectId = values.currentProjectId - const result = await requestSandboxEmailSender(String(projectId)) + if (!cache.sandboxProvisionRequest || cache.sandboxProvisionRequest.projectId !== projectId) { + const promise = requestSandboxEmailSender(String(projectId)) + .then(async (result): Promise<Awaited<ReturnType<typeof requestSandboxEmailSender>>> => { + if (values.currentProjectId === projectId && result.result === 'provisioned') { + await asyncActions.loadIntegrations() + if ( + values.currentProjectId === projectId && + !values.integrations?.some(isSandboxEmailSender) + ) { + return { + result: 'failed', + error: 'Could not load the sandbox sender after setup. Try again.', + } + } + } + return result + }) + .finally(() => { + if (cache.sandboxProvisionRequest?.promise === promise) { + cache.sandboxProvisionRequest = undefined + } + }) + cache.sandboxProvisionRequest = { projectId, promise } + } + const result: Awaited<ReturnType<typeof requestSandboxEmailSender>> = + await cache.sandboxProvisionRequest.promise if (values.currentProjectId !== projectId) { return values.sandboxEmailSenderProvisionResult } actions.setSandboxEmailSenderProvisionError(result.error) - if (result.result === 'provisioned') { - actions.loadIntegrations() - } return result.result }, }, diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 063d45406654..65962bb6c2e3 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -2,7 +2,7 @@ import { isSandboxEmailSender } from 'lib/integrations/utils' import { IntegrationType } from '~/types' -export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' +export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' | 'sandbox_sender' export interface DefaultEmailSender { integrationId: number diff --git a/products/workflows/frontend/Workflows/WorkflowActions.tsx b/products/workflows/frontend/Workflows/WorkflowActions.tsx index 4816f4e88e66..0670024bab26 100644 --- a/products/workflows/frontend/Workflows/WorkflowActions.tsx +++ b/products/workflows/frontend/Workflows/WorkflowActions.tsx @@ -32,6 +32,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { discardDisabledReason, showDraftActions, statusToggleDisabledReason, + saveBlockedReason, } = useValues(workflowLogic) const { saveWorkflowPartial, submitWorkflow, discardChanges, publishDraft, discardDraft } = useActions(workflowLogic) @@ -44,6 +45,13 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { const isCreatedFromTemplate = props.id === 'new' && !!templateId const isCreatedFromLink = props.id === 'new' && !!parseWorkflowTriggerPrefill(searchParams[TRIGGER_PREFILL_PARAM]) const isManualWorkflow = ['manual', 'batch'].includes(workflow?.trigger?.type || '') + const saveDisabledReason = saveBlockedReason + ? saveBlockedReason + : workflowHasErrors + ? 'Some fields still need work' + : isCreatedFromTemplate || isCreatedFromLink || hasUnsavedChanges + ? undefined + : 'No changes to save' const todayRailNav = useFeatureFlag('TODAY_RAIL_NAV') const [displayStatus, setDisplayStatus] = useState(workflow?.status) const [isTransitioning, setIsTransitioning] = useState(false) @@ -135,15 +143,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { editTemplateId={editTemplateId} isWorkflowSubmitting={isWorkflowSubmitting} draftActionPending={draftActionPending} - saveDisabledReason={ - workflowHasErrors - ? 'Some fields still need work' - : isCreatedFromTemplate || isCreatedFromLink - ? undefined - : hasUnsavedChanges - ? undefined - : 'No changes to save' - } + saveDisabledReason={saveDisabledReason} publishDisabledReason={publishDisabledReason} discardDisabledReason={discardDisabledReason} saveLabel={ @@ -199,15 +199,7 @@ export function WorkflowActions(props: WorkflowSceneLogicProps): JSX.Element { form="workflow" onClick={submitWorkflow} loading={isWorkflowSubmitting} - disabledReason={ - workflowHasErrors - ? 'Some fields still need work' - : isCreatedFromTemplate || isCreatedFromLink - ? undefined - : hasUnsavedChanges - ? undefined - : 'No changes to save' - } + disabledReason={saveDisabledReason} > {props.id === 'new' ? 'Create as draft' diff --git a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts index ee9520329003..29f22ba2085e 100644 --- a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.test.ts @@ -1,6 +1,8 @@ import { expectLogic } from 'kea-test-utils' import posthog from 'posthog-js' +import { lemonToast } from '@posthog/lemon-ui' + import { loadAppMetricsTotals } from 'lib/components/AppMetrics/appMetricsLogic' import { useMocks } from '~/mocks/jest' @@ -216,7 +218,7 @@ describe('tileCanvasLogic', () => { logic.mount() }) - it.each([ + const sources = [ { source: 'palette', add: (): void => { @@ -237,7 +239,9 @@ describe('tileCanvasLogic', () => { source: 'quick add', add: (): void => logic.actions.addStepAt(delayStep, logic.values.workflow.edges[0]), }, - ])('counts a step added from the $source', ({ source, add }) => { + ] + + it.each(sources)('counts a step added from the $source', ({ source, add }) => { const capture = jest.spyOn(posthog, 'capture') add() @@ -245,6 +249,20 @@ describe('tileCanvasLogic', () => { expect(capture).toHaveBeenCalledWith('workflows tile canvas step added', { source, step_type: 'delay' }) }) + it.each(sources)('asks to wait instead of adding from the $source while an email is added', ({ add }) => { + hogFlowEditorLogic().actions.setEmailInsertionPending(true) + const capture = jest.spyOn(posthog, 'capture') + const toast = jest.spyOn(lemonToast, 'info').mockImplementation() + + add() + + expect(capture).not.toHaveBeenCalledWith('workflows tile canvas step added', expect.anything()) + expect(toast).toHaveBeenCalledWith('An email step is still being added. Try again in a moment.') + expect(logic.values.nodeToBeAdded).toBeNull() + capture.mockRestore() + toast.mockRestore() + }) + it.each(['startMovingNode', 'startCopyingNode'] as const)( 'drops a pending %s when someone starts adding a step', async (startPending) => { diff --git a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts index 63f36aba7e39..ccf397ebfb57 100644 --- a/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts +++ b/products/workflows/frontend/Workflows/hogflows/editor/tile-canvas/tileCanvasLogic.ts @@ -17,6 +17,8 @@ import { subscriptions } from 'kea-subscriptions' import posthog from 'posthog-js' import type { DragEvent } from 'react' +import { lemonToast } from '@posthog/lemon-ui' + import { loadAppMetricsTotals } from 'lib/components/AppMetrics/appMetricsLogic' import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { objectsEqual } from 'lib/utils/objects' @@ -25,7 +27,7 @@ import { teamLogic } from 'scenes/teamLogic' import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic' import { HogFunctionTemplateType, TeamPublicType, TeamType } from '~/types' -import { WorkflowLogicProps, workflowLogic } from '../../../workflowLogic' +import { EMAIL_INSERTION_PENDING_REASON, WorkflowLogicProps, workflowLogic } from '../../../workflowLogic' import { getEdgeId } from '../../edgeId' import { actionMetricsFromTotals, @@ -155,6 +157,7 @@ function isTypedOnCanvas(event: KeyboardEvent): boolean { export interface tileCanvasLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic dropzoneNodes: DropzoneNode[] // hogFlowEditorLogic + emailInsertionPending: boolean // hogFlowEditorLogic isCopyingNode: boolean // hogFlowEditorLogic isMovingNode: boolean // hogFlowEditorLogic mode: HogFlowEditorMode // hogFlowEditorLogic @@ -446,6 +449,7 @@ export const tileCanvasLogic = kea<tileCanvasLogicType>([ 'selectedNodeCanBeDeleted', 'selectedNodeCanBeCopiedOrMoved', 'mode', + 'emailInsertionPending', ], featureFlagLogic, ['featureFlags'], @@ -722,6 +726,12 @@ export const tileCanvasLogic = kea<tileCanvasLogicType>([ actions.copyNodeToHighlightedDropzone() } } + const emailInsertionBlocks = (): boolean => { + if (values.emailInsertionPending) { + lemonToast.info(EMAIL_INSERTION_PENDING_REASON) + } + return values.emailInsertionPending + } const placeNewStep = (edge: HogFlowEdge, step: CreateActionType | HogFlowActionNode): void => { actions.onDrop(undefined, edge) captureStepAdded(values.placingSource, 'data' in step ? step.data.type : step.type) @@ -781,16 +791,25 @@ export const tileCanvasLogic = kea<tileCanvasLogicType>([ }, revealStep: ({ actionId }) => revealContainingPaths(actionId), startPlacing: ({ step }) => { + if (emailInsertionBlocks()) { + return + } dropPendingMoveOrCopy() actions.setNodeToBeAdded(step) }, addStepAt: ({ step, edge }) => { + if (emailInsertionBlocks()) { + return + } dropPendingMoveOrCopy() actions.setNodeToBeAdded(step) actions.onDrop(undefined, edge) captureStepAdded('quick add', step.type) }, placeAt: ({ edge }) => { + if (emailInsertionBlocks()) { + return + } if (values.isMovingNode || values.isCopyingNode) { placeExistingStep(edge) } else if (values.nodeToBeAdded) { diff --git a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts index 1e5d9ce8a1df..e42e9ac861c7 100644 --- a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.test.ts @@ -289,7 +289,7 @@ describe('hogFlowEditorLogic', () => { }, } as HogFlowAction, }, - ])('duplicates $case below itself', ({ step }) => { + ])('duplicates $case below itself', async ({ step }) => { teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, workflows_config: { @@ -303,7 +303,7 @@ describe('hogFlowEditorLogic', () => { edges: [edge(TRIGGER_NODE_ID, step.id, 'continue'), edge(step.id, EXIT_NODE_ID, 'continue')], }) - logic.actions.duplicateNodeBelow(step.id) + await expectLogic(logic, () => logic.actions.duplicateNodeBelow(step.id)).toFinishAllListeners() const duplicatedAction = logic.values.workflow.actions.find( (action) => action.id !== step.id && action.type === step.type diff --git a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx index c66aa6e1c3bc..d845401ef6de 100644 --- a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx +++ b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx @@ -41,7 +41,7 @@ import type { TeamType, UserBasicType, } from '../../../../../frontend/src/types' -import { resolveDefaultEmailSender } from '../../Channels/defaultEmailSender' +import { firstRunSenderLogic } from '../../firstRun/firstRunSenderLogic' import { optOutCategoriesLogic } from '../../OptOuts/optOutCategoriesLogic' import type { MessageCategory } from '../../OptOuts/optOutCategoriesLogic' import { EXIT_NODE_ID, TRIGGER_NODE_ID, WorkflowLogicProps, workflowLogic } from '../workflowLogic' @@ -267,6 +267,7 @@ export interface hogFlowEditorLogicValues { categoriesLoading: boolean // optOutCategoriesLogic currentTeam: TeamPublicType | TeamType | null // teamLogic edgesByActionId: Record<string, HogFlowEdge[]> // workflowLogic + emailInsertionPending: boolean // workflowLogic hogFunctionTemplatesById: Record<string, HogFunctionTemplateType> // workflowLogic workflow: HogFlow // workflowLogic actionMetricsById: Record<string, HogFlowEditorActionMetrics> | null @@ -2074,6 +2075,9 @@ export interface hogFlowEditorLogicActions { } payload?: any } // workflowLogic + setEmailInsertionPending: (pending: boolean) => { + pending: boolean + } // workflowLogic setWorkflowAction: ( actionId: string, action: HogFlowAction @@ -2299,7 +2303,7 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ connect(() => ({ values: [ workflowLogic, - ['workflow', 'edgesByActionId', 'hogFunctionTemplatesById'], + ['workflow', 'edgesByActionId', 'hogFunctionTemplatesById', 'emailInsertionPending'], optOutCategoriesLogic(), ['categories', 'categoriesLoading'], integrationsLogic, @@ -2311,7 +2315,13 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ ], actions: [ workflowLogic, - ['setWorkflowInfo', 'setWorkflowAction', 'setWorkflowActionEdges', 'loadWorkflowSuccess'], + [ + 'setWorkflowInfo', + 'setWorkflowAction', + 'setWorkflowActionEdges', + 'loadWorkflowSuccess', + 'setEmailInsertionPending', + ], optOutCategoriesLogic(), ['loadCategories'], ], @@ -2589,7 +2599,7 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ }, ], })), - listeners(({ values, actions }) => { + listeners(({ values, actions, props, cache }) => { let animationTimeout: ReturnType<typeof setTimeout> | null = null return { onEdgesChange: ({ edges }) => { @@ -2856,8 +2866,12 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ event.dataTransfer.dropEffect = 'move' }, - onDrop: ({ event, targetEdge, joinEdges }) => { + onDrop: async ({ event, targetEdge, joinEdges }) => { event?.preventDefault() + if (values.emailInsertionPending) { + return + } + const nodeToBeAdded = values.nodeToBeAdded const dropzoneNode = values.dropzoneNodes.find((x) => x.id === values.highlightedDropzoneNodeId) if (values.nodeToBeAdded && (dropzoneNode || targetEdge)) { @@ -2914,16 +2928,75 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ } } - if (!isHogFlowActionNode && 'template_id' in config && config.template_id === 'template-email') { - const emailInput = config.inputs?.email - const team = values.currentTeam - const sender = resolveDefaultEmailSender( - values.integrations, - team && 'workflows_config' in team - ? team.workflows_config?.default_email_integration_id - : null - ) - if (sender && emailInput?.value && !emailInput.value.from?.integrationId) { + if ( + !isHogFlowActionNode && + 'template_id' in config && + config.template_id === 'template-email' && + !config.inputs?.email?.value?.from?.integrationId && + !config.inputs?.email?.value?.from?.email && + !config.inputs?.email?.value?.from?.name + ) { + let emailInput = config.inputs?.email + const workflowId = values.workflow.id + const teamId = values.currentTeam?.id + const mountGeneration = cache.mountGeneration + const insertionIsCurrent = (): boolean => + cache.mountGeneration === mountGeneration && + values.workflow.id === workflowId && + values.currentTeam?.id === teamId + const abandonInsertion = (): void => { + if (cache.mountGeneration !== mountGeneration) { + return + } + lemonToast.error("The email step wasn't added because the workflow changed. Add it again.") + actions.setNodeToBeAdded(null) + actions.hideDropzones() + } + actions.setEmailInsertionPending(true) + let sender + let unmount: (() => void) | undefined + try { + if (!emailInput && !values.hogFunctionTemplatesById['template-email']) { + await workflowLogic(props).asyncActions.loadHogFunctionTemplatesById() + if (!insertionIsCurrent()) { + abandonInsertion() + return + } + const template = values.hogFunctionTemplatesById['template-email'] + if (template) { + config = { + ...config, + inputs: { ...templateToConfiguration(template).inputs, ...config.inputs }, + } + emailInput = config.inputs?.email + } + } + if ( + !emailInput?.value?.from?.integrationId && + !emailInput?.value?.from?.email && + !emailInput?.value?.from?.name + ) { + const senderLogic = firstRunSenderLogic({ + allowSandbox: + partialNewAction.type === 'function_email' && + values.workflow.origin_product !== 'broadcasts' && + !emailInput?.value?.replyTo, + }) + unmount = senderLogic.mount() + await senderLogic.asyncActions.resolveFirstRunSender() + sender = senderLogic.values.resolvedFirstRunSender + } + } finally { + unmount?.() + if (cache.mountGeneration === mountGeneration) { + actions.setEmailInsertionPending(false) + } + } + if (!insertionIsCurrent()) { + abandonInsertion() + return + } + if (sender) { config = { ...config, inputs: { @@ -2931,8 +3004,8 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ email: { ...emailInput, value: { - ...emailInput.value, - from: { ...emailInput.value.from, integrationId: sender.integrationId }, + ...emailInput?.value, + from: { ...emailInput?.value?.from, integrationId: sender.integrationId }, }, }, }, @@ -2944,7 +3017,7 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ const newAction = { id: isHogFlowActionNode - ? (values.nodeToBeAdded as HogFlowActionNode).id + ? (nodeToBeAdded as HogFlowActionNode).id : `action_${partialNewAction.type}_${uuid()}`, type: partialNewAction.type, name: partialNewAction.name, @@ -2986,8 +3059,10 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ edgesToBeReplaced ) - if (!newEdges) { - throw new Error('Edge to be replaced not found') + if (!newEdges || (joinEdges && edgesToBeReplaced.length !== joinEdges.length)) { + lemonToast.error('The insertion point changed. Choose where to insert the step again.') + actions.setNodeToBeAdded(null) + return } // Auto-create workflow variables if the action has a default output_variable @@ -3196,6 +3271,9 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ }) }, duplicateNodeBelow: ({ actionId }) => { + if (values.emailInsertionPending) { + return + } const action = values.workflow.actions.find((action) => action.id === actionId) const targetEdge = values.workflow.edges.find((edge) => edge.from === actionId) @@ -3281,8 +3359,9 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ [urls.workflow(':id', ':tab')]: reactToTabChange, } }), - events(({ actions, values }) => ({ + events(({ actions, values, cache }) => ({ afterMount: () => { + cache.mountGeneration = uuid() if (values.editorLayout === 'simple' && !isWorkflowTreeComplete(values.workflow)) { actions.setEditorLayout('advanced') } @@ -3305,6 +3384,8 @@ export const hogFlowEditorLogic = kea<hogFlowEditorLogicType>([ ;(actions as any)._keydownHandler = handleKeyDown }, beforeUnmount: () => { + cache.mountGeneration = null + actions.setEmailInsertionPending(false) const handler = (actions as any)._keydownHandler if (handler) { document.removeEventListener('keydown', handler) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx b/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx index 6d02f59f0fe5..3daf9ba92c0e 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx @@ -29,9 +29,14 @@ function HogFlowEditorToolbarNode({ children?: React.ReactNode }): JSX.Element | null { const { hideDropzones, setNodeToBeAdded, showDropzones } = useActions(hogFlowEditorLogic) + const { emailInsertionPending } = useValues(hogFlowEditorLogic) const dragPreviewRef = useRef<HTMLDivElement>(null) const onDragStart = (event: React.DragEvent): void => { + if (emailInsertionPending) { + event.preventDefault() + return + } setNodeToBeAdded(action) showDropzones() event.dataTransfer.setData('application/reactflow', action.type) @@ -53,11 +58,13 @@ function HogFlowEditorToolbarNode({ return ( <> <div - draggable={!onActionSelect} + draggable={!onActionSelect && !emailInsertionPending} onDragStart={onActionSelect ? undefined : onDragStart} onDragEnd={onActionSelect ? undefined : onDragEnd} > <LemonButton + loading={emailInsertionPending && action.type === 'function_email'} + disabledReason={emailInsertionPending ? 'Preparing the email sender…' : undefined} icon={<span style={{ color: step.color }}>{step.icon}</span>} sideIcon={onActionSelect ? undefined : <IconDrag />} fullWidth diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx index f35a39bbd1dd..6f991d06afde 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/StepView.tsx @@ -26,6 +26,7 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { nodesById, selectedNodeCanBeDeleted, selectedNodeCanBeCopiedOrMoved, + emailInsertionPending, animatingEdgePair, workflow, isZoomedOutFar, @@ -208,6 +209,9 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { selectedNodeCanBeCopiedOrMoved ? { label: 'Copy', + disabledReason: emailInsertionPending + ? 'Preparing the email sender…' + : undefined, icon: <IconCopy />, status: 'default', onClick: () => startCopyingNode(node), @@ -216,6 +220,9 @@ export function StepView({ action }: { action: HogFlowAction }): JSX.Element { selectedNodeCanBeCopiedOrMoved ? { label: 'Move', + disabledReason: emailInsertionPending + ? 'Preparing the email sender…' + : undefined, icon: <IconDrag />, status: 'default', onClick: () => startMovingNode(node), diff --git a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx index e2535300671b..16903160eb83 100644 --- a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx +++ b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeDropzone.tsx @@ -37,7 +37,7 @@ export function HogFlowTreeDropzone({ insertionLabel?: string alwaysVisible?: boolean }): JSX.Element { - const { workflow } = useValues(hogFlowEditorLogic) + const { workflow, emailInsertionPending } = useValues(hogFlowEditorLogic) const { moveNodeToEdge, onDragOver, @@ -55,6 +55,10 @@ export function HogFlowTreeDropzone({ onDragOver(event) } const handleDrop = (event: DragEvent<HTMLElement>): void => { + event.preventDefault() + if (emailInsertionPending) { + return + } setHighlighted(false) const draggedActionId = draggedActionIdRef.current if (draggedActionId) { @@ -85,6 +89,9 @@ export function HogFlowTreeDropzone({ } } const handleInsertAction = (action: CreateActionType): void => { + if (emailInsertionPending) { + return + } setPickerOpen(false) setNodeToBeAdded(action) @@ -102,7 +109,7 @@ export function HogFlowTreeDropzone({ alwaysVisible ? 'min-h-8' : compact ? 'h-2' : 'h-4' )} data-workflow-tree-dropzone-candidate - data-workflow-tree-dropzone-disabled={isAdjacentToDraggedAction || undefined} + data-workflow-tree-dropzone-disabled={isAdjacentToDraggedAction || emailInsertionPending || undefined} > <div className={cn( @@ -122,6 +129,7 @@ export function HogFlowTreeDropzone({ <PopoverTrigger render={ <LemonButton + disabledReason={emailInsertionPending ? 'Preparing the email sender…' : undefined} htmlType="button" type="tertiary" size="xsmall" diff --git a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeStep.tsx b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeStep.tsx index 93164e39e8e7..6fb0802df2f6 100644 --- a/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeStep.tsx +++ b/products/workflows/frontend/Workflows/hogflows/tree/HogFlowTreeStep.tsx @@ -29,7 +29,7 @@ export function HogFlowTreeStep({ stepId: string canDrag?: boolean }): JSX.Element { - const { animatingEdgePair, nodesById, selectedNode } = useValues(hogFlowEditorLogic) + const { animatingEdgePair, nodesById, selectedNode, emailInsertionPending } = useValues(hogFlowEditorLogic) const { duplicateNodeBelow, onNodesDelete, setSelectedNodeId } = useActions(hogFlowEditorLogic) const { setSelectedBranch } = useHogFlowBranchSelection() const { actionValidationErrorsById, workflow } = useValues(workflowLogic) @@ -130,7 +130,8 @@ export function HogFlowTreeStep({ variant="default" size="icon-sm" aria-label="Duplicate step" - title="Duplicate step" + title={emailInsertionPending ? 'Preparing the email sender…' : 'Duplicate step'} + disabled={emailInsertionPending} onClick={() => duplicateNodeBelow(action.id)} data-attr="workflow-tree-duplicate-step" > diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts index d67b71e79f1c..d019be45b674 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts @@ -1,5 +1,5 @@ import { draftMessage } from '../MessageAudience/messageDrafts' -import { prefilledWorkflow, withEmailSender } from './workflowEmailPrefill' +import { prefilledWorkflow } from './workflowEmailPrefill' import { NEW_WORKFLOW } from './workflowLogic' import type { WorkflowTriggerConfig } from './workflowTriggerPrefill' @@ -31,14 +31,4 @@ describe('workflowEmailPrefill', () => { }) expect(workflow.trigger_masking).toEqual(masking) }) - - it('keeps a sender the email step already has and fills the ones without', () => { - const workflow = prefilledWorkflow(NEW_WORKFLOW, EVENT_TRIGGER, draftMessage({ kind: 'issue_hit' })) - const filled = withEmailSender(workflow, 5) - const refilled = withEmailSender(filled.workflow, 9) - - expect(filled.filled).toBe(true) - expect((filled.workflow.actions[1].config as any).inputs.email.value.from).toEqual({ integrationId: 5 }) - expect(refilled).toEqual({ workflow: filled.workflow, filled: false }) - }) }) diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts index 1ae94083ed01..d5a4a9b12a6b 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts @@ -92,26 +92,3 @@ export function withPrefilledEmail(workflow: HogFlow, email: MessageDraft): HogF }), } } - -export function withEmailSender(workflow: HogFlow, integrationId: number): { workflow: HogFlow; filled: boolean } { - let filled = false - const actions = workflow.actions.map((action) => { - const config = action.config as Record<string, any> - const value = config?.template_id === 'template-email' ? config.inputs?.email?.value : null - if (!value || value.from?.integrationId) { - return action - } - filled = true - return { - ...action, - config: { - ...config, - inputs: { - ...config.inputs, - email: { ...config.inputs.email, value: { ...value, from: { ...value.from, integrationId } } }, - }, - }, - } as HogFlowAction - }) - return { workflow: filled ? { ...workflow, actions } : workflow, filled } -} diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts new file mode 100644 index 000000000000..dee32bc4b87a --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -0,0 +1,504 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { lemonToast } from '@posthog/lemon-ui' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { IntegrationType } from '~/types' + +import { draftMessage } from '../MessageAudience/messageDrafts' +import { hogFlowEditorLogic } from './hogflows/hogFlowEditorLogic' +import { HogFlowAction } from './hogflows/types' +import { workflowStepSections } from './hogflows/workflowStepCatalog' +import { NEW_WORKFLOW, workflowLogic } from './workflowLogic' + +const PALETTE_EMAIL_STEP = workflowStepSections({ featureFlags: {}, currentTeam: null, isRowScopedTrigger: false }) + .flatMap((section) => section.steps) + .find((step) => step.type === 'function_email')! + +const SANDBOX: Partial<IntegrationType> = { + id: 7, + kind: 'email', + display_name: 'Example via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', verified: true, email: 'sandbox@example.com' }, +} +const emailAction = (integrationId?: number): HogFlowAction => ({ + id: 'email', + type: 'function_email', + name: 'Send email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + from: integrationId ? { integrationId } : {}, + to: { email: 'member@example.com' }, + subject: 'Welcome', + html: '<p>Your account is ready.</p>', + }, + templating: 'liquid', + }, + }, + }, +}) + +describe('workflow first-run sender', () => { + let ensureCalls: number + let integrations: Partial<IntegrationType>[] + let action: HogFlowAction + beforeEach(() => { + integrations = [] + ensureCalls = 0 + action = emailAction() + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: integrations }], + '/api/projects/:team_id/hog_flow_templates/:id/': () => [ + 200, + { + ...NEW_WORKFLOW, + actions: [NEW_WORKFLOW.actions[0], action, NEW_WORKFLOW.actions[1]], + }, + ], + '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls++ + integrations = [...integrations, SANDBOX] + return [200, SANDBOX] + }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + it.each([ + ['function_email', false], + ['function', false], + ['function', true], + ] as const)('loads a %s template with an eligible first-run sender (own sender=%s)', async (type, ownSender) => { + action = { ...emailAction(), type } as HogFlowAction + if (ownSender) { + integrations = [ + { id: 5, kind: 'email', config: { verified: true, provider: 'ses', email: 'own@example.com' } }, + ] + } + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email).toMatchObject({ + type, + config: { + inputs: { + email: { + value: { + from: ownSender + ? { integrationId: 5 } + : type === 'function_email' + ? { integrationId: SANDBOX.id } + : {}, + subject: 'Welcome', + html: '<p>Your account is ready.</p>', + }, + templating: 'liquid', + }, + }, + }, + }) + const from = + email?.type === 'function_email' || email?.type === 'function' + ? email.config.inputs?.email?.value?.from + : undefined + expect(from?.integrationId).toBe(ownSender ? 5 : type === 'function_email' ? SANDBOX.id : undefined) + expect(ensureCalls).toBe(!ownSender && type === 'function_email' ? 1 : 0) + logic.unmount() + }) + + it.each([ + ['a template', { templateId: 'first-run-example' }], + [ + 'a trigger prefill', + { + triggerPrefill: JSON.stringify({ + type: 'event', + filters: { events: [{ id: '$pageview', name: '$pageview', type: 'events' }] }, + }), + emailPrefill: JSON.stringify(draftMessage({ kind: 'issue_hit' })), + }, + ], + ])('preselects the first-run sender once for a workflow from %s', async (_source, prefillProps) => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + const logic = workflowLogic({ id: 'new', ...prefillProps }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.type === 'function_email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({ + integrationId: SANDBOX.id, + }) + expect(capture.mock.calls.filter(([event]) => event === 'email sender preselected')).toEqual([ + ['email sender preselected', { surface: 'workflow', reason: 'sandbox_sender' }], + ]) + capture.mockRestore() + logic.unmount() + }) + + it('keeps the sandbox sender off a duplicated generic function email step', async () => { + action = { ...emailAction(), type: 'function' } as HogFlowAction + const workflow = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowInfo({ + edges: [ + { from: 'trigger_node', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit_node', type: 'continue' }, + ], + }) + const editor = hogFlowEditorLogic({ id: 'new', templateId: 'first-run-example' }) + editor.mount() + + await expectLogic(editor, () => editor.actions.duplicateNodeBelow('email')).toFinishAllListeners() + + const emails = workflow.values.workflow.actions.filter((step) => step.type === 'function') + expect(emails).toHaveLength(2) + expect(emails.map((step) => step.type === 'function' && step.config.inputs?.email?.value?.from)).toEqual([ + {}, + {}, + ]) + expect(ensureCalls).toBe(0) + editor.unmount() + workflow.unmount() + }) + + it('assigns the sandbox sender before an inserted email enters the editor', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + + await expectLogic(editor, () => { + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + editor.actions.setNodeToBeAdded(null) + }).toDispatchActions(['setWorkflowInfo']) + + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: { integrationId: SANDBOX.id }, subject: 'Welcome' } } } }, + }) + expect(ensureCalls).toBe(1) + editor.unmount() + workflow.unmount() + }) + + it('assigns the sender to the palette email before template defaults are available', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(PALETTE_EMAIL_STEP) + await expectLogic(editor, () => editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0])).toDispatchActions([ + 'setWorkflowInfo', + ]) + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: { integrationId: SANDBOX.id } } } } }, + }) + editor.unmount() + workflow.unmount() + }) + + it('shows pending insertion and accepts only one drop while the sender resolves', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const preventDefault = jest.fn() + await expectLogic(editor, () => { + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + expect(editor.values.emailInsertionPending).toBe(true) + editor.actions.onDrop({ preventDefault } as unknown as React.DragEvent, NEW_WORKFLOW.edges[0]) + }).toDispatchActions(['setWorkflowInfo']) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(editor.values.emailInsertionPending).toBe(false) + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(1) + editor.unmount() + workflow.unmount() + }) + + it('cancels the insertion if its target edge changes while resolving the sender', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + workflow.actions.setWorkflowValues({ edges: [] }) + await insertion + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(0) + expect(editor.values.emailInsertionPending).toBe(false) + editor.unmount() + workflow.unmount() + }) + + it('does not apply a pending drop to a reopened blank workflow', async () => { + let finishProvisioning!: () => void + let provisioningStarted!: () => void + const pendingProvisioning = new Promise<void>((resolve) => (finishProvisioning = resolve)) + const started = new Promise<void>((resolve) => (provisioningStarted = resolve)) + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + provisioningStarted() + await pendingProvisioning + integrations = [SANDBOX] + return [200, SANDBOX] + }, + }, + }) + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + await started + editor.unmount() + workflow.unmount() + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + editor.mount() + finishProvisioning() + await insertion + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(0) + editor.unmount() + workflow.unmount() + }) + + it('holds the save of a new workflow until its email step has a sender', async () => { + let finishProvisioning!: () => void + let provisioningStarted!: () => void + const pendingProvisioning = new Promise<void>((resolve) => (finishProvisioning = resolve)) + const started = new Promise<void>((resolve) => (provisioningStarted = resolve)) + let saves = 0 + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + provisioningStarted() + await pendingProvisioning + integrations = [SANDBOX] + return [200, SANDBOX] + }, + '/api/projects/:team_id/hog_flows/': () => { + saves++ + return [201, { ...NEW_WORKFLOW, id: 'saved-workflow' }] + }, + }, + }) + const toast = jest.spyOn(lemonToast, 'info').mockImplementation() + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowValues({ name: 'Welcome' }) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + await started + + expect(workflow.values.saveBlockedReason).toEqual('An email step is still being added. Try again in a moment.') + await expectLogic(workflow, () => workflow.actions.submitWorkflow()).toDispatchActions([ + 'submitWorkflowSuccess', + ]) + expect(saves).toEqual(0) + expect(toast).toHaveBeenCalledWith('An email step is still being added. Try again in a moment.') + + finishProvisioning() + await insertion + expect(workflow.values.saveBlockedReason).toBeNull() + expect(workflow.values.workflow.actions.filter((step) => step.type === 'function_email')).toHaveLength(1) + toast.mockRestore() + editor.unmount() + workflow.unmount() + }) + + it('waits for pending template defaults before inserting the palette email', async () => { + let finishTemplates!: () => void + const templatesReady = new Promise<void>((resolve) => (finishTemplates = resolve)) + useMocks({ + get: { + '/api/projects/:team_id/hog_function_templates/': async () => { + await templatesReady + return [ + 200, + { + count: 1, + results: [ + { + id: 'template-email', + name: 'Email', + code: '', + inputs_schema: [ + { + key: 'email', + type: 'email', + default: { subject: 'Default subject', from: {} }, + }, + ], + }, + ], + }, + ] + }, + }, + }) + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(PALETTE_EMAIL_STEP) + const insertion = editor.asyncActions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + finishTemplates() + await insertion + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { + inputs: { email: { value: { from: { integrationId: SANDBOX.id }, subject: 'Default subject' } } }, + }, + }) + editor.unmount() + workflow.unmount() + }) + + it('keeps email insertion into a broadcast own-sender only', async () => { + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + workflow.actions.setWorkflowInfo({ origin_product: 'broadcasts' }) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + editor.actions.setNodeToBeAdded(emailAction()) + await expectLogic(editor, () => editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0])).toFinishAllListeners() + expect(workflow.values.workflow.actions.find((step) => step.type === 'function_email')).toMatchObject({ + config: { inputs: { email: { value: { from: {} } } } }, + }) + expect(ensureCalls).toBe(0) + editor.unmount() + workflow.unmount() + }) + + it('preserves configured Reply-To without assigning an incompatible sandbox sender', async () => { + action = emailAction() + if (action.type === 'function_email') { + action.config.inputs!.email.value.replyTo = 'reply@example.com' + } + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value).toEqual({ + from: {}, + replyTo: 'reply@example.com', + to: { email: 'member@example.com' }, + subject: 'Welcome', + html: '<p>Your account is ready.</p>', + }) + expect(ensureCalls).toBe(0) + logic.unmount() + }) + + it('leaves the sender empty when the existing integration list cannot be loaded', async () => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [500, { detail: 'Unavailable' }] } }) + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(0) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({}) + logic.unmount() + }) + + it('retries a transient provisioning failure on the next explicit template load', async () => { + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls++ + if (ensureCalls === 1) { + return [500, { detail: 'Unavailable' }] + } + integrations = [SANDBOX] + return [200, SANDBOX] + }, + }, + }) + const unmountIntegrations = integrationsLogic.mount() + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(1) + await expectLogic(logic, () => logic.actions.loadWorkflow()).toDispatchActions(['loadWorkflowSuccess']) + expect(ensureCalls).toBe(2) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from).toEqual({ + integrationId: SANDBOX.id, + }) + logic.unmount() + unmountIntegrations() + }) + + it.each<{ + name: string + senders: Partial<IntegrationType>[] + configured?: number + flag: boolean + expected?: number + }>([ + { + name: 'a verified own sender', + senders: [{ id: 9, kind: 'email', config: { verified: true } }], + configured: undefined, + flag: true, + expected: 9, + }, + { + name: 'multiple verified senders', + senders: [ + { id: 9, kind: 'email', config: { verified: true } }, + { id: 10, kind: 'email', config: { verified: true } }, + SANDBOX, + ], + configured: undefined, + flag: true, + expected: undefined, + }, + { name: 'a configured sender', senders: [], configured: 12, flag: true, expected: 12 }, + { name: 'the sandbox flag off', senders: [], configured: undefined, flag: false, expected: undefined }, + ])('preserves sender policy with $name', async ({ senders, configured, flag, expected }) => { + integrations = senders + action = emailAction(configured) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: flag }) + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + const email = logic.values.workflow.actions.find((step) => step.id === 'email') + expect(email?.type === 'function_email' && email.config.inputs?.email?.value?.from?.integrationId).toBe( + expected + ) + expect(ensureCalls).toBe(0) + logic.unmount() + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index d57675e5e4b7..e816bffb82b0 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -22,7 +22,6 @@ import { objectsEqual } from 'lib/utils/objects' import { sanitizeInputs } from 'scenes/hog-functions/configuration/hogFunctionConfigurationLogic' import type { EmailFieldErrors } from 'scenes/hog-functions/email-templater/types' import { projectLogic } from 'scenes/projectLogic' -import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' @@ -41,7 +40,8 @@ import { } from '../Broadcasts/broadcastLifecycle' import { canEditInWizard, isBroadcastShaped } from '../Broadcasts/broadcastsLogic' import { loadEntrySource, saveEntrySource } from '../Broadcasts/broadcastUsage' -import { resolveDefaultEmailSender } from '../Channels/defaultEmailSender' +import { firstRunSenderLogic } from '../firstRun/firstRunSenderLogic' +import { acceptsSandboxSender, needsFirstRunSender, withFirstRunSender } from '../firstRun/withFirstRunSender' import { AiEmailDraftResult, aiEmailDraftFailureMessage, @@ -76,7 +76,7 @@ import { import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' import { prepareWorkflowDuplicate } from './workflowDuplication' -import { prefilledEmailContent, prefilledWorkflow, withEmailSender, withPrefilledEmail } from './workflowEmailPrefill' +import { prefilledEmailContent, prefilledWorkflow, withPrefilledEmail } from './workflowEmailPrefill' import { workflowSceneLogic } from './workflowSceneLogic' import { workflowsLogic } from './workflowsLogic' import { parseWorkflowTriggerPrefill } from './workflowTriggerPrefill' @@ -92,6 +92,7 @@ export interface WorkflowLogicProps { } export const TRIGGER_NODE_ID = 'trigger_node' +export const EMAIL_INSERTION_PENDING_REASON = 'An email step is still being added. Try again in a moment.' export const EXIT_NODE_ID = 'exit_node' export type TriggerAction = Extract<HogFlowAction, { type: 'trigger' }> @@ -149,25 +150,30 @@ export const PERSON_DEPENDENT_ACTION_TYPES = new Set(['wait_until_condition', 'r // the backend's ROW_SCOPED_TRIGGER_TYPES, which is the authoritative check. export const ROW_SCOPED_TRIGGER_TYPES = new Set(['data-warehouse-table', 'data-warehouse-view', 'internal-event']) -async function withDefaultEmailSender(workflow: HogFlow): Promise<HogFlow> { +async function withFirstRunEmailSender<T extends Pick<HogFlow, 'actions'>>(workflow: T): Promise<T> { + if (!workflow.actions.some(needsFirstRunSender)) { + return workflow + } + const senderLogic = firstRunSenderLogic({ allowSandbox: workflow.actions.some(acceptsSandboxSender) }) + const unmount = senderLogic.mount() try { - const team = teamLogic.findMounted()?.values.currentTeam - const sender = resolveDefaultEmailSender( - (await api.integrations.list()).results, - team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null - ) + await senderLogic.asyncActions.resolveFirstRunSender() + const sender = senderLogic.values.resolvedFirstRunSender if (!sender) { return workflow } - const result = withEmailSender(workflow, sender.integrationId) - if (result.filled) { - // pinned: analytics event name - posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + const actions = withFirstRunSender(workflow.actions, sender) + if (actions.every((action, index) => action === workflow.actions[index])) { + return workflow } - return result.workflow + // pinned: analytics event name + posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + return { ...workflow, actions } } catch { // The sender is a convenience. The person can still pick one, so a failed lookup doesn't block the editor. return workflow + } finally { + unmount() } } @@ -287,6 +293,7 @@ export interface workflowLogicValues { discardDisabledReason: string | undefined draftActionPending: 'discard' | 'publish' | null edgesByActionId: Record<string, HogFlowEdge[]> + emailInsertionPending: boolean emailSendingPauseRequiresSupport: boolean emailSendingPaused: boolean emailSendingPausedByStaff: boolean @@ -319,6 +326,7 @@ export interface workflowLogicValues { resumeEmailSendingPending: boolean saveAttemptedActionIds: string[] | null saveBaseUpdatedAt: string | null + saveBlockedReason: string | null scheduleConfigSources: { natural_language: boolean picker: boolean @@ -2561,6 +2569,9 @@ export interface workflowLogicActions { setDraftActionPending: (pending: 'discard' | 'publish' | null) => { pending: 'discard' | 'publish' | null } + setEmailInsertionPending: (pending: boolean) => { + pending: boolean + } setExternallyEdited: (externallyEdited: boolean) => { externallyEdited: boolean } @@ -3000,6 +3011,7 @@ export interface workflowLogicActions { export interface workflowLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { + saveBlockedReason: (emailInsertionPending: any) => string | null aiEmailDraftStatus: ( aiEmailDraftLoading: boolean, aiEmailDraftApplied: boolean, @@ -3262,6 +3274,7 @@ export const workflowLogic = kea<workflowLogicType>([ actions: [workflowsLogic, ['archiveWorkflow'], resourceEditedLogic, ['resourceEdited']], })), actions({ + setEmailInsertionPending: (pending: boolean) => ({ pending }), partialSetWorkflowActionConfig: (actionId: string, config: Partial<HogFlowAction['config']>) => ({ actionId, config, @@ -3350,11 +3363,11 @@ export const workflowLogic = kea<workflowLogicType>([ delete (newWorkflow as any).updated_at delete (newWorkflow as any).created_by - return (await withDefaultEmailSender(newWorkflow as HogFlow)) as typeof newWorkflow + return await withFirstRunEmailSender(newWorkflow) } const triggerConfig = parseWorkflowTriggerPrefill(props.triggerPrefill) if (triggerConfig) { - return await withDefaultEmailSender( + return await withFirstRunEmailSender( prefilledWorkflow( NEW_WORKFLOW, triggerConfig, @@ -3595,6 +3608,11 @@ export const workflowLogic = kea<workflowLogicType>([ if (!workflow) { return } + // Saving a new workflow replaces the route, which drops an email step whose sender is still resolving. + if (values.saveBlockedReason) { + lemonToast.info(values.saveBlockedReason) + return + } // A new workflow that only sends one email to a batch is a broadcast. Offer to create it // as one, so it opens in the broadcast editor and lists under broadcasts. @@ -3629,6 +3647,7 @@ export const workflowLogic = kea<workflowLogicType>([ }, })), reducers({ + emailInsertionPending: [false, { setEmailInsertionPending: (_, { pending }) => pending }], aiEmailDraftApplied: [ false, { @@ -3798,6 +3817,11 @@ export const workflowLogic = kea<workflowLogicType>([ ], }), selectors({ + saveBlockedReason: [ + (s) => [s.emailInsertionPending], + (emailInsertionPending: boolean): string | null => + emailInsertionPending ? EMAIL_INSERTION_PENDING_REASON : null, + ], aiEmailDraftStatus: [ (s) => [s.aiEmailDraftLoading, s.aiEmailDraftApplied, s.aiEmailDraft], (loading: boolean, applied: boolean, result: AiEmailDraftResult | null): AiEmailDraftStatusProps | null => { diff --git a/products/workflows/frontend/firstRun/firstRunSenderLogic.ts b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts new file mode 100644 index 000000000000..63b552c89224 --- /dev/null +++ b/products/workflows/frontend/firstRun/firstRunSenderLogic.ts @@ -0,0 +1,145 @@ +import { MakeLogicType, connect, kea, key, path, props, selectors } from 'kea' +import { loaders } from 'kea-loaders' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { teamLogic } from 'scenes/teamLogic' + +import type { IntegrationType, TeamPublicType, TeamType } from '~/types' + +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' +import { DefaultEmailSender, resolveDefaultEmailSender } from '../Channels/defaultEmailSender' + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicValues { + currentProjectId: number | string // integrationsLogic + integrations: IntegrationType[] | null // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + currentTeam: TeamPublicType | TeamType | null // teamLogic + firstRunSender: DefaultEmailSender | null + resolvedFirstRunSender: DefaultEmailSender | null + resolvedFirstRunSenderLoading: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicActions { + resolveFirstRunSender: () => any + resolveFirstRunSenderFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + resolveFirstRunSenderSuccess: ( + resolvedFirstRunSender: DefaultEmailSender | null, + payload?: any + ) => { + resolvedFirstRunSender: DefaultEmailSender | null + payload?: any + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicProps { + allowSandbox: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface firstRunSenderLogicMeta { + key: 'own' | 'sandbox' + __keaTypeGenInternalSelectorTypes: { + firstRunSender: ( + integrations: IntegrationType[] | null, + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + currentTeam: TeamPublicType | TeamType | null + ) => DefaultEmailSender | null + } +} + +export type firstRunSenderLogicType = MakeLogicType< + firstRunSenderLogicValues, + firstRunSenderLogicActions, + firstRunSenderLogicProps, + firstRunSenderLogicMeta +> + +export const firstRunSenderLogic = kea<firstRunSenderLogicType>([ + props({} as { allowSandbox: boolean }), + key((props) => (props.allowSandbox ? 'sandbox' : 'own')), + path((key) => ['products', 'workflows', 'frontend', 'firstRun', 'firstRunSenderLogic', key]), + connect({ + values: [ + integrationsLogic, + [ + 'integrations', + 'ownEmailIntegrations', + 'sandboxEmailSender', + 'sandboxEmailSenderEnabled', + 'sandboxEmailSenderProvisionResult', + 'currentProjectId', + ], + teamLogic, + ['currentTeam'], + ], + }), + selectors({ + firstRunSender: [ + (s) => [s.integrations, s.ownEmailIntegrations, s.sandboxEmailSender, s.currentTeam], + ( + integrations: IntegrationType[] | null, + ownSenders: IntegrationType[], + sandbox: IntegrationType | null, + team: TeamPublicType | TeamType | null + ): DefaultEmailSender | null => { + const own = resolveDefaultEmailSender( + integrations, + team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null + ) + return ( + own ?? + (sandbox && !ownSenders.some((sender) => sender.config?.verified === true) + ? { integrationId: sandbox.id, reason: 'sandbox_sender' } + : null) + ) + }, + ], + }), + loaders(({ values, props }) => ({ + resolvedFirstRunSender: [ + null as DefaultEmailSender | null, + { + resolveFirstRunSender: async (): Promise<DefaultEmailSender | null> => { + const projectId = values.currentProjectId + if (values.integrations === null) { + await integrationsLogic.asyncActions.loadIntegrations() + } + if (values.currentProjectId !== projectId || values.integrations === null) { + return null + } + if (!props.allowSandbox) { + return resolveDefaultEmailSender( + values.integrations, + values.currentTeam && 'workflows_config' in values.currentTeam + ? values.currentTeam.workflows_config?.default_email_integration_id + : null + ) + } + if ( + !values.firstRunSender && + values.sandboxEmailSenderEnabled && + (!values.sandboxEmailSenderProvisionResult || + values.sandboxEmailSenderProvisionResult === 'failed') && + !values.ownEmailIntegrations.some((sender) => sender.config?.verified === true) + ) { + await integrationsLogic.asyncActions.provisionSandboxEmailSender() + } + return values.currentProjectId === projectId ? values.firstRunSender : null + }, + }, + ], + })), +]) diff --git a/products/workflows/frontend/firstRun/withFirstRunSender.ts b/products/workflows/frontend/firstRun/withFirstRunSender.ts new file mode 100644 index 000000000000..f0fe8a093f25 --- /dev/null +++ b/products/workflows/frontend/firstRun/withFirstRunSender.ts @@ -0,0 +1,45 @@ +import type { DefaultEmailSender } from '../Channels/defaultEmailSender' +import type { HogFlowAction } from '../Workflows/hogflows/types' + +export function needsFirstRunSender( + action: HogFlowAction +): action is Extract<HogFlowAction, { type: 'function_email' | 'function' }> { + if ( + action.type !== 'function_email' && + !(action.type === 'function' && action.config.template_id === 'template-email') + ) { + return false + } + const from = action.config.inputs?.email?.value?.from + return !from?.integrationId && !from?.email && !from?.name +} + +export function acceptsSandboxSender(action: HogFlowAction): boolean { + return ( + action.type === 'function_email' && needsFirstRunSender(action) && !action.config.inputs?.email?.value?.replyTo + ) +} + +export function withFirstRunSender(actions: HogFlowAction[], sender: DefaultEmailSender): HogFlowAction[] { + return actions.map((action) => { + if (!needsFirstRunSender(action)) { + return action + } + if (sender.reason === 'sandbox_sender' && !acceptsSandboxSender(action)) { + return action + } + const inputs = { + ...action.config.inputs, + email: { + ...action.config.inputs?.email, + value: { + ...action.config.inputs?.email?.value, + from: { ...action.config.inputs?.email?.value?.from, integrationId: sender.integrationId }, + }, + }, + } + const updated: typeof action = { ...action } + updated.config = { ...action.config, inputs } + return updated + }) +} From 8418420305e4d7e9783c658820c2f14afd70a4b2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:45:53 +0000 Subject: [PATCH 13/38] feat(workflows): offer a switch from the sandbox sender Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../WorkflowSandboxSwitchBanner.stories.tsx | 159 ++++++++ .../Workflows/WorkflowSandboxSwitchBanner.tsx | 36 ++ .../WorkflowSandboxSwitchSenderButton.tsx | 43 ++ .../frontend/Workflows/WorkflowScene.tsx | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 375 ++++++++++++++++++ .../workflowSandboxSwitchBannerLogic.ts | 236 +++++++++++ 6 files changed, 851 insertions(+) create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx new file mode 100644 index 000000000000..697302b5664c --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' +import { AccessControlLevel } from '~/types' + +import type { HogFlow } from './hogflows/types' +import { NEW_WORKFLOW, WorkflowLogicProps, workflowLogic } from './workflowLogic' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' + +const LOGIC_PROPS: WorkflowLogicProps = { id: 'storybook-sandbox-switch-banner' } + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SECOND_OWN_SENDER = { + id: 9, + kind: 'email', + display_name: 'Acme support <support@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme support', + email: 'support@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SANDBOX_WORKFLOW: HogFlow = { + ...NEW_WORKFLOW, + id: LOGIC_PROPS.id!, + name: 'Welcome email', + user_access_level: AccessControlLevel.Editor, + actions: [ + { + id: 'trigger', + type: 'trigger', + name: 'New account created', + description: '', + config: { type: 'event', filters: {} }, + }, + { + id: 'email', + type: 'function_email', + name: 'Send welcome email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: '{{ person.properties.email }}', name: '' }, + from: { integrationId: SANDBOX_SENDER.id }, + subject: 'Welcome aboard', + text: 'Your account is ready.', + html: '<p>Your account is ready.</p>', + }, + templating: 'liquid', + }, + }, + }, + }, + { id: 'exit', type: 'exit', name: 'Exit', description: '', config: { reason: 'Completed' } }, + ], + edges: [ + { from: 'trigger', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit', type: 'continue' }, + ], +} + +type StoryArgs = { + ownSenders: (typeof OWN_SENDER)[] + narrow?: boolean + constrained?: boolean +} + +const meta: Meta<StoryArgs> = { + title: 'Products/Workflows/Sandbox switch banner', + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + testOptions: { waitForSelector: '[data-attr="workflow-sandbox-switch-sender"]' }, + }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ ownSenders, narrow, constrained }): JSX.Element => { + useStorybookMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': SANDBOX_WORKFLOW, + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] }, + '/api/projects/:team_id/hog_function_templates': { count: 0, results: [] }, + '/api/environments/:team_id/messaging_categories': { count: 0, results: [] }, + }, + }) + return ( + <BindLogic logic={workflowLogic} props={LOGIC_PROPS}> + <div className={narrow ? 'w-[520px]' : 'max-w-4xl'}> + <div className={constrained ? 'h-[400px] flex flex-col' : undefined}> + <WorkflowSandboxSwitchBanner {...LOGIC_PROPS} /> + {constrained && <div className="grow min-h-[400px]" />} + </div> + </div> + </BindLogic> + ) +} + +export const OneOwnSender = Template.bind({}) +OneOwnSender.args = { ownSenders: [OWN_SENDER] } + +export const OneOwnSenderNarrow = Template.bind({}) +OneOwnSenderNarrow.args = { ownSenders: [OWN_SENDER], narrow: true } + +export const ConstrainedScene = Template.bind({}) +ConstrainedScene.args = { ownSenders: [OWN_SENDER], constrained: true } + +export const ConstrainedSceneNarrow = Template.bind({}) +ConstrainedSceneNarrow.args = { ownSenders: [OWN_SENDER], constrained: true, narrow: true } + +export const OneOwnSenderLongAddressNarrow = Template.bind({}) +OneOwnSenderLongAddressNarrow.args = { + ownSenders: [ + { + ...OWN_SENDER, + display_name: 'Acme notifications <notifications-and-billing@mail.acme.example.com>', + config: { ...OWN_SENDER.config, email: 'notifications-and-billing@mail.acme.example.com' }, + }, + ], + narrow: true, +} + +export const SeveralOwnSenders = Template.bind({}) +SeveralOwnSenders.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER] } + +export const SeveralOwnSendersNarrow = Template.bind({}) +SeveralOwnSendersNarrow.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx new file mode 100644 index 000000000000..33e260f8b2a3 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx @@ -0,0 +1,36 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner } from '@posthog/lemon-ui' + +import { WorkflowLogicProps } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' +import { WorkflowSandboxSwitchSenderButton } from './WorkflowSandboxSwitchSenderButton' + +export function WorkflowSandboxSwitchBanner(props: WorkflowLogicProps): JSX.Element | null { + const logic = workflowSandboxSwitchBannerLogic(props) + const { bannerVisible, verifiedOwnSenders, switchDisabledReason } = useValues(logic) + const { switchToOwnSender } = useActions(logic) + + if (!bannerVisible) { + return null + } + + return ( + <LemonBanner type="info" alignItems="start" className="@container shrink-0"> + <div + className="flex flex-col gap-2 @xl:flex-row @xl:items-center" + data-attr="workflow-sandbox-switch-banner" + > + <span className="min-w-0 flex-1"> + Your project has a verified email sender. This workflow still sends from the sandbox sender, which + delivers only to verified members of your organization. + </span> + <WorkflowSandboxSwitchSenderButton + senders={verifiedOwnSenders} + disabledReason={switchDisabledReason} + onSwitch={switchToOwnSender} + /> + </div> + </LemonBanner> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx new file mode 100644 index 000000000000..c311249d50e5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx @@ -0,0 +1,43 @@ +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' + +import { IntegrationType } from '~/types' + +export function WorkflowSandboxSwitchSenderButton({ + senders, + disabledReason, + onSwitch, +}: { + senders: IntegrationType[] + disabledReason: string | null + onSwitch: (integrationId: number) => void +}): JSX.Element { + const buttonProps = { + type: 'secondary' as const, + size: 'small' as const, + disabledReason, + 'data-attr': 'workflow-sandbox-switch-sender', + className: 'max-w-full self-start @xl:self-auto', + } + + if (senders.length === 1) { + const label = `Switch to ${senders[0].config.email}` + return ( + <LemonButton {...buttonProps} truncate tooltip={label} onClick={() => onSwitch(senders[0].id)}> + {label} + </LemonButton> + ) + } + + return ( + <LemonMenu + items={senders.map((sender) => ({ + label: sender.display_name, + onClick: () => onSwitch(sender.id), + 'data-attr': 'workflow-sandbox-switch-sender-option', + }))} + placement="bottom-end" + > + <LemonButton {...buttonProps}>Switch sender</LemonButton> + </LemonMenu> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx index 1bc06a9410ab..eac0622a0c5c 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -51,6 +51,7 @@ import { WorkflowInvocations } from './WorkflowInvocations' import { WorkflowLogicProps, workflowLogic } from './workflowLogic' import { WorkflowMetrics } from './WorkflowMetrics' import { WorkflowRevisions } from './WorkflowRevisions' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' import { WorkflowSceneHeader } from './WorkflowSceneHeader' import { WorkflowSceneLogicProps, WorkflowTab, workflowSceneLogic } from './workflowSceneLogic' import { TRIGGER_PREFILL_PARAM } from './workflowTriggerPrefill' @@ -255,6 +256,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { <WorkflowEmailPauseBanner /> {isSavedWorkflow && <WorkflowBroadcastMoveBanner id={props.id!} />} {!isSavedWorkflow && <WorkflowAiEmailDraftStatus {...workflowProps} />} + <WorkflowSandboxSwitchBanner {...workflowProps} /> {suggestionsAvailable && isSavedWorkflow && <WorkflowSuggestionsNotice id={props.id!} />} {/* Only show Logs and Metrics tabs if the workflow has already been created */} {!props.id || props.id === 'new' ? ( diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts new file mode 100644 index 000000000000..d51eed316862 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -0,0 +1,375 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { AccessControlLevel } from '~/types' + +import { resourceEditedLogic } from 'products/notifications/frontend/resourceEditedLogic' + +import { HogFlow, HogFlowAction } from './hogflows/types' +import { workflowLogic } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' + +const WORKFLOW_ID = 'wf-sandbox-switch-1' +const SANDBOX_SENDER_ID = 7 +const OWN_SENDER_ID = 8 +const SECOND_OWN_SENDER_ID = 9 + +const SANDBOX_SENDER = { + id: SANDBOX_SENDER_ID, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} +const OWN_SENDER = { + id: OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { provider: 'ses', domain: 'acme.example.com', email: 'hello@acme.example.com', verified: true }, +} +const SECOND_OWN_SENDER = { + id: SECOND_OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme support <support@acme.example.com>', + config: { provider: 'ses', domain: 'acme.example.com', email: 'support@acme.example.com', verified: true }, +} +const UNVERIFIED_OWN_SENDER = { ...OWN_SENDER, id: 10, config: { ...OWN_SENDER.config, verified: false } } + +const emailStep = (id: string, from: Record<string, unknown>): HogFlowAction => ({ + id, + type: 'function_email', + name: 'Send email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: 'recipient@example.com' }, + from, + subject: 'Hello', + html: '<p>Hi</p>', + text: 'Hi', + }, + templating: 'liquid', + }, + }, + }, +}) + +const makeWorkflow = (steps: HogFlowAction[]): HogFlow => ({ + id: WORKFLOW_ID, + name: 'Sandbox switch test', + actions: [ + { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { type: 'event', filters: {} }, + }, + ...steps, + { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Default exit' }, + }, + ], + edges: [], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'draft', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', +}) + +const senderOf = (workflow: HogFlow, stepId: string): unknown => { + const action = workflow.actions.find((candidate) => candidate.id === stepId) + return action?.type === 'function_email' ? action.config.inputs.email.value.from : undefined +} + +describe('workflowSandboxSwitchBannerLogic', () => { + let logic: ReturnType<typeof workflowSandboxSwitchBannerLogic.build> + let capture: jest.SpyInstance + let successToast: jest.SpyInstance + let savedWorkflow: HogFlow + let integrationsPayload: unknown[] + + const setSandboxFlag = (enabled: boolean): void => { + featureFlagLogic.actions.setFeatureFlags( + enabled ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] : [], + enabled ? { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true } : {} + ) + } + + const mountWith = async ({ + steps, + integrations, + flag = true, + accessLevel = AccessControlLevel.Editor, + status = 'draft', + }: { + steps: HogFlowAction[] + integrations: unknown[] + flag?: boolean + accessLevel?: AccessControlLevel + status?: HogFlow['status'] + }): Promise<void> => { + savedWorkflow = { ...makeWorkflow(steps), user_access_level: accessLevel, status } + integrationsPayload = integrations + initKeaTests() + featureFlagLogic.mount() + setSandboxFlag(flag) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + workflow.mount() + integrationsLogic.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + logic = workflowSandboxSwitchBannerLogic({ id: WORKFLOW_ID }) + logic.mount() + } + + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => [200, savedWorkflow], + '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }], + '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ + 200, + { ...savedWorkflow, ...((await request.json()) as Partial<HogFlow>) }, + ], + }, + }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + successToast = jest.spyOn(lemonToast, 'success').mockImplementation() + }) + + afterEach(() => { + logic?.unmount() + featureFlagLogic.actions.setFeatureFlags([], {}) + capture.mockRestore() + successToast.mockRestore() + }) + + it.each([ + { + case: 'a sandbox email step and a verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: true, + }, + { + case: 'no verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, UNVERIFIED_OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'no email step on the sandbox sender', + steps: [emailStep('email_1', { integrationId: OWN_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'the sandbox sender flag off', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + visible: false, + }, + ])('shows the banner only with $case', async ({ steps, integrations, flag, visible }) => { + await mountWith({ steps, integrations, flag }) + + expect(logic.values.bannerVisible).toBe(visible) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + visible ? 1 : 0 + ) + }) + + it('reports the banner once the flag arrives after the workflow and integrations loaded', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + }) + expect(logic.values.bannerVisible).toBe(false) + + setSandboxFlag(true) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('reports the banner once per workflow view even when it hides and shows again', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + expect(logic.values.bannerVisible).toBe(false) + workflow.actions.setWorkflowValues({ actions: savedWorkflow.actions }) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('switches every sandbox email step to the own sender as an unsaved change', async () => { + await mountWith({ + steps: [ + emailStep('email_1', { integrationId: SANDBOX_SENDER_ID }), + emailStep('email_2', { integrationId: OWN_SENDER_ID, name: 'Acme', email: 'hello@acme.example.com' }), + emailStep('email_3', { integrationId: SANDBOX_SENDER_ID }), + ], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(senderOf(workflow.values.workflow, 'email_2')).toEqual({ + integrationId: OWN_SENDER_ID, + name: 'Acme', + email: 'hello@acme.example.com', + }) + expect(senderOf(workflow.values.workflow, 'email_3')).toEqual({ integrationId: OWN_SENDER_ID }) + const switchedEmail = workflow.values.workflow.actions.find((step) => step.id === 'email_1') + expect(switchedEmail?.type === 'function_email' && switchedEmail.config.inputs.email).toEqual({ + value: { + from: { integrationId: OWN_SENDER_ID }, + to: { email: 'recipient@example.com' }, + subject: 'Hello', + html: '<p>Hi</p>', + text: 'Hi', + }, + templating: 'liquid', + }) + expect(senderOf(workflow.values.originalWorkflow!, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(workflow.values.hasUnsavedChanges).toBe(true) + expect(logic.values.bannerVisible).toBe(false) + expect(capture).toHaveBeenCalledWith('workflows sandbox sender switched', { email_step_count: 2 }) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox sender switched')).toHaveLength(1) + }) + + it.each([ + ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], + ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(successToast).toHaveBeenCalledWith(confirmation) + }) + + it('lets the user pick the own sender when several are verified', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, SECOND_OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + + expect(logic.values.verifiedOwnSenders.map((sender) => sender.id)).toEqual([ + OWN_SENDER_ID, + SECOND_OWN_SENDER_ID, + ]) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(SECOND_OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SECOND_OWN_SENDER_ID, + }) + }) + + it('prevents switching while an external edit reloads the workflow', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => new Promise(() => {}), + }, + }) + resourceEditedLogic.actions.resourceEdited({ + notification_type: 'resource_edited', + team_id: 1, + resource_type: 'HogFlow', + resource_id: WORKFLOW_ID, + updated_at: '2026-06-01T00:00:00.000Z', + actor_user_id: 99, + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + expect(workflow.values.isSyncingExternalEdit).toBe(true) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(logic.values.switchDisabledReason).toBe('Wait for the workflow to finish syncing') + expect(successToast).not.toHaveBeenCalled() + }) + + it('leaves the workflow intact for a viewer', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + accessLevel: AccessControlLevel.Viewer, + }) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + }) + + it.each([UNVERIFIED_OWN_SENDER.id, SANDBOX_SENDER_ID, 999])( + 'rejects ineligible switch target %s', + async (targetId) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + logic.actions.switchToOwnSender(targetId) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + } + ) +}) diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts new file mode 100644 index 000000000000..a1a8dc4032bf --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts @@ -0,0 +1,236 @@ +import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' +import posthog from 'posthog-js' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' +import { pluralize } from 'lib/utils/strings' +import { selectedSenderIds } from 'scenes/hog-functions/email-templater/selectSenders' + +import { AccessControlLevel, AccessControlResourceType, IntegrationType } from '~/types' + +import { isEmailAction } from './hogflows/steps/types' +import { HogFlow, HogFlowAction } from './hogflows/types' +import { WorkflowLogicProps, workflowLogic } from './workflowLogic' + +export type EmailStep = Extract<HogFlowAction, { type: 'function_email' }> + +const usesSender = (step: EmailStep, senderId: number): boolean => + selectedSenderIds(step.config.inputs?.email?.value?.from).includes(senderId) + +const withSender = (step: EmailStep, integrationId: number): EmailStep => { + const email = step.config.inputs.email + return { + ...step, + config: { + ...step.config, + inputs: { + ...step.config.inputs, + email: { ...email, value: { ...email.value, from: { integrationId } } }, + }, + }, + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicValues { + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + isSyncingExternalEdit: boolean // workflowLogic + workflow: HogFlow // workflowLogic + workflowLoading: boolean // workflowLogic + workflowUserAccessLevel: AccessControlLevel | null // workflowLogic + bannerShownReported: boolean + bannerVisible: boolean + sandboxEmailSteps: EmailStep[] + switchDisabledReason: string | null + verifiedOwnSenders: IntegrationType[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicActions { + autoSaveWorkflow: () => { + value: true + } // workflowLogic + setWorkflowValues: (values: import('node_modules/kea-forms/lib').DeepPartial<HogFlow>) => { + values: import('node_modules/kea-forms/lib').DeepPartial<HogFlow> + } // workflowLogic + reportBannerShown: () => { + value: true + } + switchToOwnSender: (integrationId: number) => { + integrationId: number + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + switchDisabledReason: ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ) => string | null + verifiedOwnSenders: (ownEmailIntegrations: IntegrationType[]) => IntegrationType[] + sandboxEmailSteps: (workflow: HogFlow, sandboxEmailSender: IntegrationType | null) => EmailStep[] + bannerVisible: ( + sandboxEmailSteps: ({ + config: { + inputs: Record< + string, + { + bytecode?: any + order?: number | undefined + secret?: boolean | undefined + templating?: 'hog' | 'liquid' | undefined + value: any + } + > + message_category_id?: string | undefined + message_category_type?: 'marketing' | 'transactional' | undefined + template_id: 'template-email' + template_uuid?: string | undefined + tracking_enabled?: boolean | undefined + utm_params?: + | { + utm_campaign?: string | undefined + utm_content?: string | undefined + utm_medium?: string | undefined + utm_source?: string | undefined + } + | undefined + utm_params_from_default?: + | ('utm_campaign' | 'utm_content' | 'utm_medium' | 'utm_source')[] + | undefined + utm_tags_enabled?: boolean | undefined + } + created_at?: number | undefined + description: string + filters?: + | { + actions?: any[] | undefined + events?: any[] | undefined + properties?: any[] | undefined + } + | null + | undefined + id: string + name: string + on_error?: 'abort' | 'continue' | null | undefined + output_variable?: + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + } + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + }[] + | null + | undefined + type: 'function_email' + updated_at?: number | undefined + } & Record<string, unknown>)[], + verifiedOwnSenders: IntegrationType[] + ) => boolean + } +} + +export type workflowSandboxSwitchBannerLogicType = MakeLogicType< + workflowSandboxSwitchBannerLogicValues, + workflowSandboxSwitchBannerLogicActions, + WorkflowLogicProps, + workflowSandboxSwitchBannerLogicMeta +> + +export const workflowSandboxSwitchBannerLogic = kea<workflowSandboxSwitchBannerLogicType>([ + props({} as WorkflowLogicProps), + key((props) => `${props.id ?? 'new'}-${props.templateId ?? 'default'}-${props.editTemplateId ?? 'default'}`), + path((key) => ['products', 'workflows', 'frontend', 'Workflows', 'workflowSandboxSwitchBannerLogic', key]), + connect((props: WorkflowLogicProps) => ({ + values: [ + workflowLogic(props), + ['workflow', 'workflowUserAccessLevel', 'workflowLoading', 'isSyncingExternalEdit'], + integrationsLogic, + ['ownEmailIntegrations', 'sandboxEmailSender'], + ], + actions: [workflowLogic(props), ['setWorkflowValues', 'autoSaveWorkflow']], + })), + actions({ + switchToOwnSender: (integrationId: number) => ({ integrationId }), + reportBannerShown: true, + }), + reducers({ + bannerShownReported: [false, { reportBannerShown: () => true }], + }), + selectors({ + switchDisabledReason: [ + (s) => [s.workflowUserAccessLevel, s.workflowLoading, s.isSyncingExternalEdit], + ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ): string | null => + getAccessControlDisabledReason( + AccessControlResourceType.Workflow, + AccessControlLevel.Editor, + workflowUserAccessLevel ?? undefined + ) || (workflowLoading || isSyncingExternalEdit ? 'Wait for the workflow to finish syncing' : null), + ], + verifiedOwnSenders: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): IntegrationType[] => + ownEmailIntegrations.filter((integration) => integration.config?.verified === true), + ], + sandboxEmailSteps: [ + (s) => [s.workflow, s.sandboxEmailSender], + (workflow: HogFlow, sandboxEmailSender: IntegrationType | null): EmailStep[] => + sandboxEmailSender + ? workflow.actions.filter(isEmailAction).filter((step) => usesSender(step, sandboxEmailSender.id)) + : [], + ], + bannerVisible: [ + (s) => [s.sandboxEmailSteps, s.verifiedOwnSenders], + (sandboxEmailSteps: EmailStep[], verifiedOwnSenders: IntegrationType[]): boolean => + sandboxEmailSteps.length > 0 && verifiedOwnSenders.length > 0, + ], + }), + listeners(({ actions, values }) => ({ + reportBannerShown: () => { + posthog.capture('workflows sandbox switch banner shown') + }, + switchToOwnSender: ({ integrationId }) => { + const { sandboxEmailSteps } = values + const sender = values.verifiedOwnSenders.find((candidate) => candidate.id === integrationId) + if (sandboxEmailSteps.length === 0 || !sender) { + return + } + if (values.switchDisabledReason) { + return + } + const switched = new Map(sandboxEmailSteps.map((step) => [step.id, withSender(step, integrationId)])) + actions.setWorkflowValues({ + actions: values.workflow.actions.map((action) => switched.get(action.id) ?? action), + }) + actions.autoSaveWorkflow() + posthog.capture('workflows sandbox sender switched', { email_step_count: sandboxEmailSteps.length }) + const nextStep = values.workflow.status === 'active' ? ' Publish the changes to use this sender.' : '' + lemonToast.success( + `Updated ${pluralize(sandboxEmailSteps.length, 'email step')} to ${sender.config.email ?? 'your own sender'}.${nextStep}` + ) + }, + })), + subscriptions(({ actions, values }) => ({ + bannerVisible: (bannerVisible: boolean) => { + if (bannerVisible && !values.bannerShownReported) { + actions.reportBannerShown() + } + }, + })), +]) From 8ee50d709c70257178e2e6db359da7c637159d3c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:36:15 +0000 Subject: [PATCH 14/38] feat(workflows): let staff block the sandbox sender per organization Adds SandboxSenderOrganizationBlock, a staff-managed row in the Django admin with the organization, a reason, who added it and when. While a row exists, the ensure endpoint creates no new sandbox sender for the organization's projects. Deleting the row lifts the block. The migration creates the new table only. The email worker starts reading the block in the next layer, so the migration lands before any worker queries the table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/scripts/check-idor-model-coverage.py | 1 + .../security/idor-team-scoped-models.yaml | 2 + products/workflows/backend/admin/__init__.py | 1 + ...sandbox_sender_organization_block_admin.py | 31 +++++++++++ .../0038_sandbox_sender_organization_block.py | 52 +++++++++++++++++++ .../backend/migrations/max_migration.txt | 2 +- products/workflows/backend/models/__init__.py | 2 + .../sandbox_sender_organization_block.py | 15 ++++++ .../backend/services/sandbox_sender.py | 7 ++- ...sandbox_sender_organization_block_admin.py | 19 +++++++ .../backend/tests/api/test_sandbox_sender.py | 15 ++++++ 11 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 products/workflows/backend/admin/sandbox_sender_organization_block_admin.py create mode 100644 products/workflows/backend/migrations/0038_sandbox_sender_organization_block.py create mode 100644 products/workflows/backend/models/sandbox_sender_organization_block.py create mode 100644 products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py diff --git a/.github/scripts/check-idor-model-coverage.py b/.github/scripts/check-idor-model-coverage.py index aa7d0b87aa9c..34711fa14162 100644 --- a/.github/scripts/check-idor-model-coverage.py +++ b/.github/scripts/check-idor-model-coverage.py @@ -355,6 +355,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st "RoleMembership", "LinkedIdentityProviderConfig", "IdJagIdentity", + "SandboxSenderOrganizationBlock", # staff-managed switch that turns the sandbox sender off for one organization # --- User-scoped (cross-tenant by design) --- "NotificationViewed", "SCIMProvisionedUser", diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml index 2ab29ded3105..3cd3e168bec3 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -1344,6 +1344,7 @@ rules: |Role |RoleExternalReference |RoleMembership + |SandboxSenderOrganizationBlock )$ - focus-metavariable: $SINK pattern-sanitizers: @@ -1427,6 +1428,7 @@ rules: |Role |RoleExternalReference |RoleMembership + |SandboxSenderOrganizationBlock )$ - pattern-not: $M.objects. ... .$METHOD2(..., organization=$O, ...) - pattern-not: $M.objects. ... .$METHOD2(..., organization_id=$O, ...) diff --git a/products/workflows/backend/admin/__init__.py b/products/workflows/backend/admin/__init__.py index 9e90819008f7..dae51551b034 100644 --- a/products/workflows/backend/admin/__init__.py +++ b/products/workflows/backend/admin/__init__.py @@ -4,5 +4,6 @@ hog_flow_admin, hog_flow_batch_job_admin, hog_flow_template_admin, + sandbox_sender_organization_block_admin, team_workflows_config_admin, ) diff --git a/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py b/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py new file mode 100644 index 000000000000..6c5e0792b146 --- /dev/null +++ b/products/workflows/backend/admin/sandbox_sender_organization_block_admin.py @@ -0,0 +1,31 @@ +from typing import cast + +from django.contrib import admin +from django.forms import ModelForm +from django.http import HttpRequest + +from posthog.models.user import User + +from products.workflows.backend.models import SandboxSenderOrganizationBlock + + +@admin.register(SandboxSenderOrganizationBlock) +class SandboxSenderOrganizationBlockAdmin(admin.ModelAdmin): + list_display = ("organization", "reason", "created_by", "created_at") + fields = ("organization", "reason", "created_by", "created_at") + readonly_fields = ("created_by", "created_at") + raw_id_fields = ("organization",) + search_fields = ("organization__id", "organization__name", "reason") + list_select_related = ("organization", "created_by") + ordering = ("-created_at",) + + def save_model( + self, + request: HttpRequest, + obj: SandboxSenderOrganizationBlock, + form: ModelForm | None, + change: bool, + ) -> None: + if not change: + obj.created_by = cast(User, request.user) + super().save_model(request, obj, form, change) diff --git a/products/workflows/backend/migrations/0038_sandbox_sender_organization_block.py b/products/workflows/backend/migrations/0038_sandbox_sender_organization_block.py new file mode 100644 index 000000000000..94e99d8fdd26 --- /dev/null +++ b/products/workflows/backend/migrations/0038_sandbox_sender_organization_block.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.18 on 2026-10-26 06:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1411_personalapikey_expires_at"), + ("workflows", "0037_sandbox_sender_tenant_state"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SandboxSenderOrganizationBlock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("reason", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.OneToOneField( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="posthog.organization", + ), + ), + ], + ), + ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index 67cf914e224f..3229542aad34 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0037_sandbox_sender_tenant_state +0038_sandbox_sender_organization_block diff --git a/products/workflows/backend/models/__init__.py b/products/workflows/backend/models/__init__.py index fdf755e421f6..1b310b9e4de3 100644 --- a/products/workflows/backend/models/__init__.py +++ b/products/workflows/backend/models/__init__.py @@ -4,6 +4,7 @@ from .hog_flow_optimization import HogFlowOptimization from .hog_flow_revision import HogFlowRevision from .hog_flow_schedule.hog_flow_schedule import HogFlowSchedule +from .sandbox_sender_organization_block import SandboxSenderOrganizationBlock from .sandbox_sender_tenant_state import SandboxSenderTenantState from .team_workflows_config import TeamWorkflowsConfig from .workflow_idea import WorkflowIdea @@ -16,6 +17,7 @@ "HogFlowRevision", "HogFlowSchedule", "HogFlowTemplate", + "SandboxSenderOrganizationBlock", "SandboxSenderTenantState", "TeamWorkflowsConfig", "WorkflowIdea", diff --git a/products/workflows/backend/models/sandbox_sender_organization_block.py b/products/workflows/backend/models/sandbox_sender_organization_block.py new file mode 100644 index 000000000000..1539c82607f5 --- /dev/null +++ b/products/workflows/backend/models/sandbox_sender_organization_block.py @@ -0,0 +1,15 @@ +from django.db import models + + +class SandboxSenderOrganizationBlock(models.Model): + """Staff switch that turns the shared sandbox sender off for one organization; deleting the row lifts it.""" + + # db_constraint=False: a real FK constraint to a hot table takes a parent-table lock on creation. + organization = models.OneToOneField( + "posthog.Organization", on_delete=models.CASCADE, db_constraint=False, related_name="+" + ) + reason = models.TextField() + created_by = models.ForeignKey( + "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name="+" + ) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py index 2da94cd73d1f..2828edb454e5 100644 --- a/products/workflows/backend/services/sandbox_sender.py +++ b/products/workflows/backend/services/sandbox_sender.py @@ -8,6 +8,7 @@ from posthog.plugins.plugin_server_api import reload_integrations_on_workers from products.workflows.backend.facade.contracts import SandboxEmailSender, SandboxSenderUnavailable +from products.workflows.backend.models.sandbox_sender_organization_block import SandboxSenderOrganizationBlock logger = structlog.get_logger(__name__) @@ -54,9 +55,13 @@ def _sandbox_sender_enabled(team: Team) -> bool: return False +def _organization_blocked(team: Team) -> bool: + return SandboxSenderOrganizationBlock.objects.filter(organization_id=team.organization_id).exists() + + def ensure_sandbox_email_sender(team_id: int) -> SandboxEmailSender: team = Team.objects.select_related("organization").get(id=team_id) - if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team): + if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team) or _organization_blocked(team): raise SandboxSenderUnavailable() config = _sandbox_sender_config(team.organization.name) diff --git a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py new file mode 100644 index 000000000000..5c76ce6c5c11 --- /dev/null +++ b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py @@ -0,0 +1,19 @@ +from posthog.test.base import BaseTest + +from django.contrib.admin import AdminSite +from django.test import RequestFactory + +from products.workflows.backend.admin.sandbox_sender_organization_block_admin import SandboxSenderOrganizationBlockAdmin +from products.workflows.backend.models import SandboxSenderOrganizationBlock + + +class TestSandboxSenderOrganizationBlockAdmin(BaseTest): + def test_a_block_added_in_admin_records_the_staff_user(self) -> None: + admin = SandboxSenderOrganizationBlockAdmin(SandboxSenderOrganizationBlock, AdminSite()) + request = RequestFactory().post("/admin/workflows/sandboxsenderorganizationblock/add/") + request.user = self.user + block = SandboxSenderOrganizationBlock(organization=self.organization, reason="Abuse report") + + admin.save_model(request, block, form=None, change=False) + + assert SandboxSenderOrganizationBlock.objects.get(organization=self.organization).created_by == self.user diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index bf48ee499933..308306703e20 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -14,6 +14,8 @@ from posthog.models.personal_api_key import PersonalAPIKey from posthog.models.utils import hash_key_value +from products.workflows.backend.models import SandboxSenderOrganizationBlock + if TYPE_CHECKING: from rest_framework.response import _MonkeyPatchedResponse @@ -114,6 +116,19 @@ def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( assert not self._sandbox_rows().exists() assert self._provisioned_events() == [] + def test_ensure_is_not_found_for_a_blocked_organization_until_the_block_is_removed(self) -> None: + block = SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") + + blocked = self._ensure() + + assert blocked.status_code == status.HTTP_404_NOT_FOUND, blocked.json() + assert not self._sandbox_rows().exists() + assert self._provisioned_events() == [] + block.delete() + unblocked = self._ensure() + assert unblocked.status_code == status.HTTP_200_OK, unblocked.json() + assert self._sandbox_rows().count() == 1 + @parameterized.expand( [ ("read scope", "integration:read", status.HTTP_403_FORBIDDEN), From fc8dccdf088721cf8a9489d2baca0102bf39a56e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:11:46 +0000 Subject: [PATCH 15/38] test(workflows): add and delete an organization block through the admin forms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...sandbox_sender_organization_block_admin.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py index 5c76ce6c5c11..b19175a452c4 100644 --- a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py +++ b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py @@ -1,19 +1,31 @@ from posthog.test.base import BaseTest -from django.contrib.admin import AdminSite -from django.test import RequestFactory +from django.test import Client, override_settings -from products.workflows.backend.admin.sandbox_sender_organization_block_admin import SandboxSenderOrganizationBlockAdmin from products.workflows.backend.models import SandboxSenderOrganizationBlock +ADMIN_URL = "/admin/workflows/sandboxsenderorganizationblock/" + +@override_settings(STORAGES={"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}}) class TestSandboxSenderOrganizationBlockAdmin(BaseTest): - def test_a_block_added_in_admin_records_the_staff_user(self) -> None: - admin = SandboxSenderOrganizationBlockAdmin(SandboxSenderOrganizationBlock, AdminSite()) - request = RequestFactory().post("/admin/workflows/sandboxsenderorganizationblock/add/") - request.user = self.user - block = SandboxSenderOrganizationBlock(organization=self.organization, reason="Abuse report") + def setUp(self) -> None: + super().setUp() + self.user.is_staff = True + self.user.save() + self.client = Client() + self.client.force_login(self.user) + + def test_staff_add_a_block_with_its_author_and_delete_it(self) -> None: + added = self.client.post( + f"{ADMIN_URL}add/", data={"organization": str(self.organization.id), "reason": "Abuse report"} + ) + + assert added.status_code == 302, added.content + block = SandboxSenderOrganizationBlock.objects.get(organization=self.organization) + assert (block.reason, block.created_by) == ("Abuse report", self.user) - admin.save_model(request, block, form=None, change=False) + deleted = self.client.post(f"{ADMIN_URL}{block.pk}/delete/", data={"post": "yes"}) - assert SandboxSenderOrganizationBlock.objects.get(organization=self.organization).created_by == self.user + assert deleted.status_code == 302, deleted.content + assert not SandboxSenderOrganizationBlock.objects.filter(organization=self.organization).exists() From e7d822649f309da60cfd9297c000ccb8f8889f12 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:08:47 +0000 Subject: [PATCH 16/38] feat(workflows): provision a managed sandbox email sender Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 12 ++ posthog/api/integration.py | 45 ++++- posthog/api/team/team_config.py | 5 + posthog/models/integration/__init__.py | 9 +- posthog/models/integration/email.py | 24 +++ posthog/settings/ses.py | 3 + .../integrations/frontend/generated/api.ts | 14 ++ products/workflows/backend/facade/api.py | 4 + .../workflows/backend/facade/contracts.py | 10 + .../services/data_suggestions/team_context.py | 6 +- .../backend/services/email_health.py | 3 +- .../backend/services/sandbox_sender.py | 69 +++++++ .../backend/services/setup_status.py | 6 +- .../services/transactional_email_workflows.py | 7 +- .../test/test_sandbox_sender_lifecycle.py | 46 +++++ .../test/test_team_workflows_config.py | 19 +- .../test_transactional_email_workflows.py | 17 +- .../test/test_workflow_setup_status.py | 13 +- .../tests/api/test_email_reputation.py | 44 ++++- .../backend/tests/api/test_sandbox_sender.py | 177 ++++++++++++++++++ .../api/test_workflow_data_suggestions.py | 9 + 21 files changed, 509 insertions(+), 33 deletions(-) create mode 100644 products/workflows/backend/services/sandbox_sender.py create mode 100644 products/workflows/backend/test/test_sandbox_sender_lifecycle.py create mode 100644 products/workflows/backend/tests/api/test_sandbox_sender.py diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 6914badeaa51..2d8d7faebd1a 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -16,6 +16,18 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook ## Configuration +### Sandbox sender provisioning + +The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. +The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. +Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN` and `SES_SANDBOX_FROM_ADDRESS` in Django; either empty value leaves the endpoint unavailable. +The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. +Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. +The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. +Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. + +### Sending tiers + Two sides read their own env vars because the worker never reads Django settings. Set them together; drift means the UI describes limits the worker does not apply, or the reverse. diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 2b8935d8d932..c2911233fedb 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -141,7 +141,12 @@ from products.slack_app.backend.services.slack_auth import SLACK_AUTH_FAILURE_CODES from products.tasks.backend.facade.api import get_in_progress_runs_for_github_integration from products.tasks.backend.facade.contracts import InProgressGithubRunsDTO -from products.workflows.backend.facade.api import get_active_workflows_using_integration, lookup_existing_email_tools +from products.workflows.backend.facade.api import ( + SandboxSenderUnavailable, + ensure_sandbox_email_sender, + get_active_workflows_using_integration, + lookup_existing_email_tools, +) from products.workflows.backend.facade.contracts import EmailProviderUnavailableError from products.workflows.backend.facade.enums import EmailDomainSetupMethod @@ -1527,6 +1532,7 @@ class IntegrationViewSet( "github_link_existing", "github_oauth_authorize", "email_verify", + "email_sandbox_sender", # Side-effecting POST (emails admins) — a read-only token must not be able to trigger it. "request_access", ] @@ -1565,7 +1571,13 @@ def dangerously_get_permissions(self): ] # Adding an integration only requires project membership. Every edit and removal uses the # viewset permission class, including the creator exception for Google account removal. - if self.action in ("create", "github_link_existing", "github_oauth_authorize", "request_access"): + if self.action in ( + "create", + "github_link_existing", + "github_oauth_authorize", + "request_access", + "email_sandbox_sender", + ): return base_permissions if self.action == "refresh_github_repos": return [*base_permissions, TeamMemberLightManagementPermission()] @@ -1577,6 +1589,9 @@ def get_throttles(self): return super().get_throttles() def perform_destroy(self, instance: Integration) -> None: + if instance.kind == "email": + EmailIntegration(instance).ensure_user_managed() + flows_using_integration = get_active_workflows_using_integration( team_id=instance.team_id, integration_id=instance.id ) @@ -2678,6 +2693,23 @@ def gitlab_members(self, request: Request, *args: Any, **kwargs: Any) -> Respons search = query_serializer.validated_data["search"] return assignees_response(lambda: GitLabIntegration(instance).list_assignees(search)) + @extend_schema( + request=None, + responses={200: IntegrationSerializer, 404: OpenApiResponse(description="No sandbox sender for this project.")}, + ) + @action(methods=["POST"], detail=False, url_path="email_sandbox_sender") + def email_sandbox_sender(self, request: Request, *args: Any, **kwargs: Any) -> Response: + try: + sandbox_sender = ensure_sandbox_email_sender(self.team_id) + except SandboxSenderUnavailable: + raise NotFound("The sandbox sender is not available for this project.") + if sandbox_sender.created: + report_user_action( + cast(User, request.user), "workflows sandbox sender provisioned", team=self.team, request=request + ) + integration = self.get_queryset().get(id=sandbox_sender.integration_id) + return Response(IntegrationSerializer(integration, context=self.get_serializer_context()).data) + @extend_schema( request=None, responses={200: EmailDomainVerificationSerializer}, @@ -2754,14 +2786,13 @@ def _email_domain_setup_method(request: Request, instance: Integration) -> str: @extend_schema(responses={200: IntegrationSerializer}) @action(methods=["PATCH"], detail=True, url_path="email") def email_update(self, request, **kwargs) -> Response: - instance = self.get_object() - config = request.data.get("config", {}) + email = EmailIntegration(self.get_object()) + email.ensure_user_managed() - serializer = NativeEmailIntegrationSerializer(data=config) + serializer = NativeEmailIntegrationSerializer(data=request.data.get("config", {})) serializer.is_valid(raise_exception=True) - email = EmailIntegration(instance) - email.update_native_integration(serializer.validated_data, instance.team_id) + email.update_native_integration(serializer.validated_data, email.integration.team_id) return Response(IntegrationSerializer(email.integration).data) diff --git a/posthog/api/team/team_config.py b/posthog/api/team/team_config.py index eb0b9bd87fcb..b2548eaef2e4 100644 --- a/posthog/api/team/team_config.py +++ b/posthog/api/team/team_config.py @@ -7,6 +7,7 @@ from posthog.models import Team, TeamRevenueAnalyticsConfig from posthog.models.filters.utils import validate_group_type_index +from posthog.models.integration.email import SANDBOX_EMAIL_PROVIDER from posthog.models.integration.model import Integration from products.access_control.backend.presentation.access_control import UserAccessControlSerializerMixin @@ -427,6 +428,10 @@ def validate_default_email_integration_id(self, value: int | None) -> int | None ) if sender is None: raise serializers.ValidationError("Choose an email sender from this project.") + if sender.config.get("provider") == SANDBOX_EMAIL_PROVIDER: + raise serializers.ValidationError( + "The sandbox sender can't be the project default. Choose a sender on your own verified domain." + ) if not sender.config.get("verified"): raise serializers.ValidationError("Verify this sender's domain before making it the default.") return value diff --git a/posthog/models/integration/__init__.py b/posthog/models/integration/__init__.py index eb0922152a84..6f3bc600b289 100644 --- a/posthog/models/integration/__init__.py +++ b/posthog/models/integration/__init__.py @@ -38,7 +38,12 @@ dot_get, ) from .databricks import DatabricksIntegration, DatabricksIntegrationError -from .email import EmailIntegration, cleanup_ses_identity_on_integration_delete +from .email import ( + SANDBOX_EMAIL_INTEGRATION_ID, + SANDBOX_EMAIL_PROVIDER, + EmailIntegration, + cleanup_ses_identity_on_integration_delete, +) from .external_issues import ( SUPPORTED_EXTERNAL_ISSUE_PROVIDERS, external_issue_url, @@ -221,6 +226,8 @@ "ClickUpIntegration", "EmailIntegration", "cleanup_ses_identity_on_integration_delete", + "SANDBOX_EMAIL_INTEGRATION_ID", + "SANDBOX_EMAIL_PROVIDER", "LinearIntegration", "SUPPORTED_EXTERNAL_ISSUE_PROVIDERS", "external_issue_url", diff --git a/posthog/models/integration/email.py b/posthog/models/integration/email.py index 132309a4899d..183224cae2c8 100644 --- a/posthog/models/integration/email.py +++ b/posthog/models/integration/email.py @@ -39,6 +39,16 @@ class EmailDomainStatusReport: checked_at: datetime +SANDBOX_EMAIL_PROVIDER = "sandbox" +SANDBOX_EMAIL_INTEGRATION_ID = "posthog-sandbox" +SANDBOX_SENDER_MANAGED_MESSAGE = "The sandbox sender is managed by PostHog." + + +def is_sandbox_sender_domain(domain: str) -> bool: + sandbox_domain = settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN + return bool(sandbox_domain) and (domain == sandbox_domain or domain.endswith(f".{sandbox_domain}")) + + class EmailIntegration: integration: model.Integration @@ -47,6 +57,14 @@ def __init__(self, integration: model.Integration) -> None: raise Exception("EmailIntegration init called with Integration with wrong 'kind'") self.integration = integration + @property + def is_sandbox_sender(self) -> bool: + return self.integration.config.get("provider") == SANDBOX_EMAIL_PROVIDER + + def ensure_user_managed(self) -> None: + if self.is_sandbox_sender: + raise ValidationError(SANDBOX_SENDER_MANAGED_MESSAGE) + @classmethod def create_native_integration( cls, config: dict, team_id: int, organization_id: str, created_by: User | None = None @@ -60,6 +78,11 @@ def create_native_integration( if domain in free_email_domains_list or domain in disposable_email_domains_list: raise ValidationError(f"Email domain {domain} is not supported. Please use a custom domain.") + if is_sandbox_sender_domain(domain): + raise ValidationError( + f"Email domain {domain} belongs to the PostHog sandbox sender. Please use a domain you own." + ) + # Check if any other integration already exists in a different team with the same domain, # if so, ensure this team is part of the same organization. If not, we block creation. same_domain_integrations = model.Integration.objects.filter(kind="email", config__domain=domain) @@ -152,6 +175,7 @@ def update_native_integration(self, config: dict, team_id: int) -> model.Integra return self.integration def verify(self, *, on_domain_verified: Callable[[], None] | None = None) -> "EmailDomainVerification": + self.ensure_user_managed() domain = self.integration.config.get("domain") provider = self.integration.config.get("provider", "ses") mail_from_subdomain = self.integration.config.get("mail_from_subdomain", "feedback") diff --git a/posthog/settings/ses.py b/posthog/settings/ses.py index 6cd9beaff4c7..ee6db86140bb 100644 --- a/posthog/settings/ses.py +++ b/posthog/settings/ses.py @@ -29,6 +29,9 @@ if cs.strip() ] +WORKFLOWS_SANDBOX_SENDER_DOMAIN: str = os.getenv("WORKFLOWS_SANDBOX_SENDER_DOMAIN", "").strip().lower() +SES_SANDBOX_FROM_ADDRESS: str = os.getenv("SES_SANDBOX_FROM_ADDRESS", "").strip().lower() + # SNS topics allowed to deliver SES tenant reputation events (EventBridge -> SNS -> webhook). # Empty (the default) leaves the webhook inert: the SNS signature proves a message came from AWS, # but only the allowlist proves it came from *our* topic. diff --git a/products/integrations/frontend/generated/api.ts b/products/integrations/frontend/generated/api.ts index e917b18a6d7c..be184ff744f5 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -907,6 +907,20 @@ export const integrationsDomainConnectCheckRetrieve = async ( }) } +export const getIntegrationsEmailSandboxSenderCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/integrations/email_sandbox_sender/` +} + +export const integrationsEmailSandboxSenderCreate = async ( + projectId: string, + options?: RequestInit +): Promise<IntegrationConfigApi> => { + return apiMutator<IntegrationConfigApi>(getIntegrationsEmailSandboxSenderCreateUrl(projectId), { + ...options, + method: 'POST', + }) +} + export const getIntegrationsGithubAvailableInstallationsRetrieveUrl = (projectId: string) => { return `/api/projects/${projectId}/integrations/github/available_installations/` } diff --git a/products/workflows/backend/facade/api.py b/products/workflows/backend/facade/api.py index 436294a9efa8..4db34b8f46c7 100644 --- a/products/workflows/backend/facade/api.py +++ b/products/workflows/backend/facade/api.py @@ -15,6 +15,7 @@ EmailDomainVerification, FlowUtmUpdate, RecentWorkflow, + SandboxSenderUnavailable, TeamUtmDefaults, TierDecision, TwilioAccount, @@ -47,6 +48,7 @@ ) from products.workflows.backend.services.integration_usage import get_active_hog_flows_using_integration from products.workflows.backend.services.owned_workflow_health import list_owned_workflow_health +from products.workflows.backend.services.sandbox_sender import ensure_sandbox_email_sender from products.workflows.backend.services.template_input_usage import ( filter_hog_flow_references_by_access_level, get_hog_flows_referencing_template_input_keys, @@ -64,8 +66,10 @@ "WorkflowNotFound", "MIN_EMAIL_SENDING_TIER", "clear_default_email_sender", + "SandboxSenderUnavailable", "compute_next_occurrences", "create_batch_job", + "ensure_sandbox_email_sender", "ensure_workflows_config", "filter_hog_flow_references_by_access_level", "get_email_sending_state", diff --git a/products/workflows/backend/facade/contracts.py b/products/workflows/backend/facade/contracts.py index 4fc54ec3e0d7..06cf555e3445 100644 --- a/products/workflows/backend/facade/contracts.py +++ b/products/workflows/backend/facade/contracts.py @@ -281,6 +281,16 @@ class EmailSendingTierLimits: max_batch_audience: int +@frozen +class SandboxEmailSender: + integration_id: int + created: bool + + +class SandboxSenderUnavailable(Exception): + pass + + @frozen class TierDecision: team_id: int diff --git a/products/workflows/backend/services/data_suggestions/team_context.py b/products/workflows/backend/services/data_suggestions/team_context.py index 3049253397b1..a959891b8c35 100644 --- a/products/workflows/backend/services/data_suggestions/team_context.py +++ b/products/workflows/backend/services/data_suggestions/team_context.py @@ -7,7 +7,7 @@ from django.db.models.functions import Coalesce from posthog.models import EventProperty, Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.api import EmailTemplateSummary, list_email_templates from products.workflows.backend.models.hog_flow.hog_flow import HogFlow @@ -100,7 +100,9 @@ def _verified_senders(team: Team) -> list[EmailSender]: return [ EmailSender(integration_id=integration.id, email=str(config["email"]), name=str(config.get("name") or "")) for integration in Integration.objects.filter(team_id=team.id, kind="email").order_by("created_at") - if (config := integration.config or {}).get("verified") is True and config.get("email") + if (config := integration.config or {}).get("verified") is True + and config.get("email") + and config.get("provider") != SANDBOX_EMAIL_PROVIDER ] diff --git a/products/workflows/backend/services/email_health.py b/products/workflows/backend/services/email_health.py index 5be6acff5ff5..35d86d68a037 100644 --- a/products/workflows/backend/services/email_health.py +++ b/products/workflows/backend/services/email_health.py @@ -12,7 +12,7 @@ import structlog from posthog.api.app_metrics2 import fetch_app_metric_totals_by_source, fetch_app_metric_totals_by_team_and_source -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.facade.contracts import ( EMAIL_HEALTH_METRIC_NAMES, @@ -216,6 +216,7 @@ def verified_email_domains(*, team_id: int) -> list[str]: dict.fromkeys( domain for domain in Integration.objects.filter(team_id=team_id, kind="email", config__verified=True) + .exclude(config__contains={"provider": SANDBOX_EMAIL_PROVIDER}) .order_by("id") .values_list("config__domain", flat=True) if domain diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py new file mode 100644 index 000000000000..4dc3d4cc94f9 --- /dev/null +++ b/products/workflows/backend/services/sandbox_sender.py @@ -0,0 +1,69 @@ +from django.conf import settings + +import structlog + +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration +from posthog.models.team import Team +from posthog.permissions import posthog_feature_flag_enabled +from posthog.plugins.plugin_server_api import reload_integrations_on_workers + +from products.workflows.backend.facade.contracts import SandboxEmailSender, SandboxSenderUnavailable + +logger = structlog.get_logger(__name__) + +SANDBOX_SENDER_FLAG = "workflows-sandbox-sender" +SANDBOX_SENDER_FALLBACK_NAME = "PostHog sandbox" +SANDBOX_SENDER_NAME_MAX_LENGTH = 40 +_SANDBOX_SENDER_NAME_PUNCTUATION = frozenset(" .,&'-") + + +def _sandbox_sender_display_name(organization_name: str) -> str: + kept = "".join(char for char in organization_name if char.isalnum() or char in _SANDBOX_SENDER_NAME_PUNCTUATION) + name = " ".join(kept.split())[:SANDBOX_SENDER_NAME_MAX_LENGTH].strip() + return f"{name} via PostHog" if name else SANDBOX_SENDER_FALLBACK_NAME + + +def _sandbox_sender_config(organization_name: str) -> dict[str, str | bool]: + return { + "provider": SANDBOX_EMAIL_PROVIDER, + "email": settings.SES_SANDBOX_FROM_ADDRESS, + "domain": settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN, + "name": _sandbox_sender_display_name(organization_name), + "verified": True, + } + + +def _sandbox_sender_configured() -> bool: + return bool(settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN and settings.SES_SANDBOX_FROM_ADDRESS) + + +def _sandbox_sender_enabled(team: Team) -> bool: + try: + return posthog_feature_flag_enabled( + SANDBOX_SENDER_FLAG, + str(team.uuid), + organization_id=team.organization_id, + team_id=team.id, + ) + except Exception: + logger.warning("workflows.sandbox_sender_flag_check_failed", team_id=team.id, exc_info=True) + return False + + +def ensure_sandbox_email_sender(team_id: int) -> SandboxEmailSender: + team = Team.objects.select_related("organization").get(id=team_id) + if not _sandbox_sender_configured() or not _sandbox_sender_enabled(team): + raise SandboxSenderUnavailable() + + config = _sandbox_sender_config(team.organization.name) + integration, created = Integration.objects.get_or_create( + team_id=team_id, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + defaults={"config": config, "created_by": None}, + ) + if not created and integration.config != config: + integration.config = config + integration.save() + reload_integrations_on_workers(team_id, [integration.id]) + return SandboxEmailSender(integration_id=integration.id, created=created) diff --git a/products/workflows/backend/services/setup_status.py b/products/workflows/backend/services/setup_status.py index 64200b08626e..8f81060453d0 100644 --- a/products/workflows/backend/services/setup_status.py +++ b/products/workflows/backend/services/setup_status.py @@ -1,7 +1,7 @@ """Which messaging setup steps a project has done, in the order the setup wizard asks for them.""" from posthog.models import Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.api import list_email_templates from products.messaging.backend.facade.categories import list_categories @@ -33,7 +33,9 @@ def email_sending_paused(team: Team) -> bool: def get_setup_steps(team: Team) -> tuple[SetupStep, ...]: done = { - SetupStepKey.CHANNEL: Integration.objects.filter(team_id=team.id, kind="email").exists(), + SetupStepKey.CHANNEL: Integration.objects.filter(team_id=team.id, kind="email") + .exclude(config__contains={"provider": SANDBOX_EMAIL_PROVIDER}) + .exists(), SetupStepKey.DOMAIN: has_verified_email_sender(team), SetupStepKey.OPT_OUTS: len(list_categories(team.id)) > 0, SetupStepKey.SENDING_RULES: _frequency_cap_set(team), diff --git a/products/workflows/backend/services/transactional_email_workflows.py b/products/workflows/backend/services/transactional_email_workflows.py index 733ea49e5649..5f10f789b706 100644 --- a/products/workflows/backend/services/transactional_email_workflows.py +++ b/products/workflows/backend/services/transactional_email_workflows.py @@ -7,7 +7,7 @@ from django.db import transaction -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.scoping import with_team_scope from products.workflows.backend.facade.contracts import ( @@ -105,14 +105,15 @@ def _content_fields(content: TransactionalEmailContent, from_integration_id: int def resolve_transactional_email_sender(team_id: int, requested_id: int | None) -> int: - """The requested sender, or the project's default when none is requested. Raise when it isn't verified.""" + """The requested sender, or the project's default when none is requested. Raise when it isn't verified, + or when it is the sandbox sender, which only workflow email steps and test sends may use.""" sender_id = requested_id or ( TeamWorkflowsConfig.objects.filter(team_id=team_id) .values_list("default_email_integration_id", flat=True) .first() ) sender = Integration.objects.filter(team_id=team_id, kind="email", id=sender_id).first() if sender_id else None - if sender is None or not sender.config.get("verified"): + if sender is None or not sender.config.get("verified") or sender.config.get("provider") == SANDBOX_EMAIL_PROVIDER: raise TransactionalEmailSenderUnavailable() return sender.id diff --git a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py new file mode 100644 index 000000000000..e812993b80d9 --- /dev/null +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -0,0 +1,46 @@ +from posthog.test.base import BaseTest +from unittest.mock import MagicMock, patch + +from django.test import override_settings + +from posthog.management.commands.migrate_ses_tenants import migrate_ses_tenants +from posthog.models.integration import Integration + +from products.workflows.backend.facade.api import ensure_sandbox_email_sender +from products.workflows.backend.tasks.ses_tenant_state import reconcile_ses_tenant_states + + +@override_settings( + WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", + SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", +) +class TestSandboxSenderLifecycleExclusions(BaseTest): + def setUp(self) -> None: + super().setUp() + with patch("posthoganalytics.feature_enabled", return_value=True): + self.sandbox_row = Integration.objects.get( + team_id=self.team.id, id=ensure_sandbox_email_sender(self.team.id).integration_id + ) + + @patch("posthog.management.commands.migrate_ses_tenants.boto3.client") + def test_tenant_migration_skips_the_sandbox_sender(self, boto3_client: MagicMock) -> None: + counts = migrate_ses_tenants(team_ids=[self.team.id], domains=[]) + + assert counts.tenants == 0 + boto3_client.assert_not_called() + + @patch("products.workflows.backend.tasks.ses_tenant_state.SESProvider") + def test_tenant_state_sweep_skips_the_sandbox_sender(self, ses_provider: MagicMock) -> None: + reconcile_ses_tenant_states() + + ses_provider.return_value.get_tenant_reputation.assert_not_called() + + @patch("posthog.tasks.integrations.delete_ses_identity_if_unused") + def test_deleting_a_project_with_a_sandbox_sender_keeps_the_shared_identity( + self, delete_identity_task: MagicMock + ) -> None: + with self.captureOnCommitCallbacks(execute=True): + self.team.delete() + + assert not Integration.objects.filter(pk=self.sandbox_row.pk).exists() + delete_identity_task.delay.assert_not_called() diff --git a/products/workflows/backend/test/test_team_workflows_config.py b/products/workflows/backend/test/test_team_workflows_config.py index 2499e3807dd3..47e442da52a3 100644 --- a/products/workflows/backend/test/test_team_workflows_config.py +++ b/products/workflows/backend/test/test_team_workflows_config.py @@ -1,3 +1,5 @@ +from typing import Any + from posthog.test.base import APIBaseTest from django.contrib.admin import AdminSite @@ -8,7 +10,7 @@ from posthog.api.project import update_team_workflows_config from posthog.models import OrganizationMembership, Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.admin.team_workflows_config_admin import TeamWorkflowsConfigAdmin from products.workflows.backend.models.team_workflows_config import TeamWorkflowsConfig @@ -156,16 +158,17 @@ def test_patch_ignores_unknown_keys_in_workflows_config(self) -> None: @parameterized.expand( [ - ("unverified_sender", "email", False, False), - ("other_projects_sender", "email", True, True), - ("not_an_email_sender", "slack", True, False), + ("unverified_sender", "email", {"verified": False}, False), + ("other_projects_sender", "email", {"verified": True}, True), + ("not_an_email_sender", "slack", {"verified": True}, False), + ("sandbox_sender", "email", {"verified": True, "provider": SANDBOX_EMAIL_PROVIDER}, False), ] ) - def test_patch_rejects_default_email_sender(self, _name: str, kind: str, verified: bool, other_team: bool) -> None: + def test_patch_rejects_default_email_sender( + self, _name: str, kind: str, config: dict[str, Any], other_team: bool + ) -> None: team = Team.objects.create(organization=self.organization, name="other") if other_team else self.team - sender = Integration.objects.create( - team=team, kind=kind, integration_id="hello@example.com", config={"verified": verified} - ) + sender = Integration.objects.create(team=team, kind=kind, integration_id="hello@example.com", config=config) response = self.client.patch(self.url, {"workflows_config": {"default_email_integration_id": sender.id}}) diff --git a/products/workflows/backend/test/test_transactional_email_workflows.py b/products/workflows/backend/test/test_transactional_email_workflows.py index bcdb89b99817..9bc094edc2ad 100644 --- a/products/workflows/backend/test/test_transactional_email_workflows.py +++ b/products/workflows/backend/test/test_transactional_email_workflows.py @@ -6,7 +6,7 @@ from parameterized import parameterized from posthog.models import Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.scoping import team_scope from products.cdp.backend.api.test.test_hog_function_templates import MOCK_NODE_TEMPLATES @@ -122,13 +122,26 @@ def test_the_stored_workflow_passes_the_workflows_api_validation(self): assert serializer.validated_data["trigger"] == flow.trigger == {"type": "api"} assert serializer.validated_data["billable_action_types"] == flow.billable_action_types - @parameterized.expand([("unverified_default", False, None), ("another_teams_sender", True, "other")]) + @parameterized.expand( + [ + ("unverified_default", False, None), + ("another_teams_sender", True, "other"), + ("sandbox_sender", True, "sandbox"), + ] + ) def test_a_new_transactional_email_needs_a_verified_sender_of_its_own(self, _name, verified, requested): self.sender.config = {"verified": verified} self.sender.save() requested_id = None if requested == "other": requested_id = self._other_teams_sender() + elif requested == "sandbox": + requested_id = Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ).id with self.assertRaises(TransactionalEmailSenderUnavailable): create_transactional_email_workflow( diff --git a/products/workflows/backend/test/test_workflow_setup_status.py b/products/workflows/backend/test/test_workflow_setup_status.py index 295020c053a0..703d272f6ed7 100644 --- a/products/workflows/backend/test/test_workflow_setup_status.py +++ b/products/workflows/backend/test/test_workflow_setup_status.py @@ -5,7 +5,7 @@ from parameterized import parameterized from rest_framework import status -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from products.messaging.backend.facade.testing import create_message_category_for_test, create_message_template_for_test from products.workflows.backend.models import HogFlow @@ -40,7 +40,16 @@ def _add_workflow(self, *, action_type: str, status: str = "draft", origin_produ actions=[{"id": "step", "type": action_type}], ) - def test_a_new_project_starts_at_the_channel(self) -> None: + @parameterized.expand([("no sender", False), ("only the sandbox sender", True)]) + def test_a_new_project_starts_at_the_channel(self, _name: str, has_sandbox_sender: bool) -> None: + if has_sandbox_sender: + Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ) + body = self._status() assert not any(self._done(body).values()) diff --git a/products/workflows/backend/tests/api/test_email_reputation.py b/products/workflows/backend/tests/api/test_email_reputation.py index 6f4a65ffa139..6d88e11e306d 100644 --- a/products/workflows/backend/tests/api/test_email_reputation.py +++ b/products/workflows/backend/tests/api/test_email_reputation.py @@ -11,7 +11,7 @@ from posthog.constants import AvailableFeature from posthog.models import Team from posthog.models.integration import Integration -from posthog.models.organization import OrganizationMembership +from posthog.models.organization import Organization, OrganizationMembership from posthog.models.user import User from products.access_control.backend.models.access_control import AccessControl @@ -356,14 +356,19 @@ def test_reputation_endpoint_returns_the_per_provider_breakdown(self): @parameterized.expand( [ - ("sibling verified on the same domain", True, ["mail.example.com"]), - ("sibling not verified yet", False, []), + ("sibling verified on the same domain", True, ["mail.example.com"], "ses"), + ("sibling not verified yet", False, [], "ses"), + ("legacy sender without a provider", True, ["mail.example.com"], None), ] ) def test_reputation_endpoint_names_domains_a_sibling_project_also_sends_from( - self, _name: str, sibling_verified: bool, expected: list[str] - ): + self, _name: str, sibling_verified: bool, expected: list[str], provider: str | None + ) -> None: self._verify_sending_domain() + if provider is None: + Integration.objects.filter(team=self.team, kind="email", integration_id="mail.example.com").update( + config={"domain": "mail.example.com", "verified": True} + ) sibling = Team.objects.create(organization=self.organization, name="Sibling") Integration.objects.create( team=sibling, @@ -376,6 +381,35 @@ def test_reputation_endpoint_names_domains_a_sibling_project_also_sends_from( assert body["isp_shared_domains"] == expected + def test_reputation_endpoint_leaves_the_sandbox_sender_out_of_the_breakdown(self) -> None: + other_team = Team.objects.create(organization=Organization.objects.create(name="Other"), name="Other") + for team in (self.team, other_team): + Integration.objects.create( + team=team, + kind="email", + integration_id="posthog-sandbox", + config={"domain": "sandbox.example.com", "provider": "sandbox", "verified": True}, + ) + + body = self._get_reputation( + {}, + isp_metrics=[ + IspSendingMetrics( + isp="Gmail", + emails_sent=900, + delivery_rate=0.97, + bounce_rate=0.01, + transient_bounce_rate=0.0, + complaint_rate=None, + complaint_base=0, + ) + ], + ) + + assert body["isps"] == [] + assert body["isp_shared_domains"] == [] + assert body["isp_withheld_domains"] == [] + def test_reputation_endpoint_omits_domains_only_this_project_sends_from(self): self._verify_sending_domain() self._verify_sending_domain("solo.example.com") diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py new file mode 100644 index 000000000000..aa9429216bc9 --- /dev/null +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -0,0 +1,177 @@ +from typing import TYPE_CHECKING, Any + +from posthog.test.base import APIBaseTest +from unittest.mock import MagicMock, _Call, patch + +from django.db.models import QuerySet +from django.test import override_settings + +from parameterized import parameterized +from rest_framework import status + +from posthog.models.integration import Integration +from posthog.models.organization import OrganizationMembership +from posthog.models.personal_api_key import PersonalAPIKey +from posthog.models.utils import hash_key_value + +if TYPE_CHECKING: + from rest_framework.response import _MonkeyPatchedResponse + +SANDBOX_SETTINGS = { + "WORKFLOWS_SANDBOX_SENDER_DOMAIN": "sandbox.example.com", + "SES_SANDBOX_FROM_ADDRESS": "hello@sandbox.example.com", +} + + +@override_settings(**SANDBOX_SETTINGS) +class TestSandboxSenderAPI(APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.boto3_client = self._patch("products.workflows.backend.providers.ses.boto3.client") + self.flag_enabled = self._patch("posthoganalytics.feature_enabled", return_value=True) + self.capture = self._patch("posthoganalytics.capture") + + def _patch(self, target: str, **kwargs: Any) -> MagicMock: + patcher = patch(target, **kwargs) + mock = patcher.start() + self.addCleanup(patcher.stop) + return mock + + def _ensure(self) -> "_MonkeyPatchedResponse": + return self.client.post(f"/api/projects/{self.team.id}/integrations/email_sandbox_sender/") + + def _sandbox_rows(self) -> QuerySet[Integration]: + return Integration.objects.filter(team=self.team, kind="email", integration_id="posthog-sandbox") + + def _provisioned_events(self) -> list[_Call]: + return [ + c for c in self.capture.call_args_list if c.kwargs.get("event") == "workflows sandbox sender provisioned" + ] + + def test_ensure_keeps_one_row_per_project_and_refreshes_its_name(self) -> None: + self.organization.name = "Acme" + self.organization.save() + + first = self._ensure() + assert first.status_code == status.HTTP_200_OK, first.json() + assert len(self._provisioned_events()) == 1 + self.organization.name = "Acme Rockets" + self.organization.save() + second = self._ensure() + + assert second.status_code == status.HTTP_200_OK, second.json() + row = self._sandbox_rows().get() + assert first.json()["id"] == second.json()["id"] == row.id + assert row.created_by is None + assert row.config == { + "provider": "sandbox", + "email": "hello@sandbox.example.com", + "domain": "sandbox.example.com", + "name": "Acme Rockets via PostHog", + "verified": True, + } + assert second.json()["config"]["name"] == "Acme Rockets via PostHog" + assert len(self._provisioned_events()) == 1 + self.boto3_client.assert_not_called() + + @parameterized.expand( + [ + ("symbols and markup dropped", "Acme <script> Inc!", "Acme script Inc via PostHog"), + ("allowed punctuation kept", "O'Brien & Sons, Ltd.-Co", "O'Brien & Sons, Ltd.-Co via PostHog"), + ("cut to forty characters", "A" * 50, f"{'A' * 40} via PostHog"), + ("nothing left falls back", "<<<>>>", "PostHog sandbox"), + ] + ) + def test_ensure_builds_the_display_name_from_the_organization_name( + self, _name: str, organization_name: str, expected: str + ) -> None: + self.organization.name = organization_name + self.organization.save() + + response = self._ensure() + + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["config"]["name"] == expected + + @parameterized.expand( + [ + ("flag off", {}, False), + ("domain unset", {"WORKFLOWS_SANDBOX_SENDER_DOMAIN": ""}, True), + ("from address unset", {"SES_SANDBOX_FROM_ADDRESS": ""}, True), + ] + ) + def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( + self, _name: str, settings_override: dict[str, str], flag_enabled: bool + ) -> None: + self.flag_enabled.return_value = flag_enabled + + with override_settings(**settings_override): + response = self._ensure() + + assert response.status_code == status.HTTP_404_NOT_FOUND, response.json() + assert not self._sandbox_rows().exists() + assert self._provisioned_events() == [] + + @parameterized.expand( + [ + ("read scope", "integration:read", status.HTTP_403_FORBIDDEN), + ("write scope", "integration:write", status.HTTP_200_OK), + ] + ) + def test_ensure_with_an_api_key_needs_the_integration_write_scope( + self, _name: str, scope: str, expected_status: int + ) -> None: + PersonalAPIKey.objects.create( + label="Sandbox", user=self.user, secure_value=hash_key_value("phx_sandbox_key"), scopes=[scope] + ) + self.client.logout() + + response = self.client.post( + f"/api/projects/{self.team.id}/integrations/email_sandbox_sender/", + HTTP_AUTHORIZATION="Bearer phx_sandbox_key", + ) + + assert response.status_code == expected_status, response.json() + assert self._sandbox_rows().exists() == (expected_status == status.HTTP_200_OK) + + @parameterized.expand( + [ + ("verify", "post", "email/verify/", {}), + ( + "edit", + "patch", + "email/", + {"config": {"email": "x@sandbox.example.com", "name": "Other", "provider": "sandbox"}}, + ), + ("delete", "delete", "", None), + ] + ) + def test_users_cannot_change_the_sandbox_sender( + self, _name: str, method: str, path: str, body: dict[str, Any] | None + ) -> None: + self.organization_membership.level = OrganizationMembership.Level.ADMIN + self.organization_membership.save() + row_id = self._ensure().json()["id"] + config_before = self._sandbox_rows().get().config + + response = getattr(self.client, method)( + f"/api/projects/{self.team.id}/integrations/{row_id}/{path}", body, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert response.json()["detail"] == "The sandbox sender is managed by PostHog." + assert self._sandbox_rows().get().config == config_before + self.boto3_client.assert_not_called() + + @parameterized.expand([("the sandbox domain", "sandbox.example.com"), ("a subdomain", "mail.sandbox.example.com")]) + def test_teams_cannot_add_an_own_sender_on_the_sandbox_domain(self, _name: str, domain: str) -> None: + response = self.client.post( + f"/api/projects/{self.team.id}/integrations/", + {"kind": "email", "config": {"email": f"news@{domain}", "name": "News", "provider": "ses"}}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert "sandbox sender" in response.json()["detail"] + assert not Integration.objects.filter(team=self.team, config__domain=domain).exists() + self.boto3_client.assert_not_called() diff --git a/products/workflows/backend/tests/api/test_workflow_data_suggestions.py b/products/workflows/backend/tests/api/test_workflow_data_suggestions.py index 6db63a850bff..8663f66a3d55 100644 --- a/products/workflows/backend/tests/api/test_workflow_data_suggestions.py +++ b/products/workflows/backend/tests/api/test_workflow_data_suggestions.py @@ -799,6 +799,7 @@ def test_a_saved_build_is_read_again_from_its_draft(self, suggest_ideas: MagicMo ("the only verified sender", ["only@example.com"], None, "only@example.com"), ("several senders and no default", ["a@example.com", "b@example.com"], None, None), ("no verified sender", [], None, None), + ("only the sandbox sender", [], None, None, True), ] ) def test_drafts_send_from( @@ -807,7 +808,15 @@ def test_drafts_send_from( senders: list[str], default: str | None, expected: str | None, + with_sandbox_sender: bool = False, ) -> None: + if with_sandbox_sender: + Integration.objects.create( + team=self.team, + kind="email", + integration_id="posthog-sandbox", + config={"provider": "sandbox", "email": "hello@sandbox.example.com", "verified": True}, + ) integrations = { address: Integration.objects.create( team=self.team, kind="email", config={"email": address, "verified": True} From 5da89ec31f17e8868e97c97cba01220ee731ec58 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:18:12 +0000 Subject: [PATCH 17/38] feat(workflows): offer the sandbox sender only with its ses tenant set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- posthog/settings/ses.py | 1 + products/workflows/backend/services/sandbox_sender.py | 6 +++++- .../workflows/backend/test/test_sandbox_sender_lifecycle.py | 1 + products/workflows/backend/tests/api/test_sandbox_sender.py | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/posthog/settings/ses.py b/posthog/settings/ses.py index ee6db86140bb..74a23a5699cd 100644 --- a/posthog/settings/ses.py +++ b/posthog/settings/ses.py @@ -31,6 +31,7 @@ WORKFLOWS_SANDBOX_SENDER_DOMAIN: str = os.getenv("WORKFLOWS_SANDBOX_SENDER_DOMAIN", "").strip().lower() SES_SANDBOX_FROM_ADDRESS: str = os.getenv("SES_SANDBOX_FROM_ADDRESS", "").strip().lower() +SES_SANDBOX_TENANT_NAME: str = os.getenv("SES_SANDBOX_TENANT_NAME", "").strip() # SNS topics allowed to deliver SES tenant reputation events (EventBridge -> SNS -> webhook). # Empty (the default) leaves the webhook inert: the SNS signature proves a message came from AWS, diff --git a/products/workflows/backend/services/sandbox_sender.py b/products/workflows/backend/services/sandbox_sender.py index 4dc3d4cc94f9..2da94cd73d1f 100644 --- a/products/workflows/backend/services/sandbox_sender.py +++ b/products/workflows/backend/services/sandbox_sender.py @@ -34,7 +34,11 @@ def _sandbox_sender_config(organization_name: str) -> dict[str, str | bool]: def _sandbox_sender_configured() -> bool: - return bool(settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN and settings.SES_SANDBOX_FROM_ADDRESS) + return bool( + settings.WORKFLOWS_SANDBOX_SENDER_DOMAIN + and settings.SES_SANDBOX_FROM_ADDRESS + and settings.SES_SANDBOX_TENANT_NAME + ) def _sandbox_sender_enabled(team: Team) -> bool: diff --git a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py index e812993b80d9..d293e7ef49cb 100644 --- a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -13,6 +13,7 @@ @override_settings( WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", + SES_SANDBOX_TENANT_NAME="posthog-sandbox", ) class TestSandboxSenderLifecycleExclusions(BaseTest): def setUp(self) -> None: diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index aa9429216bc9..bf48ee499933 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -20,6 +20,7 @@ SANDBOX_SETTINGS = { "WORKFLOWS_SANDBOX_SENDER_DOMAIN": "sandbox.example.com", "SES_SANDBOX_FROM_ADDRESS": "hello@sandbox.example.com", + "SES_SANDBOX_TENANT_NAME": "posthog-sandbox", } @@ -98,6 +99,7 @@ def test_ensure_builds_the_display_name_from_the_organization_name( ("flag off", {}, False), ("domain unset", {"WORKFLOWS_SANDBOX_SENDER_DOMAIN": ""}, True), ("from address unset", {"SES_SANDBOX_FROM_ADDRESS": ""}, True), + ("tenant name unset", {"SES_SANDBOX_TENANT_NAME": ""}, True), ] ) def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( From 6868e0851c6fd74efbb35a46803a199d66cf67d2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:19:17 +0000 Subject: [PATCH 18/38] feat(workflows): keep the sandbox sender out of sends until it can deliver Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 3 +- frontend/src/lib/integrations/utils.ts | 3 + .../email-templater/EmailTemplater.test.tsx | 60 ++++++++++++++----- .../email-templater/EmailTemplater.tsx | 3 +- .../services/messaging/email.service.test.ts | 32 ++++++++++ .../cdp/services/messaging/email.service.ts | 12 ++++ .../frontend/Channels/channelSummary.test.ts | 3 + .../frontend/Channels/channelSummary.ts | 6 +- .../Channels/defaultEmailSender.test.ts | 10 ++++ .../frontend/Channels/defaultEmailSender.ts | 5 +- 10 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 2d8d7faebd1a..d0c38975f4c2 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -20,8 +20,9 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. -Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN` and `SES_SANDBOX_FROM_ADDRESS` in Django; either empty value leaves the endpoint unavailable. +Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable. The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. +Until the worker has a sandbox delivery route, it skips every send through the managed integration and logs the reason. The workflow email step does not offer it. Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 25a66c942229..251ee6214f1c 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -139,6 +139,9 @@ export const ICONS: Record<IntegrationKind, any> = { // surface, so invert them in dark mode (`dark:invert`) wherever the integration icon is rendered. export const DARK_MODE_INVERT_ICON_KINDS = new Set<IntegrationKind>(['apns', 'apple-ads', 'github']) +export const isSandboxEmailSender = (integration: { kind: string; config?: unknown }): boolean => + integration.kind === 'email' && (integration.config as { provider?: unknown } | undefined)?.provider === 'sandbox' + export const getIntegrationNameFromKind = (kind: string): string => { switch (kind) { case 'google-pubsub': diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index 4965bb411682..02710a4045bb 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -18,28 +18,40 @@ jest.mock('kea', () => ({ jest.mock('@posthog/lemon-ui', () => ({ ...jest.requireActual('@posthog/lemon-ui'), - LemonInputSelect: () => <div data-attr="multiple-email-sender-select" />, - LemonSelect: () => <div data-attr="single-email-sender-select" />, + LemonInputSelect: ({ options }: { options: { key: string; label: string }[] }) => ( + <div data-attr="multiple-email-sender-select"> + {options.map((option) => ( + <span key={option.key}>{option.label}</span> + ))} + </div> + ), + LemonSelect: ({ options }: { options: { options: { value: number; label: string }[] }[] }) => ( + <div data-attr="single-email-sender-select"> + {options + .flatMap((section) => section.options) + .map((option) => ( + <span key={option.value}>{option.label}</span> + ))} + </div> + ), })) +const ALICE = { id: 1, kind: 'email', display_name: 'Alice <alice@example.com>', config: { provider: 'ses' } } +const BOB = { id: 2, kind: 'email', display_name: 'Bob <bob@example.com>', config: { provider: 'ses' } } +const SANDBOX = { + id: 3, + kind: 'email', + display_name: 'Acme via PostHog <hello@sandbox.example.com>', + config: { provider: 'sandbox' }, +} + describe('NativeEmailIntegrationChoice', () => { afterEach(cleanup) beforeEach(() => { jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, - integrations: [ - { - id: 1, - kind: 'email', - display_name: 'Alice <alice@example.com>', - }, - { - id: 2, - kind: 'email', - display_name: 'Bob <bob@example.com>', - }, - ], + integrations: [ALICE, BOB, SANDBOX], }) }) @@ -54,4 +66,24 @@ describe('NativeEmailIntegrationChoice', () => { expect(screen.getByTestId(expectedPicker)).toBeInTheDocument() expect(screen.getByText('Custom sender')).toBeInTheDocument() }) + + it.each([false, true])( + 'leaves the sandbox sender out of the picker when the rotation flag is %s', + (flagEnabled) => { + mockSenderRotationEnabled = flagEnabled + + render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />) + + expect(screen.getByText(ALICE.display_name)).toBeInTheDocument() + expect(screen.queryByText(SANDBOX.display_name)).not.toBeInTheDocument() + } + ) + + it('treats a project with only the sandbox sender as having no email sender', () => { + jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, integrations: [SANDBOX] }) + + render(<NativeEmailIntegrationChoice label="From" value={{}} onChange={jest.fn()} />) + + expect(screen.getByText('No email senders configured yet')).toBeInTheDocument() + }) }) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx index e990f2d12462..3d028025c8c8 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -26,6 +26,7 @@ import { } from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea' @@ -279,7 +280,7 @@ export function NativeEmailIntegrationChoice({ const { integrationsLoading, integrations } = useValues(integrationsLogic) const { logicProps } = useValues(emailTemplaterLogic) const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') - const integrationsOfKind = integrations?.filter((x) => x.kind === 'email') + const integrationsOfKind = integrations?.filter((x) => x.kind === 'email' && !isSandboxEmailSender(x)) const selectedIntegrationIds = value?.integrationIds?.length ? value.integrationIds : value?.integrationId diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 7c3cd05338a0..3489c52a81c2 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -1165,6 +1165,38 @@ describe('EmailService', () => { }) }) + describe('sandbox sender', () => { + it.each([ + ['a workflow send', false], + ['a test send', true], + ])('skips %s with a reason instead of reporting success', async (_name, isTest) => { + await insertIntegration(hub.postgres, team.id, { + id: getIntegrationId(2), + kind: 'email', + config: { + email: 'hello@sandbox.example.com', + name: 'Acme via PostHog', + domain: 'sandbox.example.com', + verified: true, + provider: 'sandbox', + }, + }) + invocation.queueParameters = createEmailParams({ from: { integrationId: 2 } }) + sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.emailSendResult).toEqual({ + status: 'skipped', + reason: "Skipping send: the sandbox sender can't send email yet. Choose a sender on your own verified domain in this email step.", + }) + expect(result.metrics.map((m) => m.metric_name)).not.toContain('email_sent') + expect(invocation.state.vmState?.stack).toEqual([{ success: false }]) + }) + }) + describe('team suspension enforcement at send time', () => { // Guards both suspension switches: while a team is suspended, no send path may // reach SES — including editor test sends, which count against the tenant too. diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index ff83d8746de4..062f604ae043 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -485,6 +485,18 @@ export class EmailService { ) } + // The sandbox sender has no delivery route in this worker yet, so a send through it + // must skip with a reason instead of falling through the provider switch as a success. + if (integration.config.provider === 'sandbox') { + const skipReason = + "Skipping send: the sandbox sender can't send email yet. Choose a sender on your own verified domain in this email step." + addLog('warn', skipReason) + result.emailSendResult = { status: 'skipped', reason: skipReason } + result.skipped = true + result.invocation.state.vmState?.stack.push({ success: false }) + return result + } + const from = this.resolveFromSender(integration, params.from, addLog) // An empty `to` comes from the customer's data (a person without an email property, or no diff --git a/products/workflows/frontend/Channels/channelSummary.test.ts b/products/workflows/frontend/Channels/channelSummary.test.ts index da3118f48f50..9fc451b7f5cc 100644 --- a/products/workflows/frontend/Channels/channelSummary.test.ts +++ b/products/workflows/frontend/Channels/channelSummary.test.ts @@ -4,10 +4,13 @@ import type { ChannelType } from './MessageChannels' const email = (verified: boolean): any => ({ kind: 'email', config: { verified } }) const slack = (errors?: string): any => ({ kind: 'slack', config: {}, errors }) const github: any = { kind: 'github', config: {} } +const sandbox: any = { kind: 'email', config: { provider: 'sandbox', verified: true } } describe('summarizeChannel', () => { test.each<[string, ChannelType, any[], ChannelStatus, number]>([ ['nothing connected', 'email', [], 'not-set-up', 0], + ['sandbox is separate from own email setup', 'email', [sandbox], 'not-set-up', 0], + ['sandbox does not verify an own sender', 'email', [sandbox, email(false)], 'needs-verification', 1], ['other kinds do not count', 'slack', [github, email(true)], 'not-set-up', 0], ['an unverified email address needs verification', 'email', [email(false)], 'needs-verification', 1], ['one verified address makes email active', 'email', [email(false), email(true)], 'active', 2], diff --git a/products/workflows/frontend/Channels/channelSummary.ts b/products/workflows/frontend/Channels/channelSummary.ts index 1b32d0d360f9..ca139d53f1df 100644 --- a/products/workflows/frontend/Channels/channelSummary.ts +++ b/products/workflows/frontend/Channels/channelSummary.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import type { IntegrationType } from '~/types' import type { ChannelType } from './MessageChannels' @@ -31,7 +33,9 @@ export function summarizeChannel( kind: ChannelType, integrations: Pick<IntegrationType, 'kind' | 'config' | 'errors'>[] ): ChannelSummary { - const connected = integrations.filter((integration) => integration.kind === kind) + const connected = integrations.filter( + (integration) => integration.kind === kind && !isSandboxEmailSender(integration) + ) const count = connected.length if (count === 0) { return { kind, status: 'not-set-up', count } diff --git a/products/workflows/frontend/Channels/defaultEmailSender.test.ts b/products/workflows/frontend/Channels/defaultEmailSender.test.ts index ce1b0d280f58..c5c5999cc209 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.test.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.test.ts @@ -5,8 +5,18 @@ import { resolveDefaultEmailSender } from './defaultEmailSender' const sender = (id: number, verified: boolean, kind = 'email'): IntegrationType => ({ id, kind, config: { verified } }) as unknown as IntegrationType +const sandboxSender = { ...sender(7, true), config: { verified: true, provider: 'sandbox' } } + describe('resolveDefaultEmailSender', () => { it.each([ + { case: 'nothing when only sandbox exists', integrations: [sandboxSender], defaultId: null, expected: null }, + { + case: 'the own sender beside sandbox', + integrations: [sandboxSender, sender(1, true)], + defaultId: null, + expected: 1, + }, + { case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null }, { case: 'the project default', integrations: [sender(1, true), sender(2, true)], defaultId: 2, expected: 2 }, { case: 'the only verified sender', diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 82f05e42c8f8..063d45406654 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import { IntegrationType } from '~/types' export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' @@ -12,7 +14,8 @@ export function resolveDefaultEmailSender( defaultIntegrationId: number | null | undefined ): DefaultEmailSender | null { const verified = (integrations ?? []).filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true + (integration) => + integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true ) const projectDefault = verified.find((integration) => integration.id === defaultIntegrationId) if (projectDefault) { From 525bfeefb3491ce00149f846e86c8f27c903a983 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:40:20 +0000 Subject: [PATCH 19/38] chore(workflows): check only the sandbox team in the tenant sweep test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../backend/test/test_sandbox_sender_lifecycle.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py index d293e7ef49cb..8aaf32d2127d 100644 --- a/products/workflows/backend/test/test_sandbox_sender_lifecycle.py +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -31,10 +31,14 @@ def test_tenant_migration_skips_the_sandbox_sender(self, boto3_client: MagicMock boto3_client.assert_not_called() @patch("products.workflows.backend.tasks.ses_tenant_state.SESProvider") - def test_tenant_state_sweep_skips_the_sandbox_sender(self, ses_provider: MagicMock) -> None: + @patch("products.workflows.backend.tasks.ses_tenant_state.sync_ses_tenant_state") + def test_tenant_state_sweep_skips_the_sandbox_sender( + self, sync_ses_tenant_state: MagicMock, _ses_provider: MagicMock + ) -> None: reconcile_ses_tenant_states() - ses_provider.return_value.get_tenant_reputation.assert_not_called() + swept_team_ids = [call.args[0] for call in sync_ses_tenant_state.call_args_list] + assert self.team.id not in swept_team_ids @patch("posthog.tasks.integrations.delete_ses_identity_if_unused") def test_deleting_a_project_with_a_sandbox_sender_keeps_the_shared_identity( From 8ee685e5b53720208bb4a7771543bb7bebee4c9f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:18:16 +0000 Subject: [PATCH 20/38] feat(workflows): show the managed sandbox sender in channels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- frontend/src/lib/constants.tsx | 1 + .../src/lib/integrations/IntegrationsList.tsx | 6 +- .../integrations/integrationsLogic.test.ts | 96 +++++++++++++ .../src/lib/integrations/integrationsLogic.ts | 95 +++++++++--- .../frontend/Channels/ChannelCatalog.tsx | 44 ++++-- .../Channels/MessageChannels.stories.tsx | 63 ++++++++ .../frontend/Channels/MessageChannels.tsx | 30 +++- .../Channels/SandboxEmailSenderRow.tsx | 51 +++++++ .../Channels/channelCatalogLogic.test.ts | 136 ++++++++++++++++++ .../frontend/Channels/channelCatalogLogic.ts | 129 +++++++++++++---- .../setupGuide/wizard/WizardChannelStep.tsx | 7 +- .../workflowsOnboardingWizardLogic.test.ts | 36 +++++ .../wizard/workflowsOnboardingWizardLogic.ts | 2 +- .../setupGuide/workflowsSetupGuideLogic.ts | 2 +- 14 files changed, 637 insertions(+), 61 deletions(-) create mode 100644 products/workflows/frontend/Channels/MessageChannels.stories.tsx create mode 100644 products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx create mode 100644 products/workflows/frontend/Channels/channelCatalogLogic.test.ts create mode 100644 products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 90a3873e0dfc..d7d4e80169f3 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -627,6 +627,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows WORKFLOWS_OVERVIEW_TAB: 'workflows-overview-tab', // owner: @dmarchuk #team-workflows, experimental Overview tab on a workflow WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows + WORKFLOWS_SANDBOX_SENDER: 'workflows-sandbox-sender', // owner: #team-workflows WORKFLOWS_SEND_CALENDAR: 'workflows-send-calendar', // owner: @meikelmosby #team-workflows, calendar of scheduled email sends in Broadcasts and Workflows WORKFLOWS_TILE_CANVAS: 'workflows-tile-canvas', // owner: #team-workflows WORKFLOWS_TRANSACTIONAL_EMAIL_API: 'workflows-transactional-email-api', // owner: @mayteio #team-workflows diff --git a/frontend/src/lib/integrations/IntegrationsList.tsx b/frontend/src/lib/integrations/IntegrationsList.tsx index 8e2ec973d3ee..cb224c510316 100644 --- a/frontend/src/lib/integrations/IntegrationsList.tsx +++ b/frontend/src/lib/integrations/IntegrationsList.tsx @@ -20,14 +20,14 @@ export function IntegrationsList({ /** Rendered once the list has loaded and nothing matches. */ emptyState?: JSX.Element }): JSX.Element { - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { userManagedIntegrations, integrationsLoading } = useValues(integrationsLogic) const { startPolling, stopPolling } = useActions(integrationsLogic) useOnMountEffect(() => { startPolling() return () => stopPolling() }) - const filteredIntegrations = integrations?.filter((integration) => { + const filteredIntegrations = userManagedIntegrations?.filter((integration) => { if (onlyKinds && !onlyKinds.includes(integration.kind)) { return false } @@ -46,7 +46,7 @@ export function IntegrationsList({ filteredIntegrations.map((integration) => ( <IntegrationView key={integration.id} integration={integration} /> )) - ) : integrationsLoading || integrations === null ? ( + ) : integrationsLoading || userManagedIntegrations === null ? ( <LemonSkeleton className="h-10" /> ) : ( emptyState diff --git a/frontend/src/lib/integrations/integrationsLogic.test.ts b/frontend/src/lib/integrations/integrationsLogic.test.ts index bd55fe125b36..c6ea32ab4fb2 100644 --- a/frontend/src/lib/integrations/integrationsLogic.test.ts +++ b/frontend/src/lib/integrations/integrationsLogic.test.ts @@ -7,6 +7,8 @@ import { expectLogic } from 'kea-test-utils' import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import apiReal, { ApiError } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { teamLogic } from 'scenes/teamLogic' import { useMocks } from '~/mocks/jest' @@ -78,6 +80,100 @@ describe('integrationsLogic', () => { jest.restoreAllMocks() }) + describe('sandbox email sender', () => { + const sandboxSender: IntegrationType = { + id: 9, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + icon_url: '', + config: { + provider: 'sandbox', + email: 'sandbox@example.com', + domain: 'example.com', + name: 'Acme via PostHog', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', + } + const ownSender: IntegrationType = { + id: 10, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + icon_url: '', + config: { provider: 'ses', email: 'hello@acme.example.com', domain: 'acme.example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', + } + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + const enableFlag = (): void => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + + it.each([ + [ + 'with an API detail', + { detail: 'Could not load senders. Try again.' }, + 'Could not load senders. Try again.', + ], + ['without an API detail', '<html>Bad gateway</html>', null], + ])( + 'retains a failed integrations load %s without treating the unresolved list as empty', + async (_name, body, expectedError) => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [502, body] } }) + logic.actions.clearIntegrations() + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsFailure', + ]) + expect(logic.values.integrations).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.integrationsLoadError).toBe(expectedError) + + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [] } } }) + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsSuccess', + ]) + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.integrationsLoadError).toBeNull() + } + ) + + it.each([ + { flagState: 'on', flag: true, expectedSandbox: expect.objectContaining({ id: sandboxSender.id }) }, + { flagState: 'off', flag: false, expectedSandbox: null }, + ])( + 'keeps the sandbox row out of the own-sender lists and exposes it only with the flag $flagState', + async ({ flag, expectedSandbox }) => { + if (flag) { + enableFlag() + } + integrationsPayload = [sandboxSender, ownSender] + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }) + .toDispatchActions(['loadIntegrationsSuccess']) + .toMatchValues({ + sandboxEmailSender: expectedSandbox, + ownEmailIntegrations: [expect.objectContaining({ id: ownSender.id })], + userManagedIntegrations: [expect.objectContaining({ id: ownSender.id })], + domainGroupedEmailIntegrations: [ + { + domain: 'acme.example.com', + integrations: [expect.objectContaining({ id: ownSender.id })], + }, + ], + }) + expect(logic.values.getIntegrationsByKind(['email'])).toEqual([ + expect.objectContaining({ id: ownSender.id }), + ]) + } + ) + }) + describe('GitHub discovery freshness', () => { const discovery = (id: string): GitHubAvailableInstallationsResponseApi => ({ discovery_id: id, diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 7a3dc2f0de55..f2286e0f50cc 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -6,6 +6,7 @@ import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import api, { ApiError, getCookie } from 'lib/api' import { globalSetupLogic } from 'lib/components/ProductSetup' +import { FEATURE_FLAGS } from 'lib/constants' import { buildGithubDisconnectDescription } from 'lib/integrations/githubDisconnectCopy' import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors' import { describeScopeShortfall, getMissingScopes, requestedScopesForKind } from 'lib/integrations/integrationScopes' @@ -14,6 +15,7 @@ import { INTEGRATION_ERROR_PARAM, OAUTH_STATE_EXPIRED, } from 'lib/integrations/oauthCallbackErrors' +import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'lib/logic/preflightLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { isKeyOf } from 'lib/utils/guards' @@ -44,7 +46,7 @@ import { ChannelType } from 'products/workflows/frontend/Channels/MessageChannel import type { AvailableSetupTaskIdsEnumApi } from '../../generated/core/api.schemas' import type { PreflightStatus, UserBasicType } from '../../types' -import { ICONS, getIntegrationNameFromKind } from './utils' +import { ICONS, getIntegrationNameFromKind, isSandboxEmailSender } from './utils' const INTEGRATIONS_POLL_INTERVAL_MS = 30_000 @@ -105,6 +107,7 @@ export function reconnectReturnUrl(pathname: string, search: string): string { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface integrationsLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic preflight: PreflightStatus | null // preflightLogic siteUrlMisconfigured: boolean // preflightLogic currentProjectId: number | string // teamLogic @@ -179,19 +182,25 @@ export interface integrationsLogicValues { githubRepositoriesTotal: Record<number, number> githubSuggestionSubscribers: number integrations: IntegrationType[] | null + integrationsLoadError: string | null + integrationsLoadFailed: boolean integrationsLoading: boolean linearIntegrations: IntegrationType[] linkedGithubInstallation: IntegrationConfigApi | null linkedGithubInstallationLoading: boolean newIntegrationModalId: string | null newIntegrationModalKind: IntegrationKind | null + ownEmailIntegrations: IntegrationType[] pollingSubscribers: number requestedAccessKinds: IntegrationKind[] + sandboxEmailSender: IntegrationType | null + sandboxEmailSenderEnabled: boolean selectedIntegration: IntegrationType | null setupModalOpen: boolean setupModalType: ChannelType | null slackAvailable: boolean | undefined slackIntegrations: IntegrationType[] | undefined + userManagedIntegrations: IntegrationType[] | null } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. @@ -709,7 +718,7 @@ export interface integrationsLogicMeta { githubAvailableInstallationsResponse: GitHubAvailableInstallationsResponseApi | null ) => boolean | null getIntegrationsByKind: ( - integrations: IntegrationType[] | null + userManagedIntegrations: IntegrationType[] | null ) => ( kinds: ( | 'apns' @@ -770,7 +779,14 @@ export interface integrationsLogicMeta { getGitHubRepositoriesTotal: ( githubRepositoriesTotal: Record<number, number> ) => (integrationId: number) => number | null - domainGroupedEmailIntegrations: (integrations: IntegrationType[] | null) => EmailIntegrationDomainGroupedType[] + sandboxEmailSenderEnabled: (featureFlags: FeatureFlagsSet) => boolean + sandboxEmailSender: ( + integrations: IntegrationType[] | null, + sandboxEmailSenderEnabled: boolean + ) => IntegrationType | null + userManagedIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] | null + ownEmailIntegrations: (userManagedIntegrations: IntegrationType[] | null) => IntegrationType[] + domainGroupedEmailIntegrations: (ownEmailIntegrations: IntegrationType[]) => EmailIntegrationDomainGroupedType[] } } @@ -784,7 +800,14 @@ export type integrationsLogicType = MakeLogicType< export const integrationsLogic = kea<integrationsLogicType>([ path(['lib', 'integrations', 'integrationsLogic']), connect(() => ({ - values: [preflightLogic, ['siteUrlMisconfigured', 'preflight'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['siteUrlMisconfigured', 'preflight'], + teamLogic, + ['currentProjectId'], + featureFlagLogic, + ['featureFlags'], + ], actions: [ globalSetupLogic, ['markTaskAsCompleted'], @@ -838,6 +861,25 @@ export const integrationsLogic = kea<integrationsLogicType>([ }), reducers({ integrations: [null as IntegrationType[] | null, { clearIntegrations: () => null }], + integrationsLoadError: [ + null as string | null, + { + loadIntegrations: () => null, + loadIntegrationsSuccess: () => null, + clearIntegrations: () => null, + loadIntegrationsFailure: (_, { errorObject }) => + errorObject instanceof ApiError && errorObject.detail ? errorObject.detail : null, + }, + ], + integrationsLoadFailed: [ + false, + { + loadIntegrations: () => false, + loadIntegrationsSuccess: () => false, + clearIntegrations: () => false, + loadIntegrationsFailure: () => true, + }, + ], githubDisconnecting: [false, { setGithubDisconnecting: (_, { disconnecting }) => disconnecting }], githubDiscoveryGeneration: [ 0, @@ -1479,7 +1521,7 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, ], getIntegrationsByKind: [ - (s) => [s.integrations], + (s) => [s.userManagedIntegrations], (integrations: IntegrationType[] | null) => { return (kinds: IntegrationKind[]) => integrations?.filter((i) => kinds.includes(i.kind)) || [] }, @@ -1511,23 +1553,40 @@ export const integrationsLogic = kea<integrationsLogicType>([ }, ], - domainGroupedEmailIntegrations: [ + sandboxEmailSenderEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + ], + sandboxEmailSender: [ + (s) => [s.integrations, s.sandboxEmailSenderEnabled], + (integrations: IntegrationType[] | null, sandboxEmailSenderEnabled: boolean): IntegrationType | null => + sandboxEmailSenderEnabled ? (integrations?.find(isSandboxEmailSender) ?? null) : null, + ], + userManagedIntegrations: [ (s) => [s.integrations], - (integrations: IntegrationType[] | null): EmailIntegrationDomainGroupedType[] => { + (integrations: IntegrationType[] | null): IntegrationType[] | null => + integrations?.filter((x) => !isSandboxEmailSender(x)) ?? null, + ], + ownEmailIntegrations: [ + (s) => [s.userManagedIntegrations], + (userManagedIntegrations: IntegrationType[] | null): IntegrationType[] => + userManagedIntegrations?.filter((x) => x.kind === 'email') ?? [], + ], + domainGroupedEmailIntegrations: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): EmailIntegrationDomainGroupedType[] => { const domainGroupedIntegrations: Record<string, EmailIntegrationDomainGroupedType> = {} - integrations - ?.filter((x) => x.kind === 'email') - .forEach((integration) => { - const domain = integration.config.domain - if (!domainGroupedIntegrations[domain]) { - domainGroupedIntegrations[domain] = { - domain, - integrations: [], - } + ownEmailIntegrations.forEach((integration) => { + const domain = integration.config.domain + if (!domainGroupedIntegrations[domain]) { + domainGroupedIntegrations[domain] = { + domain, + integrations: [], } - domainGroupedIntegrations[domain].integrations.push(integration) - }) + } + domainGroupedIntegrations[domain].integrations.push(integration) + }) return Object.values(domainGroupedIntegrations) }, diff --git a/products/workflows/frontend/Channels/ChannelCatalog.tsx b/products/workflows/frontend/Channels/ChannelCatalog.tsx index 4dfa0205512a..53ab31dbd122 100644 --- a/products/workflows/frontend/Channels/ChannelCatalog.tsx +++ b/products/workflows/frontend/Channels/ChannelCatalog.tsx @@ -1,6 +1,6 @@ -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' -import { LemonBanner, LemonSkeleton } from '@posthog/lemon-ui' +import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui' import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' import { TeamMembershipLevel } from 'lib/constants' @@ -13,7 +13,9 @@ const GRID_CLASS = 'grid gap-2 grid-cols-1 @min-[44rem]/main-content:grid-cols-2 /** Every channel a workflow can send through, with whether this project has it set up. */ export function ChannelCatalog(): JSX.Element { - const { channels, activeCount, integrationsLoadFailed } = useValues(channelCatalogLogic) + const { channels, activeCount, integrationsLoadFailed, integrationsLoading, integrationsLoadError } = + useValues(channelCatalogLogic) + const { loadIntegrations } = useActions(channelCatalogLogic) const restrictedReason = useRestrictedArea({ scope: RestrictionScope.Project, minimumAccessLevel: TeamMembershipLevel.Admin, @@ -28,13 +30,37 @@ export function ChannelCatalog(): JSX.Element { )} </div> {channels ? ( - <div className={GRID_CLASS}> - {channels.map((channel) => ( - <ChannelCatalogCard key={channel.kind} channel={channel} disabledReason={restrictedReason} /> - ))} - </div> + <> + {integrationsLoadFailed && ( + <div className="flex flex-wrap items-center gap-2 text-xs text-secondary"> + <span>Couldn't refresh your channels. Showing the last loaded connections.</span> + <LemonButton + size="xsmall" + type="tertiary" + onClick={loadIntegrations} + loading={integrationsLoading} + > + Try again + </LemonButton> + </div> + )} + <div className={GRID_CLASS}> + {channels.map((channel) => ( + <ChannelCatalogCard + key={channel.kind} + channel={channel} + disabledReason={restrictedReason} + /> + ))} + </div> + </> ) : integrationsLoadFailed ? ( - <LemonBanner type="error">Couldn't load your channels. Refresh the page to try again.</LemonBanner> + <LemonBanner + type="error" + action={{ children: 'Try again', onClick: loadIntegrations, loading: integrationsLoading }} + > + {integrationsLoadError || "Couldn't load your channels."} + </LemonBanner> ) : ( <div className={GRID_CLASS}> {CATALOG_CHANNELS.map((kind) => ( diff --git a/products/workflows/frontend/Channels/MessageChannels.stories.tsx b/products/workflows/frontend/Channels/MessageChannels.stories.tsx new file mode 100644 index 000000000000..6f527664481f --- /dev/null +++ b/products/workflows/frontend/Channels/MessageChannels.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { MessageChannels } from './MessageChannels' + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +type StoryArgs = { integrations: Record<string, unknown>[]; narrow?: boolean } + +const meta: Meta<StoryArgs> = { + title: 'Products/Workflows/Channels', + component: MessageChannels, + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ integrations, narrow }) => { + useStorybookMocks({ + get: { '/api/projects/:team_id/integrations/': { results: integrations } }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER }, + }) + return ( + <div className={narrow ? 'w-[520px]' : undefined}> + <MessageChannels /> + </div> + ) +} + +export const SandboxSenderOnly: StoryFn<StoryArgs> = Template.bind({}) +SandboxSenderOnly.args = { integrations: [SANDBOX_SENDER] } + +export const SandboxAndOwnSender: StoryFn<StoryArgs> = Template.bind({}) +SandboxAndOwnSender.args = { integrations: [SANDBOX_SENDER, OWN_SENDER] } + +export const SandboxAndOwnSenderNarrow: StoryFn<StoryArgs> = Template.bind({}) +SandboxAndOwnSenderNarrow.args = { integrations: [SANDBOX_SENDER, OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Channels/MessageChannels.tsx b/products/workflows/frontend/Channels/MessageChannels.tsx index 7c51179f101b..ccbd62a829c3 100644 --- a/products/workflows/frontend/Channels/MessageChannels.tsx +++ b/products/workflows/frontend/Channels/MessageChannels.tsx @@ -1,22 +1,31 @@ import { useActions, useValues } from 'kea' +import { LemonBanner } from '@posthog/lemon-ui' + import { SetupTaskId } from 'lib/components/ProductSetup' import { EmailIntegrationsList } from 'lib/integrations/EmailIntegrationsList' import { IntegrationsList } from 'lib/integrations/IntegrationsList' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' import { ChannelSetupModal } from './ChannelSetupModal' +import { SandboxEmailSenderRow } from './SandboxEmailSenderRow' const MESSAGING_CHANNEL_TYPES = ['email', 'slack', 'twilio', 'firebase', 'apns'] as const export type ChannelType = (typeof MESSAGING_CHANNEL_TYPES)[number] export function MessageChannels(): JSX.Element { - const { setupModalOpen, integrations, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { setupModalOpen, userManagedIntegrations, setupModalType, selectedIntegration, sandboxEmailSender } = + useValues(integrationsLogic) const { closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) + const { sandboxSetupError, sandboxSetupLoading } = useValues(channelCatalogLogic) + const { retrySandboxSenderSetup } = useActions(channelCatalogLogic) - const allWorkflowIntegrations = - integrations?.filter((integration) => MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType)) ?? [] + const ownWorkflowIntegrations = + userManagedIntegrations?.filter((integration) => + MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType) + ) ?? [] return ( <> @@ -33,7 +42,20 @@ export function MessageChannels(): JSX.Element { <div className="flex flex-col gap-4" data-attr="message-channels"> <ChannelCatalog /> - {allWorkflowIntegrations.length > 0 && ( + {sandboxSetupError && ( + <LemonBanner + type="error" + action={{ + children: 'Try again', + onClick: retrySandboxSenderSetup, + loading: sandboxSetupLoading, + }} + > + {sandboxSetupError} + </LemonBanner> + )} + {sandboxEmailSender && <SandboxEmailSenderRow integration={sandboxEmailSender} />} + {ownWorkflowIntegrations.length > 0 && ( <h3 className="mb-0 text-base font-semibold">Your connections</h3> )} <EmailIntegrationsList /> diff --git a/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx new file mode 100644 index 000000000000..3f02a3149fad --- /dev/null +++ b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx @@ -0,0 +1,51 @@ +import { useActions } from 'kea' + +import { IconLetter, IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' +import { TeamMembershipLevel } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' + +import { IntegrationType } from '~/types' + +export function SandboxEmailSenderRow({ integration }: { integration: IntegrationType }): JSX.Element { + const { openSetupModal } = useActions(integrationsLogic) + const restrictedReason = useRestrictedArea({ + scope: RestrictionScope.Project, + minimumAccessLevel: TeamMembershipLevel.Admin, + }) + + return ( + <div className="rounded border bg-surface-primary p-2 @container" data-attr="sandbox-email-sender"> + <div className="flex flex-col gap-3 ml-2 @lg:flex-row @lg:items-center"> + <div className="flex flex-1 gap-4 items-start min-w-0"> + <IconLetter className="w-8 h-8 shrink-0" /> + <div className="flex flex-col gap-1 min-w-0"> + <div className="flex flex-wrap gap-2 items-center"> + <strong>Sandbox sender</strong> + <span className="text-xs text-secondary truncate" translate="no"> + {`${integration.config.name} <${integration.config.email}>`} + </span> + </div> + <span className="text-sm text-secondary"> + Sends from a PostHog address, so it works before you verify a domain. It delivers only to + verified members of your organization. Add your own sender to email anyone. + </span> + </div> + </div> + <LemonButton + type="secondary" + size="small" + icon={<IconPlus />} + className="shrink-0 self-start @lg:self-center" + onClick={() => openSetupModal(undefined, 'email')} + disabledReason={restrictedReason} + data-attr="sandbox-email-sender-add-own" + > + Add your own sender + </LemonButton> + </div> + </div> + ) +} diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts new file mode 100644 index 000000000000..569872b3d399 --- /dev/null +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -0,0 +1,136 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { expectLogic } from 'kea-test-utils' +import { createElement } from 'react' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' + +describe('sandbox setup in the channel catalog', () => { + let logic: ReturnType<typeof channelCatalogLogic.build> + + beforeEach(() => initKeaTests()) + afterEach(() => { + cleanup() + logic?.unmount() + }) + + it.each([404, 500])('starts setup on mount and exposes a %s request for retry', async (failureStatus) => { + let status = failureStatus + let requests = 0 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return status === 200 + ? [200, sandbox] + : [status, { detail: 'Could not set up your sandbox sender.' }] + }, + }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await waitFor(() => expect(requests).toBe(1)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBe('Could not set up your sandbox sender.') + + status = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await waitFor(() => expect(requests).toBe(2)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBeNull() + }) + + it('keeps a channel load failure separate from sandbox setup and reloads on retry', async () => { + let loadStatus = 500 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => + loadStatus === 200 + ? [200, { results: [sandbox] }] + : [500, { detail: 'Could not load your channels.' }], + }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': [200, sandbox] }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure']) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.channels).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.sandboxSetupError).toBeNull() + + render(createElement(ChannelCatalog)) + expect(screen.getByText('Could not load your channels.')).toBeInTheDocument() + cleanup() + + loadStatus = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.channels).not.toBeNull() + expect(logic.values.sandboxSetupError).toBeNull() + render(createElement(ChannelCatalog)) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + + loadStatus = 500 + await act(async () => { + await expectLogic(integrationsLogic, () => + integrationsLogic.actions.loadIntegrations() + ).toFinishAllListeners() + }) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + expect(screen.getByText(/Couldn't refresh your channels/)).toBeInTheDocument() + }) + + it.each([ + { when: 'the flag is on at mount', flagAtMount: true }, + { when: 'the flag arrives after mount', flagAtMount: false }, + ])('sets up the sandbox sender once when $when and shows the new row', async ({ flagAtMount }) => { + let requests = 0 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: requests > 0 ? [sandbox] : [] }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return [200, sandbox] + }, + }, + }) + const enableFlag = (): void => + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + featureFlagLogic.actions.setFeatureFlags([], {}) + if (flagAtMount) { + enableFlag() + } + logic = channelCatalogLogic() + logic.mount() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(requests).toBe(flagAtMount ? 1 : 0) + + enableFlag() + await waitFor(() => expect(integrationsLogic.values.sandboxEmailSender).not.toBeNull()) + await expectLogic(logic).toFinishAllListeners() + expect(requests).toBe(1) + }) +}) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts index b0881131a1f9..a16a2b0b5c94 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts @@ -1,12 +1,16 @@ -import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, selectors } from 'kea' +import { loaders } from 'kea-loaders' import { router } from 'kea-router' import posthog from 'posthog-js' -import api from 'lib/api' +import api, { ApiError } from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { removeProjectIdIfPresent } from 'lib/utils/kea-router' +import { teamLogic } from 'scenes/teamLogic' + +import { integrationsEmailSandboxSenderCreate } from 'products/integrations/frontend/generated/api' import type { FeatureFlagsSet } from '../../../../frontend/src/lib/logic/featureFlagLogic' import type { IntegrationType } from '../../../../frontend/src/types' @@ -18,27 +22,30 @@ import type { ChannelType } from './MessageChannels' export interface channelCatalogLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic + integrationsLoading: boolean // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + currentProjectId: number | null // teamLogic activeCount: number channels: ChannelSummary[] | null - integrationsLoadFailed: boolean + sandboxProvisionError: string | null + sandboxProvisionErrorLoading: boolean + sandboxSetupError: string | null + sandboxSetupLoading: boolean } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicActions { - loadIntegrationsFailure: ( - error: string, - errorObject?: any + setFeatureFlags: ( + flags: string[], + variants: Record<string, boolean | string> ) => { - error: string - errorObject?: any - } // integrationsLogic - loadIntegrationsSuccess: ( - integrations: IntegrationType[], - payload?: any - ) => { - integrations: IntegrationType[] - payload?: any - } // integrationsLogic + flags: string[] + variants: Record<string, boolean | string> + } // featureFlagLogic + loadIntegrations: (_?: any) => any // integrationsLogic openSetupModal: ( integration?: IntegrationType | undefined, channelType?: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined @@ -46,6 +53,24 @@ export interface channelCatalogLogicActions { channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined integration: IntegrationType | undefined } // integrationsLogic + retrySandboxSenderSetup: () => { + value: true + } + setUpSandboxSender: () => any + setUpSandboxSenderFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + setUpSandboxSenderSuccess: ( + sandboxProvisionError: string | null, + payload?: any + ) => { + sandboxProvisionError: string | null + payload?: any + } startChannelSetup: (kind: ChannelType) => { kind: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' } @@ -54,6 +79,8 @@ export interface channelCatalogLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicMeta { __keaTypeGenInternalSelectorTypes: { + sandboxSetupLoading: (integrationsLoading: boolean, sandboxProvisionErrorLoading: boolean) => boolean + sandboxSetupError: (integrationsLoadFailed: boolean, sandboxProvisionError: string | null) => string | null channels: (integrations: IntegrationType[] | null, featureFlags: FeatureFlagsSet) => ChannelSummary[] | null activeCount: (channels: ChannelSummary[] | null) => number } @@ -69,22 +96,53 @@ export type channelCatalogLogicType = MakeLogicType< export const channelCatalogLogic = kea<channelCatalogLogicType>([ path(['products', 'workflows', 'frontend', 'Channels', 'channelCatalogLogic']), connect(() => ({ - values: [integrationsLogic, ['integrations'], featureFlagLogic, ['featureFlags']], - actions: [integrationsLogic, ['openSetupModal', 'loadIntegrationsFailure', 'loadIntegrationsSuccess']], + values: [ + integrationsLogic, + [ + 'integrations', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'integrationsLoading', + 'sandboxEmailSender', + 'sandboxEmailSenderEnabled', + ], + featureFlagLogic, + ['featureFlags'], + teamLogic, + ['currentProjectId'], + ], + actions: [integrationsLogic, ['openSetupModal', 'loadIntegrations'], featureFlagLogic, ['setFeatureFlags']], })), actions({ startChannelSetup: (kind: ChannelType) => ({ kind }), + retrySandboxSenderSetup: true, }), - reducers({ - integrationsLoadFailed: [ - false, + loaders(({ actions, values }) => ({ + // The endpoint is idempotent, so the page asks for the sandbox sender on every visit. + sandboxProvisionError: [ + null as string | null, { - loadIntegrationsFailure: () => true, - loadIntegrationsSuccess: () => false, + setUpSandboxSender: async (): Promise<string | null> => { + try { + await integrationsEmailSandboxSenderCreate(String(values.currentProjectId)) + } catch (error) { + return (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' + } + actions.loadIntegrations() + return null + }, }, ], - }), + })), selectors({ + sandboxSetupLoading: [ + (s) => [s.integrationsLoading, s.sandboxProvisionErrorLoading], + (loading: boolean, provisioning: boolean): boolean => loading || provisioning, + ], + sandboxSetupError: [ + (s) => [s.integrationsLoadFailed, s.sandboxProvisionError], + (loadFailed: boolean, setupError: string | null): string | null => (loadFailed ? null : setupError), + ], /** `null` until the integrations load, so the catalog never shows a channel as not set up by mistake. */ channels: [ (s) => [s.integrations, s.featureFlags], @@ -104,6 +162,24 @@ export const channelCatalogLogic = kea<channelCatalogLogicType>([ ], }), listeners(({ actions, values }) => ({ + setFeatureFlags: () => { + if ( + values.sandboxEmailSenderEnabled && + !values.sandboxEmailSender && + !values.sandboxProvisionErrorLoading + ) { + actions.setUpSandboxSender() + } + }, + retrySandboxSenderSetup: () => { + if (values.sandboxSetupLoading) { + return + } + actions.loadIntegrations() + if (values.sandboxEmailSenderEnabled) { + actions.setUpSandboxSender() + } + }, startChannelSetup: ({ kind }) => { // pinned: analytics event name - renaming breaks dashboards posthog.capture('workflows channel setup started', { @@ -125,4 +201,9 @@ export const channelCatalogLogic = kea<channelCatalogLogicType>([ actions.openSetupModal(undefined, kind) }, })), + afterMount(({ actions, values }) => { + if (values.sandboxEmailSenderEnabled) { + actions.setUpSandboxSender() + } + }), ]) diff --git a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx index 59b20a30429c..bce67c310fba 100644 --- a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx +++ b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx @@ -10,7 +10,12 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelSetupModal } from '../../Channels/ChannelSetupModal' export function WizardChannelStep(): JSX.Element { - const { integrations, setupModalOpen, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { + userManagedIntegrations: integrations, + setupModalOpen, + setupModalType, + selectedIntegration, + } = useValues(integrationsLogic) const { openSetupModal, closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) const hasEmailChannel = !!integrations?.some((integration) => integration.kind === 'email') diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts new file mode 100644 index 000000000000..e58833a3dbec --- /dev/null +++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts @@ -0,0 +1,36 @@ +import { waitFor } from '@testing-library/react' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { workflowsSetupGuideLogic } from '../workflowsSetupGuideLogic' +import { workflowsOnboardingWizardLogic } from './workflowsOnboardingWizardLogic' + +describe('workflowsOnboardingWizardLogic', () => { + let logic: ReturnType<typeof workflowsOnboardingWizardLogic.build> + + beforeEach(() => { + initKeaTests() + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { + results: [{ id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } }], + }, + '/api/projects/:project_id/messaging_categories/': { results: [], count: 0 }, + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:project_id/hog_flows/': { results: [], count: 0 }, + }, + }) + logic = workflowsOnboardingWizardLogic() + logic.mount() + }) + + afterEach(() => logic.unmount()) + + it('leaves the email channel and domain steps open when only the sandbox sender exists', async () => { + await waitFor(() => expect(workflowsSetupGuideLogic.values.steps).not.toBeNull()) + + expect(workflowsSetupGuideLogic.values.steps?.filter((step) => step.done).map((step) => step.key)).toEqual([]) + expect(logic.values.stepDone).toMatchObject({ channel: false, domain: false }) + }) +}) diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts index 09b48e850579..429ffc206fff 100644 --- a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts +++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts @@ -257,7 +257,7 @@ export const workflowsOnboardingWizardLogic = kea<workflowsOnboardingWizardLogic workflowsSetupGuideLogic, ['steps as guideSteps'], integrationsLogic, - ['integrations'], + ['userManagedIntegrations as integrations'], featureFlagLogic, ['featureFlags'], workflowTemplatesLogic, diff --git a/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts b/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts index ce490206e701..1f6be4602b5a 100644 --- a/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts +++ b/products/workflows/frontend/setupGuide/workflowsSetupGuideLogic.ts @@ -143,7 +143,7 @@ export const workflowsSetupGuideLogic = kea<workflowsSetupGuideLogicType>([ projectLogic, ['currentProjectId'], integrationsLogic, - ['integrations'], + ['userManagedIntegrations as integrations'], optOutCategoriesLogic, ['categories', 'categoriesLoading'], messageTemplatesLogic, From 69e32ffd1be4671f6d2bba1acecc7a0451d8df06 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:27:11 +0000 Subject: [PATCH 21/38] feat(workflows): validate sandbox sender use at save time Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 1 + .../cdp/backend/presentation/serializers.py | 1 + products/cdp/backend/test/test_validation.py | 2 +- products/cdp/backend/validation.py | 112 +++++- .../backend/presentation/views/hog_flow.py | 41 ++- .../backend/services/broadcast_conversion.py | 55 ++- .../tests/api/test_broadcast_conversion.py | 46 +++ .../api/test_sandbox_sender_validation.py | 345 ++++++++++++++++++ 8 files changed, 570 insertions(+), 33 deletions(-) create mode 100644 products/workflows/backend/tests/api/test_sandbox_sender_validation.py diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index d0c38975f4c2..2aac968f7cce 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -26,6 +26,7 @@ Until the worker has a sandbox delivery route, it skips every send through the m Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. +The sandbox sender is available only in workflow email steps and test sends. Broadcasts, destinations, sender rotation, custom From values, and Reply-To overrides are rejected. Unchanged stored sender settings remain editable after the flag turns off. ### Sending tiers diff --git a/products/cdp/backend/presentation/serializers.py b/products/cdp/backend/presentation/serializers.py index 78de717168e9..f1841ed412c5 100644 --- a/products/cdp/backend/presentation/serializers.py +++ b/products/cdp/backend/presentation/serializers.py @@ -8,4 +8,5 @@ InputsSchemaSerializer as InputsSchemaSerializer, InputsSerializer as InputsSerializer, require_viewer_access_to_added_actions as require_viewer_access_to_added_actions, + validate_sandbox_email_sender as validate_sandbox_email_sender, ) diff --git a/products/cdp/backend/test/test_validation.py b/products/cdp/backend/test/test_validation.py index fb45b2764a76..7a3f02513889 100644 --- a/products/cdp/backend/test/test_validation.py +++ b/products/cdp/backend/test/test_validation.py @@ -633,7 +633,7 @@ def _validate_email_from(self, from_value, existing_from=None, get_team=True, ca if get_team: context_extra["get_team"] = lambda: self.team if cache is not None: - context_extra["email_integration_domain_cache"] = cache + context_extra["email_integration_cache"] = cache return validate_inputs(inputs_schema, {"email": {"value": value}}, context_extra=context_extra) @parameterized.expand( diff --git a/products/cdp/backend/validation.py b/products/cdp/backend/validation.py index c0b8ad0de0ad..5e67bf77ae05 100644 --- a/products/cdp/backend/validation.py +++ b/products/cdp/backend/validation.py @@ -18,9 +18,10 @@ from posthog.hogql.parser import parse_program, parse_string_template from posthog.hogql.visitor import TraversingVisitor -from posthog.models.integration import POSTHOG_CONNECT_KIND, Integration +from posthog.models.integration import POSTHOG_CONNECT_KIND, SANDBOX_EMAIL_PROVIDER, Integration from posthog.models.team.team import Team from posthog.models.user import User +from posthog.permissions import posthog_feature_flag_enabled from products.actions.backend.models.action import Action from products.cdp.backend.filters import ( @@ -81,13 +82,98 @@ def masked_secret_input_keys(stored_inputs: object) -> list[str]: def _sender_integration_ids(from_value: dict) -> set[int]: + rotation = from_value.get("integrationIds") return { - integration_id - for integration_id in [from_value.get("integrationId"), *(from_value.get("integrationIds") or [])] - if isinstance(integration_id, int) and not isinstance(integration_id, bool) + int(integration_id) + for integration_id in [from_value.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + if isinstance(integration_id, (int, float)) + and not isinstance(integration_id, bool) + and (not isinstance(integration_id, float) or integration_id.is_integer()) } +def _email_integrations(integration_ids: set[int], context: dict[str, Any]) -> dict[int, Integration | None]: + cache: dict[int, Integration | None] = context.setdefault("email_integration_cache", {}) + missing_ids = integration_ids - cache.keys() + if missing_ids: + integrations = { + integration.id: integration + for integration in Integration.objects.filter( + team_id=context["get_team"]().id, id__in=missing_ids, kind="email" + ) + } + cache.update({integration_id: integrations.get(integration_id) for integration_id in missing_ids}) + return cache + + +def validate_sandbox_email_sender(email_value: object, context: dict[str, Any]) -> None: + if not isinstance(email_value, dict) or not isinstance(email_value.get("from"), dict): + return + rotation = email_value["from"].get("integrationIds") + if isinstance(rotation, list) and len(rotation) > MAX_WORKFLOW_EMAIL_SENDERS: + raise serializers.ValidationError({"input": f"At most {MAX_WORKFLOW_EMAIL_SENDERS} email senders are allowed."}) + integration_ids = _sender_integration_ids(email_value["from"]) + get_team = context.get("get_team") + if not integration_ids or get_team is None: + return + integrations = _email_integrations(integration_ids, context) + if not any( + (integration := integrations[integration_id]) is not None + and (integration.config or {}).get("provider") == SANDBOX_EMAIL_PROVIDER + for integration_id in integration_ids + ): + return + if context.get("workflow_origin_product") == "broadcasts": + raise serializers.ValidationError( + {"input": "The sandbox sender cannot be used in broadcasts. Select a sender on your own verified domain."} + ) + is_test_send = getattr(context.get("view"), "action", None) == "invocations" + if context.get("workflow_action_type") != "function_email" and not is_test_send: + raise serializers.ValidationError( + { + "input": "The sandbox sender can only be used in workflow email steps and test sends. " + "Select a sender on your own verified domain for destinations." + } + ) + for stored in context.get("existing_email_values") or []: + if ( + isinstance(stored, dict) + and email_value["from"] == stored.get("from") + and (email_value.get("replyTo") or "") == (stored.get("replyTo") or "") + ): + return + if len(integration_ids) != 1: + raise serializers.ValidationError( + { + "input": "The sandbox sender must be the only sender. Remove the other senders " + "or select a sender on your own verified domain." + } + ) + if email_value["from"].get("email") or email_value["from"].get("name") or email_value.get("replyTo"): + raise serializers.ValidationError( + { + "input": "The sandbox sender uses a fixed From address and name and does not support Reply-To. " + "Remove these overrides or select a sender on your own verified domain." + } + ) + team = get_team() + flag_cache: dict[int, bool] = context.setdefault("sandbox_sender_enabled_cache", {}) + if team.id not in flag_cache: + try: + flag_cache[team.id] = posthog_feature_flag_enabled( + "workflows-sandbox-sender", str(team.uuid), organization_id=team.organization_id, team_id=team.id + ) + except Exception: + flag_cache[team.id] = False + if not flag_cache[team.id]: + raise serializers.ValidationError( + { + "input": "The sandbox sender is not available for this project. " + "Select a sender on your own verified domain." + } + ) + + def _validate_not_posthog_connection(integration_ids: list[int], context: dict) -> None: # A PostHog connection acts as the user who created it, so only that user may use it. A function # runs for the whole team and the runtime never resolves one, so reject it where the author sees why. @@ -146,20 +232,11 @@ def _validate_email_sender_override(from_value: dict, context: dict) -> None: return override_domain = override.split("@")[1].lower() - # An empty cached domain means the id resolved to no email integration for this team; the - # save is not blocked on it (there is no domain to compare), matching the uncached behavior. - shared_cache = context.get("email_integration_domain_cache") - domain_cache: dict[int, str] = shared_cache if isinstance(shared_cache, dict) else {} - missing_ids = [integration_id for integration_id in integration_ids if integration_id not in domain_cache] - if missing_ids: - for integration in Integration.objects.filter(team_id=get_team().id, id__in=missing_ids, kind="email"): - config = integration.config or {} - domain_cache[integration.id] = (config.get("domain") or (config.get("email") or "").split("@")[-1]).lower() - for integration_id in missing_ids: - domain_cache.setdefault(integration_id, "") - + integrations = _email_integrations(integration_ids, context) for integration_id in sorted(integration_ids): - integration_domain = domain_cache.get(integration_id) or "" + integration = integrations[integration_id] + config = (integration.config or {}) if integration is not None else {} + integration_domain = (config.get("domain") or (config.get("email") or "").split("@")[-1]).lower() if integration_domain and override_domain != integration_domain: raise serializers.ValidationError( { @@ -851,6 +928,7 @@ def validate(self, attrs): elif item_type == "email" or item_type == "native_email": if not isinstance(value, dict): raise serializers.ValidationError({"input": f"Value must be an email object."}) + validate_sandbox_email_sender(value, self.context) # Report every missing key in one error: these objects are typically authored # programmatically, and a one-at-a-time raise forces a round trip per missing key. missing = [f"'{key_}'" for key_ in ("from", "to", "subject") if not value.get(key_)] diff --git a/products/workflows/backend/presentation/views/hog_flow.py b/products/workflows/backend/presentation/views/hog_flow.py index 6f7b6570b1be..bd901bfcbe35 100644 --- a/products/workflows/backend/presentation/views/hog_flow.py +++ b/products/workflows/backend/presentation/views/hog_flow.py @@ -128,6 +128,7 @@ InputsSchemaSerializer, InputsSerializer, require_viewer_access_to_added_actions, + validate_sandbox_email_sender, ) from products.cohorts.backend.facade.api import get_cohort_ref from products.messaging.backend.facade.categories import MessageCategoryMissing, get_category @@ -1085,14 +1086,14 @@ def _has_exact_string_filter(filters: dict, key: str) -> bool: return False -def _existing_email_from_by_action(instance: WorkflowEditState) -> dict[str, list[dict]]: - """Stored email sender overrides keyed by action id, live and draft variants both kept. +def _existing_email_values_by_action(instance: WorkflowEditState) -> dict[str, list[tuple[str | None, dict]]]: + """Stored email inputs keyed by action id, live and draft variants both kept. Which variant a save should be compared against depends on how the request routes (a builder save targets the draft, a raw API write targets live), so validation grandfathers a value matching either - both are values already stored on the workflow. """ - result: dict[str, list[dict]] = {} + result: dict[str, list[tuple[str | None, dict]]] = {} draft = instance.draft if isinstance(instance.draft, dict) else {} for actions in (instance.actions, draft.get("actions")): for stored_action in actions or []: @@ -1101,8 +1102,8 @@ def _existing_email_from_by_action(instance: WorkflowEditState) -> dict[str, lis email_input = ((stored_action.get("config") or {}).get("inputs") or {}).get("email") value = email_input.get("value") if isinstance(email_input, dict) else None from_value = value.get("from") if isinstance(value, dict) else None - if isinstance(from_value, dict): - result.setdefault(stored_action["id"], []).append(from_value) + if isinstance(value, dict) and isinstance(from_value, dict): + result.setdefault(stored_action["id"], []).append((stored_action.get("type"), value)) return result @@ -2133,6 +2134,23 @@ def _subscribes_to(event_id: str) -> bool: else: input_schema = template.inputs_schema inputs = data.get("config", {}).get("inputs", {}) + existing_emails = (self.context.get("existing_action_emails") or {}).get(data.get("id")) or [] + input_context = { + **self.context, + "workflow_action_type": data.get("type"), + "existing_email_values": [ + email for action_type, email in existing_emails if action_type == "function_email" + ], + # Request-scoped: a drip sequence's steps share senders, and the actions + # list validates one action at a time (mirrors _message_template_cache). + "email_integration_cache": self.context.setdefault("_email_integration_cache", {}), + "sandbox_sender_enabled_cache": self.context.setdefault("_sandbox_sender_enabled_cache", {}), + } + for schema in input_schema or []: + if schema.get("type") in {"email", "native_email"} and isinstance(inputs, dict): + email_input = inputs.get(schema["key"]) + if isinstance(email_input, dict): + validate_sandbox_email_sender(email_input.get("value"), input_context) function_config_serializer = HogFlowConfigFunctionInputsSerializer( data={ @@ -2140,6 +2158,7 @@ def _subscribes_to(event_id: str) -> bool: "inputs": inputs, }, context={ + **input_context, "function_type": template.type, "is_dwh_source": self.context.get("is_dwh_source", False), # The existing (decrypted) secret inputs for this action, so a resent @@ -2150,14 +2169,7 @@ def _subscribes_to(event_id: str) -> bool: # sender address is checked against the integration's verified domain at # save time, while an unchanged stored value is grandfathered. "get_team": self.context.get("get_team"), - "existing_email_from": (self.context.get("existing_action_email_from") or {}).get( - data.get("id") - ), - # Request-scoped: a drip sequence's steps share senders, and the actions - # list validates one action at a time (mirrors _message_template_cache). - "email_integration_domain_cache": self.context.setdefault( - "_email_integration_domain_cache", {} - ), + "existing_email_from": [email["from"] for _, email in existing_emails], }, ) @@ -3463,6 +3475,7 @@ def to_internal_value(self, data): # When used as a nested field (the `configuration` override on test invocations) DRF never # binds `self.instance`, so fall back to the flow passed in via context so recovery still works. instance = cast(Optional[WorkflowEditState], self.instance) or self.context.get("instance") + self.context["workflow_origin_product"] = instance.origin_product if instance else data.get("origin_product") # Who a "Create AI task" step runs as: the existing creator for an update, or the # requesting user for a brand-new flow (matches the `created_by` a create() actually @@ -3584,7 +3597,7 @@ def to_internal_value(self, data): # Stored sender overrides, keyed by action id, so child action validation only holds # newly written custom sender addresses to the verified-domain rule. Draft wins over # live for the same reason as secrets: it is the value the client last saw. - self.context["existing_action_email_from"] = _existing_email_from_by_action(instance) + self.context["existing_action_emails"] = _existing_email_values_by_action(instance) # Live "Run scout" notes, so only a new or changed note needs skill editor access. self.context["live_run_scout_notes"] = _live_run_scout_notes_by_action(instance) diff --git a/products/workflows/backend/services/broadcast_conversion.py b/products/workflows/backend/services/broadcast_conversion.py index 63d72ca384eb..0bb24b6db6d4 100644 --- a/products/workflows/backend/services/broadcast_conversion.py +++ b/products/workflows/backend/services/broadcast_conversion.py @@ -6,6 +6,8 @@ from django.db import models, transaction from django.utils import timezone +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration + from products.access_control.backend.facade.user_access_control import UserAccessControl from products.workflows.backend.facade.contracts import WorkflowActor, WorkflowSummary from products.workflows.backend.models.hog_flow.hog_flow import HogFlow @@ -37,9 +39,44 @@ def is_convertible_to_broadcast(flow: HogFlow) -> bool: config = trigger.get("config") or {} if config.get("type") != BROADCAST_TRIGGER_TYPE or (config.get("filters") or {}).get("audience_type") == "accounts": return False + if _sender_ids(_email_sender(email)) & _sandbox_sender_ids(flow.team_id): + return False return _joins_in_order(flow, trigger.get("id"), email.get("id"), exit_step.get("id")) +def _email_sender(email_step: dict) -> object: + """The step's stored `from` value. Drafts save leniently, so any level can hold another type.""" + value: object = email_step + for key in ("config", "inputs", "email", "value", "from"): + value = value.get(key) if isinstance(value, dict) else None + return value + + +def _sender_ids(sender: object) -> set[str]: + """The integrations a `from` value sends from, as strings, since stored ids can be ints, floats or strings.""" + if not isinstance(sender, dict): + return set() + rotation = sender.get("integrationIds") + ids = [sender.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + return {_integration_id_key(integration_id) for integration_id in ids if integration_id is not None} + + +def _integration_id_key(integration_id: object) -> str: + if isinstance(integration_id, float) and integration_id.is_integer(): + return str(int(integration_id)) + return str(integration_id) + + +def _sandbox_sender_ids(team_id: int) -> set[str]: + """Broadcasts can't send through the sandbox sender, so a workflow that uses it stays a workflow.""" + return { + str(integration_id) + for integration_id in Integration.objects.filter( + team_id=team_id, kind="email", config__provider=SANDBOX_EMAIL_PROVIDER + ).values_list("id", flat=True) + } + + def _joins_in_order(flow: HogFlow, trigger_id: object, email_id: object, exit_id: object) -> bool: """Whether the only edges run trigger to email and email to exit.""" paths = {(edge.get("from"), edge.get("to")) for edge in flow.edges or [] if isinstance(edge, dict)} @@ -60,6 +97,15 @@ def _step_count(step_type: str) -> models.Func: return models.Func(steps, function="jsonb_array_length", output_field=models.IntegerField()) +def _email_sender_value() -> models.Func: + return models.Func( + models.F("actions"), + _json_path('$[*] ? (@.type == "function_email").config.inputs.email.value.from'), + function="jsonb_path_query_first", + output_field=models.JSONField(), + ) + + def _step_id(step_type: str) -> models.Func: return models.Func( models.F("actions"), @@ -91,6 +137,7 @@ def list_broadcast_conversion_candidates( _exit_count=_step_count("exit"), _trigger_id=_step_id("trigger"), _email_id=_step_id("function_email"), + _email_sender=_email_sender_value(), _exit_id=_step_id("exit"), _account_audience=models.Func( models.F("actions"), @@ -111,6 +158,7 @@ def list_broadcast_conversion_candidates( .only("id", "team_id", "name", "status", "edges", "created_by") .order_by("-updated_at", "id") ) + sandbox_sender_ids = _sandbox_sender_ids(team_id) found: list[HogFlow] = [] offset = 0 while len(found) < limit: @@ -118,7 +166,12 @@ def list_broadcast_conversion_candidates( if not page: break offset += len(page) - page = [flow for flow in page if _joins_in_order(flow, flow._trigger_id, flow._email_id, flow._exit_id)] + page = [ + flow + for flow in page + if _joins_in_order(flow, flow._trigger_id, flow._email_id, flow._exit_id) + and not _sender_ids(flow._email_sender) & sandbox_sender_ids + ] if user_access_control is not None: # Converting needs editor access, so a workflow the caller can only view is not offered. user_access_control.preload_object_access_controls(cast("list[models.Model]", page)) diff --git a/products/workflows/backend/tests/api/test_broadcast_conversion.py b/products/workflows/backend/tests/api/test_broadcast_conversion.py index 54498d98385f..b6bb1bed62d1 100644 --- a/products/workflows/backend/tests/api/test_broadcast_conversion.py +++ b/products/workflows/backend/tests/api/test_broadcast_conversion.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from typing import Any from posthog.test.base import APIBaseTest @@ -7,6 +8,7 @@ from rest_framework import status from posthog.models.activity_logging.activity_log import ActivityLog +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER, Integration from products.workflows.backend.models import HogFlow @@ -68,6 +70,11 @@ def _convert(self, flow: HogFlow) -> Any: ), ("extra step", {"actions": [TRIGGER, EMAIL, DELAY, EXIT]}, False), ("edge that skips the email", {"edges": [*EDGES, {"from": "trigger_node", "to": "exit_node"}]}, False), + ( + "email step without a sender object", + {"actions": [TRIGGER, {**EMAIL, "config": {"inputs": {"email": {"value": "draft"}}}}, EXIT]}, + True, + ), ] ) def test_only_workflows_the_broadcast_editor_can_own_are_offered_and_converted( @@ -91,6 +98,45 @@ def test_only_workflows_the_broadcast_editor_can_own_are_offered_and_converted( assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() assert flow.origin_product == overrides.get("origin_product") + @parameterized.expand( + [ + ("sandbox sender", {"integrationId": "sandbox"}, int, False), + ("sandbox sender stored as a whole-number float", {"integrationId": "sandbox"}, float, False), + ("sandbox sender stored as a string", {"integrationId": "sandbox"}, str, False), + ("sandbox sender in a rotation", {"integrationIds": ["own", "sandbox"]}, int, False), + ("own sender", {"integrationId": "own"}, int, True), + ] + ) + def test_a_workflow_sending_through_the_sandbox_sender_does_not_become_a_broadcast( + self, _name: str, sender: dict[str, Any], stored_as: Callable[[int], Any], convertible: bool + ) -> None: + integration_ids = { + "sandbox": Integration.objects.create( + team=self.team, + kind="email", + integration_id=SANDBOX_EMAIL_INTEGRATION_ID, + config={"provider": SANDBOX_EMAIL_PROVIDER, "verified": True}, + ).id, + "own": Integration.objects.create( + team=self.team, + kind="email", + integration_id="sender@example.com", + config={"provider": "ses", "domain": "example.com", "verified": True}, + ).id, + } + sender_from = { + key: [stored_as(integration_ids[name]) for name in value] + if isinstance(value, list) + else stored_as(integration_ids[value]) + for key, value in sender.items() + } + email = {**EMAIL, "config": {"inputs": {"email": {"value": {"from": sender_from}}}}} + flow = self._flow(actions=[TRIGGER, email, EXIT]) + + assert (str(flow.id) in self._candidate_ids()) is convertible + response = self._convert(flow) + assert (response.status_code == status.HTTP_200_OK) is convertible, response.json() + def test_candidates_stop_at_the_cap_and_say_more_exist(self) -> None: newest = self._flow(name="Newest") self._flow(name="Broken", edges=[{"from": "trigger_node", "to": "exit_node"}]) diff --git a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py new file mode 100644 index 000000000000..41fa33bbf220 --- /dev/null +++ b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py @@ -0,0 +1,345 @@ +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.db import connection +from django.test import override_settings +from django.test.utils import CaptureQueriesContext + +from parameterized import parameterized + +from posthog.models.integration import Integration + +from products.cdp.backend.api.hog_function import HogFunctionInvocationSerializer +from products.cdp.backend.models.hog_function_template import HogFunctionTemplate +from products.workflows.backend.facade.api import ensure_sandbox_email_sender +from products.workflows.backend.facade.workflows import get_workflow_edit_state +from products.workflows.backend.models.hog_flow import HogFlow +from products.workflows.backend.presentation.views.hog_flow import HogFlowInvocationSerializer + + +@override_settings( + WORKFLOWS_SANDBOX_SENDER_DOMAIN="sandbox.example.com", + SES_SANDBOX_FROM_ADDRESS="hello@sandbox.example.com", + SES_SANDBOX_TENANT_NAME="posthog-sandbox", +) +class TestSandboxSenderValidation(APIBaseTest): + def setUp(self) -> None: + super().setUp() + flag = patch("posthoganalytics.feature_enabled", return_value=True) + self.flag_enabled = flag.start() + self.addCleanup(flag.stop) + capture = patch("posthoganalytics.capture") + capture.start() + self.addCleanup(capture.stop) + self.sender = Integration.objects.get( + team_id=self.team.id, id=ensure_sandbox_email_sender(self.team.id).integration_id + ) + self.email_schema = [{"key": "email", "type": "native_email", "required": True, "templating": "liquid"}] + HogFunctionTemplate.objects.create( + template_id="template-email", + name="Email", + type="destination", + status="hidden", + code="return sendEmail(inputs.email);", + inputs_schema=self.email_schema, + ) + + def _workflow(self) -> dict[str, Any]: + return { + "name": "Sandbox workflow", + "actions": [ + { + "id": "trigger", + "type": "trigger", + "name": "Trigger", + "config": { + "type": "event", + "filters": {"events": [{"id": "$pageview", "type": "events", "order": 0}]}, + }, + }, + { + "id": "email", + "type": "function_email", + "name": "Send email", + "config": {"inputs": {"email": {"value": self._email()}}}, + }, + {"id": "exit", "type": "exit", "name": "Exit", "config": {}}, + ], + "edges": [ + {"from": "trigger", "to": "email", "type": "continue"}, + {"from": "email", "to": "exit", "type": "continue"}, + ], + } + + def _email(self) -> dict[str, Any]: + return { + "from": {"integrationId": self.sender.id}, + "to": "member@example.com", + "subject": "Hello", + "html": "<p>Hello</p>", + } + + def _function(self) -> dict[str, Any]: + return { + "name": "Sandbox destination", + "type": "destination", + "hog": "return 1;", + "inputs_schema": self.email_schema, + "inputs": {"email": {"value": self._email()}}, + } + + @parameterized.expand([("integer",), ("float",), ("malformed_rotation",)]) + def test_broadcast_cannot_select_the_sandbox_sender(self, sender_shape: str) -> None: + workflow = self._workflow() + workflow["origin_product"] = "broadcasts" + sender = workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"] + if sender_shape == "float": + sender["integrationId"] = float(self.sender.id) + elif sender_shape == "malformed_rotation": + sender["integrationIds"] = self.sender.id + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "sandbox sender" in response.json()["detail"].lower() + assert "broadcast" in response.json()["detail"].lower() + + def test_destination_cannot_select_the_sandbox_sender(self) -> None: + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + self._function(), + ) + + assert response.status_code == 400, response.json() + assert "sandbox sender" in response.json()["detail"].lower() + assert "workflow email steps and test sends" in response.json()["detail"].lower() + + def test_generic_workflow_step_cannot_select_the_sandbox_sender_with_a_legacy_email_schema(self) -> None: + template = HogFunctionTemplate.objects.get(template_id="template-email") + template.inputs_schema = [{**self.email_schema[0], "type": "email"}] + template.save() + workflow = self._workflow() + workflow["actions"][1]["type"] = "function" + workflow["actions"][1]["config"]["template_id"] = "template-email" + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "workflow email steps and test sends" in response.json()["detail"].lower() + + def test_workflow_can_select_the_sandbox_sender_alone(self) -> None: + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + + assert response.status_code == 201, response.json() + assert response.json()["actions"][1]["config"]["inputs"]["email"]["value"]["from"] == { + "integrationId": self.sender.id + } + + @parameterized.expand([("exit",), ("function",)]) + def test_inert_email_data_is_not_an_existing_sandbox_sender(self, action_type: str) -> None: + workflow = self._workflow() + action = workflow["actions"][1] + action["type"] = action_type + action["config"]["template_id"] = "missing-template" + email = action["config"]["inputs"]["email"]["value"] + email["from"]["name"] = "New sender" + email["replyTo"] = "reply@example.com" + created = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + assert created.status_code == 201, created.json() + action["type"] = "function_email" + action["config"].pop("template_id") + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{created.json()['id']}", + {"actions": workflow["actions"]}, + ) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + + def test_sandbox_sender_cannot_rotate_with_own_senders(self) -> None: + own_sender = Integration.objects.create( + team=self.team, + kind="email", + config={"provider": "ses", "domain": "example.com", "email": "sender@example.com", "verified": True}, + ) + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"] = { + "integrationIds": [self.sender.id, own_sender.id] + } + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "sandbox sender must be the only sender" in response.json()["detail"].lower() + + def test_oversized_sender_rotation_is_rejected_before_looking_up_integrations(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"]["integrationIds"] = list(range(100)) + + with CaptureQueriesContext(connection) as queries: + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "At most 10 email senders" in response.json()["detail"] + assert not [query for query in queries if 'FROM "posthog_integration"' in query["sql"]] + + @parameterized.expand( + [ + ("custom From address", "email", "custom@sandbox.example.com"), + ("templated From address", "email", "{{ person.properties.email }}"), + ("custom From name", "name", "Custom sender"), + ("Reply-To", "replyTo", "reply@example.com"), + ] + ) + def test_sandbox_sender_cannot_use_sender_overrides(self, _name: str, field: str, value: str) -> None: + workflow = self._workflow() + email = workflow["actions"][1]["config"]["inputs"]["email"]["value"] + if field == "replyTo": + email[field] = value + else: + email["from"][field] = value + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + assert "Reply-To" in response.json()["detail"] + + def test_workflow_cannot_select_the_sandbox_sender_after_the_flag_turns_off(self) -> None: + self.flag_enabled.return_value = False + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + + assert response.status_code == 400, response.json() + assert "sandbox sender is not available for this project" in response.json()["detail"].lower() + + def test_unchanged_sandbox_sender_keeps_saving_after_the_flag_turns_off(self) -> None: + created = self.client.post(f"/api/projects/{self.team.id}/hog_flows", self._workflow()) + assert created.status_code == 201, created.json() + actions = created.json()["actions"] + actions[1]["config"]["inputs"]["email"]["value"]["subject"] = "Updated subject" + self.flag_enabled.return_value = False + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{created.json()['id']}", + {"name": "Renamed workflow", "actions": actions}, + ) + + assert response.status_code == 200, response.json() + email = response.json()["actions"][1]["config"]["inputs"]["email"]["value"] + assert email["from"] == {"integrationId": self.sender.id} + assert email["subject"] == "Updated subject" + + def test_test_send_configuration_can_select_the_sandbox_sender(self) -> None: + serializer = HogFunctionInvocationSerializer( + data={"configuration": self._function()}, + context={"get_team": lambda: self.team, "view": SimpleNamespace(action="invocations")}, + ) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["configuration"]["inputs"]["email"]["value"]["from"] == { + "integrationId": self.sender.id + } + + @parameterized.expand([("missing",), ("cleared",)]) + def test_broadcast_test_cannot_clear_its_origin(self, origin: str) -> None: + workflow = self._workflow() + stored = HogFlow.objects.create( + team=self.team, + name="Broadcast", + origin_product="broadcasts", + actions=workflow["actions"], + edges=workflow["edges"], + ) + if origin == "cleared": + workflow["origin_product"] = None + serializer = HogFlowInvocationSerializer( + data={"configuration": workflow}, + context={ + "get_team": lambda: self.team, + "view": SimpleNamespace(action="invocations"), + "instance": get_workflow_edit_state( + team_id=self.team.id, workflow_id=stored.id, user_access_control=None, required_level=None + ), + }, + ) + + assert not serializer.is_valid(), serializer.validated_data + assert "broadcast" in str(serializer.errors).lower() + + @parameterized.expand([("live",), ("draft",)]) + def test_unchanged_legacy_sender_survives_unrelated_edits(self, variant: str) -> None: + live = self._workflow() + live_email = live["actions"][1]["config"]["inputs"]["email"]["value"] + live_email["from"].update({"email": "legacy@example.com", "name": "Legacy sender"}) + live_email["replyTo"] = "legacy-reply@example.com" + draft = deepcopy(live) + draft_email = draft["actions"][1]["config"]["inputs"]["email"]["value"] + draft_email["from"]["name"] = "Draft legacy sender" + draft_email["replyTo"] = "draft-reply@example.com" + stored = HogFlow.objects.create( + team=self.team, + name="Legacy workflow", + actions=live["actions"], + edges=live["edges"], + status=HogFlow.State.ACTIVE, + draft=draft, + ) + actions = deepcopy(live["actions"] if variant == "live" else draft["actions"]) + actions[1]["config"]["inputs"]["email"]["value"]["subject"] = "Updated legacy subject" + self.flag_enabled.return_value = False + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{stored.id}", + {"name": "Renamed legacy workflow", "actions": actions}, + ) + + assert response.status_code == 200, response.json() + + @parameterized.expand([("email",), ("name",), ("replyTo",)]) + def test_legacy_sender_overrides_cannot_be_changed(self, field: str) -> None: + workflow = self._workflow() + email = workflow["actions"][1]["config"]["inputs"]["email"]["value"] + email["from"].update({"email": "legacy@example.com", "name": "Legacy sender"}) + email["replyTo"] = "legacy-reply@example.com" + stored = HogFlow.objects.create( + team=self.team, + name="Legacy workflow", + actions=workflow["actions"], + edges=workflow["edges"], + status=HogFlow.State.ACTIVE, + ) + if field == "replyTo": + email[field] = "changed@example.com" + else: + email["from"][field] = "changed@example.com" if field == "email" else "Changed sender" + + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{stored.id}", + {"actions": workflow["actions"]}, + ) + + assert response.status_code == 400, response.json() + assert "fixed From address and name" in response.json()["detail"] + + def test_malformed_sender_rotation_returns_a_validation_error(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"]["email"]["value"]["from"]["integrationIds"] = self.sender.id + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow, HTTP_X_POSTHOG_CLIENT="mcp") + + assert response.status_code == 400, response.json() + assert "list of Integration IDs" in response.json()["detail"] + + def test_null_email_inputs_return_a_validation_error(self) -> None: + workflow = self._workflow() + workflow["actions"][1]["config"]["inputs"] = None + + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow, HTTP_X_POSTHOG_CLIENT="mcp") + + assert response.status_code == 400, response.json() From bab461f03f547cd6299cd159e5ceca0d1d0ff57e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:41:43 +0000 Subject: [PATCH 22/38] fix(workflows): match worker sender selection and keep cleanup saves working Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- products/cdp/backend/api/hog_function.py | 11 +++++++ products/cdp/backend/validation.py | 18 +++++----- .../backend/services/broadcast_conversion.py | 7 ++-- .../tests/api/test_broadcast_conversion.py | 6 ++++ .../api/test_sandbox_sender_validation.py | 33 +++++++++++++++++++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/products/cdp/backend/api/hog_function.py b/products/cdp/backend/api/hog_function.py index 8df10aeb1498..cf6be6b7adaa 100644 --- a/products/cdp/backend/api/hog_function.py +++ b/products/cdp/backend/api/hog_function.py @@ -195,6 +195,16 @@ def snapshot_hog_function_content(hog_function: HogFunction) -> dict: return snapshot +def _stored_input_values(instance: Optional[HogFunction]) -> list[dict]: + """Stored input values, so a save that leaves an email value unchanged, such as disabling the function, keeps working.""" + stored_inputs = instance.inputs if instance and isinstance(instance.inputs, dict) else {} + return [ + entry["value"] + for entry in stored_inputs.values() + if isinstance(entry, dict) and isinstance(entry.get("value"), dict) + ] + + def _named_warehouse_tables(entries: Any) -> list[Any]: """The warehouse tables a filters blob actually names, ignoring the picker's placeholder row. @@ -632,6 +642,7 @@ def to_internal_value(self, data): ): raise serializers.ValidationError({"filters": "Select the materialized view to trigger on."}) self.context["encrypted_inputs"] = instance.encrypted_inputs if instance else {} + self.context["existing_email_values"] = _stored_input_values(instance) template = None if data["template_id"]: diff --git a/products/cdp/backend/validation.py b/products/cdp/backend/validation.py index 5e67bf77ae05..77e434c6470b 100644 --- a/products/cdp/backend/validation.py +++ b/products/cdp/backend/validation.py @@ -83,9 +83,11 @@ def masked_secret_input_keys(stored_inputs: object) -> list[str]: def _sender_integration_ids(from_value: dict) -> set[int]: rotation = from_value.get("integrationIds") + # Mirrors selectEmailSenderIntegrationId in the worker: a non-empty rotation replaces integrationId. + configured = rotation if isinstance(rotation, list) and rotation else [from_value.get("integrationId")] return { int(integration_id) - for integration_id in [from_value.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + for integration_id in configured if isinstance(integration_id, (int, float)) and not isinstance(integration_id, bool) and (not isinstance(integration_id, float) or integration_id.is_integer()) @@ -127,6 +129,13 @@ def validate_sandbox_email_sender(email_value: object, context: dict[str, Any]) raise serializers.ValidationError( {"input": "The sandbox sender cannot be used in broadcasts. Select a sender on your own verified domain."} ) + for stored in context.get("existing_email_values") or []: + if ( + isinstance(stored, dict) + and email_value["from"] == stored.get("from") + and (email_value.get("replyTo") or "") == (stored.get("replyTo") or "") + ): + return is_test_send = getattr(context.get("view"), "action", None) == "invocations" if context.get("workflow_action_type") != "function_email" and not is_test_send: raise serializers.ValidationError( @@ -135,13 +144,6 @@ def validate_sandbox_email_sender(email_value: object, context: dict[str, Any]) "Select a sender on your own verified domain for destinations." } ) - for stored in context.get("existing_email_values") or []: - if ( - isinstance(stored, dict) - and email_value["from"] == stored.get("from") - and (email_value.get("replyTo") or "") == (stored.get("replyTo") or "") - ): - return if len(integration_ids) != 1: raise serializers.ValidationError( { diff --git a/products/workflows/backend/services/broadcast_conversion.py b/products/workflows/backend/services/broadcast_conversion.py index 0bb24b6db6d4..eb150b2c5350 100644 --- a/products/workflows/backend/services/broadcast_conversion.py +++ b/products/workflows/backend/services/broadcast_conversion.py @@ -53,11 +53,14 @@ def _email_sender(email_step: dict) -> object: def _sender_ids(sender: object) -> set[str]: - """The integrations a `from` value sends from, as strings, since stored ids can be ints, floats or strings.""" + """The integrations a `from` value sends from, as the worker picks them: a non-empty rotation replaces `integrationId`. + + Ids are compared as strings, since stored ids can be ints, floats or strings. + """ if not isinstance(sender, dict): return set() rotation = sender.get("integrationIds") - ids = [sender.get("integrationId"), *(rotation if isinstance(rotation, list) else [])] + ids = rotation if isinstance(rotation, list) and rotation else [sender.get("integrationId")] return {_integration_id_key(integration_id) for integration_id in ids if integration_id is not None} diff --git a/products/workflows/backend/tests/api/test_broadcast_conversion.py b/products/workflows/backend/tests/api/test_broadcast_conversion.py index b6bb1bed62d1..24dad6eb6238 100644 --- a/products/workflows/backend/tests/api/test_broadcast_conversion.py +++ b/products/workflows/backend/tests/api/test_broadcast_conversion.py @@ -105,6 +105,12 @@ def test_only_workflows_the_broadcast_editor_can_own_are_offered_and_converted( ("sandbox sender stored as a string", {"integrationId": "sandbox"}, str, False), ("sandbox sender in a rotation", {"integrationIds": ["own", "sandbox"]}, int, False), ("own sender", {"integrationId": "own"}, int, True), + ( + "own rotation with a stale sandbox fallback", + {"integrationId": "sandbox", "integrationIds": ["own"]}, + int, + True, + ), ] ) def test_a_workflow_sending_through_the_sandbox_sender_does_not_become_a_broadcast( diff --git a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py index 41fa33bbf220..d1cf1ec68b0a 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py @@ -15,6 +15,7 @@ from products.cdp.backend.api.hog_function import HogFunctionInvocationSerializer from products.cdp.backend.models.hog_function_template import HogFunctionTemplate +from products.cdp.backend.models.hog_functions.hog_function import HogFunction from products.workflows.backend.facade.api import ensure_sandbox_email_sender from products.workflows.backend.facade.workflows import get_workflow_edit_state from products.workflows.backend.models.hog_flow import HogFlow @@ -118,6 +119,38 @@ def test_destination_cannot_select_the_sandbox_sender(self) -> None: assert "sandbox sender" in response.json()["detail"].lower() assert "workflow email steps and test sends" in response.json()["detail"].lower() + @parameterized.expand([("disabled", {"enabled": False}), ("deleted", {"deleted": True})]) + def test_stored_destination_with_the_sandbox_sender_can_still_be(self, _name: str, change: dict) -> None: + function = HogFunction.objects.create( + team=self.team, + name="Stored destination", + type="destination", + hog="return 1;", + inputs_schema=self.email_schema, + inputs={"email": {"value": self._email()}}, + enabled=True, + ) + + response = self.client.patch(f"/api/projects/{self.team.id}/hog_functions/{function.id}/", change) + + assert response.status_code == 200, response.json() + + def test_destination_with_an_own_rotation_ignores_a_stale_sandbox_fallback(self) -> None: + own_sender = Integration.objects.create( + team=self.team, + kind="email", + config={"provider": "ses", "domain": "example.com", "email": "sender@example.com", "verified": True}, + ) + function = self._function() + function["inputs"]["email"]["value"]["from"] = { + "integrationId": self.sender.id, + "integrationIds": [own_sender.id], + } + + response = self.client.post(f"/api/projects/{self.team.id}/hog_functions/", function) + + assert response.status_code == 201, response.json() + def test_generic_workflow_step_cannot_select_the_sandbox_sender_with_a_legacy_email_schema(self) -> None: template = HogFunctionTemplate.objects.get(template_id="template-email") template.inputs_schema = [{**self.email_schema[0], "type": "email"}] From 53c404e90a4833bc4dcf75b9b440781645ce812c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:58:21 +0000 Subject: [PATCH 23/38] fix(workflows): keep the sandbox sender out of test sends and domain status Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- posthog/models/integration/email.py | 1 + .../workflows/backend/tests/api/test_sandbox_sender.py | 1 + .../TemplateLibrary/messageTemplateTestSendLogic.test.ts | 8 +++++++- .../TemplateLibrary/messageTemplateTestSendLogic.ts | 6 +++++- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/posthog/models/integration/email.py b/posthog/models/integration/email.py index 183224cae2c8..a792ee155101 100644 --- a/posthog/models/integration/email.py +++ b/posthog/models/integration/email.py @@ -216,6 +216,7 @@ def status( EmailDomainCheck, # noqa: PLC0415 — keeps the workflows facade off the model import path ) + self.ensure_user_managed() cache_key = f"email_domain_status:{self.integration.id}" if not refresh and isinstance(cached := self._cached_status(cache_key), str): entry = json.loads(cached) diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index bf48ee499933..45fd51429a48 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -146,6 +146,7 @@ def test_ensure_with_an_api_key_needs_the_integration_write_scope( {"config": {"email": "x@sandbox.example.com", "name": "Other", "provider": "sandbox"}}, ), ("delete", "delete", "", None), + ("check the domain status", "get", "email/status/", None), ] ) def test_users_cannot_change_the_sandbox_sender( diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts index 81a4245849aa..06a9ff1427c5 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts @@ -31,6 +31,12 @@ describe('messageTemplateTestSendLogic', () => { get: { '/api/projects/:team_id/integrations/': { results: [ + { + id: 3, + kind: 'email', + display_name: 'Acme via PostHog <hello@sandbox.example.com>', + config: { provider: 'sandbox', verified: true }, + }, { id: 4, kind: 'email', @@ -85,7 +91,7 @@ describe('messageTemplateTestSendLogic', () => { }).toMatchValues({ recipientEmail: 'john.doe@posthog.com' }) }) - it('defaults the sender to the first verified email integration, excluding unverified and non-email kinds', async () => { + it('defaults the sender to the first verified email integration, excluding unverified, sandbox and non-email kinds', async () => { await expectLogic(logic).toMatchValues({ senderIntegrationId: 5, emailIntegrations: [expect.objectContaining({ id: 5 }), expect.objectContaining({ id: 7 })], diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index dc1d9315665d..d132a15f2e14 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -3,6 +3,7 @@ import { loaders } from 'kea-loaders' import api from 'lib/api' import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { lemonToast } from 'lib/lemon-ui/LemonToast' import { isEmail } from 'lib/utils/url' import { teamLogic } from 'scenes/teamLogic' @@ -215,7 +216,10 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType (s) => [s.integrations], (integrations: IntegrationType[] | null): IntegrationType[] => integrations?.filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true + (integration) => + integration.kind === 'email' && + integration.config?.verified === true && + !isSandboxEmailSender(integration) ) ?? [], ], senderIntegrationId: [ From 163f55124fe2431a3895aafb751c5ad5f86d4339 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:00:18 +0000 Subject: [PATCH 24/38] fix(workflows): drop stale sandbox setup results and cover the retry banner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../Channels/channelCatalogLogic.test.ts | 27 +++++++++++++------ .../frontend/Channels/channelCatalogLogic.ts | 14 ++++++---- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts index 569872b3d399..872d514bf116 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { expectLogic } from 'kea-test-utils' import { createElement } from 'react' @@ -13,6 +13,7 @@ import { initKeaTests } from '~/test/init' import { ChannelCatalog } from './ChannelCatalog' import { channelCatalogLogic } from './channelCatalogLogic' +import { MessageChannels } from './MessageChannels' describe('sandbox setup in the channel catalog', () => { let logic: ReturnType<typeof channelCatalogLogic.build> @@ -23,10 +24,19 @@ describe('sandbox setup in the channel catalog', () => { logic?.unmount() }) - it.each([404, 500])('starts setup on mount and exposes a %s request for retry', async (failureStatus) => { + it.each([404, 500])('starts setup on mount and recovers from a %s through Try again', async (failureStatus) => { let status = failureStatus let requests = 0 - const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + const sandbox = { + id: 9, + kind: 'email', + config: { + provider: 'sandbox', + verified: true, + name: 'Acme via PostHog', + email: 'hello@sandbox.example.com', + }, + } useMocks({ get: { '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }] }, post: { @@ -45,13 +55,14 @@ describe('sandbox setup in the channel catalog', () => { logic.mount() await waitFor(() => expect(requests).toBe(1)) await expectLogic(integrationsLogic).toFinishAllListeners() - expect(logic.values.sandboxSetupError).toBe('Could not set up your sandbox sender.') + render(createElement(MessageChannels)) + expect(await screen.findByText('Could not set up your sandbox sender.')).toBeInTheDocument() status = 200 - await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() - await waitFor(() => expect(requests).toBe(2)) - await expectLogic(integrationsLogic).toFinishAllListeners() - expect(logic.values.sandboxSetupError).toBeNull() + fireEvent.click(screen.getAllByText('Try again')[0]) + expect(await screen.findByText('Acme via PostHog <hello@sandbox.example.com>')).toBeInTheDocument() + expect(requests).toBe(2) + expect(screen.queryByText('Could not set up your sandbox sender.')).not.toBeInTheDocument() }) it('keeps a channel load failure separate from sandbox setup and reloads on retry', async () => { diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts index a16a2b0b5c94..194dcaec6be0 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts @@ -56,7 +56,7 @@ export interface channelCatalogLogicActions { retrySandboxSenderSetup: () => { value: true } - setUpSandboxSender: () => any + setUpSandboxSender: (_?: any) => any setUpSandboxSenderFailure: ( error: string, errorObject?: any @@ -122,14 +122,18 @@ export const channelCatalogLogic = kea<channelCatalogLogicType>([ sandboxProvisionError: [ null as string | null, { - setUpSandboxSender: async (): Promise<string | null> => { + setUpSandboxSender: async (_, breakpoint): Promise<string | null> => { + let failure: string | null = null try { await integrationsEmailSandboxSenderCreate(String(values.currentProjectId)) } catch (error) { - return (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' + failure = (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' } - actions.loadIntegrations() - return null + breakpoint() + if (!failure) { + actions.loadIntegrations() + } + return failure }, }, ], From 1b4950821c41b8853ba7f3cb1c31a7b4797d9588 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:08:36 +0000 Subject: [PATCH 25/38] fix(workflows): keep the step-type rule when a sandbox email step changes type A stored email step that turns into a generic function step no longer keeps its sandbox sender as an unchanged value, so the step-type rule rejects it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../backend/presentation/views/hog_flow.py | 4 +++- .../tests/api/test_sandbox_sender_validation.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/products/workflows/backend/presentation/views/hog_flow.py b/products/workflows/backend/presentation/views/hog_flow.py index bd901bfcbe35..7a753a4c2b7d 100644 --- a/products/workflows/backend/presentation/views/hog_flow.py +++ b/products/workflows/backend/presentation/views/hog_flow.py @@ -2139,7 +2139,9 @@ def _subscribes_to(event_id: str) -> bool: **self.context, "workflow_action_type": data.get("type"), "existing_email_values": [ - email for action_type, email in existing_emails if action_type == "function_email" + email + for action_type, email in existing_emails + if action_type == "function_email" and data.get("type") == "function_email" ], # Request-scoped: a drip sequence's steps share senders, and the actions # list validates one action at a time (mirrors _message_template_cache). diff --git a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py index d1cf1ec68b0a..8d21545e6037 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender_validation.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender_validation.py @@ -151,15 +151,28 @@ def test_destination_with_an_own_rotation_ignores_a_stale_sandbox_fallback(self) assert response.status_code == 201, response.json() - def test_generic_workflow_step_cannot_select_the_sandbox_sender_with_a_legacy_email_schema(self) -> None: + @parameterized.expand([("created",), ("changed_from_an_email_step",)]) + def test_generic_workflow_step_cannot_select_the_sandbox_sender_with_a_legacy_email_schema( + self, origin: str + ) -> None: template = HogFunctionTemplate.objects.get(template_id="template-email") template.inputs_schema = [{**self.email_schema[0], "type": "email"}] template.save() workflow = self._workflow() + created_id = None + if origin == "changed_from_an_email_step": + created = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + assert created.status_code == 201, created.json() + created_id = created.json()["id"] workflow["actions"][1]["type"] = "function" workflow["actions"][1]["config"]["template_id"] = "template-email" - response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + if created_id is None: + response = self.client.post(f"/api/projects/{self.team.id}/hog_flows", workflow) + else: + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{created_id}", {"actions": workflow["actions"]} + ) assert response.status_code == 400, response.json() assert "workflow email steps and test sends" in response.json()["detail"].lower() From 1b7fc9fc32bfaf3a98a0b171e83795f739723898 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:10:21 +0000 Subject: [PATCH 26/38] test(workflows): cover a stale sandbox setup response after Channels reopens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../Channels/channelCatalogLogic.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts index 872d514bf116..73b74a1adc3e 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -111,6 +111,42 @@ describe('sandbox setup in the channel catalog', () => { expect(screen.getByText(/Couldn't refresh your channels/)).toBeInTheDocument() }) + it('ignores a setup failure from an earlier visit after Channels opens again', async () => { + let failFirstRequest: () => void = () => {} + let requests = 0 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: requests > 1 ? [sandbox] : [] }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + if (requests > 1) { + return [200, sandbox] + } + return new Promise((resolve) => { + failFirstRequest = () => resolve([500, { detail: 'Could not set up your sandbox sender.' }]) + }) + }, + }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await waitFor(() => expect(requests).toBe(1)) + logic.unmount() + + logic = channelCatalogLogic() + logic.mount() + await waitFor(() => expect(integrationsLogic.values.sandboxEmailSender).not.toBeNull()) + failFirstRequest() + await expectLogic(logic).toFinishAllListeners() + + expect(requests).toBe(2) + expect(logic.values.sandboxSetupError).toBeNull() + }) + it.each([ { when: 'the flag is on at mount', flagAtMount: true }, { when: 'the flag arrives after mount', flagAtMount: false }, From 7d92de370c3be6b9841b4b0f21a4088d248c6f01 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:22:44 +0000 Subject: [PATCH 27/38] fix(cdp): skip email sends through the sandbox sender in the worker The sandbox sender has no delivery route in the worker yet. A send through it now skips with a reason in the logs instead of passing the provider switch as a success. This lands before any API can create a sandbox sender row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../services/messaging/email.service.test.ts | 32 +++++++++++++++++++ .../cdp/services/messaging/email.service.ts | 12 +++++++ 2 files changed, 44 insertions(+) diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 7c3cd05338a0..3489c52a81c2 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -1165,6 +1165,38 @@ describe('EmailService', () => { }) }) + describe('sandbox sender', () => { + it.each([ + ['a workflow send', false], + ['a test send', true], + ])('skips %s with a reason instead of reporting success', async (_name, isTest) => { + await insertIntegration(hub.postgres, team.id, { + id: getIntegrationId(2), + kind: 'email', + config: { + email: 'hello@sandbox.example.com', + name: 'Acme via PostHog', + domain: 'sandbox.example.com', + verified: true, + provider: 'sandbox', + }, + }) + invocation.queueParameters = createEmailParams({ from: { integrationId: 2 } }) + sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) + + const result = await service.executeSendEmail(invocation, isTest) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.skipped).toBe(true) + expect(result.emailSendResult).toEqual({ + status: 'skipped', + reason: "Skipping send: the sandbox sender can't send email yet. Choose a sender on your own verified domain in this email step.", + }) + expect(result.metrics.map((m) => m.metric_name)).not.toContain('email_sent') + expect(invocation.state.vmState?.stack).toEqual([{ success: false }]) + }) + }) + describe('team suspension enforcement at send time', () => { // Guards both suspension switches: while a team is suspended, no send path may // reach SES — including editor test sends, which count against the tenant too. diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index ff83d8746de4..062f604ae043 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -485,6 +485,18 @@ export class EmailService { ) } + // The sandbox sender has no delivery route in this worker yet, so a send through it + // must skip with a reason instead of falling through the provider switch as a success. + if (integration.config.provider === 'sandbox') { + const skipReason = + "Skipping send: the sandbox sender can't send email yet. Choose a sender on your own verified domain in this email step." + addLog('warn', skipReason) + result.emailSendResult = { status: 'skipped', reason: skipReason } + result.skipped = true + result.invocation.state.vmState?.stack.push({ success: false }) + return result + } + const from = this.resolveFromSender(integration, params.from, addLog) // An empty `to` comes from the customer's data (a person without an email property, or no From 38ff1d97907ff97f78c8f9373ec7805cec232cb6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:28:19 +0000 Subject: [PATCH 28/38] feat(workflows): offer the sandbox sender in workflow email steps Workflow email steps list the managed sandbox sender last, tagged Sandbox, with a members-only note once it is selected. Picking it replaces any rotation and clears the custom sender and Reply-To; adding another sender drops it again. Broadcasts, destinations and transactional templates keep leaving it out. Ported from Silthus/posthog#450 in place in EmailTemplater.tsx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../CyclotronJob/CyclotronJobInputs.tsx | 11 ++ frontend/src/lib/integrations/utils.ts | 2 + .../EmailSenderSummary.test.tsx | 70 +++++++++ .../email-templater/EmailSenderSummary.tsx | 6 +- .../email-templater/EmailTemplater.test.tsx | 26 ++-- .../email-templater/EmailTemplater.tsx | 120 ++++++++------- .../emailTemplaterLogic.test.ts | 110 ++++++++++++++ .../email-templater/emailTemplaterLogic.tsx | 139 ++++++++++++++++-- .../email-templater/selectSenders.test.ts | 49 ++++++ .../email-templater/selectSenders.ts | 38 +++++ .../Broadcasts/BroadcastsEmptyState.tsx | 2 +- .../Broadcasts/steps/BroadcastContentStep.tsx | 4 +- .../HogFlowFunctionConfiguration.tsx | 1 + 13 files changed, 488 insertions(+), 90 deletions(-) create mode 100644 frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.ts diff --git a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx index 8639c256a962..66cf48e39d7f 100644 --- a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx +++ b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx @@ -140,6 +140,7 @@ export type CyclotronJobInputsProps = { // (the workflow builder's auto-save). Only the email input types read these. emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean parentConfiguration?: CyclotronJobInputConfiguration onInputSchemaChange?: (schema: CyclotronJobInputSchemaType[]) => void // Classes for the column the inputs are laid out in, so a host with height to spare can let @@ -160,6 +161,7 @@ export function CyclotronJobInputs({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, showSource, sampleGlobalsWithInputs, className, @@ -205,6 +207,7 @@ export function CyclotronJobInputs({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> ) })} @@ -308,6 +311,7 @@ function EmailTemplateField({ fieldErrors, liveChanges, saveIndicator, + sandboxSenderAllowed, }: { schema: CyclotronJobInputSchemaType value: any @@ -316,6 +320,7 @@ function EmailTemplateField({ fieldErrors?: EmailFieldErrors liveChanges?: boolean saveIndicator?: ReactNode + sandboxSenderAllowed?: boolean }): JSX.Element { return ( <EmailTemplater @@ -328,6 +333,7 @@ function EmailTemplateField({ fieldErrors={fieldErrors} liveChanges={liveChanges} saveIndicator={saveIndicator} + sandboxSenderAllowed={sandboxSenderAllowed} /> ) } @@ -717,6 +723,7 @@ type CyclotronJobInputProps = { emailFieldErrors?: EmailFieldErrors emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean } function NonFailureStatusCodesField({ @@ -772,6 +779,7 @@ function CyclotronJobInputRenderer({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputProps): JSX.Element { const templating = schema.templating ?? true @@ -917,6 +925,7 @@ function CyclotronJobInputRenderer({ fieldErrors={emailFieldErrors} liveChanges={emailLiveChanges} saveIndicator={emailSaveIndicator} + sandboxSenderAllowed={emailSandboxSenderAllowed} /> ) case 'non_failure_status_codes': @@ -1106,6 +1115,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputWithSchemaProps): JSX.Element | null { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key }) const [editing, setEditing] = useState(false) @@ -1276,6 +1286,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> )} {warning && !value?.secret ? ( diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 251ee6214f1c..1d9ef34222e7 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -142,6 +142,8 @@ export const DARK_MODE_INVERT_ICON_KINDS = new Set<IntegrationKind>(['apns', 'ap export const isSandboxEmailSender = (integration: { kind: string; config?: unknown }): boolean => integration.kind === 'email' && (integration.config as { provider?: unknown } | undefined)?.provider === 'sandbox' +export const SANDBOX_EMAIL_SENDER_NOTE = 'Delivers only to verified members of your organization.' + export const getIntegrationNameFromKind = (kind: string): string => { switch (kind) { case 'google-pubsub': diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx new file mode 100644 index 000000000000..e0bbe575df94 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { EmailSenderSummary } from './EmailSenderSummary' +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' + +const OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { name: 'Acme', email: 'hello@acme.example.com', verified: true }, +} +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} + +describe('EmailSenderSummary', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + '/api/projects/:team_id/integrations/': { results: [OWN_SENDER, SANDBOX_SENDER] }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(cleanup) + + it.each([ + ['the own sender', OWN_SENDER.id, 'Acme'], + ['the sandbox sender', SANDBOX_SENDER.id, 'Choose who this email comes from'], + ])('shows a transactional template sending from %s', async (_name, integrationId, expectedText) => { + const props: EmailTemplaterLogicProps = { + type: 'native_email_transactional_template', + value: { subject: 'Hello', text: 'Hello', html: '<div>Hello</div>', from: { integrationId } } as any, + onChange: jest.fn(), + } + const logic = emailTemplaterLogic(props) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + render( + <BindLogic logic={emailTemplaterLogic} props={props}> + <EmailSenderSummary /> + </BindLogic> + ) + + expect(await screen.findByText(expectedText)).toBeInTheDocument() + expect(screen.queryByText('Acme via PostHog')).not.toBeInTheDocument() + logic.unmount() + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx index a59fe39c9031..e21998f57a1e 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx @@ -4,19 +4,15 @@ import { useState } from 'react' import { IconPencil } from '@posthog/icons' import { LemonButton, LemonDropdown, LemonLabel } from '@posthog/lemon-ui' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' - import { senderAddressError, senderFrom, senderIntegrationFor } from './emailSenderFields' import { EmailSenderForm } from './EmailSenderForm' import { emailTemplaterLogic } from './emailTemplaterLogic' export function EmailSenderSummary(): JSX.Element { - const { emailTemplate } = useValues(emailTemplaterLogic) + const { emailTemplate, senderIntegrations: senders } = useValues(emailTemplaterLogic) const { setEmailTemplateValue } = useActions(emailTemplaterLogic) - const { integrations } = useValues(integrationsLogic) const [isEditing, setIsEditing] = useState(false) - const senders = (integrations ?? []).filter((integration) => integration.kind === 'email') const from = senderFrom(emailTemplate) const sender = senderIntegrationFor(emailTemplate, senders) const name = from.name || sender?.config?.name diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index 02710a4045bb..c2fb8541cf53 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -14,6 +14,7 @@ jest.mock('lib/hooks/useFeatureFlag', () => ({ jest.mock('kea', () => ({ ...jest.requireActual('kea'), useValues: jest.fn(), + useActions: () => ({ chooseSenders: jest.fn() }), })) jest.mock('@posthog/lemon-ui', () => ({ @@ -48,18 +49,23 @@ const SANDBOX = { describe('NativeEmailIntegrationChoice', () => { afterEach(cleanup) - beforeEach(() => { + function mockPicker(values: Record<string, unknown>): void { jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, - integrations: [ALICE, BOB, SANDBOX], + senderIntegrations: [ALICE, BOB], + sandboxSenderId: undefined, + isSandboxSenderSelected: false, + logicProps: {}, + ...values, }) - }) + } it.each([ [false, 'single-email-sender-select'], [true, 'multiple-email-sender-select'], ])('renders the expected sender picker when the rotation flag is %s', (flagEnabled, expectedPicker) => { mockSenderRotationEnabled = flagEnabled + mockPicker({}) render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />) @@ -68,19 +74,21 @@ describe('NativeEmailIntegrationChoice', () => { }) it.each([false, true])( - 'leaves the sandbox sender out of the picker when the rotation flag is %s', + 'offers the sandbox sender the step lists, with its members-only note once selected, when the rotation flag is %s', (flagEnabled) => { mockSenderRotationEnabled = flagEnabled + mockPicker({ senderIntegrations: [ALICE, SANDBOX], sandboxSenderId: 3, isSandboxSenderSelected: true }) - render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />) + render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 3 }} onChange={jest.fn()} />) - expect(screen.getByText(ALICE.display_name)).toBeInTheDocument() - expect(screen.queryByText(SANDBOX.display_name)).not.toBeInTheDocument() + expect(screen.getByText(SANDBOX.display_name)).toBeInTheDocument() + expect(screen.getByText('Delivers only to verified members of your organization.')).toBeInTheDocument() + expect(screen.queryByText('Custom sender')).not.toBeInTheDocument() } ) - it('treats a project with only the sandbox sender as having no email sender', () => { - jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, integrations: [SANDBOX] }) + it('treats a step with no listed sender as having no email sender', () => { + mockPicker({ senderIntegrations: [] }) render(<NativeEmailIntegrationChoice label="From" value={{}} onChange={jest.fn()} />) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx index 3d028025c8c8..c4bef9c1fcaa 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -14,6 +14,7 @@ import { LemonModal, LemonSegmentedButton, LemonSelect, + LemonTag, } from '@posthog/lemon-ui' import { @@ -26,7 +27,7 @@ import { } from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { integrationsLogic } from 'lib/integrations/integrationsLogic' -import { isSandboxEmailSender } from 'lib/integrations/utils' +import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea' @@ -36,6 +37,7 @@ import { sceneAgentPanelLogic } from 'scenes/max/sceneAgentPanelLogic' import { urls } from 'scenes/urls' import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic' +import { IntegrationType } from '~/types' import 'products/workflows/frontend/TemplateLibrary/MessageTemplatesGrid.scss' import { MessageTemplateCard } from 'products/workflows/frontend/TemplateLibrary/MessageTemplateCard' @@ -45,6 +47,7 @@ import { previewLinkTargetCustomJs } from './custom-tools/previewLinkTarget' import { unsubscribeLinkToolCustomJs } from './custom-tools/unsubscribeLinkTool' import { EmailSenderSummary } from './EmailSenderSummary' import { EMAIL_TYPE_SUPPORTED_FIELDS, EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' +import { selectedSenderIds } from './selectSenders' import { EmailFieldErrors, EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' export type EmailEditorMode = 'full' | 'preview' @@ -277,40 +280,35 @@ export function NativeEmailIntegrationChoice({ onChange: (value: EmailTemplateFrom) => void value?: EmailTemplateFrom }): JSX.Element { - const { integrationsLoading, integrations } = useValues(integrationsLogic) - const { logicProps } = useValues(emailTemplaterLogic) + const { integrationsLoading } = useValues(integrationsLogic) + const { logicProps, senderIntegrations, sandboxSenderId, isSandboxSenderSelected } = useValues(emailTemplaterLogic) + const { chooseSenders } = useActions(emailTemplaterLogic) const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') - const integrationsOfKind = integrations?.filter((x) => x.kind === 'email' && !isSandboxEmailSender(x)) - const selectedIntegrationIds = value?.integrationIds?.length - ? value.integrationIds - : value?.integrationId - ? [value.integrationId] - : [] // Presence of the override keys is what reveals the inputs, so a saved override is // visible again on reopen without any separate reveal state. - const overridesVisible = value?.email !== undefined || value?.name !== undefined + const overridesVisible = !isSandboxSenderSelected && (value?.email !== undefined || value?.name !== undefined) const onChangeIntegration = (integrationId: number): void => { if (integrationId === -1) { window.open(urls.workflows('channels'), '_blank') return } - onChange({ ...value, integrationId, integrationIds: undefined }) + chooseSenders([integrationId]) } - const onChangeIntegrations = (integrationIds: number[]): void => { - if (integrationIds.length > MAX_WORKFLOW_EMAIL_SENDERS) { - return - } - onChange({ - ...value, - integrationId: integrationIds[0], - integrationIds: integrationIds.length > 1 ? integrationIds : undefined, - }) - } + const senderLabel = (integration: IntegrationType): JSX.Element => ( + <> + <span translate="no">{integration.display_name}</span> + {integration.id === sandboxSenderId && ( + <LemonTag type="highlight" className="ml-2 align-middle"> + Sandbox + </LemonTag> + )} + </> + ) - if (!integrationsLoading && integrationsOfKind?.length === 0) { + if (!integrationsLoading && senderIntegrations.length === 0) { return ( <div className="flex gap-2 justify-between items-center"> {label} @@ -342,16 +340,17 @@ export function NativeEmailIntegrationChoice({ mode="multiple" placeholder="Choose email senders" loading={integrationsLoading} - options={(integrationsOfKind || []).map((integration) => ({ + options={senderIntegrations.map((integration) => ({ key: String(integration.id), label: integration.display_name, + labelComponent: senderLabel(integration), value: integration.id, }))} - value={selectedIntegrationIds} + value={selectedSenderIds(value)} size="small" fullWidth autoWidth={false} - onChange={onChangeIntegrations} + onChange={chooseSenders} data-attr="workflow-email-sender-select" action={{ children: 'Add new email sender', @@ -360,42 +359,49 @@ export function NativeEmailIntegrationChoice({ }} /> <span className="px-2 pb-1 text-xs text-muted"> - Choose up to {MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from - this list. + {isSandboxSenderSelected + ? SANDBOX_EMAIL_SENDER_NOTE + : `Choose up to ${MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from this list.`} </span> </div> ) : ( - <LemonSelect - className="m-1 flex-1" - type="tertiary" - placeholder="Choose email sender" - loading={integrationsLoading} - options={[ - { - title: 'Email senders', - options: (integrationsOfKind || []).map((integration) => ({ - label: integration.display_name, - value: integration.id, - })), - }, - { - options: [ - { - label: 'Add new email sender', - icon: <IconExternal />, - value: -1, - }, - ], - }, - ]} - value={value?.integrationId} - size="small" - fullWidth - onChange={onChangeIntegration} - data-attr="workflow-email-sender-select" - /> + <div className="flex flex-col flex-1 min-w-0"> + <LemonSelect + className="m-1 flex-1" + type="tertiary" + placeholder="Choose email sender" + loading={integrationsLoading} + options={[ + { + title: 'Email senders', + options: senderIntegrations.map((integration) => ({ + label: integration.display_name, + labelInMenu: senderLabel(integration), + value: integration.id, + })), + }, + { + options: [ + { + label: 'Add new email sender', + icon: <IconExternal />, + value: -1, + }, + ], + }, + ]} + value={value?.integrationId} + size="small" + fullWidth + onChange={onChangeIntegration} + data-attr="workflow-email-sender-select" + /> + {isSandboxSenderSelected && ( + <span className="px-2 pb-1 text-xs text-muted">{SANDBOX_EMAIL_SENDER_NOTE}</span> + )} + </div> )} - {!overridesVisible && ( + {!overridesVisible && !isSandboxSenderSelected && ( <LemonButton size="xsmall" type="secondary" diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts index 2dc5302a5b56..c7969447fd8a 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts @@ -2,6 +2,7 @@ import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -59,6 +60,115 @@ describe('emailTemplaterLogic', () => { jest.useRealTimers() }) + describe('sender picker', () => { + const OWN_SENDER = { id: 5, kind: 'email', display_name: 'Acme <hello@acme.example.com>', config: {} } + const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', verified: true }, + } + + function enableSandboxFlag(): void { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + + async function mountWithSenders(overrides?: Partial<EmailTemplaterLogicProps>): Promise<void> { + logic = emailTemplaterLogic(makeProps(overrides)) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + } + + beforeEach(() => { + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } }) + }) + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + it.each([ + { surface: 'a workflow email step', sandboxSenderAllowed: true, flag: true, expectedIds: [5, 7] }, + { + surface: 'a workflow email step with the flag off', + sandboxSenderAllowed: true, + flag: false, + expectedIds: [5], + }, + { surface: 'a broadcast or destination', sandboxSenderAllowed: false, flag: true, expectedIds: [5] }, + ])('lists the sandbox sender last only on $surface', async ({ sandboxSenderAllowed, flag, expectedIds }) => { + if (flag) { + enableSandboxFlag() + } + await mountWithSenders({ sandboxSenderAllowed }) + + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual(expectedIds) + }) + + it('keeps a saved sandbox sender listed after the flag turns off, until another sender replaces it', async () => { + const onChange = jest.fn() + await mountWithSenders({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 } }, + }) + + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5, 7]) + expect(logic.values.isSandboxSenderSelected).toBe(true) + + logic.actions.chooseSenders([7, 5]) + + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ from: { integrationId: 5, integrationIds: undefined } }) + ) + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5]) + }) + + it('picking the sandbox sender replaces the selection and clears the custom sender and Reply-To', async () => { + enableSandboxFlag() + const onChange = jest.fn() + await mountWithSenders({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { + ...DEFAULT_EMAIL_TEMPLATE, + from: { integrationId: 5, email: 'hello@acme.example.com', name: 'Acme' }, + replyTo: 'replies@acme.example.com', + }, + }) + + logic.actions.chooseSenders([5, 7]) + + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + replyTo: '', + }) + ) + expect(logic.values.isSandboxSenderSelected).toBe(true) + }) + + it('offers no Reply-To while the sandbox sender is selected', async () => { + enableSandboxFlag() + await mountWithSenders({ + sandboxSenderAllowed: true, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 }, replyTo: 'replies@example.com' }, + }) + + expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo') + expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo') + + logic.actions.setEmailTemplateValue('from', { integrationId: 5 }) + + expect(logic.values.isSandboxSenderSelected).toBe(false) + expect(logic.values.visibleFields.map((field) => field.key)).toContain('replyTo') + }) + }) + describe('advanced fields', () => { it('hides advanced fields by default', async () => { logic = emailTemplaterLogic(makeProps()) diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx index 761790c09b16..8dee9005a973 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx @@ -22,17 +22,21 @@ import { LemonDialog } from '@posthog/lemon-ui' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { lemonToast } from 'lib/lemon-ui/LemonToast' import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' import { objectsEqual } from 'lib/utils/objects' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { teamLogic } from 'scenes/teamLogic' -import { PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' +import { IntegrationType, PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' import { MessageTemplate } from 'products/workflows/frontend/TemplateLibrary/types' import { registerEmailMediaLibrary } from './emailMediaLibrary' +import { senderFrom } from './emailSenderFields' +import { selectSenders, selectedSenderIds } from './selectSenders' import type { EmailFieldErrors, EmailTemplate } from './types' export type { EmailTemplate } @@ -215,6 +219,8 @@ export interface EmailTemplaterLogicProps { // not compute these itself; a caller that validates the email step (e.g. the workflow builder) // decides what and when to show. fieldErrors?: EmailFieldErrors + // Only workflow email steps can send through the sandbox sender; every other surface hides it. + sandboxSenderAllowed?: boolean } function autoRevealAdvancedFields( @@ -237,6 +243,9 @@ function autoRevealAdvancedFields( // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic + integrations: IntegrationType[] | null // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic preflight: PreflightStatus | null // preflightLogic currentProjectId: number | string // teamLogic activeContentTab: 'plaintext' | 'visual' @@ -257,6 +266,7 @@ export interface emailTemplaterLogicValues { isEmailTemplateSubmitting: boolean isEmailTemplateValid: boolean isModalOpen: boolean + isSandboxSenderSelected: boolean isSaveTemplateModalOpen: boolean isTemplatePickerOpen: boolean logicProps: EmailTemplaterLogicProps @@ -264,7 +274,11 @@ export interface emailTemplaterLogicValues { personPropertyDefinitions: PropertyDefinition[] personPropertyDefinitionsLoading: boolean revealedAdvancedFields: EmailMetaFieldKey[] + sandboxSenderId: number | undefined + selectedSandboxSender: IntegrationType | null + senderIntegrations: IntegrationType[] showEmailTemplateErrors: boolean + supportedFields: EmailMetaField[] templates: MessageTemplate[] templatesLoading: boolean templatingEngine: 'hog' | 'liquid' @@ -282,6 +296,9 @@ export interface emailTemplaterLogicActions { applyTemplate: (template: MessageTemplate) => { template: MessageTemplate } + chooseSenders: (integrationIds: number[]) => { + integrationIds: number[] + } closeWithConfirmation: () => { value: true } @@ -411,8 +428,24 @@ export interface emailTemplaterLogicMeta { id: string } | undefined - visibleFields: (arg: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]) => EmailMetaField[] - hiddenAdvancedFields: (arg: EmailTemplaterType, visibleFields: EmailMetaField[]) => EmailMetaField[] + selectedSandboxSender: ( + integrations: IntegrationType[] | null, + emailTemplate: EmailTemplate + ) => IntegrationType | null + senderIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + arg: boolean + ) => IntegrationType[] + sandboxSenderId: (senderIntegrations: IntegrationType[]) => number | undefined + isSandboxSenderSelected: (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined) => boolean + supportedFields: (arg: EmailTemplaterType, isSandboxSenderSelected: boolean) => EmailMetaField[] + visibleFields: ( + supportedFields: EmailMetaField[], + revealedAdvancedFields: EmailMetaFieldKey[] + ) => EmailMetaField[] + hiddenAdvancedFields: (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]) => EmailMetaField[] } } @@ -427,7 +460,16 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ props({} as EmailTemplaterLogicProps), path(['scenes', 'hog-functions', 'email-templater', 'emailTemplaterLogic']), connect(() => ({ - values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['preflight'], + integrationsLogic, + ['integrations', 'ownEmailIntegrations', 'sandboxEmailSender'], + featureFlagLogic, + ['featureFlags'], + teamLogic, + ['currentProjectId'], + ], })), actions({ setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }), @@ -444,6 +486,7 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ setActiveContentTab: (tab: 'visual' | 'plaintext') => ({ tab }), revealAdvancedField: (key: EmailMetaFieldKey) => ({ key }), hideAdvancedField: (key: EmailMetaFieldKey) => ({ key }), + chooseSenders: (integrationIds: number[]) => ({ integrationIds }), }), reducers({ emailEditorRef: [ @@ -590,18 +633,6 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ // shared by the whole project, so the project is the user it sees. emailMediaLibraryEnabled ? { id: `project-${currentProjectId}` } : undefined, ], - visibleFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.revealedAdvancedFields], - (type: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( - (field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key) - ), - ], - hiddenAdvancedFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.visibleFields], - (type: EmailTemplaterType, visibleFields: EmailMetaField[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter((f) => f.isAdvancedField && !visibleFields.includes(f)), - ], }), forms(({ actions, values, props, cache }) => ({ @@ -657,7 +688,83 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ }, })), + selectors({ + // A step saved with the sandbox sender keeps showing it after the flag turns off, so the + // picker can replace it instead of showing a bare id. + selectedSandboxSender: [ + (s) => [s.integrations, s.emailTemplate], + (integrations: IntegrationType[] | null, emailTemplate: EmailTemplate): IntegrationType | null => { + const ids = selectedSenderIds(senderFrom(emailTemplate)) + return ( + integrations?.find( + (integration) => isSandboxEmailSender(integration) && ids.includes(integration.id) + ) ?? null + ) + }, + ], + senderIntegrations: [ + (s) => [ + s.ownEmailIntegrations, + s.sandboxEmailSender, + s.selectedSandboxSender, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + sandboxSenderAllowed: boolean + ): IntegrationType[] => { + const sandboxSender = sandboxEmailSender ?? selectedSandboxSender + return sandboxSenderAllowed && sandboxSender + ? [...ownEmailIntegrations, sandboxSender] + : ownEmailIntegrations + }, + ], + sandboxSenderId: [ + (s) => [s.senderIntegrations], + (senderIntegrations: IntegrationType[]): number | undefined => + senderIntegrations.find(isSandboxEmailSender)?.id, + ], + isSandboxSenderSelected: [ + (s) => [s.emailTemplate, s.sandboxSenderId], + (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined): boolean => + sandboxSenderId !== undefined && selectedSenderIds(senderFrom(emailTemplate)).includes(sandboxSenderId), + ], + // The sandbox sender always replies from its own identity, so it offers no Reply-To. + supportedFields: [ + (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.isSandboxSenderSelected], + (type: EmailTemplaterType, isSandboxSenderSelected: boolean): EmailMetaField[] => + EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( + (field) => !isSandboxSenderSelected || field.key !== EMAIL_META_FIELDS.REPLY_TO.key + ), + ], + visibleFields: [ + (s) => [s.supportedFields, s.revealedAdvancedFields], + (supportedFields: EmailMetaField[], revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => + supportedFields.filter((field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key)), + ], + hiddenAdvancedFields: [ + (s) => [s.supportedFields, s.visibleFields], + (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]): EmailMetaField[] => + supportedFields.filter((f) => f.isAdvancedField && !visibleFields.includes(f)), + ], + }), + listeners(({ props, values, actions, cache }) => ({ + chooseSenders: ({ integrationIds }) => { + const { sandboxSenderId } = values + const next = selectSenders(senderFrom(values.emailTemplate), integrationIds, sandboxSenderId) + if (!next) { + return + } + if (sandboxSenderId !== undefined && next.integrationId === sandboxSenderId) { + actions.setEmailTemplateValues({ from: next, replyTo: '' }) + actions.hideAdvancedField('replyTo') + return + } + actions.setEmailTemplateValue('from', next) + }, onEmailEditorReady: () => { const editor = values.emailEditorRef?.editor if (editor && values.emailMediaLibraryEnabled) { diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts new file mode 100644 index 000000000000..18ad53cd434c --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts @@ -0,0 +1,49 @@ +import { selectSenders } from './selectSenders' + +describe('selectSenders', () => { + const current = { integrationId: 1, email: 'custom@example.com', name: 'Custom' } + + it.each([ + { + case: 'picking the sandbox sender keeps only it and drops the custom sender values', + ids: [1, 2, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + }, + { + case: 'adding an own sender while the sandbox sender is selected drops the sandbox sender', + value: { integrationId: 7 }, + ids: [7, 1], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: undefined }, + }, + { + case: 'own senders rotate and keep the custom sender values', + ids: [1, 2], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: [1, 2], email: 'custom@example.com', name: 'Custom' }, + }, + { + case: 'picking the sandbox sender over a full rotation still works', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined }, + }, + { + case: 'a surface without the sandbox sender treats its id like any other', + ids: [1, 7], + sandboxId: undefined, + expected: { integrationId: 1, integrationIds: [1, 7] }, + }, + { + case: 'more senders than the limit are refused', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12], + sandboxId: 7, + expected: null, + }, + ])('$case', ({ value = current, ids, sandboxId, expected }) => { + expect(selectSenders(value, ids, sandboxId)).toEqual( + expected === null ? null : expect.objectContaining(expected) + ) + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts new file mode 100644 index 000000000000..d822a83691cd --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts @@ -0,0 +1,38 @@ +import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' + +export function selectedSenderIds(value: EmailTemplateFrom | undefined): number[] { + if (value?.integrationIds?.length) { + return value.integrationIds + } + return value?.integrationId ? [value.integrationId] : [] +} + +function exclusiveSandboxSenders( + integrationIds: number[], + sandboxSenderId: number | undefined, + sandboxSelected: boolean +): number[] { + if (sandboxSenderId === undefined || !integrationIds.includes(sandboxSenderId)) { + return integrationIds + } + return sandboxSelected ? integrationIds.filter((id) => id !== sandboxSenderId) : [sandboxSenderId] +} + +export function selectSenders( + value: EmailTemplateFrom | undefined, + integrationIds: number[], + sandboxSenderId: number | undefined +): EmailTemplateFrom | null { + const sandboxSelected = sandboxSenderId !== undefined && selectedSenderIds(value).includes(sandboxSenderId) + const senders = exclusiveSandboxSenders(integrationIds, sandboxSenderId, sandboxSelected) + if (senders.length > MAX_WORKFLOW_EMAIL_SENDERS) { + return null + } + const sandboxChosen = sandboxSenderId !== undefined && senders.includes(sandboxSenderId) + return { + ...value, + integrationId: senders[0], + integrationIds: senders.length > 1 ? senders : undefined, + ...(sandboxChosen ? { email: undefined, name: undefined } : {}), + } +} diff --git a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx index e2ad5442a586..5a7ee8c20c5a 100644 --- a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx +++ b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx @@ -14,7 +14,7 @@ import { newBroadcastAgentLogic } from './newBroadcastAgentLogic' /** The Broadcasts tab before the first broadcast: set up a verified sender first, then write one. */ export function BroadcastsEmptyState(): JSX.Element { - const { integrations } = useValues(integrationsLogic) + const { userManagedIntegrations: integrations } = useValues(integrationsLogic) const { startNewBroadcast } = useActions(newBroadcastAgentLogic) const editorDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.Workflow, diff --git a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx index e86b968e06a2..40f63a5e4603 100644 --- a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx +++ b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx @@ -27,13 +27,13 @@ export function BroadcastContentStep(): JSX.Element { const { broadcast, email, name, stepValidationErrors, selectedSender, emailSettings, aiEmailDraftStatus } = useValues(broadcastWizardLogic) const { setEmail, setEmailSettings } = useActions(broadcastWizardLogic) - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { integrations, integrationsLoading, ownEmailIntegrations } = useValues(integrationsLogic) const { loadIntegrations } = useActions(integrationsLogic) const [templatePickerOpen, setTemplatePickerOpen] = useState(false) // null: closed. 'new': set up a sender. An integration: finish verifying that one. const [senderSetup, setSenderSetup] = useState<'new' | IntegrationType | null>(null) - const hasSenders = !!integrations?.some((integration) => integration.kind === 'email') + const hasSenders = ownEmailIntegrations.length > 0 const senderUnverified = !!selectedSender && selectedSender.config?.verified !== true // A deleted sender would otherwise show as its bare id, so the picker leaves it out. const missingSenderIds = integrations ? getMissingSenderIds(email.from, integrations) : [] diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index 7cb2788dbe9c..c16d7b727ba1 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -269,6 +269,7 @@ export function HogFlowFunctionConfiguration({ // (into the staged draft on active workflows), so the editor needs no save step. emailLiveChanges emailSaveIndicator={<WorkflowAutoSaveIndicator />} + emailSandboxSenderAllowed={isEmailStep && workflow?.origin_product !== 'broadcasts'} configuration={{ inputs: inputs as Record<string, CyclotronJobInputType>, inputs_schema: schema }} showSource={false} sampleGlobalsWithInputs={sampleGlobals} From bffa8da477adb443c0e037cb01e6855219adddde Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:30:54 +0000 Subject: [PATCH 29/38] feat(workflows): confirm before the sandbox sender replaces senders Picking the sandbox sender in a workflow email step now asks first when it would replace several senders or a custom sender or Reply-To. Before you send to an audience, a project with only the sandbox sender learns that it reaches only verified members of the organization. Ported from Silthus/posthog#450. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../emailTemplaterLogic.test.ts | 85 ++++++++++++++----- .../email-templater/emailTemplaterLogic.tsx | 45 +++++++++- .../frontend/Channels/emailSendingMessages.ts | 3 + .../messageAudienceWarnings.test.ts | 9 ++ .../messageAudienceWarnings.tsx | 16 ++-- 5 files changed, 127 insertions(+), 31 deletions(-) diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts index c7969447fd8a..a4b1e009f7a1 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts @@ -1,6 +1,8 @@ import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { LemonDialog } from '@posthog/lemon-ui' + import { FEATURE_FLAGS } from 'lib/constants' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -127,30 +129,67 @@ describe('emailTemplaterLogic', () => { expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5]) }) - it('picking the sandbox sender replaces the selection and clears the custom sender and Reply-To', async () => { - enableSandboxFlag() - const onChange = jest.fn() - await mountWithSenders({ - sandboxSenderAllowed: true, - liveChanges: true, - onChange, - value: { - ...DEFAULT_EMAIL_TEMPLATE, - from: { integrationId: 5, email: 'hello@acme.example.com', name: 'Acme' }, - replyTo: 'replies@acme.example.com', - }, - }) - - logic.actions.chooseSenders([5, 7]) - - expect(onChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, - replyTo: '', + it.each([ + { + field: 'a custom address', + from: { integrationId: 5, email: 'hello@acme.example.com' }, + replyTo: '', + confirm: true, + }, + { field: 'a custom name', from: { integrationId: 5, name: 'Acme' }, replyTo: '', confirm: true }, + { field: 'a Reply-To', from: { integrationId: 5 }, replyTo: 'replies@acme.example.com', confirm: true }, + { field: 'a rotation', from: { integrationId: 5, integrationIds: [5, 6] }, replyTo: '', confirm: true }, + { field: 'one plain sender', from: { integrationId: 5 }, replyTo: '', confirm: false }, + ])( + 'picking the sandbox sender over $field replaces it after any confirmation', + async ({ from, replyTo, confirm }) => { + enableSandboxFlag() + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { + results: [ + SANDBOX_SENDER, + OWN_SENDER, + { ...OWN_SENDER, id: 6, display_name: 'Second <second@acme.example.com>' }, + ], + }, + }, }) - ) - expect(logic.values.isSandboxSenderSelected).toBe(true) - }) + const onChange = jest.fn() + const dialog = jest.spyOn(LemonDialog, 'open').mockImplementation(jest.fn()) + const props = { sandboxSenderAllowed: true, liveChanges: true, onChange } + await mountWithSenders({ ...props, value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo } }) + onChange.mockClear() + + logic.actions.chooseSenders([...(from.integrationIds ?? [from.integrationId]), 7]) + + expect(dialog).toHaveBeenCalledTimes(confirm ? 1 : 0) + if (confirm) { + expect(onChange).not.toHaveBeenCalled() + if (from.integrationIds) { + expect(dialog.mock.calls[0][0].description).toContain( + 'This replaces Acme <hello@acme.example.com> and Second <second@acme.example.com>' + ) + } + emailTemplaterLogic( + makeProps({ + ...props, + value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo, subject: 'Changed during confirmation' }, + }) + ) + ;(dialog.mock.calls[0][0].primaryButton?.onClick as () => void)() + } + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + replyTo: '', + subject: confirm ? 'Changed during confirmation' : DEFAULT_EMAIL_TEMPLATE.subject, + }) + ) + expect(logic.values.isSandboxSenderSelected).toBe(true) + dialog.mockRestore() + } + ) it('offers no Reply-To while the sandbox sender is selected', async () => { enableSandboxFlag() diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx index 8dee9005a973..a1d6d6a98a41 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx @@ -223,6 +223,22 @@ export interface EmailTemplaterLogicProps { sandboxSenderAllowed?: boolean } +function joinSenderNames(names: string[]): string { + return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] +} + +function sandboxSwitchDescription(replacedSenderNames: string[], hasCustomSenderDetails: boolean): string { + return [ + replacedSenderNames.length > 1 + ? `This replaces ${joinSenderNames(replacedSenderNames)} with the sandbox sender.` + : '', + hasCustomSenderDetails ? 'This removes your custom sender and Reply-To.' : '', + 'The sandbox sender sends from its own address.', + ] + .filter(Boolean) + .join(' ') +} + function autoRevealAdvancedFields( actions: { revealAdvancedField: (key: EmailMetaFieldKey) => void }, props: EmailTemplaterLogicProps @@ -261,6 +277,7 @@ export interface emailTemplaterLogicValues { emailTemplateTouched: boolean emailTemplateTouches: Record<string, boolean> emailTemplateValidationErrors: DeepPartialMap<EmailTemplate, ValidationErrorType> + hasCustomSenderDetails: boolean hiddenAdvancedFields: EmailMetaField[] isEmailEditorReady: boolean isEmailTemplateSubmitting: boolean @@ -440,6 +457,7 @@ export interface emailTemplaterLogicMeta { ) => IntegrationType[] sandboxSenderId: (senderIntegrations: IntegrationType[]) => number | undefined isSandboxSenderSelected: (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined) => boolean + hasCustomSenderDetails: (emailTemplate: EmailTemplate) => boolean supportedFields: (arg: EmailTemplaterType, isSandboxSenderSelected: boolean) => EmailMetaField[] visibleFields: ( supportedFields: EmailMetaField[], @@ -731,6 +749,13 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined): boolean => sandboxSenderId !== undefined && selectedSenderIds(senderFrom(emailTemplate)).includes(sandboxSenderId), ], + hasCustomSenderDetails: [ + (s) => [s.emailTemplate], + (emailTemplate: EmailTemplate): boolean => { + const from = senderFrom(emailTemplate) + return !!(from.email || from.name || emailTemplate?.replyTo) + }, + ], // The sandbox sender always replies from its own identity, so it offers no Reply-To. supportedFields: [ (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.isSandboxSenderSelected], @@ -759,8 +784,24 @@ export const emailTemplaterLogic = kea<emailTemplaterLogicType>([ return } if (sandboxSenderId !== undefined && next.integrationId === sandboxSenderId) { - actions.setEmailTemplateValues({ from: next, replyTo: '' }) - actions.hideAdvancedField('replyTo') + const selectSandbox = (): void => { + actions.setEmailTemplateValues({ from: next, replyTo: '' }) + actions.hideAdvancedField('replyTo') + } + const replacedSenderIds = selectedSenderIds(senderFrom(values.emailTemplate)) + if (!values.hasCustomSenderDetails && replacedSenderIds.length < 2) { + selectSandbox() + return + } + const replacedSenderNames = replacedSenderIds.map( + (id) => values.senderIntegrations.find((sender) => sender.id === id)?.display_name || `sender ${id}` + ) + LemonDialog.open({ + title: 'Switch to the sandbox sender?', + description: sandboxSwitchDescription(replacedSenderNames, values.hasCustomSenderDetails), + primaryButton: { children: 'Switch sender', onClick: selectSandbox }, + secondaryButton: { children: 'Cancel' }, + }) return } actions.setEmailTemplateValue('from', next) diff --git a/products/workflows/frontend/Channels/emailSendingMessages.ts b/products/workflows/frontend/Channels/emailSendingMessages.ts index 1573829036cf..52fb836d018e 100644 --- a/products/workflows/frontend/Channels/emailSendingMessages.ts +++ b/products/workflows/frontend/Channels/emailSendingMessages.ts @@ -4,3 +4,6 @@ export const EMAIL_SENDING_SUSPENDED_MESSAGE = export const UNVERIFIED_SENDER_MESSAGE = "Verify the sender's domain before sending" export const NO_EMAIL_SENDER_MESSAGE = 'Set up an email sender before sending' + +export const SANDBOX_ONLY_SENDER_MESSAGE = + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.' diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts index 5ac223a19584..7c892e04bdef 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts @@ -17,6 +17,7 @@ const LIST_MATCHING = '"Signups" is still matching people. You can launch when i const verified = { kind: 'email', config: { verified: true } } const unverified = { kind: 'email', config: { verified: false } } +const sandbox = { kind: 'email', config: { provider: 'sandbox', verified: true } } const matchingList = { name: 'Signups', is_static: true, is_calculating: true, last_calculation: null } const audience: MessageAudience = { source: 'cohort', @@ -51,6 +52,14 @@ describe('loadMessageAudienceWarnings', () => { senders: [unverified], expected: ["Verify the sender's domain before sending"], }, + ...(['broadcast', 'workflow'] as const).map((destination) => ({ + case: `only the sandbox sender (${destination})`, + destination, + senders: [sandbox], + expected: [ + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.', + ], + })), { case: 'unverified senders on the first of several pages', destination: 'broadcast', diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx index 03758588a586..205e7c21312c 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx @@ -3,6 +3,7 @@ import { router } from 'kea-router' import { LemonDialog } from '@posthog/lemon-ui' import { ApiConfig } from 'lib/api' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { urls } from 'scenes/urls' import { cohortsRetrieve } from 'products/cohorts/frontend/generated/api' @@ -14,14 +15,16 @@ import { audienceCohortIds, audienceCohortLaunchError, toAudienceCohort } from ' import { EMAIL_SENDING_SUSPENDED_MESSAGE, NO_EMAIL_SENDER_MESSAGE, + SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE, } from '../Channels/emailSendingMessages' import { MessageAudience, MessageAudienceDestination, messageAudienceUrl } from './messageAudience' export function emailSenderWarning(integrations: Pick<IntegrationConfigApi, 'kind' | 'config'>[]): string | null { - const senders = integrations.filter((integration) => integration.kind === 'email') + const emailIntegrations = integrations.filter((integration) => integration.kind === 'email') + const senders = emailIntegrations.filter((integration) => !isSandboxEmailSender(integration)) if (senders.length === 0) { - return NO_EMAIL_SENDER_MESSAGE + return emailIntegrations.length > 0 ? SANDBOX_ONLY_SENDER_MESSAGE : NO_EMAIL_SENDER_MESSAGE } const verified = senders.some( (integration) => (integration.config as { verified?: boolean } | undefined)?.verified === true @@ -111,10 +114,11 @@ export async function openMessageAudience( onClick: navigate, 'data-attr': `message-audience-${audience.source}-continue`, }, - secondaryButton: - warnings.includes(NO_EMAIL_SENDER_MESSAGE) || warnings.includes(UNVERIFIED_SENDER_MESSAGE) - ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } - : { children: 'Cancel' }, + secondaryButton: [NO_EMAIL_SENDER_MESSAGE, SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE].some( + (message) => warnings.includes(message) + ) + ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } + : { children: 'Cancel' }, }) } finally { opening = false From 3e0abcbc166ab9b10a61ddc65a2279e04ec4e7d7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:39:44 +0000 Subject: [PATCH 30/38] fix(workflows): offer the sandbox sender only in email action steps A generic function step can use the email template too, and saving the sandbox sender there fails. The step now passes its action type, and only a function_email step outside broadcasts offers the sandbox sender. A rendered test checks that both pickers send a pick through chooseSenders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../email-templater/EmailTemplater.test.tsx | 45 ++++++++++++++++--- .../Workflows/hogflows/steps/StepFunction.tsx | 1 + .../HogFlowFunctionConfiguration.test.ts | 18 +++++++- .../HogFlowFunctionConfiguration.tsx | 8 +++- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index c2fb8541cf53..679d5617ef65 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -1,11 +1,12 @@ import '@testing-library/jest-dom' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { useValues } from 'kea' import { NativeEmailIntegrationChoice } from './EmailTemplater' let mockSenderRotationEnabled = false +const mockChooseSenders = jest.fn() jest.mock('lib/hooks/useFeatureFlag', () => ({ useFeatureFlag: () => mockSenderRotationEnabled, @@ -14,24 +15,42 @@ jest.mock('lib/hooks/useFeatureFlag', () => ({ jest.mock('kea', () => ({ ...jest.requireActual('kea'), useValues: jest.fn(), - useActions: () => ({ chooseSenders: jest.fn() }), + useActions: () => ({ chooseSenders: mockChooseSenders }), })) jest.mock('@posthog/lemon-ui', () => ({ ...jest.requireActual('@posthog/lemon-ui'), - LemonInputSelect: ({ options }: { options: { key: string; label: string }[] }) => ( + LemonInputSelect: ({ + options, + value, + onChange, + }: { + options: { key: string; label: string; value: number }[] + value: number[] + onChange: (value: number[]) => void + }) => ( <div data-attr="multiple-email-sender-select"> {options.map((option) => ( - <span key={option.key}>{option.label}</span> + <button key={option.key} onClick={() => onChange([...value, option.value])}> + {option.label} + </button> ))} </div> ), - LemonSelect: ({ options }: { options: { options: { value: number; label: string }[] }[] }) => ( + LemonSelect: ({ + options, + onChange, + }: { + options: { options: { value: number; label: string }[] }[] + onChange: (value: number) => void + }) => ( <div data-attr="single-email-sender-select"> {options .flatMap((section) => section.options) .map((option) => ( - <span key={option.value}>{option.label}</span> + <button key={option.value} onClick={() => onChange(option.value)}> + {option.label} + </button> ))} </div> ), @@ -87,6 +106,20 @@ describe('NativeEmailIntegrationChoice', () => { } ) + it.each([ + [false, [3]], + [true, [1, 3]], + ])('sends a pick through chooseSenders when the rotation flag is %s', (flagEnabled, expectedSenderIds) => { + mockSenderRotationEnabled = flagEnabled + mockPicker({ senderIntegrations: [ALICE, SANDBOX], sandboxSenderId: 3 }) + mockChooseSenders.mockClear() + + render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />) + fireEvent.click(screen.getByText(SANDBOX.display_name)) + + expect(mockChooseSenders).toHaveBeenCalledWith(expectedSenderIds) + }) + it('treats a step with no listed sender as having no email sender', () => { mockPicker({ senderIntegrations: [] }) diff --git a/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx b/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx index 49321f3fa801..e5419bcb0311 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx @@ -40,6 +40,7 @@ export function StepFunctionConfiguration({ node }: { node: StepFunctionNode }): // another step of the same template must remount to show the newly selected node's inputs. key={node.id} templateId={templateId} + actionType={node.data.type} inputs={inputs} setInputs={(inputs) => partialSetWorkflowActionConfig(node.id, { inputs })} mappings={mappings} diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts index 81488de1516b..7c83a3d6a0e9 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts @@ -1,6 +1,6 @@ import { CyclotronJobInvocationGlobals } from '~/types' -import { buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration' +import { allowsSandboxSender, buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration' // The stored shape a Slack message workflow carries. The trigger type alone no longer identifies // it, so every case below passes the whole config. @@ -15,6 +15,22 @@ const githubTrigger = { } describe('HogFlowFunctionConfiguration', () => { + describe('allowsSandboxSender', () => { + it.each([ + { step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true }, + { + step: 'a broadcast email step', + actionType: 'function_email', + originProduct: 'broadcasts', + expected: false, + }, + { step: 'a generic function step', actionType: 'function', originProduct: null, expected: false }, + { step: 'a trigger', actionType: undefined, originProduct: null, expected: false }, + ])('is $expected for $step', ({ actionType, originProduct, expected }) => { + expect(allowsSandboxSender(actionType, originProduct)).toBe(expected) + }) + }) + describe('buildSampleGlobals', () => { const realSampleGlobals: CyclotronJobInvocationGlobals = { project: { id: 2, name: 'Real project', url: 'https://app.example.com/project/2' }, diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index c16d7b727ba1..77fbc8648bfd 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -191,8 +191,13 @@ export function filterInputsSchemaForTrigger<T extends { key: string }>( return inputsSchema } +export function allowsSandboxSender(actionType: string | undefined, originProduct: string | null | undefined): boolean { + return actionType === 'function_email' && originProduct !== 'broadcasts' +} + export function HogFlowFunctionConfiguration({ templateId, + actionType, inputs, setInputs, mappings, @@ -202,6 +207,7 @@ export function HogFlowFunctionConfiguration({ emailFieldErrors, }: { templateId: string + actionType?: string inputs: Record<string, CyclotronJobInputType> mappings?: HogFunctionMappingType[] setInputs: (inputs: Record<string, CyclotronJobInputType>) => void @@ -269,7 +275,7 @@ export function HogFlowFunctionConfiguration({ // (into the staged draft on active workflows), so the editor needs no save step. emailLiveChanges emailSaveIndicator={<WorkflowAutoSaveIndicator />} - emailSandboxSenderAllowed={isEmailStep && workflow?.origin_product !== 'broadcasts'} + emailSandboxSenderAllowed={allowsSandboxSender(actionType, workflow?.origin_product)} configuration={{ inputs: inputs as Record<string, CyclotronJobInputType>, inputs_schema: schema }} showSource={false} sampleGlobalsWithInputs={sampleGlobals} From ccf8489c4b7985e68ed0c8bc759fc114fa430b83 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:43:20 +0000 Subject: [PATCH 31/38] test(workflows): cover own senders beside the sandbox sender in send warnings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../MessageAudience/messageAudienceWarnings.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts index 7c892e04bdef..eff6833b5c28 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts @@ -60,6 +60,18 @@ describe('loadMessageAudienceWarnings', () => { 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.', ], })), + { + case: 'an unverified own sender beside the sandbox sender', + destination: 'broadcast', + senders: [sandbox, unverified], + expected: ["Verify the sender's domain before sending"], + }, + { + case: 'a verified own sender beside the sandbox sender', + destination: 'broadcast', + senders: [sandbox, verified], + expected: [], + }, { case: 'unverified senders on the first of several pages', destination: 'broadcast', From ae5de13605a7406aba2e784e8e1076e009d830ed Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:44:48 +0000 Subject: [PATCH 32/38] fix(workflows): require the action type for step sender eligibility Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../frontend/Workflows/hogflows/steps/StepTrigger.tsx | 2 ++ .../steps/components/HogFlowFunctionConfiguration.test.ts | 5 +++-- .../steps/components/HogFlowFunctionConfiguration.tsx | 8 ++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx b/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx index 6e8bb2fd7935..4ba18f014a35 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx @@ -514,6 +514,7 @@ function StepTriggerConfigurationWebhook({ /> <HogFlowFunctionConfiguration templateId={config.template_id} + actionType={action.type} inputs={config.inputs} setInputs={(inputs) => setWorkflowActionConfig(action.id, { @@ -878,6 +879,7 @@ function StepTriggerConfigurationTrackingPixel({ <HogFlowFunctionConfiguration templateId={config.template_id} + actionType={action.type} inputs={config.inputs} setInputs={(inputs) => setWorkflowActionConfig(action.id, { diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts index 7c83a3d6a0e9..0a500199fc5c 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts @@ -1,5 +1,6 @@ import { CyclotronJobInvocationGlobals } from '~/types' +import type { HogFlowAction } from '../../types' import { allowsSandboxSender, buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration' // The stored shape a Slack message workflow carries. The trigger type alone no longer identifies @@ -16,7 +17,7 @@ const githubTrigger = { describe('HogFlowFunctionConfiguration', () => { describe('allowsSandboxSender', () => { - it.each([ + it.each<{ step: string; actionType: HogFlowAction['type']; originProduct: string | null; expected: boolean }>([ { step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true }, { step: 'a broadcast email step', @@ -25,7 +26,7 @@ describe('HogFlowFunctionConfiguration', () => { expected: false, }, { step: 'a generic function step', actionType: 'function', originProduct: null, expected: false }, - { step: 'a trigger', actionType: undefined, originProduct: null, expected: false }, + { step: 'a trigger', actionType: 'trigger', originProduct: null, expected: false }, ])('is $expected for $step', ({ actionType, originProduct, expected }) => { expect(allowsSandboxSender(actionType, originProduct)).toBe(expected) }) diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index 77fbc8648bfd..27abee39fa55 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -17,6 +17,7 @@ import { workflowLogic } from '../../../workflowLogic' import { hogFlowEditorTestLogic } from '../../panel/testing/hogFlowEditorTestLogic' import { isGithubEventTriggerConfig } from '../../registry/triggers/githubTriggerFilters' import { isSlackMessageTriggerConfig } from '../../registry/triggers/slackTriggerFilters' +import type { HogFlowAction } from '../../types' import { CustomerTaskWorkflowReferenceInput } from './CustomerTaskWorkflowReferenceInput' import { HogFlowFunctionMappings } from './HogFlowFunctionMappings' import { WorkflowAutoSaveIndicator } from './WorkflowAutoSaveIndicator' @@ -191,7 +192,10 @@ export function filterInputsSchemaForTrigger<T extends { key: string }>( return inputsSchema } -export function allowsSandboxSender(actionType: string | undefined, originProduct: string | null | undefined): boolean { +export function allowsSandboxSender( + actionType: HogFlowAction['type'], + originProduct: string | null | undefined +): boolean { return actionType === 'function_email' && originProduct !== 'broadcasts' } @@ -207,7 +211,7 @@ export function HogFlowFunctionConfiguration({ emailFieldErrors, }: { templateId: string - actionType?: string + actionType: HogFlowAction['type'] inputs: Record<string, CyclotronJobInputType> mappings?: HogFunctionMappingType[] setInputs: (inputs: Record<string, CyclotronJobInputType>) => void From 282568a6ceeb34e1f46b5b0bd3dbb48228105d14 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:21:01 +0000 Subject: [PATCH 33/38] feat(workflows): offer a switch from the sandbox sender A workflow whose email steps use the sandbox sender shows a banner once the project has a verified own sender. The banner switches every sandbox email step to that sender as an unsaved change, behind the workflows-sandbox-sender flag. Refs Silthus/personal#202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../WorkflowSandboxSwitchBanner.stories.tsx | 159 ++++++++ .../Workflows/WorkflowSandboxSwitchBanner.tsx | 36 ++ .../WorkflowSandboxSwitchSenderButton.tsx | 43 ++ .../frontend/Workflows/WorkflowScene.tsx | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 375 ++++++++++++++++++ .../workflowSandboxSwitchBannerLogic.ts | 236 +++++++++++ 6 files changed, 851 insertions(+) create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx new file mode 100644 index 000000000000..697302b5664c --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' +import { AccessControlLevel } from '~/types' + +import type { HogFlow } from './hogflows/types' +import { NEW_WORKFLOW, WorkflowLogicProps, workflowLogic } from './workflowLogic' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' + +const LOGIC_PROPS: WorkflowLogicProps = { id: 'storybook-sandbox-switch-banner' } + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SECOND_OWN_SENDER = { + id: 9, + kind: 'email', + display_name: 'Acme support <support@acme.example.com>', + config: { + provider: 'ses', + name: 'Acme support', + email: 'support@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SANDBOX_WORKFLOW: HogFlow = { + ...NEW_WORKFLOW, + id: LOGIC_PROPS.id!, + name: 'Welcome email', + user_access_level: AccessControlLevel.Editor, + actions: [ + { + id: 'trigger', + type: 'trigger', + name: 'New account created', + description: '', + config: { type: 'event', filters: {} }, + }, + { + id: 'email', + type: 'function_email', + name: 'Send welcome email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: '{{ person.properties.email }}', name: '' }, + from: { integrationId: SANDBOX_SENDER.id }, + subject: 'Welcome aboard', + text: 'Your account is ready.', + html: '<p>Your account is ready.</p>', + }, + templating: 'liquid', + }, + }, + }, + }, + { id: 'exit', type: 'exit', name: 'Exit', description: '', config: { reason: 'Completed' } }, + ], + edges: [ + { from: 'trigger', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit', type: 'continue' }, + ], +} + +type StoryArgs = { + ownSenders: (typeof OWN_SENDER)[] + narrow?: boolean + constrained?: boolean +} + +const meta: Meta<StoryArgs> = { + title: 'Products/Workflows/Sandbox switch banner', + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + testOptions: { waitForSelector: '[data-attr="workflow-sandbox-switch-sender"]' }, + }, +} +export default meta + +const Template: StoryFn<StoryArgs> = ({ ownSenders, narrow, constrained }): JSX.Element => { + useStorybookMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': SANDBOX_WORKFLOW, + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] }, + '/api/projects/:team_id/hog_function_templates': { count: 0, results: [] }, + '/api/environments/:team_id/messaging_categories': { count: 0, results: [] }, + }, + }) + return ( + <BindLogic logic={workflowLogic} props={LOGIC_PROPS}> + <div className={narrow ? 'w-[520px]' : 'max-w-4xl'}> + <div className={constrained ? 'h-[400px] flex flex-col' : undefined}> + <WorkflowSandboxSwitchBanner {...LOGIC_PROPS} /> + {constrained && <div className="grow min-h-[400px]" />} + </div> + </div> + </BindLogic> + ) +} + +export const OneOwnSender = Template.bind({}) +OneOwnSender.args = { ownSenders: [OWN_SENDER] } + +export const OneOwnSenderNarrow = Template.bind({}) +OneOwnSenderNarrow.args = { ownSenders: [OWN_SENDER], narrow: true } + +export const ConstrainedScene = Template.bind({}) +ConstrainedScene.args = { ownSenders: [OWN_SENDER], constrained: true } + +export const ConstrainedSceneNarrow = Template.bind({}) +ConstrainedSceneNarrow.args = { ownSenders: [OWN_SENDER], constrained: true, narrow: true } + +export const OneOwnSenderLongAddressNarrow = Template.bind({}) +OneOwnSenderLongAddressNarrow.args = { + ownSenders: [ + { + ...OWN_SENDER, + display_name: 'Acme notifications <notifications-and-billing@mail.acme.example.com>', + config: { ...OWN_SENDER.config, email: 'notifications-and-billing@mail.acme.example.com' }, + }, + ], + narrow: true, +} + +export const SeveralOwnSenders = Template.bind({}) +SeveralOwnSenders.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER] } + +export const SeveralOwnSendersNarrow = Template.bind({}) +SeveralOwnSendersNarrow.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx new file mode 100644 index 000000000000..33e260f8b2a3 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx @@ -0,0 +1,36 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner } from '@posthog/lemon-ui' + +import { WorkflowLogicProps } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' +import { WorkflowSandboxSwitchSenderButton } from './WorkflowSandboxSwitchSenderButton' + +export function WorkflowSandboxSwitchBanner(props: WorkflowLogicProps): JSX.Element | null { + const logic = workflowSandboxSwitchBannerLogic(props) + const { bannerVisible, verifiedOwnSenders, switchDisabledReason } = useValues(logic) + const { switchToOwnSender } = useActions(logic) + + if (!bannerVisible) { + return null + } + + return ( + <LemonBanner type="info" alignItems="start" className="@container shrink-0"> + <div + className="flex flex-col gap-2 @xl:flex-row @xl:items-center" + data-attr="workflow-sandbox-switch-banner" + > + <span className="min-w-0 flex-1"> + Your project has a verified email sender. This workflow still sends from the sandbox sender, which + delivers only to verified members of your organization. + </span> + <WorkflowSandboxSwitchSenderButton + senders={verifiedOwnSenders} + disabledReason={switchDisabledReason} + onSwitch={switchToOwnSender} + /> + </div> + </LemonBanner> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx new file mode 100644 index 000000000000..c311249d50e5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx @@ -0,0 +1,43 @@ +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' + +import { IntegrationType } from '~/types' + +export function WorkflowSandboxSwitchSenderButton({ + senders, + disabledReason, + onSwitch, +}: { + senders: IntegrationType[] + disabledReason: string | null + onSwitch: (integrationId: number) => void +}): JSX.Element { + const buttonProps = { + type: 'secondary' as const, + size: 'small' as const, + disabledReason, + 'data-attr': 'workflow-sandbox-switch-sender', + className: 'max-w-full self-start @xl:self-auto', + } + + if (senders.length === 1) { + const label = `Switch to ${senders[0].config.email}` + return ( + <LemonButton {...buttonProps} truncate tooltip={label} onClick={() => onSwitch(senders[0].id)}> + {label} + </LemonButton> + ) + } + + return ( + <LemonMenu + items={senders.map((sender) => ({ + label: sender.display_name, + onClick: () => onSwitch(sender.id), + 'data-attr': 'workflow-sandbox-switch-sender-option', + }))} + placement="bottom-end" + > + <LemonButton {...buttonProps}>Switch sender</LemonButton> + </LemonMenu> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx index 09b7847055b9..6c04ab5e1341 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -51,6 +51,7 @@ import { WorkflowInvocations } from './WorkflowInvocations' import { WorkflowLogicProps, workflowLogic } from './workflowLogic' import { WorkflowMetrics } from './WorkflowMetrics' import { WorkflowRevisions } from './WorkflowRevisions' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' import { WorkflowSceneHeader } from './WorkflowSceneHeader' import { WorkflowSceneLogicProps, WorkflowTab, workflowSceneLogic } from './workflowSceneLogic' import { TRIGGER_PREFILL_PARAM } from './workflowTriggerPrefill' @@ -255,6 +256,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { <WorkflowEmailPauseBanner /> {isSavedWorkflow && <WorkflowBroadcastMoveBanner id={props.id!} />} {!isSavedWorkflow && <WorkflowAiEmailDraftStatus {...workflowProps} />} + <WorkflowSandboxSwitchBanner {...workflowProps} /> {suggestionsAvailable && isSavedWorkflow && <WorkflowSuggestionsNotice id={props.id!} />} {/* Only show Logs and Metrics tabs if the workflow has already been created */} {!props.id || props.id === 'new' ? ( diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts new file mode 100644 index 000000000000..d51eed316862 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -0,0 +1,375 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { AccessControlLevel } from '~/types' + +import { resourceEditedLogic } from 'products/notifications/frontend/resourceEditedLogic' + +import { HogFlow, HogFlowAction } from './hogflows/types' +import { workflowLogic } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' + +const WORKFLOW_ID = 'wf-sandbox-switch-1' +const SANDBOX_SENDER_ID = 7 +const OWN_SENDER_ID = 8 +const SECOND_OWN_SENDER_ID = 9 + +const SANDBOX_SENDER = { + id: SANDBOX_SENDER_ID, + kind: 'email', + display_name: 'Acme via PostHog <sandbox@example.com>', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} +const OWN_SENDER = { + id: OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme <hello@acme.example.com>', + config: { provider: 'ses', domain: 'acme.example.com', email: 'hello@acme.example.com', verified: true }, +} +const SECOND_OWN_SENDER = { + id: SECOND_OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme support <support@acme.example.com>', + config: { provider: 'ses', domain: 'acme.example.com', email: 'support@acme.example.com', verified: true }, +} +const UNVERIFIED_OWN_SENDER = { ...OWN_SENDER, id: 10, config: { ...OWN_SENDER.config, verified: false } } + +const emailStep = (id: string, from: Record<string, unknown>): HogFlowAction => ({ + id, + type: 'function_email', + name: 'Send email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: 'recipient@example.com' }, + from, + subject: 'Hello', + html: '<p>Hi</p>', + text: 'Hi', + }, + templating: 'liquid', + }, + }, + }, +}) + +const makeWorkflow = (steps: HogFlowAction[]): HogFlow => ({ + id: WORKFLOW_ID, + name: 'Sandbox switch test', + actions: [ + { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { type: 'event', filters: {} }, + }, + ...steps, + { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Default exit' }, + }, + ], + edges: [], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'draft', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', +}) + +const senderOf = (workflow: HogFlow, stepId: string): unknown => { + const action = workflow.actions.find((candidate) => candidate.id === stepId) + return action?.type === 'function_email' ? action.config.inputs.email.value.from : undefined +} + +describe('workflowSandboxSwitchBannerLogic', () => { + let logic: ReturnType<typeof workflowSandboxSwitchBannerLogic.build> + let capture: jest.SpyInstance + let successToast: jest.SpyInstance + let savedWorkflow: HogFlow + let integrationsPayload: unknown[] + + const setSandboxFlag = (enabled: boolean): void => { + featureFlagLogic.actions.setFeatureFlags( + enabled ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] : [], + enabled ? { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true } : {} + ) + } + + const mountWith = async ({ + steps, + integrations, + flag = true, + accessLevel = AccessControlLevel.Editor, + status = 'draft', + }: { + steps: HogFlowAction[] + integrations: unknown[] + flag?: boolean + accessLevel?: AccessControlLevel + status?: HogFlow['status'] + }): Promise<void> => { + savedWorkflow = { ...makeWorkflow(steps), user_access_level: accessLevel, status } + integrationsPayload = integrations + initKeaTests() + featureFlagLogic.mount() + setSandboxFlag(flag) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + workflow.mount() + integrationsLogic.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + logic = workflowSandboxSwitchBannerLogic({ id: WORKFLOW_ID }) + logic.mount() + } + + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => [200, savedWorkflow], + '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }], + '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ + 200, + { ...savedWorkflow, ...((await request.json()) as Partial<HogFlow>) }, + ], + }, + }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + successToast = jest.spyOn(lemonToast, 'success').mockImplementation() + }) + + afterEach(() => { + logic?.unmount() + featureFlagLogic.actions.setFeatureFlags([], {}) + capture.mockRestore() + successToast.mockRestore() + }) + + it.each([ + { + case: 'a sandbox email step and a verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: true, + }, + { + case: 'no verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, UNVERIFIED_OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'no email step on the sandbox sender', + steps: [emailStep('email_1', { integrationId: OWN_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'the sandbox sender flag off', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + visible: false, + }, + ])('shows the banner only with $case', async ({ steps, integrations, flag, visible }) => { + await mountWith({ steps, integrations, flag }) + + expect(logic.values.bannerVisible).toBe(visible) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + visible ? 1 : 0 + ) + }) + + it('reports the banner once the flag arrives after the workflow and integrations loaded', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + }) + expect(logic.values.bannerVisible).toBe(false) + + setSandboxFlag(true) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('reports the banner once per workflow view even when it hides and shows again', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + expect(logic.values.bannerVisible).toBe(false) + workflow.actions.setWorkflowValues({ actions: savedWorkflow.actions }) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('switches every sandbox email step to the own sender as an unsaved change', async () => { + await mountWith({ + steps: [ + emailStep('email_1', { integrationId: SANDBOX_SENDER_ID }), + emailStep('email_2', { integrationId: OWN_SENDER_ID, name: 'Acme', email: 'hello@acme.example.com' }), + emailStep('email_3', { integrationId: SANDBOX_SENDER_ID }), + ], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(senderOf(workflow.values.workflow, 'email_2')).toEqual({ + integrationId: OWN_SENDER_ID, + name: 'Acme', + email: 'hello@acme.example.com', + }) + expect(senderOf(workflow.values.workflow, 'email_3')).toEqual({ integrationId: OWN_SENDER_ID }) + const switchedEmail = workflow.values.workflow.actions.find((step) => step.id === 'email_1') + expect(switchedEmail?.type === 'function_email' && switchedEmail.config.inputs.email).toEqual({ + value: { + from: { integrationId: OWN_SENDER_ID }, + to: { email: 'recipient@example.com' }, + subject: 'Hello', + html: '<p>Hi</p>', + text: 'Hi', + }, + templating: 'liquid', + }) + expect(senderOf(workflow.values.originalWorkflow!, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(workflow.values.hasUnsavedChanges).toBe(true) + expect(logic.values.bannerVisible).toBe(false) + expect(capture).toHaveBeenCalledWith('workflows sandbox sender switched', { email_step_count: 2 }) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox sender switched')).toHaveLength(1) + }) + + it.each([ + ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], + ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(successToast).toHaveBeenCalledWith(confirmation) + }) + + it('lets the user pick the own sender when several are verified', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, SECOND_OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + + expect(logic.values.verifiedOwnSenders.map((sender) => sender.id)).toEqual([ + OWN_SENDER_ID, + SECOND_OWN_SENDER_ID, + ]) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(SECOND_OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SECOND_OWN_SENDER_ID, + }) + }) + + it('prevents switching while an external edit reloads the workflow', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => new Promise(() => {}), + }, + }) + resourceEditedLogic.actions.resourceEdited({ + notification_type: 'resource_edited', + team_id: 1, + resource_type: 'HogFlow', + resource_id: WORKFLOW_ID, + updated_at: '2026-06-01T00:00:00.000Z', + actor_user_id: 99, + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + expect(workflow.values.isSyncingExternalEdit).toBe(true) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(logic.values.switchDisabledReason).toBe('Wait for the workflow to finish syncing') + expect(successToast).not.toHaveBeenCalled() + }) + + it('leaves the workflow intact for a viewer', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + accessLevel: AccessControlLevel.Viewer, + }) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + }) + + it.each([UNVERIFIED_OWN_SENDER.id, SANDBOX_SENDER_ID, 999])( + 'rejects ineligible switch target %s', + async (targetId) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + logic.actions.switchToOwnSender(targetId) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + } + ) +}) diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts new file mode 100644 index 000000000000..a1a8dc4032bf --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts @@ -0,0 +1,236 @@ +import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' +import posthog from 'posthog-js' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' +import { pluralize } from 'lib/utils/strings' +import { selectedSenderIds } from 'scenes/hog-functions/email-templater/selectSenders' + +import { AccessControlLevel, AccessControlResourceType, IntegrationType } from '~/types' + +import { isEmailAction } from './hogflows/steps/types' +import { HogFlow, HogFlowAction } from './hogflows/types' +import { WorkflowLogicProps, workflowLogic } from './workflowLogic' + +export type EmailStep = Extract<HogFlowAction, { type: 'function_email' }> + +const usesSender = (step: EmailStep, senderId: number): boolean => + selectedSenderIds(step.config.inputs?.email?.value?.from).includes(senderId) + +const withSender = (step: EmailStep, integrationId: number): EmailStep => { + const email = step.config.inputs.email + return { + ...step, + config: { + ...step.config, + inputs: { + ...step.config.inputs, + email: { ...email, value: { ...email.value, from: { integrationId } } }, + }, + }, + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicValues { + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + isSyncingExternalEdit: boolean // workflowLogic + workflow: HogFlow // workflowLogic + workflowLoading: boolean // workflowLogic + workflowUserAccessLevel: AccessControlLevel | null // workflowLogic + bannerShownReported: boolean + bannerVisible: boolean + sandboxEmailSteps: EmailStep[] + switchDisabledReason: string | null + verifiedOwnSenders: IntegrationType[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicActions { + autoSaveWorkflow: () => { + value: true + } // workflowLogic + setWorkflowValues: (values: import('node_modules/kea-forms/lib').DeepPartial<HogFlow>) => { + values: import('node_modules/kea-forms/lib').DeepPartial<HogFlow> + } // workflowLogic + reportBannerShown: () => { + value: true + } + switchToOwnSender: (integrationId: number) => { + integrationId: number + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + switchDisabledReason: ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ) => string | null + verifiedOwnSenders: (ownEmailIntegrations: IntegrationType[]) => IntegrationType[] + sandboxEmailSteps: (workflow: HogFlow, sandboxEmailSender: IntegrationType | null) => EmailStep[] + bannerVisible: ( + sandboxEmailSteps: ({ + config: { + inputs: Record< + string, + { + bytecode?: any + order?: number | undefined + secret?: boolean | undefined + templating?: 'hog' | 'liquid' | undefined + value: any + } + > + message_category_id?: string | undefined + message_category_type?: 'marketing' | 'transactional' | undefined + template_id: 'template-email' + template_uuid?: string | undefined + tracking_enabled?: boolean | undefined + utm_params?: + | { + utm_campaign?: string | undefined + utm_content?: string | undefined + utm_medium?: string | undefined + utm_source?: string | undefined + } + | undefined + utm_params_from_default?: + | ('utm_campaign' | 'utm_content' | 'utm_medium' | 'utm_source')[] + | undefined + utm_tags_enabled?: boolean | undefined + } + created_at?: number | undefined + description: string + filters?: + | { + actions?: any[] | undefined + events?: any[] | undefined + properties?: any[] | undefined + } + | null + | undefined + id: string + name: string + on_error?: 'abort' | 'continue' | null | undefined + output_variable?: + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + } + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + }[] + | null + | undefined + type: 'function_email' + updated_at?: number | undefined + } & Record<string, unknown>)[], + verifiedOwnSenders: IntegrationType[] + ) => boolean + } +} + +export type workflowSandboxSwitchBannerLogicType = MakeLogicType< + workflowSandboxSwitchBannerLogicValues, + workflowSandboxSwitchBannerLogicActions, + WorkflowLogicProps, + workflowSandboxSwitchBannerLogicMeta +> + +export const workflowSandboxSwitchBannerLogic = kea<workflowSandboxSwitchBannerLogicType>([ + props({} as WorkflowLogicProps), + key((props) => `${props.id ?? 'new'}-${props.templateId ?? 'default'}-${props.editTemplateId ?? 'default'}`), + path((key) => ['products', 'workflows', 'frontend', 'Workflows', 'workflowSandboxSwitchBannerLogic', key]), + connect((props: WorkflowLogicProps) => ({ + values: [ + workflowLogic(props), + ['workflow', 'workflowUserAccessLevel', 'workflowLoading', 'isSyncingExternalEdit'], + integrationsLogic, + ['ownEmailIntegrations', 'sandboxEmailSender'], + ], + actions: [workflowLogic(props), ['setWorkflowValues', 'autoSaveWorkflow']], + })), + actions({ + switchToOwnSender: (integrationId: number) => ({ integrationId }), + reportBannerShown: true, + }), + reducers({ + bannerShownReported: [false, { reportBannerShown: () => true }], + }), + selectors({ + switchDisabledReason: [ + (s) => [s.workflowUserAccessLevel, s.workflowLoading, s.isSyncingExternalEdit], + ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ): string | null => + getAccessControlDisabledReason( + AccessControlResourceType.Workflow, + AccessControlLevel.Editor, + workflowUserAccessLevel ?? undefined + ) || (workflowLoading || isSyncingExternalEdit ? 'Wait for the workflow to finish syncing' : null), + ], + verifiedOwnSenders: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): IntegrationType[] => + ownEmailIntegrations.filter((integration) => integration.config?.verified === true), + ], + sandboxEmailSteps: [ + (s) => [s.workflow, s.sandboxEmailSender], + (workflow: HogFlow, sandboxEmailSender: IntegrationType | null): EmailStep[] => + sandboxEmailSender + ? workflow.actions.filter(isEmailAction).filter((step) => usesSender(step, sandboxEmailSender.id)) + : [], + ], + bannerVisible: [ + (s) => [s.sandboxEmailSteps, s.verifiedOwnSenders], + (sandboxEmailSteps: EmailStep[], verifiedOwnSenders: IntegrationType[]): boolean => + sandboxEmailSteps.length > 0 && verifiedOwnSenders.length > 0, + ], + }), + listeners(({ actions, values }) => ({ + reportBannerShown: () => { + posthog.capture('workflows sandbox switch banner shown') + }, + switchToOwnSender: ({ integrationId }) => { + const { sandboxEmailSteps } = values + const sender = values.verifiedOwnSenders.find((candidate) => candidate.id === integrationId) + if (sandboxEmailSteps.length === 0 || !sender) { + return + } + if (values.switchDisabledReason) { + return + } + const switched = new Map(sandboxEmailSteps.map((step) => [step.id, withSender(step, integrationId)])) + actions.setWorkflowValues({ + actions: values.workflow.actions.map((action) => switched.get(action.id) ?? action), + }) + actions.autoSaveWorkflow() + posthog.capture('workflows sandbox sender switched', { email_step_count: sandboxEmailSteps.length }) + const nextStep = values.workflow.status === 'active' ? ' Publish the changes to use this sender.' : '' + lemonToast.success( + `Updated ${pluralize(sandboxEmailSteps.length, 'email step')} to ${sender.config.email ?? 'your own sender'}.${nextStep}` + ) + }, + })), + subscriptions(({ actions, values }) => ({ + bannerVisible: (bannerVisible: boolean) => { + if (bannerVisible && !values.bannerShownReported) { + actions.reportBannerShown() + } + }, + })), +]) From e63cf5bd0d7cc5cd9cfc886aac206bd9ca3a676c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:04:33 +0000 Subject: [PATCH 34/38] test(workflows): prove the sandbox switch saves the new sender Also document the switch banner next to the sandbox sender setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../internal/workflows-email-sending-tiers.md | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 44 ++++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 2aac968f7cce..39a9020eb96f 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -27,6 +27,8 @@ Users cannot edit, verify, or remove the managed integration, or register their The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. The sandbox sender is available only in workflow email steps and test sends. Broadcasts, destinations, sender rotation, custom From values, and Reply-To overrides are rejected. Unchanged stored sender settings remain editable after the flag turns off. +When a project has a verified own sender, a workflow with email steps on the sandbox sender shows a banner that moves all of those steps to the own sender in one click. +The switch saves as a draft change. An active workflow keeps sending from the sandbox sender until someone publishes. ### Sending tiers diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts index d51eed316862..a2b8b89b4d3a 100644 --- a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -111,6 +111,7 @@ describe('workflowSandboxSwitchBannerLogic', () => { let successToast: jest.SpyInstance let savedWorkflow: HogFlow let integrationsPayload: unknown[] + let savedPatch: Partial<HogFlow> & { stage_draft?: boolean } const setSandboxFlag = (enabled: boolean): void => { featureFlagLogic.actions.setFeatureFlags( @@ -154,10 +155,10 @@ describe('workflowSandboxSwitchBannerLogic', () => { '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), }, patch: { - '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ - 200, - { ...savedWorkflow, ...((await request.json()) as Partial<HogFlow>) }, - ], + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + savedPatch = (await request.json()) as typeof savedPatch + return [200, { ...savedWorkflow, ...savedPatch }] + }, }, }) capture = jest.spyOn(posthog, 'capture').mockImplementation() @@ -165,6 +166,7 @@ describe('workflowSandboxSwitchBannerLogic', () => { }) afterEach(() => { + jest.useRealTimers() logic?.unmount() featureFlagLogic.actions.setFeatureFlags([], {}) capture.mockRestore() @@ -285,19 +287,31 @@ describe('workflowSandboxSwitchBannerLogic', () => { }) it.each([ - ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], - ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], - ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { - await mountWith({ - steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], - integrations: [SANDBOX_SENDER, OWN_SENDER], - status, - }) + [ + 'active', + true, + 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.', + ], + ['draft', false, 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)( + 'saves the switch for a workflow in %s status and explains what remains', + async (status, stagesDraft, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + jest.useFakeTimers() - logic.actions.switchToOwnSender(OWN_SENDER_ID) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + await jest.advanceTimersByTimeAsync(3500) + await expectLogic(workflowLogic({ id: WORKFLOW_ID })).toDispatchActions(['saveWorkflowSuccess']) - expect(successToast).toHaveBeenCalledWith(confirmation) - }) + expect(senderOf(savedPatch as HogFlow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(savedPatch.stage_draft === true).toBe(stagesDraft) + expect(successToast).toHaveBeenCalledWith(confirmation) + } + ) it('lets the user pick the own sender when several are verified', async () => { await mountWith({ From 99424e332410d5494e25b0f542b28711f1254f11 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:43:37 +0000 Subject: [PATCH 35/38] test(workflows): prove a block only covers its own organization Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- products/workflows/backend/tests/api/test_sandbox_sender.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index e8888cb14bd0..bcbcaefc5ef1 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -10,7 +10,7 @@ from rest_framework import status from posthog.models.integration import Integration -from posthog.models.organization import OrganizationMembership +from posthog.models.organization import Organization, OrganizationMembership from posthog.models.personal_api_key import PersonalAPIKey from posthog.models.utils import hash_key_value @@ -117,6 +117,8 @@ def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( assert self._provisioned_events() == [] def test_ensure_is_not_found_for_a_blocked_organization_until_the_block_is_removed(self) -> None: + other_organization = Organization.objects.create(name="Other organization") + SandboxSenderOrganizationBlock.objects.create(organization=other_organization, reason="Abuse report") block = SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") blocked = self._ensure() From 8d92e19e7bc0e9eb43ff554c0b7113cfce03ea93 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:49:52 +0000 Subject: [PATCH 36/38] fix(workflows): name the organization on admin pages for a sandbox sender block Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../backend/models/sandbox_sender_organization_block.py | 3 +++ .../test/test_sandbox_sender_organization_block_admin.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/products/workflows/backend/models/sandbox_sender_organization_block.py b/products/workflows/backend/models/sandbox_sender_organization_block.py index 1539c82607f5..a2518da982c5 100644 --- a/products/workflows/backend/models/sandbox_sender_organization_block.py +++ b/products/workflows/backend/models/sandbox_sender_organization_block.py @@ -13,3 +13,6 @@ class SandboxSenderOrganizationBlock(models.Model): "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False, related_name="+" ) created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.organization.name diff --git a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py index b19175a452c4..46946a4e78c4 100644 --- a/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py +++ b/products/workflows/backend/test/test_sandbox_sender_organization_block_admin.py @@ -13,6 +13,8 @@ def setUp(self) -> None: super().setUp() self.user.is_staff = True self.user.save() + self.organization.name = "Blocked example organization" + self.organization.save() self.client = Client() self.client.force_login(self.user) @@ -25,6 +27,9 @@ def test_staff_add_a_block_with_its_author_and_delete_it(self) -> None: block = SandboxSenderOrganizationBlock.objects.get(organization=self.organization) assert (block.reason, block.created_by) == ("Abuse report", self.user) + confirmation = self.client.get(f"{ADMIN_URL}{block.pk}/delete/") + assert self.organization.name in confirmation.content.decode() + deleted = self.client.post(f"{ADMIN_URL}{block.pk}/delete/", data={"post": "yes"}) assert deleted.status_code == 302, deleted.content From 11f1440e84b4706d5cdcce47f7794024a43c908a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:54:13 +0000 Subject: [PATCH 37/38] chore(workflows): drop comments from the organization block model Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/scripts/check-idor-model-coverage.py | 2 +- .../backend/models/sandbox_sender_organization_block.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/scripts/check-idor-model-coverage.py b/.github/scripts/check-idor-model-coverage.py index c04546113476..fbb76d026e3c 100644 --- a/.github/scripts/check-idor-model-coverage.py +++ b/.github/scripts/check-idor-model-coverage.py @@ -354,7 +354,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st "RoleMembership", "LinkedIdentityProviderConfig", "IdJagIdentity", - "SandboxSenderOrganizationBlock", # staff-managed switch that turns the sandbox sender off for one organization + "SandboxSenderOrganizationBlock", # --- User-scoped (cross-tenant by design) --- "NotificationViewed", "SCIMProvisionedUser", diff --git a/products/workflows/backend/models/sandbox_sender_organization_block.py b/products/workflows/backend/models/sandbox_sender_organization_block.py index a2518da982c5..6e579d32d197 100644 --- a/products/workflows/backend/models/sandbox_sender_organization_block.py +++ b/products/workflows/backend/models/sandbox_sender_organization_block.py @@ -2,9 +2,6 @@ class SandboxSenderOrganizationBlock(models.Model): - """Staff switch that turns the shared sandbox sender off for one organization; deleting the row lifts it.""" - - # db_constraint=False: a real FK constraint to a hot table takes a parent-table lock on creation. organization = models.OneToOneField( "posthog.Organization", on_delete=models.CASCADE, db_constraint=False, related_name="+" ) From 9258b97383a997791a259e6a64f4a66f7466aca3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:06:05 +0000 Subject: [PATCH 38/38] test(workflows): keep existing sandbox senders while an organization is blocked Document the staff block next to the sandbox sender provisioning endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/internal/workflows-email-sending-tiers.md | 2 ++ products/workflows/backend/tests/api/test_sandbox_sender.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 865e62487730..6db1b6190e27 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -28,6 +28,8 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook The `workflows-sandbox-sender` flag enables a PostHog-managed email integration per project through `POST /api/projects/:project_id/integrations/email_sandbox_sender/`. The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. +Staff turn the sandbox sender off for one organization by adding a `SandboxSenderOrganizationBlock` row in the Django admin, and lift the block by deleting the row. +While the block exists, the endpoint returns 404 for the organization's projects. Existing sandbox senders stay, and the email worker does not read the block. Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable. The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. Workflow email steps and test sends offer it as a sender. diff --git a/products/workflows/backend/tests/api/test_sandbox_sender.py b/products/workflows/backend/tests/api/test_sandbox_sender.py index bcbcaefc5ef1..b70307dd0c95 100644 --- a/products/workflows/backend/tests/api/test_sandbox_sender.py +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -116,7 +116,7 @@ def test_ensure_is_not_found_while_the_sandbox_sender_is_unavailable( assert not self._sandbox_rows().exists() assert self._provisioned_events() == [] - def test_ensure_is_not_found_for_a_blocked_organization_until_the_block_is_removed(self) -> None: + def test_ensure_is_not_found_while_the_organization_is_blocked_and_keeps_existing_senders(self) -> None: other_organization = Organization.objects.create(name="Other organization") SandboxSenderOrganizationBlock.objects.create(organization=other_organization, reason="Abuse report") block = SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") @@ -130,6 +130,10 @@ def test_ensure_is_not_found_for_a_blocked_organization_until_the_block_is_remov unblocked = self._ensure() assert unblocked.status_code == status.HTTP_200_OK, unblocked.json() assert self._sandbox_rows().count() == 1 + SandboxSenderOrganizationBlock.objects.create(organization=self.organization, reason="Abuse report") + reblocked = self._ensure() + assert reblocked.status_code == status.HTTP_404_NOT_FOUND, reblocked.json() + assert self._sandbox_rows().count() == 1 @parameterized.expand( [