From 356ab576a80262291ab63be612e205bba9808fc3 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 1/5] feat(workflows): provision a managed sandbox email sender Co-Authored-By: Claude Opus 5.5 (cherry picked from commit e7d822649f309da60cfd9297c000ccb8f8889f12) --- .../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 c615d9bdd65d..7b7833fcb531 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -24,6 +24,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 a2719dd612f5..b99adde4097e 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -147,7 +147,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 @@ -1533,6 +1538,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", "push_tokens", @@ -1572,7 +1578,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()] @@ -1586,6 +1598,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 ) @@ -2710,6 +2725,23 @@ def push_tokens(self, request: ValidatedRequest, **kwargs) -> Response: ).data ) + @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}, @@ -2786,14 +2818,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 b15714e51df5..9447e9883052 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -931,6 +931,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 060614e43bb8..3f4e19b23776 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 1adbe803c266..0aec7bdd216d 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 b04e098576c8..c61964309a30 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, PropertyDefinition, Team -from posthog.models.integration import Integration +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER, Integration from products.access_control.backend.facade.api import person_property_names_with_access_rules from products.messaging.backend.facade.api import EmailTemplateSummary, list_email_templates @@ -111,7 +111,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 63822cdbb738..656dfb01d9bd 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 @@ -351,14 +351,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, @@ -371,6 +376,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