diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index c615d9bdd65d..315eff6dc6d0 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -24,6 +24,19 @@ 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`, `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. +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. + +### 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/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 50277e330046..36933d1b3a12 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -141,6 +141,9 @@ export const ICONS: Record = { // surface, so invert them in dark mode (`dark:invert`) wherever the integration icon is rendered. export const DARK_MODE_INVERT_ICON_KINDS = new Set(['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: () =>
, - LemonSelect: () =>
, + LemonInputSelect: ({ options }: { options: { key: string; label: string }[] }) => ( +
+ {options.map((option) => ( + {option.label} + ))} +
+ ), + LemonSelect: ({ options }: { options: { options: { value: number; label: string }[] }[] }) => ( +
+ {options + .flatMap((section) => section.options) + .map((option) => ( + {option.label} + ))} +
+ ), })) +const ALICE = { id: 1, kind: 'email', display_name: 'Alice ', config: { provider: 'ses' } } +const BOB = { id: 2, kind: 'email', display_name: 'Bob ', config: { provider: 'ses' } } +const SANDBOX = { + id: 3, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox' }, +} + describe('NativeEmailIntegrationChoice', () => { afterEach(cleanup) beforeEach(() => { jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, - integrations: [ - { - id: 1, - kind: 'email', - display_name: 'Alice ', - }, - { - id: 2, - kind: 'email', - display_name: 'Bob ', - }, - ], + 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() + + 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() + + 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 0a714108861d..f8db9a4db5c0 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -27,6 +27,7 @@ import { import { TagSelect } from 'lib/components/TagSelect' 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' @@ -285,7 +286,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/posthog/api/integration.py b/posthog/api/integration.py index a84e8a925b9c..362b89043a3e 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -153,7 +153,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 @@ -1569,6 +1574,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", @@ -1616,7 +1622,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()] @@ -1630,6 +1642,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 ) @@ -2781,6 +2796,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}, @@ -2857,14 +2889,13 @@ def _email_domain_setup_method(request: Request, instance: IntegrationRecord) -> @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 757bc8eeae60..daf8ec2a536d 100644 --- a/posthog/api/team/team_config.py +++ b/posthog/api/team/team_config.py @@ -8,6 +8,7 @@ from posthog.integrations import IntegrationManager, IntegrationNotFound 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 products.access_control.backend.presentation.access_control import UserAccessControlSerializerMixin from products.customer_analytics.backend.facade.account_property_pins import ( @@ -428,6 +429,10 @@ def validate_default_email_integration_id(self, value: int | None) -> int | None pass 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 efa63c38c926..c788552e8a28 100644 --- a/posthog/models/integration/__init__.py +++ b/posthog/models/integration/__init__.py @@ -47,7 +47,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, @@ -237,6 +242,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 9a25cf0b274f..99422ab30ef6 100644 --- a/posthog/models/integration/email.py +++ b/posthog/models/integration/email.py @@ -40,6 +40,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 @@ -48,6 +58,14 @@ def __init__(self, integration: IntegrationRecord) -> None: raise Exception("EmailIntegration init called with Integration with wrong 'kind'") self.integration = model.integration_model(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 @@ -61,6 +79,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) @@ -153,6 +176,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") @@ -193,6 +217,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/posthog/settings/ses.py b/posthog/settings/ses.py index 6cd9beaff4c7..74a23a5699cd 100644 --- a/posthog/settings/ses.py +++ b/posthog/settings/ses.py @@ -29,6 +29,10 @@ 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() +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, # 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 7cea480691f2..14b8c99add12 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -968,6 +968,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 592ae8378fe8..74edb23dd4bf 100644 --- a/products/workflows/backend/facade/contracts.py +++ b/products/workflows/backend/facade/contracts.py @@ -283,6 +283,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 b7edc1cf37e5..c1c61c7fd86a 100644 --- a/products/workflows/backend/services/data_suggestions/team_context.py +++ b/products/workflows/backend/services/data_suggestions/team_context.py @@ -8,6 +8,7 @@ from posthog.integrations import IntegrationManager from posthog.models import EventProperty, PropertyDefinition, Team +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER 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 @@ -103,7 +104,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 IntegrationManager.email.credentials.list(team.id) - 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 3b83c79e9875..1a4a58ffb381 100644 --- a/products/workflows/backend/services/email_health.py +++ b/products/workflows/backend/services/email_health.py @@ -13,7 +13,7 @@ from posthog.api.app_metrics2 import fetch_app_metric_totals_by_source, fetch_app_metric_totals_by_team_and_source from posthog.integrations import IntegrationManager -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, @@ -219,6 +219,7 @@ def verified_email_domains(*, team_id: int) -> list[str]: for domain in ( (row.config or {}).get("domain") for row in IntegrationManager.email.credentials.list(team_id, where={"verified": True}) + if (row.config or {}).get("provider") != SANDBOX_EMAIL_PROVIDER ) 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..ef7997cc094f --- /dev/null +++ b/products/workflows/backend/services/sandbox_sender.py @@ -0,0 +1,71 @@ +from django.conf import settings + +import structlog + +from posthog.integrations import IntegrationManager +from posthog.models.integration import SANDBOX_EMAIL_INTEGRATION_ID, SANDBOX_EMAIL_PROVIDER +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 + and settings.SES_SANDBOX_TENANT_NAME + ) + + +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) + senders = IntegrationManager.email.credentials + current = senders.find(team_id, external_id=SANDBOX_EMAIL_INTEGRATION_ID) + if current is not None and current.config == config: + return SandboxEmailSender(integration_id=current.id, created=False) + integration, created = senders.upsert(team_id, SANDBOX_EMAIL_INTEGRATION_ID, config=config) + if not created: + 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 85258ad3051d..a092319ab495 100644 --- a/products/workflows/backend/services/setup_status.py +++ b/products/workflows/backend/services/setup_status.py @@ -2,6 +2,7 @@ from posthog.integrations import IntegrationManager from posthog.models import Team +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER from products.messaging.backend.facade.api import list_email_templates from products.messaging.backend.facade.categories import list_categories @@ -33,7 +34,10 @@ def email_sending_paused(team: Team) -> bool: def get_setup_steps(team: Team) -> tuple[SetupStep, ...]: done = { - SetupStepKey.CHANNEL: IntegrationManager.email.credentials.exists(team.id), + SetupStepKey.CHANNEL: any( + (sender.config or {}).get("provider") != SANDBOX_EMAIL_PROVIDER + for sender in IntegrationManager.email.credentials.list(team.id) + ), 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 5204bfbb220c..cdefb01699c8 100644 --- a/products/workflows/backend/services/transactional_email_workflows.py +++ b/products/workflows/backend/services/transactional_email_workflows.py @@ -8,6 +8,7 @@ from django.db import transaction from posthog.integrations import IntegrationManager, IntegrationNotFound +from posthog.models.integration import SANDBOX_EMAIL_PROVIDER from posthog.models.scoping import with_team_scope from products.workflows.backend.facade.contracts import ( @@ -104,7 +105,8 @@ 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) @@ -116,7 +118,7 @@ def resolve_transactional_email_sender(team_id: int, requested_id: int | None) - sender = IntegrationManager.email.credentials.get(team_id, sender_id) except IntegrationNotFound: pass - 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..8aaf32d2127d --- /dev/null +++ b/products/workflows/backend/test/test_sandbox_sender_lifecycle.py @@ -0,0 +1,51 @@ +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", + SES_SANDBOX_TENANT_NAME="posthog-sandbox", +) +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") + @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() + + 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( + 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..45fd51429a48 --- /dev/null +++ b/products/workflows/backend/tests/api/test_sandbox_sender.py @@ -0,0 +1,180 @@ +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", + "SES_SANDBOX_TENANT_NAME": "posthog-sandbox", +} + + +@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