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
Hello Hello
Example', 'Hello
Example
', null],
+ ['Example', 'Example
', null],
+ [
+ 'Hello