From 0d48e51f6be7d02151c77dd0e8bf89769ff78fd5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:41:10 +0000 Subject: [PATCH] feat(workflows): offer the sandbox sender in workflow email steps Co-Authored-By: Claude Opus 5.5 --- common/hogvm/typescript/src/stl/print.ts | 2 +- .../CyclotronJob/CyclotronJobInputs.tsx | 11 + frontend/src/lib/constants.tsx | 1 + .../src/lib/integrations/IntegrationsList.tsx | 6 +- .../integrations/integrationsLogic.test.ts | 262 ++++++++++++++++ .../src/lib/integrations/integrationsLogic.ts | 241 +++++++++++++-- frontend/src/lib/integrations/utils.ts | 5 + .../EmailSenderSummary.test.tsx | 70 +++++ .../email-templater/EmailSenderSummary.tsx | 6 +- .../email-templater/EmailTemplater.test.tsx | 96 +++++- .../email-templater/EmailTemplater.tsx | 250 +--------------- .../email-templater/LiquidSupportedText.tsx | 59 ++++ .../NativeEmailIntegrationChoice.stories.tsx | 114 +++++++ .../NativeEmailIntegrationChoice.tsx | 263 ++++++++++++++++ .../emailTemplaterLogic.test.ts | 282 ++++++++++++++++++ .../email-templater/emailTemplaterLogic.tsx | 274 ++++++++++++++++- .../email-templater/selectSenders.test.ts | 49 +++ .../email-templater/selectSenders.ts | 38 +++ .../Broadcasts/BroadcastsEmptyState.tsx | 2 +- .../Broadcasts/steps/BroadcastContentStep.tsx | 4 +- .../frontend/Channels/ChannelCatalog.tsx | 44 ++- .../Channels/MessageChannels.stories.tsx | 63 ++++ .../frontend/Channels/MessageChannels.tsx | 30 +- .../Channels/SandboxEmailSenderRow.tsx | 51 ++++ .../Channels/channelCatalogLogic.test.ts | 102 +++++++ .../frontend/Channels/channelCatalogLogic.ts | 95 ++++-- .../frontend/Channels/channelSummary.test.ts | 3 + .../frontend/Channels/channelSummary.ts | 6 +- .../Channels/defaultEmailSender.test.ts | 10 + .../frontend/Channels/defaultEmailSender.ts | 5 +- .../frontend/Channels/emailSendingMessages.ts | 3 + .../messageAudienceWarnings.test.ts | 9 + .../messageAudienceWarnings.tsx | 16 +- .../HogFlowFunctionConfiguration.tsx | 1 + .../setupGuide/wizard/WizardChannelStep.tsx | 7 +- .../wizard/workflowsOnboardingWizardLogic.ts | 2 +- .../setupGuide/workflowsSetupGuideLogic.ts | 2 +- 37 files changed, 2133 insertions(+), 351 deletions(-) create mode 100644 frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts create mode 100644 frontend/src/scenes/hog-functions/email-templater/selectSenders.ts create mode 100644 products/workflows/frontend/Channels/MessageChannels.stories.tsx create mode 100644 products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx create mode 100644 products/workflows/frontend/Channels/channelCatalogLogic.test.ts diff --git a/common/hogvm/typescript/src/stl/print.ts b/common/hogvm/typescript/src/stl/print.ts index 0a53f8d6e906..744cf703fb3a 100644 --- a/common/hogvm/typescript/src/stl/print.ts +++ b/common/hogvm/typescript/src/stl/print.ts @@ -119,7 +119,7 @@ export function printHogStringOutput(obj: any): string { return printHogValue(obj) } -type ASTNode = Map | null +export type ASTNode = Map | null // Note: this printer currently is experimental and used for debugging/printing SQL only. // When making queries via run(query), we send the AST nodes directly to the server. diff --git a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx index 8639c256a962..66cf48e39d7f 100644 --- a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx +++ b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx @@ -140,6 +140,7 @@ export type CyclotronJobInputsProps = { // (the workflow builder's auto-save). Only the email input types read these. emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean parentConfiguration?: CyclotronJobInputConfiguration onInputSchemaChange?: (schema: CyclotronJobInputSchemaType[]) => void // Classes for the column the inputs are laid out in, so a host with height to spare can let @@ -160,6 +161,7 @@ export function CyclotronJobInputs({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, showSource, sampleGlobalsWithInputs, className, @@ -205,6 +207,7 @@ export function CyclotronJobInputs({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> ) })} @@ -308,6 +311,7 @@ function EmailTemplateField({ fieldErrors, liveChanges, saveIndicator, + sandboxSenderAllowed, }: { schema: CyclotronJobInputSchemaType value: any @@ -316,6 +320,7 @@ function EmailTemplateField({ fieldErrors?: EmailFieldErrors liveChanges?: boolean saveIndicator?: ReactNode + sandboxSenderAllowed?: boolean }): JSX.Element { return ( ) } @@ -717,6 +723,7 @@ type CyclotronJobInputProps = { emailFieldErrors?: EmailFieldErrors emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean } function NonFailureStatusCodesField({ @@ -772,6 +779,7 @@ function CyclotronJobInputRenderer({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputProps): JSX.Element { const templating = schema.templating ?? true @@ -917,6 +925,7 @@ function CyclotronJobInputRenderer({ fieldErrors={emailFieldErrors} liveChanges={emailLiveChanges} saveIndicator={emailSaveIndicator} + sandboxSenderAllowed={emailSandboxSenderAllowed} /> ) case 'non_failure_status_codes': @@ -1106,6 +1115,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputWithSchemaProps): JSX.Element | null { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key }) const [editing, setEditing] = useState(false) @@ -1276,6 +1286,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> )} {warning && !value?.secret ? ( diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index b2b1810e4a7c..df60d2f723f0 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -611,6 +611,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows WORKFLOWS_OVERVIEW_TAB: 'workflows-overview-tab', // owner: @dmarchuk #team-workflows, experimental Overview tab on a workflow WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows + WORKFLOWS_SANDBOX_SENDER: 'workflows-sandbox-sender', // owner: #team-workflows WORKFLOWS_SEND_CALENDAR: 'workflows-send-calendar', // owner: @meikelmosby #team-workflows, calendar of scheduled email sends in Broadcasts and Workflows WORKFLOWS_TILE_CANVAS: 'workflows-tile-canvas', // owner: #team-workflows WORKFLOWS_TRANSACTIONAL_EMAIL_API: 'workflows-transactional-email-api', // owner: @mayteio #team-workflows diff --git a/frontend/src/lib/integrations/IntegrationsList.tsx b/frontend/src/lib/integrations/IntegrationsList.tsx index 8e2ec973d3ee..cb224c510316 100644 --- a/frontend/src/lib/integrations/IntegrationsList.tsx +++ b/frontend/src/lib/integrations/IntegrationsList.tsx @@ -20,14 +20,14 @@ export function IntegrationsList({ /** Rendered once the list has loaded and nothing matches. */ emptyState?: JSX.Element }): JSX.Element { - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { userManagedIntegrations, integrationsLoading } = useValues(integrationsLogic) const { startPolling, stopPolling } = useActions(integrationsLogic) useOnMountEffect(() => { startPolling() return () => stopPolling() }) - const filteredIntegrations = integrations?.filter((integration) => { + const filteredIntegrations = userManagedIntegrations?.filter((integration) => { if (onlyKinds && !onlyKinds.includes(integration.kind)) { return false } @@ -46,7 +46,7 @@ export function IntegrationsList({ filteredIntegrations.map((integration) => ( )) - ) : integrationsLoading || integrations === null ? ( + ) : integrationsLoading || userManagedIntegrations === null ? ( ) : ( emptyState diff --git a/frontend/src/lib/integrations/integrationsLogic.test.ts b/frontend/src/lib/integrations/integrationsLogic.test.ts index bd55fe125b36..11507a2825b9 100644 --- a/frontend/src/lib/integrations/integrationsLogic.test.ts +++ b/frontend/src/lib/integrations/integrationsLogic.test.ts @@ -7,6 +7,8 @@ import { expectLogic } from 'kea-test-utils' import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import apiReal, { ApiError } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { teamLogic } from 'scenes/teamLogic' import { useMocks } from '~/mocks/jest' @@ -78,6 +80,266 @@ describe('integrationsLogic', () => { jest.restoreAllMocks() }) + describe('sandbox email sender', () => { + const sandboxSender: IntegrationType = { + id: 9, + kind: 'email', + display_name: 'Acme via PostHog ', + icon_url: '', + config: { + provider: 'sandbox', + email: 'sandbox@example.com', + domain: 'example.com', + name: 'Acme via PostHog', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', + } + const ownSender: IntegrationType = { + id: 10, + kind: 'email', + display_name: 'Acme ', + icon_url: '', + config: { provider: 'ses', email: 'hello@acme.example.com', domain: 'acme.example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', + } + let ensureCalls: number + let ensureStatus: number + + beforeEach(() => { + ensureCalls = 0 + ensureStatus = 200 + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + if (ensureStatus !== 200) { + return [ensureStatus, { detail: 'The sandbox sender is not available for this project.' }] + } + integrationsPayload = [...integrationsPayload, sandboxSender] + return [200, sandboxSender] + }, + }, + }) + }) + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + const enableFlag = (): void => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + + it('provisions the sandbox sender once when the flag is on and no row exists, then reloads', async () => { + enableFlag() + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }) + .toDispatchActions(['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + + expect(ensureCalls).toBe(1) + }) + + it('waits for the integrations list before deciding whether to provision', async () => { + enableFlag() + logic.actions.clearIntegrations() + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + + expect(ensureCalls).toBe(1) + }) + + it.each([ + { reason: 'the flag is off', flag: false, existing: [] as IntegrationType[] }, + { reason: 'a sandbox row already exists', flag: true, existing: [sandboxSender] }, + ])('does not call the endpoint when $reason', async ({ flag, existing }) => { + if (flag) { + enableFlag() + } + integrationsPayload = existing + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess']) + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + + expect(ensureCalls).toBe(0) + }) + + it.each([ + { outcome: 'a 404', status: 404 }, + { outcome: 'a server error', status: 500 }, + ])( + 'stops after $outcome from the endpoint, without a failure toast or a retry on reload', + async ({ status }) => { + enableFlag() + ensureStatus = status + + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }) + .toDispatchActions(['provisionSandboxEmailSender', 'provisionSandboxEmailSenderSuccess']) + .toNotHaveDispatchedActions(['provisionSandboxEmailSenderFailure']) + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }) + .toDispatchActions(['loadIntegrationsSuccess']) + .toFinishAllListeners() + + expect(ensureCalls).toBe(1) + expect(logic.values.sandboxEmailSender).toBeNull() + expect(logic.values.sandboxEmailSenderProvisionError).toBe( + 'The sandbox sender is not available for this project.' + ) + if (status === 404) { + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toFinishAllListeners() + expect(ensureCalls).toBe(1) + } + } + ) + + it.each([ + [ + 'with an API detail', + { detail: 'Could not load senders. Try again.' }, + 'Could not load senders. Try again.', + ], + ['without an API detail', 'Bad gateway', null], + ])( + 'retains a failed integrations load %s without treating the unresolved list as empty', + async (_name, body, expectedError) => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [502, body] } }) + logic.actions.clearIntegrations() + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsFailure', + ]) + expect(logic.values.integrations).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.integrationsLoadError).toBe(expectedError) + + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [] } } }) + await expectLogic(logic, () => logic.actions.loadIntegrations()).toDispatchActions([ + 'loadIntegrationsSuccess', + ]) + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.integrationsLoadError).toBeNull() + } + ) + + it('retries a failed provision when a surface explicitly asks again', async () => { + enableFlag() + ensureStatus = 500 + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()).toDispatchActions([ + 'provisionSandboxEmailSenderSuccess', + ]) + + ensureStatus = 200 + await expectLogic(logic, () => logic.actions.ensureSandboxEmailSender()) + .toDispatchActions(['provisionSandboxEmailSenderSuccess', 'loadIntegrationsSuccess']) + .toMatchValues({ sandboxEmailSender: expect.objectContaining({ id: sandboxSender.id }) }) + + expect(ensureCalls).toBe(2) + }) + + it('provisions once the flag arrives after a surface asked for the sandbox sender', async () => { + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + enableFlag() + }).toDispatchActions(['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + + expect(ensureCalls).toBe(1) + }) + + it('forgets an in-flight result when the project switches, and still provisions the new project when asked', async () => { + enableFlag() + let releaseEnsure: () => void = () => {} + const ensureGate = new Promise((resolve) => (releaseEnsure = resolve)) + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + ensureCalls += 1 + await ensureGate + return [404, {}] + }, + }, + }) + await expectLogic(logic, () => { + logic.actions.ensureSandboxEmailSender() + }).toDispatchActions(['provisionSandboxEmailSender']) + + await expectLogic(logic, () => { + teamLogic.actions.loadCurrentTeamSuccess({ ...teamLogic.values.currentTeam!, id: 999, project_id: 999 }) + }).toDispatchActions(['clearIntegrations', 'loadIntegrationsSuccess']) + logic.actions.ensureSandboxEmailSender() + expect(ensureCalls).toBe(1) + + releaseEnsure() + await expectLogic(logic).toDispatchActions([ + 'provisionSandboxEmailSenderSuccess', + 'provisionSandboxEmailSender', + 'provisionSandboxEmailSenderSuccess', + ]) + + expect(ensureCalls).toBe(2) + expect(logic.values.sandboxEmailSenderProvisionResult).toBe('unavailable') + }) + + it.each([ + { flagState: 'on', flag: true, expectedSandbox: expect.objectContaining({ id: sandboxSender.id }) }, + { flagState: 'off', flag: false, expectedSandbox: null }, + ])( + 'keeps the sandbox row out of the own-sender lists and exposes it only with the flag $flagState', + async ({ flag, expectedSandbox }) => { + if (flag) { + enableFlag() + } + integrationsPayload = [sandboxSender, ownSender] + + await expectLogic(logic, () => { + logic.actions.loadIntegrations() + }) + .toDispatchActions(['loadIntegrationsSuccess']) + .toMatchValues({ + sandboxEmailSender: expectedSandbox, + ownEmailIntegrations: [expect.objectContaining({ id: ownSender.id })], + userManagedIntegrations: [expect.objectContaining({ id: ownSender.id })], + domainGroupedEmailIntegrations: [ + { + domain: 'acme.example.com', + integrations: [expect.objectContaining({ id: ownSender.id })], + }, + ], + }) + expect(logic.values.getIntegrationsByKind(['email'])).toEqual([ + expect.objectContaining({ id: ownSender.id }), + ]) + } + ) + }) + describe('GitHub discovery freshness', () => { const discovery = (id: string): GitHubAvailableInstallationsResponseApi => ({ discovery_id: id, diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 7a3dc2f0de55..0480cb243340 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -6,6 +6,7 @@ import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import api, { ApiError, getCookie } from 'lib/api' import { globalSetupLogic } from 'lib/components/ProductSetup' +import { FEATURE_FLAGS } from 'lib/constants' import { buildGithubDisconnectDescription } from 'lib/integrations/githubDisconnectCopy' import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors' import { describeScopeShortfall, getMissingScopes, requestedScopesForKind } from 'lib/integrations/integrationScopes' @@ -14,6 +15,7 @@ import { INTEGRATION_ERROR_PARAM, OAUTH_STATE_EXPIRED, } from 'lib/integrations/oauthCallbackErrors' +import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'lib/logic/preflightLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { isKeyOf } from 'lib/utils/guards' @@ -26,6 +28,7 @@ import { EmailIntegrationDomainGroupedType, IntegrationKind, IntegrationType } f import { integrationsCreate, + integrationsEmailSandboxSenderCreate, integrationsGithubAvailableInstallationsRetrieve, integrationsGithubReposRetrieve, integrationsGithubLinkExistingCreate, @@ -44,9 +47,28 @@ import { ChannelType } from 'products/workflows/frontend/Channels/MessageChannel import type { AvailableSetupTaskIdsEnumApi } from '../../generated/core/api.schemas' import type { PreflightStatus, UserBasicType } from '../../types' -import { ICONS, getIntegrationNameFromKind } from './utils' +import { ICONS, getIntegrationNameFromKind, isSandboxEmailSender } from './utils' + +export type SandboxEmailSenderProvisionResult = 'provisioned' | 'unavailable' | 'failed' const INTEGRATIONS_POLL_INTERVAL_MS = 30_000 +const SANDBOX_ENSURE_TIMEOUT_MS = 15_000 + +async function requestSandboxEmailSender( + projectId: string +): Promise<{ result: SandboxEmailSenderProvisionResult; error: string | null }> { + try { + await integrationsEmailSandboxSenderCreate(projectId, { + signal: AbortSignal.timeout(SANDBOX_ENSURE_TIMEOUT_MS), + }) + return { result: 'provisioned', error: null } + } catch (error) { + return { + result: error instanceof ApiError && error.status === 404 ? 'unavailable' : 'failed', + error: error instanceof ApiError ? error.detail : null, + } + } +} function toastApiError(e: unknown): void { const detail = e instanceof ApiError ? e.detail : null @@ -105,6 +127,7 @@ export function reconnectReturnUrl(pathname: string, search: string): string { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface integrationsLogicValues { + featureFlags: FeatureFlagsSet // featureFlagLogic preflight: PreflightStatus | null // preflightLogic siteUrlMisconfigured: boolean // preflightLogic currentProjectId: number | string // teamLogic @@ -179,19 +202,29 @@ export interface integrationsLogicValues { githubRepositoriesTotal: Record githubSuggestionSubscribers: number integrations: IntegrationType[] | null + integrationsLoadError: string | null + integrationsLoadFailed: boolean integrationsLoading: boolean linearIntegrations: IntegrationType[] linkedGithubInstallation: IntegrationConfigApi | null linkedGithubInstallationLoading: boolean newIntegrationModalId: string | null newIntegrationModalKind: IntegrationKind | null + ownEmailIntegrations: IntegrationType[] pollingSubscribers: number requestedAccessKinds: IntegrationKind[] + sandboxEmailSender: IntegrationType | null + sandboxEmailSenderEnabled: boolean + sandboxEmailSenderProvisionError: string | null + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null + sandboxEmailSenderProvisionResultLoading: boolean + sandboxEmailSenderSetupError: string | null selectedIntegration: IntegrationType | null setupModalOpen: boolean setupModalType: ChannelType | null slackAvailable: boolean | undefined slackIntegrations: IntegrationType[] | undefined + userManagedIntegrations: IntegrationType[] | null } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. @@ -210,6 +243,13 @@ export interface integrationsLogicActions { kind: string missingScopes: string[] } // eventUsageLogic + setFeatureFlags: ( + flags: string[], + variants: Record + ) => { + flags: string[] + variants: Record + } // featureFlagLogic markTaskAsCompleted: (taskIdOrIds: AvailableSetupTaskIdsEnumApi | AvailableSetupTaskIdsEnumApi[]) => { taskIdOrIds: AvailableSetupTaskIdsEnumApi | AvailableSetupTaskIdsEnumApi[] } // globalSetupLogic @@ -232,6 +272,9 @@ export interface integrationsLogicActions { deleteIntegration: (id: number) => { id: number } + ensureSandboxEmailSender: () => { + value: true + } handleOauthCallback: ( kind: IntegrationKind, searchParams: any @@ -552,6 +595,24 @@ export interface integrationsLogicActions { channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined integration: IntegrationType | undefined } + provisionMissingSandboxEmailSender: () => { + value: true + } + provisionSandboxEmailSender: () => any + provisionSandboxEmailSenderFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + provisionSandboxEmailSenderSuccess: ( + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null, + payload?: any + ) => { + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null + payload?: any + } requestIntegrationAccess: ({ kind }: { kind: IntegrationKind }) => { kind: IntegrationKind } @@ -665,6 +726,9 @@ export interface integrationsLogicActions { kind: IntegrationKind } } + retrySandboxEmailSender: () => { + value: true + } setAccessRequestReason: (reason: string) => { reason: string } @@ -680,6 +744,9 @@ export interface integrationsLogicActions { projectId: number | string response: GitHubAvailableInstallationsResponseApi | null } + setSandboxEmailSenderProvisionError: (error: string | null) => { + error: string | null + } startPolling: () => { value: true } @@ -709,7 +776,7 @@ export interface integrationsLogicMeta { githubAvailableInstallationsResponse: GitHubAvailableInstallationsResponseApi | null ) => boolean | null getIntegrationsByKind: ( - integrations: IntegrationType[] | null + userManagedIntegrations: IntegrationType[] | null ) => ( kinds: ( | 'apns' @@ -770,7 +837,19 @@ export interface integrationsLogicMeta { getGitHubRepositoriesTotal: ( githubRepositoriesTotal: Record ) => (integrationId: number) => number | null - domainGroupedEmailIntegrations: (integrations: IntegrationType[] | null) => EmailIntegrationDomainGroupedType[] + sandboxEmailSenderEnabled: (featureFlags: FeatureFlagsSet) => boolean + sandboxEmailSenderSetupError: ( + sandboxEmailSenderEnabled: boolean, + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null, + sandboxEmailSenderProvisionError: string | null + ) => string | null + sandboxEmailSender: ( + integrations: IntegrationType[] | null, + sandboxEmailSenderEnabled: boolean + ) => IntegrationType | null + userManagedIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] | null + ownEmailIntegrations: (userManagedIntegrations: IntegrationType[] | null) => IntegrationType[] + domainGroupedEmailIntegrations: (ownEmailIntegrations: IntegrationType[]) => EmailIntegrationDomainGroupedType[] } } @@ -784,7 +863,14 @@ export type integrationsLogicType = MakeLogicType< export const integrationsLogic = kea([ path(['lib', 'integrations', 'integrationsLogic']), connect(() => ({ - values: [preflightLogic, ['siteUrlMisconfigured', 'preflight'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['siteUrlMisconfigured', 'preflight'], + teamLogic, + ['currentProjectId'], + featureFlagLogic, + ['featureFlags'], + ], actions: [ globalSetupLogic, ['markTaskAsCompleted'], @@ -792,6 +878,8 @@ export const integrationsLogic = kea([ ['reportIntegrationConnectRejected', 'reportIntegrationScopeShortfallShown'], teamLogic, ['loadCurrentTeamSuccess'], + featureFlagLogic, + ['setFeatureFlags'], ], })), @@ -803,6 +891,10 @@ export const integrationsLogic = kea([ callback, }), deleteIntegration: (id: number) => ({ id }), + ensureSandboxEmailSender: true, + retrySandboxEmailSender: true, + setSandboxEmailSenderProvisionError: (error: string | null) => ({ error }), + provisionMissingSandboxEmailSender: true, invalidateGithubSuggestions: true, setGithubDisconnecting: (disconnecting: boolean) => ({ disconnecting }), loadGithubAvailableInstallations: true, @@ -838,6 +930,42 @@ export const integrationsLogic = kea([ }), reducers({ integrations: [null as IntegrationType[] | null, { clearIntegrations: () => null }], + integrationsLoadError: [ + null as string | null, + { + loadIntegrations: () => null, + loadIntegrationsSuccess: () => null, + clearIntegrations: () => null, + loadIntegrationsFailure: (_, { errorObject }) => + errorObject instanceof ApiError && errorObject.detail ? errorObject.detail : null, + }, + ], + integrationsLoadFailed: [ + false, + { + loadIntegrations: () => false, + loadIntegrationsSuccess: () => false, + clearIntegrations: () => false, + loadIntegrationsFailure: () => true, + }, + ], + sandboxEmailSenderProvisionError: [ + null as string | null, + { + clearIntegrations: () => null, + provisionSandboxEmailSender: () => null, + retrySandboxEmailSender: () => null, + setSandboxEmailSenderProvisionError: (_, { error }) => error, + }, + ], + sandboxEmailSenderProvisionResult: [ + null as SandboxEmailSenderProvisionResult | null, + { + clearIntegrations: () => null, + ensureSandboxEmailSender: (state) => (state === 'failed' ? null : state), + retrySandboxEmailSender: () => null, + }, + ], githubDisconnecting: [false, { setGithubDisconnecting: (_, { disconnecting }) => disconnecting }], githubDiscoveryGeneration: [ 0, @@ -1100,6 +1228,23 @@ export const integrationsLogic = kea([ }, }, ], + sandboxEmailSenderProvisionResult: [ + null as SandboxEmailSenderProvisionResult | null, + { + provisionSandboxEmailSender: async (): Promise => { + const projectId = values.currentProjectId + const result = await requestSandboxEmailSender(String(projectId)) + if (values.currentProjectId !== projectId) { + return values.sandboxEmailSenderProvisionResult + } + actions.setSandboxEmailSenderProvisionError(result.error) + if (result.result === 'provisioned') { + actions.loadIntegrations() + } + return result.result + }, + }, + ], accessRequest: [ null as IntegrationKind | null, { @@ -1128,6 +1273,7 @@ export const integrationsLogic = kea([ cache.githubProjectGeneration = (cache.githubProjectGeneration ?? 0) + 1 actions.linkExistingGithubInstallationSuccess(null) cache.githubConnectionFingerprint = undefined + cache.sandboxEmailSenderWanted = false // Clear only on a project switch: same-project team refreshes keep the connected integrations. actions.clearIntegrations() actions.invalidateGithubSuggestions() @@ -1153,7 +1299,38 @@ export const integrationsLogic = kea([ } } }, + retrySandboxEmailSender: () => actions.ensureSandboxEmailSender(), + ensureSandboxEmailSender: () => { + cache.sandboxEmailSenderWanted = true + if (values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, + provisionMissingSandboxEmailSender: () => { + if ( + !values.sandboxEmailSenderEnabled || + values.sandboxEmailSender || + values.sandboxEmailSenderProvisionResult || + values.sandboxEmailSenderProvisionResultLoading + ) { + return + } + actions.provisionSandboxEmailSender() + }, + setFeatureFlags: () => { + if (cache.sandboxEmailSenderWanted && values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, + provisionSandboxEmailSenderSuccess: () => { + if (cache.sandboxEmailSenderWanted && values.integrations) { + actions.provisionMissingSandboxEmailSender() + } + }, loadIntegrationsSuccess: ({ integrations }) => { + if (cache.sandboxEmailSenderWanted) { + actions.provisionMissingSandboxEmailSender() + } const fingerprint = JSON.stringify( integrations ?.filter((row) => row.kind === 'github') @@ -1479,7 +1656,7 @@ export const integrationsLogic = kea([ }, ], getIntegrationsByKind: [ - (s) => [s.integrations], + (s) => [s.userManagedIntegrations], (integrations: IntegrationType[] | null) => { return (kinds: IntegrationKind[]) => integrations?.filter((i) => kinds.includes(i.kind)) || [] }, @@ -1511,23 +1688,51 @@ export const integrationsLogic = kea([ }, ], - domainGroupedEmailIntegrations: [ + sandboxEmailSenderEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + ], + sandboxEmailSenderSetupError: [ + (s) => [ + s.sandboxEmailSenderEnabled, + s.sandboxEmailSenderProvisionResult, + s.sandboxEmailSenderProvisionError, + ], + (enabled: boolean, result: string | null, provisionError: string | null): string | null => + enabled && (result === 'failed' || result === 'unavailable') + ? provisionError || 'Could not set up the sandbox sender.' + : null, + ], + sandboxEmailSender: [ + (s) => [s.integrations, s.sandboxEmailSenderEnabled], + (integrations: IntegrationType[] | null, sandboxEmailSenderEnabled: boolean): IntegrationType | null => + sandboxEmailSenderEnabled ? (integrations?.find(isSandboxEmailSender) ?? null) : null, + ], + userManagedIntegrations: [ (s) => [s.integrations], - (integrations: IntegrationType[] | null): EmailIntegrationDomainGroupedType[] => { + (integrations: IntegrationType[] | null): IntegrationType[] | null => + integrations?.filter((x) => !isSandboxEmailSender(x)) ?? null, + ], + ownEmailIntegrations: [ + (s) => [s.userManagedIntegrations], + (userManagedIntegrations: IntegrationType[] | null): IntegrationType[] => + userManagedIntegrations?.filter((x) => x.kind === 'email') ?? [], + ], + domainGroupedEmailIntegrations: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): EmailIntegrationDomainGroupedType[] => { const domainGroupedIntegrations: Record = {} - integrations - ?.filter((x) => x.kind === 'email') - .forEach((integration) => { - const domain = integration.config.domain - if (!domainGroupedIntegrations[domain]) { - domainGroupedIntegrations[domain] = { - domain, - integrations: [], - } + ownEmailIntegrations.forEach((integration) => { + const domain = integration.config.domain + if (!domainGroupedIntegrations[domain]) { + domainGroupedIntegrations[domain] = { + domain, + integrations: [], } - domainGroupedIntegrations[domain].integrations.push(integration) - }) + } + domainGroupedIntegrations[domain].integrations.push(integration) + }) return Object.values(domainGroupedIntegrations) }, diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 25a66c942229..1d9ef34222e7 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -139,6 +139,11 @@ export const ICONS: Record = { // 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 SANDBOX_EMAIL_SENDER_NOTE = 'Delivers only to verified members of your organization.' + export const getIntegrationNameFromKind = (kind: string): string => { switch (kind) { case 'google-pubsub': diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx new file mode 100644 index 000000000000..e0bbe575df94 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { EmailSenderSummary } from './EmailSenderSummary' +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' + +const OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Acme ', + config: { name: 'Acme', email: 'hello@acme.example.com', verified: true }, +} +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} + +describe('EmailSenderSummary', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + '/api/projects/:team_id/integrations/': { results: [OWN_SENDER, SANDBOX_SENDER] }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(cleanup) + + it.each([ + ['the own sender', OWN_SENDER.id, 'Acme'], + ['the sandbox sender', SANDBOX_SENDER.id, 'Choose who this email comes from'], + ])('shows a transactional template sending from %s', async (_name, integrationId, expectedText) => { + const props: EmailTemplaterLogicProps = { + type: 'native_email_transactional_template', + value: { subject: 'Hello', text: 'Hello', html: '
Hello
', from: { integrationId } } as any, + onChange: jest.fn(), + } + const logic = emailTemplaterLogic(props) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + render( + + + + ) + + expect(await screen.findByText(expectedText)).toBeInTheDocument() + expect(screen.queryByText('Acme via PostHog')).not.toBeInTheDocument() + logic.unmount() + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx index a59fe39c9031..e21998f57a1e 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx @@ -4,19 +4,15 @@ import { useState } from 'react' import { IconPencil } from '@posthog/icons' import { LemonButton, LemonDropdown, LemonLabel } from '@posthog/lemon-ui' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' - import { senderAddressError, senderFrom, senderIntegrationFor } from './emailSenderFields' import { EmailSenderForm } from './EmailSenderForm' import { emailTemplaterLogic } from './emailTemplaterLogic' export function EmailSenderSummary(): JSX.Element { - const { emailTemplate } = useValues(emailTemplaterLogic) + const { emailTemplate, senderIntegrations: senders } = useValues(emailTemplaterLogic) const { setEmailTemplateValue } = useActions(emailTemplaterLogic) - const { integrations } = useValues(integrationsLogic) const [isEditing, setIsEditing] = useState(false) - const senders = (integrations ?? []).filter((integration) => integration.kind === 'email') const from = senderFrom(emailTemplate) const sender = senderIntegrationFor(emailTemplate, senders) const name = from.name || sender?.config?.name diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index 4965bb411682..04148f4979ad 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom' import { cleanup, render, screen } from '@testing-library/react' import { useValues } from 'kea' -import { NativeEmailIntegrationChoice } from './EmailTemplater' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' let mockSenderRotationEnabled = false @@ -14,6 +14,7 @@ jest.mock('lib/hooks/useFeatureFlag', () => ({ jest.mock('kea', () => ({ ...jest.requireActual('kea'), useValues: jest.fn(), + useActions: () => ({ chooseSenders: jest.fn() }), })) jest.mock('@posthog/lemon-ui', () => ({ @@ -22,25 +23,31 @@ jest.mock('@posthog/lemon-ui', () => ({ LemonSelect: () =>
, })) +const OWN_SENDERS = [ + { id: 1, kind: 'email', display_name: 'Alice ' }, + { id: 2, kind: 'email', display_name: 'Bob ' }, +] +const SANDBOX_SENDER = { id: 7, kind: 'email', display_name: 'Acme via PostHog ' } + describe('NativeEmailIntegrationChoice', () => { afterEach(cleanup) - beforeEach(() => { + const mockValues = ( + sandboxEmailSender: typeof SANDBOX_SENDER | null, + overrides: Record = {} + ): void => { jest.mocked(useValues).mockReturnValue({ - integrationsLoading: false, - integrations: [ - { - id: 1, - kind: 'email', - display_name: 'Alice ', - }, - { - id: 2, - kind: 'email', - display_name: 'Bob ', - }, - ], + senderIntegrationsLoading: false, + logicProps: {}, + senderIntegrations: sandboxEmailSender ? [...OWN_SENDERS, sandboxEmailSender] : OWN_SENDERS, + sandboxSenderId: sandboxEmailSender?.id, + isSandboxSenderSelected: false, + ...overrides, }) + } + + beforeEach(() => { + mockValues(null) }) it.each([ @@ -54,4 +61,63 @@ describe('NativeEmailIntegrationChoice', () => { expect(screen.getByTestId(expectedPicker)).toBeInTheDocument() expect(screen.getByText('Custom sender')).toBeInTheDocument() }) + + it('hides the custom sender controls and explains the recipient rule while the sandbox sender is selected', () => { + mockSenderRotationEnabled = true + mockValues(SANDBOX_SENDER, { isSandboxSenderSelected: true }) + + render( + + ) + + expect(screen.queryByText('Custom sender')).not.toBeInTheDocument() + expect(screen.queryByText('Custom address')).not.toBeInTheDocument() + expect(screen.getByText('Delivers only to verified members of your organization.')).toBeInTheDocument() + }) + + it.each([ + { offered: false, selected: [1, 2], custom: false, warns: false }, + { offered: true, selected: [1, 2], custom: false, warns: true }, + { offered: true, selected: [1], custom: false, warns: false }, + { offered: true, selected: [1], custom: true, warns: true }, + ])( + 'mentions sandbox selection changes for $selected with custom values $custom and option offered $offered', + ({ offered, selected, custom, warns }) => { + mockSenderRotationEnabled = true + mockValues(offered ? SANDBOX_SENDER : null, { hasCustomSenderDetails: custom }) + + render( + + ) + + expect(screen.queryByText(/Choosing (the )?sandbox/)).toEqual(warns ? expect.anything() : null) + } + ) + + it('shows the picker, not the empty state, while the sandbox sender is still being created', () => { + mockSenderRotationEnabled = false + mockValues(null, { senderIntegrations: [], senderIntegrationsLoading: true }) + + render() + + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByTestId('single-email-sender-select')).toBeInTheDocument() + }) + + it('keeps the connect action available when loading senders fails', () => { + mockValues(null, { senderIntegrations: [], senderIntegrationsError: 'Could not load your senders.' }) + + render() + + expect(screen.getByText('Could not load your senders.')).toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute('href', '/workflows/channels') + }) }) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx index e990f2d12462..fcc5ac871399 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx @@ -5,16 +5,8 @@ import { ReactNode, useEffect, useState } from 'react' import { createPortal } from 'react-dom' import EmailEditor, { EditorRef } from 'react-email-editor' -import { IconCollapse, IconExpand, IconExternal, IconPlus, IconX } from '@posthog/icons' -import { - LemonButton, - LemonCard, - LemonInputSelect, - LemonLabel, - LemonModal, - LemonSegmentedButton, - LemonSelect, -} from '@posthog/lemon-ui' +import { IconCollapse, IconExpand, IconPlus, IconX } from '@posthog/icons' +import { LemonButton, LemonCard, LemonLabel, LemonModal, LemonSegmentedButton } from '@posthog/lemon-ui' import { CyclotronJobTemplateSuggestionsButton, @@ -24,15 +16,12 @@ import { insertTemplateReference, templateReferenceForOption, } from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' -import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput' import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea' import { CodeEditorInline } from 'lib/monaco/CodeEditorInline' import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable' import { sceneAgentPanelLogic } from 'scenes/max/sceneAgentPanelLogic' -import { urls } from 'scenes/urls' import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic' @@ -44,7 +33,9 @@ import { previewLinkTargetCustomJs } from './custom-tools/previewLinkTarget' import { unsubscribeLinkToolCustomJs } from './custom-tools/unsubscribeLinkTool' import { EmailSenderSummary } from './EmailSenderSummary' import { EMAIL_TYPE_SUPPORTED_FIELDS, EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' -import { EmailFieldErrors, EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' +import { LiquidSupportedText } from './LiquidSupportedText' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' +import { EmailFieldErrors } from './types' export type EmailEditorMode = 'full' | 'preview' @@ -266,237 +257,6 @@ function DestinationEmailTemplaterForm({ ) } -export function NativeEmailIntegrationChoice({ - label, - onChange, - value, -}: { - /** Rendered inside the sender row so it aligns with the select rather than spanning the override rows. */ - label: ReactNode - onChange: (value: EmailTemplateFrom) => void - value?: EmailTemplateFrom -}): JSX.Element { - const { integrationsLoading, integrations } = useValues(integrationsLogic) - const { logicProps } = useValues(emailTemplaterLogic) - const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') - const integrationsOfKind = integrations?.filter((x) => x.kind === 'email') - const selectedIntegrationIds = value?.integrationIds?.length - ? value.integrationIds - : value?.integrationId - ? [value.integrationId] - : [] - - // Presence of the override keys is what reveals the inputs, so a saved override is - // visible again on reopen without any separate reveal state. - const overridesVisible = value?.email !== undefined || value?.name !== undefined - - const onChangeIntegration = (integrationId: number): void => { - if (integrationId === -1) { - window.open(urls.workflows('channels'), '_blank') - return - } - onChange({ ...value, integrationId, integrationIds: undefined }) - } - - const onChangeIntegrations = (integrationIds: number[]): void => { - if (integrationIds.length > MAX_WORKFLOW_EMAIL_SENDERS) { - return - } - onChange({ - ...value, - integrationId: integrationIds[0], - integrationIds: integrationIds.length > 1 ? integrationIds : undefined, - }) - } - - if (!integrationsLoading && integrationsOfKind?.length === 0) { - return ( -
- {label} -
- No email senders configured yet - } - > - Connect email sender - -
-
- ) - } - - return ( -
-
- {label} - {senderRotationEnabled ? ( -
- - className="m-1 flex-1" - mode="multiple" - placeholder="Choose email senders" - loading={integrationsLoading} - options={(integrationsOfKind || []).map((integration) => ({ - key: String(integration.id), - label: integration.display_name, - value: integration.id, - }))} - value={selectedIntegrationIds} - size="small" - fullWidth - autoWidth={false} - onChange={onChangeIntegrations} - data-attr="workflow-email-sender-select" - action={{ - children: 'Add new email sender', - icon: , - onClick: () => window.open(urls.workflows('channels'), '_blank'), - }} - /> - - Choose up to {MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from - this list. - -
- ) : ( - ({ - label: integration.display_name, - value: integration.id, - })), - }, - { - options: [ - { - label: 'Add new email sender', - icon: , - value: -1, - }, - ], - }, - ]} - value={value?.integrationId} - size="small" - fullWidth - onChange={onChangeIntegration} - data-attr="workflow-email-sender-select" - /> - )} - {!overridesVisible && ( - } - className="mr-2 shrink-0" - data-attr="email-from-custom-sender" - onClick={() => onChange({ ...value, email: '', name: '' })} - tooltip="Set a custom sender address or name, with template support" - > - Custom sender - - )} -
- {overridesVisible && ( - <> -
- - Custom address - - onChange({ ...value, email })} - globals={logicProps.variables} - /> - } - className="mr-2" - data-attr="email-from-custom-sender-remove" - onClick={() => onChange({ ...value, email: undefined, name: undefined })} - tooltip="Remove custom sender" - /> -
-
- - Custom name - - onChange({ ...value, name })} - globals={logicProps.variables} - /> -
- - )} -
- ) -} - -function LiquidSupportedText({ - value, - onChange, - globals, -}: { - value: string - onChange: (value?: string) => void - globals: any -}): JSX.Element { - const { templatingEngine } = useValues(emailTemplaterLogic) - const { setTemplatingEngine } = useActions(emailTemplaterLogic) - const { onEditorMount, cursorOffset } = useTemplateEditorCursor() - - const templating = templatingEngine ?? 'hog' - - return ( - - - { - onChange?.( - insertTemplateReference( - value || '', - templateReferenceForOption(option, templating), - cursorOffset() - ) - ) - }} - /> - - - - ) -} - export function TemplatePickerModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }): JSX.Element { const { templates } = useValues(emailTemplaterLogic) const { applyTemplate } = useActions(emailTemplaterLogic) diff --git a/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx b/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx new file mode 100644 index 000000000000..573651adf4e6 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/LiquidSupportedText.tsx @@ -0,0 +1,59 @@ +import { useActions, useValues } from 'kea' + +import { + CyclotronJobTemplateSuggestionsButton, + useTemplateEditorCursor, +} from 'lib/components/CyclotronJob/CyclotronJobTemplateSuggestions' +import { + insertTemplateReference, + templateReferenceForOption, +} from 'lib/components/CyclotronJob/cyclotronJobTemplateSuggestionsLogic' +import { CodeEditorInline } from 'lib/monaco/CodeEditorInline' + +import { emailTemplaterLogic } from './emailTemplaterLogic' + +export function LiquidSupportedText({ + value, + onChange, + globals, +}: { + value: string + onChange: (value?: string) => void + globals: any +}): JSX.Element { + const { templatingEngine } = useValues(emailTemplaterLogic) + const { setTemplatingEngine } = useActions(emailTemplaterLogic) + const { onEditorMount, cursorOffset } = useTemplateEditorCursor() + + const templating = templatingEngine ?? 'hog' + + return ( + + + { + onChange?.( + insertTemplateReference( + value || '', + templateReferenceForOption(option, templating), + cursorOffset() + ) + ) + }} + /> + + + + ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx new file mode 100644 index 000000000000..20d8b9d989ae --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.stories.tsx @@ -0,0 +1,114 @@ +import { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' +import { useState } from 'react' + +import { LemonLabel } from '@posthog/lemon-ui' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' +import { NativeEmailIntegrationChoice } from './NativeEmailIntegrationChoice' +import { EmailTemplate, EmailTemplateFrom } from './types' + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme ', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +type StoryArgs = { + from: EmailTemplateFrom + sandboxSenderAllowed: boolean + narrow?: boolean +} + +const EMPTY_EMAIL: EmailTemplate = { design: null, html: '', subject: '', text: '', from: {}, to: '' } + +const meta: Meta = { + title: 'Scenes-App/HogFunctions/Email sender picker', + parameters: { layout: 'padded', viewMode: 'story' }, +} +export default meta + +const Template: StoryFn = ({ from, sandboxSenderAllowed, narrow }) => { + useStorybookMocks({ + get: { + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] }, + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER }, + }) + const [template, setTemplate] = useState({ ...EMPTY_EMAIL, from }) + const logicProps: EmailTemplaterLogicProps = { + value: template, + onChange: setTemplate, + type: 'native_email', + liveChanges: true, + sandboxSenderAllowed, + } + return ( + +
+ From} + value={template.from as EmailTemplateFrom} + onChange={(from) => setTemplate({ ...template, from })} + /> +
+
+ ) +} + +const sandboxFlag = (rotation: boolean): string[] => + rotation + ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER, FEATURE_FLAGS.WORKFLOWS_EMAIL_SENDER_ROTATION] + : [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] + +export const OwnSenderSelected: StoryFn = Template.bind({}) +OwnSenderSelected.args = { from: { integrationId: OWN_SENDER.id }, sandboxSenderAllowed: true } +OwnSenderSelected.parameters = { featureFlags: sandboxFlag(false) } + +export const SandboxSenderSelected: StoryFn = Template.bind({}) +SandboxSenderSelected.args = { from: { integrationId: SANDBOX_SENDER.id }, sandboxSenderAllowed: true } +SandboxSenderSelected.parameters = { featureFlags: sandboxFlag(false) } + +export const SandboxSenderSelectedInRotation: StoryFn = Template.bind({}) +SandboxSenderSelectedInRotation.args = { + from: { integrationId: SANDBOX_SENDER.id }, + sandboxSenderAllowed: true, +} +SandboxSenderSelectedInRotation.parameters = { featureFlags: sandboxFlag(true) } + +export const SandboxSenderSelectedInRotationNarrow: StoryFn = Template.bind({}) +SandboxSenderSelectedInRotationNarrow.args = { + from: { integrationId: SANDBOX_SENDER.id }, + sandboxSenderAllowed: true, + narrow: true, +} +SandboxSenderSelectedInRotationNarrow.parameters = { featureFlags: sandboxFlag(true) } + +export const SandboxSenderHiddenOnBroadcast: StoryFn = Template.bind({}) +SandboxSenderHiddenOnBroadcast.args = { + from: { integrationId: OWN_SENDER.id }, + sandboxSenderAllowed: false, +} +SandboxSenderHiddenOnBroadcast.parameters = { featureFlags: sandboxFlag(false) } diff --git a/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx new file mode 100644 index 000000000000..d4282178d8a8 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/NativeEmailIntegrationChoice.tsx @@ -0,0 +1,263 @@ +import { useActions, useValues } from 'kea' +import { ReactNode } from 'react' + +import { IconExternal, IconPlus, IconX } from '@posthog/icons' +import { LemonBanner, LemonButton, LemonInputSelect, LemonLabel, LemonSelect, LemonTag } from '@posthog/lemon-ui' + +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' +import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils' +import { urls } from 'scenes/urls' + +import { IntegrationType } from '~/types' + +import { emailTemplaterLogic } from './emailTemplaterLogic' +import { LiquidSupportedText } from './LiquidSupportedText' +import { selectedSenderIds } from './selectSenders' +import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' + +function rotationSenderHint({ + sandboxSenderAvailable, + hasCustomSenderDetails, + selectedSenderCount, +}: { + sandboxSenderAvailable: boolean + hasCustomSenderDetails: boolean + selectedSenderCount: number +}): string { + const hints = [`Choose up to ${MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender.`] + if (sandboxSenderAvailable && (hasCustomSenderDetails || selectedSenderCount > 1)) { + hints.push('Choosing the sandbox sender replaces the selection.') + if (hasCustomSenderDetails) { + hints.push('It also removes custom sender and Reply-To.') + } + } + return hints.join(' ') +} + +export function NativeEmailIntegrationChoice({ + label, + onChange, + value, +}: { + /** Rendered inside the sender row so it aligns with the select rather than spanning the override rows. */ + label: ReactNode + onChange: (value: EmailTemplateFrom) => void + value?: EmailTemplateFrom +}): JSX.Element { + const { + logicProps, + senderIntegrations, + sandboxSenderId, + isSandboxSenderSelected, + senderIntegrationsLoading, + senderIntegrationsError, + hasCustomSenderDetails, + } = useValues(emailTemplaterLogic) + const { chooseSenders, retrySenderIntegrations } = useActions(emailTemplaterLogic) + const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION') + + // Presence of the override keys is what reveals the inputs, so a saved override is + // visible again on reopen without any separate reveal state. + const overridesVisible = !isSandboxSenderSelected && (value?.email !== undefined || value?.name !== undefined) + + const onChangeIntegration = (integrationId: number): void => { + if (integrationId === -1) { + window.open(urls.workflows('channels'), '_blank') + return + } + chooseSenders([integrationId]) + } + + const senderLabel = (integration: IntegrationType): JSX.Element => ( + <> + {integration.display_name} + {integration.id === sandboxSenderId && ( + + Sandbox + + )} + + ) + + const errorBanner = senderIntegrationsError ? ( + + {senderIntegrationsError} + + ) : null + + const connectSender = ( + } + > + Connect email sender + + ) + + if (!senderIntegrationsLoading && senderIntegrations.length === 0 && errorBanner) { + return ( +
+ {label} + {errorBanner} + {connectSender} +
+ ) + } + if (!senderIntegrationsLoading && senderIntegrations.length === 0) { + return ( +
+ {label} +
+ No email senders configured yet + {connectSender} +
+
+ ) + } + + return ( +
+ {errorBanner} +
+ {label} + {senderRotationEnabled ? ( +
+ + className="m-1 flex-1" + mode="multiple" + placeholder="Choose email senders" + loading={senderIntegrationsLoading} + options={senderIntegrations.map((integration) => ({ + key: String(integration.id), + label: integration.display_name, + labelComponent: senderLabel(integration), + value: integration.id, + }))} + value={selectedSenderIds(value)} + size="small" + fullWidth + autoWidth={false} + onChange={chooseSenders} + data-attr="workflow-email-sender-select" + action={{ + children: 'Add new email sender', + icon: , + onClick: () => window.open(urls.workflows('channels'), '_blank'), + }} + /> + + {isSandboxSenderSelected + ? SANDBOX_EMAIL_SENDER_NOTE + : rotationSenderHint({ + sandboxSenderAvailable: sandboxSenderId !== undefined, + hasCustomSenderDetails, + selectedSenderCount: selectedSenderIds(value).length, + })} + +
+ ) : ( +
+ ({ + label: integration.display_name, + labelInMenu: senderLabel(integration), + value: integration.id, + })), + }, + { + options: [ + { + label: 'Add new email sender', + icon: , + value: -1, + }, + ], + }, + ]} + value={value?.integrationId} + size="small" + fullWidth + onChange={onChangeIntegration} + data-attr="workflow-email-sender-select" + /> + {isSandboxSenderSelected && ( + {SANDBOX_EMAIL_SENDER_NOTE} + )} + {!isSandboxSenderSelected && sandboxSenderId !== undefined && hasCustomSenderDetails && ( + + Choosing the sandbox sender removes custom sender and Reply-To. + + )} +
+ )} + {!overridesVisible && !isSandboxSenderSelected && ( + } + className="mr-2 shrink-0" + data-attr="email-from-custom-sender" + onClick={() => onChange({ ...value, email: '', name: '' })} + tooltip="Set a custom sender address or name, with template support" + > + Custom sender + + )} +
+ {overridesVisible && ( + <> +
+ + Custom address + + onChange({ ...value, email })} + globals={logicProps.variables} + /> + } + className="mr-2" + data-attr="email-from-custom-sender-remove" + onClick={() => onChange({ ...value, email: undefined, name: undefined })} + tooltip="Remove custom sender" + /> +
+
+ + Custom name + + onChange({ ...value, name })} + globals={logicProps.variables} + /> +
+ + )} +
+ ) +} diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts index 2dc5302a5b56..23ffe8f40dc8 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.test.ts @@ -1,7 +1,10 @@ import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { LemonDialog } from '@posthog/lemon-ui' + import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -56,9 +59,288 @@ describe('emailTemplaterLogic', () => { afterEach(() => { logic?.unmount() + jest.restoreAllMocks() jest.useRealTimers() }) + describe('sender picker', () => { + const OWN_SENDER = { id: 5, kind: 'email', display_name: 'Acme ', config: {} } + const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', verified: true }, + } + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + it.each([ + { surface: 'a workflow email step', sandboxSenderAllowed: true, flag: true, expectedIds: [5, 7] }, + { + surface: 'a workflow email step with the flag off', + sandboxSenderAllowed: true, + flag: false, + expectedIds: [5], + }, + { surface: 'a broadcast or destination', sandboxSenderAllowed: false, flag: true, expectedIds: [5] }, + ])('lists the sandbox sender last only on $surface', async ({ sandboxSenderAllowed, flag, expectedIds }) => { + if (flag) { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed })) + logic.mount() + + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual(expectedIds) + }) + + it('creates a missing sandbox sender once on mount and lists it after the reload', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + let integrationsPayload: Record[] = [OWN_SENDER] + let ensureCalls = 0 + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + integrationsPayload = [...integrationsPayload, SANDBOX_SENDER] + return [200, SANDBOX_SENDER] + }, + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed: true })) + logic.mount() + + await expectLogic(integrationsLogic).toDispatchActions([ + 'loadIntegrationsSuccess', + 'provisionSandboxEmailSender', + 'loadIntegrationsSuccess', + ]) + + expect(ensureCalls).toBe(1) + expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5, 7]) + }) + + it.each([{ ownSenders: [] }, { ownSenders: [OWN_SENDER] }])( + 'keeps the saved sandbox restrictions after flag rollback with own senders $ownSenders', + async ({ ownSenders }) => { + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] } }, + }) + const onChange = jest.fn() + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 } }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + expect(logic.values.senderIntegrations).toContainEqual(expect.objectContaining({ id: 7 })) + expect(logic.values.isSandboxSenderSelected).toBe(true) + expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo') + if (ownSenders.length) { + logic.actions.chooseSenders([7, 5]) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ from: { integrationId: 5, integrationIds: undefined } }) + ) + expect(logic.values.senderIntegrations).not.toContainEqual(expect.objectContaining({ id: 7 })) + } + } + ) + + it.each([ + { + surface: 'a workflow email step with no own sender yet', + sandboxSenderAllowed: true, + ownSenders: [], + expectedLoading: true, + }, + { + surface: 'a workflow email step that already lists an own sender', + sandboxSenderAllowed: true, + ownSenders: [OWN_SENDER], + expectedLoading: false, + }, + { + surface: 'a broadcast or destination', + sandboxSenderAllowed: false, + ownSenders: [], + expectedLoading: false, + }, + ])( + 'waits for sandbox provisioning only on $surface', + async ({ sandboxSenderAllowed, ownSenders, expectedLoading }) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + let releaseEnsure: () => void = () => {} + const ensureGate = new Promise((resolve) => (releaseEnsure = resolve)) + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: ownSenders } }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + await ensureGate + return [200, SANDBOX_SENDER] + }, + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed })) + logic.mount() + await expectLogic(integrationsLogic, () => { + integrationsLogic.actions.ensureSandboxEmailSender() + }).toDispatchActions(['loadIntegrationsSuccess', 'provisionSandboxEmailSender']) + + expect(logic.values.senderIntegrationsLoading).toBe(expectedLoading) + releaseEnsure() + await expectLogic(integrationsLogic).toDispatchActions(['provisionSandboxEmailSenderSuccess']) + } + ) + + it.each([ + { + field: 'custom from address', + from: { integrationId: 5, email: 'hello@example.com' }, + replyTo: '', + confirm: true, + }, + { field: 'sender name', from: { integrationId: 5, name: 'Custom' }, replyTo: '', confirm: true }, + { field: 'Reply-To', from: { integrationId: 5 }, replyTo: 'replies@example.com', confirm: true }, + { field: 'no custom values', from: { integrationId: 5 }, replyTo: '', confirm: false }, + { + field: 'a rotation selection', + from: { integrationId: 5, integrationIds: [5, 6] }, + replyTo: '', + confirm: true, + }, + ])('switches with $field after any required confirmation', async ({ from, replyTo, confirm }) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ + get: { + '/api/projects/:team_id/integrations/': { + results: [ + SANDBOX_SENDER, + OWN_SENDER, + { ...OWN_SENDER, id: 6, display_name: 'Second ' }, + ], + }, + }, + }) + const onChange = jest.fn() + const dialog = jest.spyOn(LemonDialog, 'open').mockImplementation(jest.fn()) + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + onChange.mockClear() + logic.actions.chooseSenders([7]) + expect(dialog).toHaveBeenCalledTimes(confirm ? 1 : 0) + if (confirm) { + expect(onChange).not.toHaveBeenCalled() + if (from.integrationIds) { + expect(dialog.mock.calls[0][0].description).toContain('Acme ') + expect(dialog.mock.calls[0][0].description).toContain('Second ') + } + emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + liveChanges: true, + onChange, + value: { ...DEFAULT_EMAIL_TEMPLATE, from, replyTo, subject: 'Changed during confirmation' }, + }) + ) + const confirmSwitch = dialog.mock.calls[0][0].primaryButton?.onClick as (() => void) | undefined + confirmSwitch?.() + } + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + replyTo: '', + subject: confirm ? 'Changed during confirmation' : DEFAULT_EMAIL_TEMPLATE.subject, + }) + ) + expect(logic.values.isSandboxSenderSelected).toBe(true) + expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo') + }) + + it('exposes a load error to the picker instead of treating the null list as empty', async () => { + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [ + 500, + { detail: 'Could not load senders. Try again.' }, + ], + }, + }) + logic = emailTemplaterLogic(makeProps()) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure']) + expect(logic.values.senderIntegrationsError).toBe('Could not load senders. Try again.') + expect(logic.values.senderIntegrationsLoading).toBe(false) + }) + + it.each([404, 500])('exposes a sandbox setup failure (%s) beside available own senders', async (status) => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ + get: { '/api/projects/:team_id/integrations/': { results: [OWN_SENDER] } }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => [ + status, + { detail: 'Sandbox setup is unavailable. Try again.' }, + ], + }, + }) + logic = emailTemplaterLogic(makeProps({ sandboxSenderAllowed: true })) + logic.mount() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.senderIntegrationsError).toBe('Sandbox setup is unavailable. Try again.') + expect(logic.values.senderIntegrations.map((sender) => sender.id)).toEqual([5]) + }) + + it('offers no Reply-To while the sandbox sender is selected, since the sandbox sender does not support it', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } }) + logic = emailTemplaterLogic( + makeProps({ + sandboxSenderAllowed: true, + value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 }, replyTo: 'replies@example.com' }, + }) + ) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + await expectLogic(logic).toMatchValues({ isSandboxSenderSelected: true }) + expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo') + expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo') + + logic.actions.setEmailTemplateValue('from', { integrationId: 8 }) + await expectLogic(logic).toMatchValues({ isSandboxSenderSelected: false }) + expect(logic.values.visibleFields.map((field) => field.key)).toContain('replyTo') + }) + }) + describe('advanced fields', () => { it('hides advanced fields by default', async () => { logic = emailTemplaterLogic(makeProps()) diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx index 761790c09b16..6c8244ae7546 100644 --- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx @@ -22,17 +22,21 @@ import { LemonDialog } from '@posthog/lemon-ui' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { lemonToast } from 'lib/lemon-ui/LemonToast' import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' import { objectsEqual } from 'lib/utils/objects' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { teamLogic } from 'scenes/teamLogic' -import { PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' +import { IntegrationType, PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types' import { MessageTemplate } from 'products/workflows/frontend/TemplateLibrary/types' +import type { SandboxEmailSenderProvisionResult } from '../../../lib/integrations/integrationsLogic' import { registerEmailMediaLibrary } from './emailMediaLibrary' +import { selectSenders, selectedSenderIds } from './selectSenders' import type { EmailFieldErrors, EmailTemplate } from './types' export type { EmailTemplate } @@ -215,6 +219,7 @@ export interface EmailTemplaterLogicProps { // not compute these itself; a caller that validates the email step (e.g. the workflow builder) // decides what and when to show. fieldErrors?: EmailFieldErrors + sandboxSenderAllowed?: boolean } function autoRevealAdvancedFields( @@ -237,6 +242,17 @@ function autoRevealAdvancedFields( // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic + integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic + integrationsLoading: boolean // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic preflight: PreflightStatus | null // preflightLogic currentProjectId: number | string // teamLogic activeContentTab: 'plaintext' | 'visual' @@ -252,11 +268,13 @@ export interface emailTemplaterLogicValues { emailTemplateTouched: boolean emailTemplateTouches: Record emailTemplateValidationErrors: DeepPartialMap + hasCustomSenderDetails: boolean hiddenAdvancedFields: EmailMetaField[] isEmailEditorReady: boolean isEmailTemplateSubmitting: boolean isEmailTemplateValid: boolean isModalOpen: boolean + isSandboxSenderSelected: boolean isSaveTemplateModalOpen: boolean isTemplatePickerOpen: boolean logicProps: EmailTemplaterLogicProps @@ -264,7 +282,13 @@ export interface emailTemplaterLogicValues { personPropertyDefinitions: PropertyDefinition[] personPropertyDefinitionsLoading: boolean revealedAdvancedFields: EmailMetaFieldKey[] + sandboxSenderId: number | undefined + selectedSandboxSender: IntegrationType | null + senderIntegrations: IntegrationType[] + senderIntegrationsError: string | null + senderIntegrationsLoading: boolean showEmailTemplateErrors: boolean + supportedFields: EmailMetaField[] templates: MessageTemplate[] templatesLoading: boolean templatingEngine: 'hog' | 'liquid' @@ -279,9 +303,19 @@ export interface emailTemplaterLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicActions { + ensureSandboxEmailSender: () => { + value: true + } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic applyTemplate: (template: MessageTemplate) => { template: MessageTemplate } + chooseSenders: (integrationIds: number[]) => { + integrationIds: number[] + } closeWithConfirmation: () => { value: true } @@ -330,6 +364,9 @@ export interface emailTemplaterLogicActions { resetEmailTemplate: (values?: EmailTemplate) => { values?: EmailTemplate } + retrySenderIntegrations: () => { + value: true + } revealAdvancedField: (key: EmailMetaFieldKey) => { key: EmailMetaFieldKey } @@ -395,6 +432,12 @@ export interface emailTemplaterLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface emailTemplaterLogicMeta { __keaTypeGenInternalSelectorTypes: { + senderIntegrationsError: ( + integrationsLoadError: string | null, + integrationsLoadFailed: any, + sandboxEmailSenderSetupError: any, + arg: boolean + ) => string | null logicProps: (arg: any) => EmailTemplaterLogicProps mergeTags: (personPropertyDefinitions: PropertyDefinition[]) => UnlayerMergeTags unlayerEditorProjectId: (preflight: PreflightStatus | null) => 275430 | undefined @@ -411,8 +454,33 @@ export interface emailTemplaterLogicMeta { id: string } | undefined - visibleFields: (arg: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]) => EmailMetaField[] - hiddenAdvancedFields: (arg: EmailTemplaterType, visibleFields: EmailMetaField[]) => EmailMetaField[] + senderIntegrationsLoading: ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: any, + sandboxEmailSenderProvisionResultLoading: boolean, + ownEmailIntegrations: IntegrationType[], + arg: boolean + ) => boolean + selectedSandboxSender: ( + integrations: IntegrationType[] | null, + emailTemplate: EmailTemplate + ) => IntegrationType | null + senderIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + arg: boolean + ) => IntegrationType[] + supportedFields: (arg: EmailTemplaterType, isSandboxSenderSelected: boolean) => EmailMetaField[] + visibleFields: ( + supportedFields: EmailMetaField[], + revealedAdvancedFields: EmailMetaFieldKey[] + ) => EmailMetaField[] + hiddenAdvancedFields: (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]) => EmailMetaField[] + sandboxSenderId: (senderIntegrations: IntegrationType[]) => number | undefined + hasCustomSenderDetails: (emailTemplate: EmailTemplate) => boolean + isSandboxSenderSelected: (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined) => boolean } } @@ -427,9 +495,32 @@ export const emailTemplaterLogic = kea([ props({} as EmailTemplaterLogicProps), path(['scenes', 'hog-functions', 'email-templater', 'emailTemplaterLogic']), connect(() => ({ - values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags'], teamLogic, ['currentProjectId']], + values: [ + preflightLogic, + ['preflight'], + integrationsLogic, + [ + 'integrations', + 'ownEmailIntegrations', + 'sandboxEmailSender', + 'integrationsLoading', + 'sandboxEmailSenderProvisionResultLoading', + 'sandboxEmailSenderProvisionResult', + 'sandboxEmailSenderProvisionError', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'sandboxEmailSenderSetupError', + 'sandboxEmailSenderEnabled', + ], + featureFlagLogic, + ['featureFlags'], + teamLogic, + ['currentProjectId'], + ], + actions: [integrationsLogic, ['ensureSandboxEmailSender', 'retrySandboxEmailSender', 'loadIntegrations']], })), actions({ + retrySenderIntegrations: true, setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }), onEmailEditorReady: true, setIsModalOpen: (isModalOpen: boolean) => ({ isModalOpen }), @@ -444,6 +535,7 @@ export const emailTemplaterLogic = kea([ setActiveContentTab: (tab: 'visual' | 'plaintext') => ({ tab }), revealAdvancedField: (key: EmailMetaFieldKey) => ({ key }), hideAdvancedField: (key: EmailMetaFieldKey) => ({ key }), + chooseSenders: (integrationIds: number[]) => ({ integrationIds }), }), reducers({ emailEditorRef: [ @@ -538,6 +630,23 @@ export const emailTemplaterLogic = kea([ })), selectors({ + senderIntegrationsError: [ + (s) => [ + s.integrationsLoadError, + s.integrationsLoadFailed, + s.sandboxEmailSenderSetupError, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + loadError: string | null, + loadFailed: boolean, + sandboxSetupError: string | null, + sandboxAllowed: boolean + ): string | null => + loadError || + (loadFailed ? 'Could not load email senders.' : null) || + (sandboxAllowed ? sandboxSetupError : null), + ], logicProps: [() => [(_, props) => props], (props: EmailTemplaterLogicProps) => props], mergeTags: [ (s) => [s.personPropertyDefinitions], @@ -590,17 +699,26 @@ export const emailTemplaterLogic = kea([ // shared by the whole project, so the project is the user it sees. emailMediaLibraryEnabled ? { id: `project-${currentProjectId}` } : undefined, ], - visibleFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.revealedAdvancedFields], - (type: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( - (field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key) - ), - ], - hiddenAdvancedFields: [ - (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.visibleFields], - (type: EmailTemplaterType, visibleFields: EmailMetaField[]): EmailMetaField[] => - EMAIL_TYPE_SUPPORTED_FIELDS[type].filter((f) => f.isAdvancedField && !visibleFields.includes(f)), + senderIntegrationsLoading: [ + (s) => [ + s.integrationsLoading, + s.integrations, + s.integrationsLoadFailed, + s.sandboxEmailSenderProvisionResultLoading, + s.ownEmailIntegrations, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderProvisionResultLoading: boolean, + ownEmailIntegrations: IntegrationType[], + sandboxSenderAllowed: boolean + ): boolean => + integrationsLoading || + (integrations === null && !integrationsLoadFailed) || + (sandboxSenderAllowed && sandboxEmailSenderProvisionResultLoading && ownEmailIntegrations.length === 0), ], }), @@ -657,7 +775,130 @@ export const emailTemplaterLogic = kea([ }, })), + selectors({ + selectedSandboxSender: [ + (s) => [s.integrations, s.emailTemplate], + (integrations: IntegrationType[] | null, emailTemplate: EmailTemplate): IntegrationType | null => { + const from = emailTemplate?.from + const ids = selectedSenderIds(typeof from === 'object' ? from : undefined) + return ( + integrations?.find( + (integration) => isSandboxEmailSender(integration) && ids.includes(integration.id) + ) ?? null + ) + }, + ], + senderIntegrations: [ + (s) => [ + s.ownEmailIntegrations, + s.sandboxEmailSender, + s.selectedSandboxSender, + (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed, + ], + ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null, + selectedSandboxSender: IntegrationType | null, + sandboxSenderAllowed: boolean + ): IntegrationType[] => { + const sandboxSender = sandboxEmailSender ?? selectedSandboxSender + return sandboxSenderAllowed && sandboxSender + ? [...ownEmailIntegrations, sandboxSender] + : ownEmailIntegrations + }, + ], + supportedFields: [ + (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.isSandboxSenderSelected], + (type: EmailTemplaterType, isSandboxSenderSelected: boolean): EmailMetaField[] => + EMAIL_TYPE_SUPPORTED_FIELDS[type].filter( + (field) => !isSandboxSenderSelected || field.key !== EMAIL_META_FIELDS.REPLY_TO.key + ), + ], + visibleFields: [ + (s) => [s.supportedFields, s.revealedAdvancedFields], + (supportedFields: EmailMetaField[], revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] => + supportedFields.filter((field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key)), + ], + hiddenAdvancedFields: [ + (s) => [s.supportedFields, s.visibleFields], + (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]): EmailMetaField[] => + supportedFields.filter((f) => f.isAdvancedField && !visibleFields.includes(f)), + ], + sandboxSenderId: [ + (s) => [s.senderIntegrations], + (senderIntegrations: IntegrationType[]): number | undefined => + senderIntegrations.find(isSandboxEmailSender)?.id, + ], + hasCustomSenderDetails: [ + (s) => [s.emailTemplate], + (template: EmailTemplate): boolean => { + const from = typeof template?.from === 'object' ? template.from : undefined + return !!(from?.email || from?.name || template?.replyTo) + }, + ], + isSandboxSenderSelected: [ + (s) => [s.emailTemplate, s.sandboxSenderId], + (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined): boolean => { + const from = emailTemplate?.from + return ( + sandboxSenderId !== undefined && + selectedSenderIds(typeof from === 'object' ? from : undefined).includes(sandboxSenderId) + ) + }, + ], + }), + listeners(({ props, values, actions, cache }) => ({ + retrySenderIntegrations: () => { + if (values.senderIntegrationsLoading) { + return + } + actions.loadIntegrations() + if (props.sandboxSenderAllowed) { + actions.retrySandboxEmailSender() + } + }, + chooseSenders: ({ integrationIds }) => { + const from = values.emailTemplate?.from + const { sandboxSenderId } = values + const next = selectSenders(typeof from === 'object' ? from : undefined, integrationIds, sandboxSenderId) + if (!next) { + return + } + if (sandboxSenderId !== undefined && next.integrationId === sandboxSenderId) { + const selectedIds = selectedSenderIds(typeof from === 'object' ? from : undefined) + const selectSandbox = (): void => { + actions.setEmailTemplateValues({ from: next, replyTo: '' }) + actions.hideAdvancedField('replyTo') + } + if (values.hasCustomSenderDetails || selectedIds.length > 1) { + const selectedNames = selectedIds.map( + (id) => + values.senderIntegrations.find((sender) => sender.id === id)?.display_name || `sender ${id}` + ) + LemonDialog.open({ + title: 'Switch to the sandbox sender?', + description: [ + selectedIds.length > 1 + ? `This replaces ${selectedNames.join(' and ')} with the sandbox sender.` + : '', + values.hasCustomSenderDetails + ? 'This removes your custom sender address, sender name, and Reply-To.' + : '', + 'The sandbox sender uses its own identity.', + ] + .filter(Boolean) + .join(' '), + primaryButton: { children: 'Switch sender', onClick: selectSandbox }, + secondaryButton: { children: 'Cancel' }, + }) + } else { + selectSandbox() + } + return + } + actions.setEmailTemplateValue('from', next) + }, onEmailEditorReady: () => { const editor = values.emailEditorRef?.editor if (editor && values.emailMediaLibraryEnabled) { @@ -976,6 +1217,9 @@ export const emailTemplaterLogic = kea([ actions.loadTemplates() actions.loadPersonPropertyDefinitions() + if (props.sandboxSenderAllowed) { + actions.ensureSandboxEmailSender() + } }), beforeUnmount(({ props, values }) => { diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts new file mode 100644 index 000000000000..18ad53cd434c --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts @@ -0,0 +1,49 @@ +import { selectSenders } from './selectSenders' + +describe('selectSenders', () => { + const current = { integrationId: 1, email: 'custom@example.com', name: 'Custom' } + + it.each([ + { + case: 'picking the sandbox sender keeps only it and drops the custom sender values', + ids: [1, 2, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined }, + }, + { + case: 'adding an own sender while the sandbox sender is selected drops the sandbox sender', + value: { integrationId: 7 }, + ids: [7, 1], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: undefined }, + }, + { + case: 'own senders rotate and keep the custom sender values', + ids: [1, 2], + sandboxId: 7, + expected: { integrationId: 1, integrationIds: [1, 2], email: 'custom@example.com', name: 'Custom' }, + }, + { + case: 'picking the sandbox sender over a full rotation still works', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + sandboxId: 7, + expected: { integrationId: 7, integrationIds: undefined }, + }, + { + case: 'a surface without the sandbox sender treats its id like any other', + ids: [1, 7], + sandboxId: undefined, + expected: { integrationId: 1, integrationIds: [1, 7] }, + }, + { + case: 'more senders than the limit are refused', + ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12], + sandboxId: 7, + expected: null, + }, + ])('$case', ({ value = current, ids, sandboxId, expected }) => { + expect(selectSenders(value, ids, sandboxId)).toEqual( + expected === null ? null : expect.objectContaining(expected) + ) + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts new file mode 100644 index 000000000000..d822a83691cd --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts @@ -0,0 +1,38 @@ +import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types' + +export function selectedSenderIds(value: EmailTemplateFrom | undefined): number[] { + if (value?.integrationIds?.length) { + return value.integrationIds + } + return value?.integrationId ? [value.integrationId] : [] +} + +function exclusiveSandboxSenders( + integrationIds: number[], + sandboxSenderId: number | undefined, + sandboxSelected: boolean +): number[] { + if (sandboxSenderId === undefined || !integrationIds.includes(sandboxSenderId)) { + return integrationIds + } + return sandboxSelected ? integrationIds.filter((id) => id !== sandboxSenderId) : [sandboxSenderId] +} + +export function selectSenders( + value: EmailTemplateFrom | undefined, + integrationIds: number[], + sandboxSenderId: number | undefined +): EmailTemplateFrom | null { + const sandboxSelected = sandboxSenderId !== undefined && selectedSenderIds(value).includes(sandboxSenderId) + const senders = exclusiveSandboxSenders(integrationIds, sandboxSenderId, sandboxSelected) + if (senders.length > MAX_WORKFLOW_EMAIL_SENDERS) { + return null + } + const sandboxChosen = sandboxSenderId !== undefined && senders.includes(sandboxSenderId) + return { + ...value, + integrationId: senders[0], + integrationIds: senders.length > 1 ? senders : undefined, + ...(sandboxChosen ? { email: undefined, name: undefined } : {}), + } +} diff --git a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx index e2ad5442a586..5a7ee8c20c5a 100644 --- a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx +++ b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx @@ -14,7 +14,7 @@ import { newBroadcastAgentLogic } from './newBroadcastAgentLogic' /** The Broadcasts tab before the first broadcast: set up a verified sender first, then write one. */ export function BroadcastsEmptyState(): JSX.Element { - const { integrations } = useValues(integrationsLogic) + const { userManagedIntegrations: integrations } = useValues(integrationsLogic) const { startNewBroadcast } = useActions(newBroadcastAgentLogic) const editorDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.Workflow, diff --git a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx index b8d9c58abd41..fc5bf6e01788 100644 --- a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx +++ b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx @@ -27,13 +27,13 @@ export function BroadcastContentStep(): JSX.Element { const { broadcast, email, name, stepValidationErrors, selectedSender, emailSettings, aiEmailDraftStatus } = useValues(broadcastWizardLogic) const { setEmail, setEmailSettings } = useActions(broadcastWizardLogic) - const { integrations, integrationsLoading } = useValues(integrationsLogic) + const { integrations, integrationsLoading, ownEmailIntegrations } = useValues(integrationsLogic) const { loadIntegrations } = useActions(integrationsLogic) const [templatePickerOpen, setTemplatePickerOpen] = useState(false) // null: closed. 'new': set up a sender. An integration: finish verifying that one. const [senderSetup, setSenderSetup] = useState<'new' | IntegrationType | null>(null) - const hasSenders = !!integrations?.some((integration) => integration.kind === 'email') + const hasSenders = ownEmailIntegrations.length > 0 const senderUnverified = !!selectedSender && selectedSender.config?.verified !== true // A deleted sender would otherwise show as its bare id, so the picker leaves it out. const missingSenderIds = integrations ? getMissingSenderIds(email.from, integrations) : [] diff --git a/products/workflows/frontend/Channels/ChannelCatalog.tsx b/products/workflows/frontend/Channels/ChannelCatalog.tsx index 4dfa0205512a..53ab31dbd122 100644 --- a/products/workflows/frontend/Channels/ChannelCatalog.tsx +++ b/products/workflows/frontend/Channels/ChannelCatalog.tsx @@ -1,6 +1,6 @@ -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' -import { LemonBanner, LemonSkeleton } from '@posthog/lemon-ui' +import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui' import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' import { TeamMembershipLevel } from 'lib/constants' @@ -13,7 +13,9 @@ const GRID_CLASS = 'grid gap-2 grid-cols-1 @min-[44rem]/main-content:grid-cols-2 /** Every channel a workflow can send through, with whether this project has it set up. */ export function ChannelCatalog(): JSX.Element { - const { channels, activeCount, integrationsLoadFailed } = useValues(channelCatalogLogic) + const { channels, activeCount, integrationsLoadFailed, integrationsLoading, integrationsLoadError } = + useValues(channelCatalogLogic) + const { loadIntegrations } = useActions(channelCatalogLogic) const restrictedReason = useRestrictedArea({ scope: RestrictionScope.Project, minimumAccessLevel: TeamMembershipLevel.Admin, @@ -28,13 +30,37 @@ export function ChannelCatalog(): JSX.Element { )}
{channels ? ( -
- {channels.map((channel) => ( - - ))} -
+ <> + {integrationsLoadFailed && ( +
+ Couldn't refresh your channels. Showing the last loaded connections. + + Try again + +
+ )} +
+ {channels.map((channel) => ( + + ))} +
+ ) : integrationsLoadFailed ? ( - Couldn't load your channels. Refresh the page to try again. + + {integrationsLoadError || "Couldn't load your channels."} + ) : (
{CATALOG_CHANNELS.map((kind) => ( diff --git a/products/workflows/frontend/Channels/MessageChannels.stories.tsx b/products/workflows/frontend/Channels/MessageChannels.stories.tsx new file mode 100644 index 000000000000..6f527664481f --- /dev/null +++ b/products/workflows/frontend/Channels/MessageChannels.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' + +import { MessageChannels } from './MessageChannels' + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme ', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +type StoryArgs = { integrations: Record[]; narrow?: boolean } + +const meta: Meta = { + title: 'Products/Workflows/Channels', + component: MessageChannels, + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + }, +} +export default meta + +const Template: StoryFn = ({ integrations, narrow }) => { + useStorybookMocks({ + get: { '/api/projects/:team_id/integrations/': { results: integrations } }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER }, + }) + return ( +
+ +
+ ) +} + +export const SandboxSenderOnly: StoryFn = Template.bind({}) +SandboxSenderOnly.args = { integrations: [SANDBOX_SENDER] } + +export const SandboxAndOwnSender: StoryFn = Template.bind({}) +SandboxAndOwnSender.args = { integrations: [SANDBOX_SENDER, OWN_SENDER] } + +export const SandboxAndOwnSenderNarrow: StoryFn = Template.bind({}) +SandboxAndOwnSenderNarrow.args = { integrations: [SANDBOX_SENDER, OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Channels/MessageChannels.tsx b/products/workflows/frontend/Channels/MessageChannels.tsx index 7c51179f101b..ccbd62a829c3 100644 --- a/products/workflows/frontend/Channels/MessageChannels.tsx +++ b/products/workflows/frontend/Channels/MessageChannels.tsx @@ -1,22 +1,31 @@ import { useActions, useValues } from 'kea' +import { LemonBanner } from '@posthog/lemon-ui' + import { SetupTaskId } from 'lib/components/ProductSetup' import { EmailIntegrationsList } from 'lib/integrations/EmailIntegrationsList' import { IntegrationsList } from 'lib/integrations/IntegrationsList' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' import { ChannelSetupModal } from './ChannelSetupModal' +import { SandboxEmailSenderRow } from './SandboxEmailSenderRow' const MESSAGING_CHANNEL_TYPES = ['email', 'slack', 'twilio', 'firebase', 'apns'] as const export type ChannelType = (typeof MESSAGING_CHANNEL_TYPES)[number] export function MessageChannels(): JSX.Element { - const { setupModalOpen, integrations, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { setupModalOpen, userManagedIntegrations, setupModalType, selectedIntegration, sandboxEmailSender } = + useValues(integrationsLogic) const { closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) + const { sandboxSetupError, sandboxSetupLoading } = useValues(channelCatalogLogic) + const { retrySandboxSenderSetup } = useActions(channelCatalogLogic) - const allWorkflowIntegrations = - integrations?.filter((integration) => MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType)) ?? [] + const ownWorkflowIntegrations = + userManagedIntegrations?.filter((integration) => + MESSAGING_CHANNEL_TYPES.includes(integration.kind as ChannelType) + ) ?? [] return ( <> @@ -33,7 +42,20 @@ export function MessageChannels(): JSX.Element {
- {allWorkflowIntegrations.length > 0 && ( + {sandboxSetupError && ( + + {sandboxSetupError} + + )} + {sandboxEmailSender && } + {ownWorkflowIntegrations.length > 0 && (

Your connections

)} diff --git a/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx new file mode 100644 index 000000000000..3f02a3149fad --- /dev/null +++ b/products/workflows/frontend/Channels/SandboxEmailSenderRow.tsx @@ -0,0 +1,51 @@ +import { useActions } from 'kea' + +import { IconLetter, IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea' +import { TeamMembershipLevel } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' + +import { IntegrationType } from '~/types' + +export function SandboxEmailSenderRow({ integration }: { integration: IntegrationType }): JSX.Element { + const { openSetupModal } = useActions(integrationsLogic) + const restrictedReason = useRestrictedArea({ + scope: RestrictionScope.Project, + minimumAccessLevel: TeamMembershipLevel.Admin, + }) + + return ( +
+
+
+ +
+
+ Sandbox sender + + {`${integration.config.name} <${integration.config.email}>`} + +
+ + Sends from a PostHog address, so it works before you verify a domain. It delivers only to + verified members of your organization. Add your own sender to email anyone. + +
+
+ } + className="shrink-0 self-start @lg:self-center" + onClick={() => openSetupModal(undefined, 'email')} + disabledReason={restrictedReason} + data-attr="sandbox-email-sender-add-own" + > + Add your own sender + +
+
+ ) +} diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts new file mode 100644 index 000000000000..0d20a05d44ff --- /dev/null +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { expectLogic } from 'kea-test-utils' +import { createElement } from 'react' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { ChannelCatalog } from './ChannelCatalog' +import { channelCatalogLogic } from './channelCatalogLogic' + +describe('sandbox setup in the channel catalog', () => { + let logic: ReturnType + + beforeEach(() => initKeaTests()) + afterEach(() => { + cleanup() + logic?.unmount() + }) + + it.each([404, 500])('starts setup on mount and exposes a %s request for retry', async (failureStatus) => { + let status = failureStatus + let requests = 0 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return status === 200 + ? [200, sandbox] + : [status, { detail: 'Could not set up your sandbox sender.' }] + }, + }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await waitFor(() => expect(requests).toBe(1)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBe('Could not set up your sandbox sender.') + + status = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await waitFor(() => expect(requests).toBe(2)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.sandboxSetupError).toBeNull() + }) + + it('keeps a channel load failure separate from sandbox setup and reloads on retry', async () => { + let loadStatus = 500 + const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } } + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => + loadStatus === 200 + ? [200, { results: [sandbox] }] + : [500, { detail: 'Could not load your channels.' }], + }, + post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': [200, sandbox] }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + logic = channelCatalogLogic() + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure']) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.channels).toBeNull() + expect(logic.values.integrationsLoadFailed).toBe(true) + expect(logic.values.sandboxSetupError).toBeNull() + + render(createElement(ChannelCatalog)) + expect(screen.getByText('Could not load your channels.')).toBeInTheDocument() + cleanup() + + loadStatus = 200 + await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(logic.values.integrationsLoadFailed).toBe(false) + expect(logic.values.channels).not.toBeNull() + expect(logic.values.sandboxSetupError).toBeNull() + render(createElement(ChannelCatalog)) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + + loadStatus = 500 + await act(async () => { + await expectLogic(integrationsLogic, () => + integrationsLogic.actions.loadIntegrations() + ).toFinishAllListeners() + }) + expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument() + expect(screen.getByText(/Couldn't refresh your channels/)).toBeInTheDocument() + }) +}) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts index 842e323f374c..a038b8213f1c 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts @@ -1,4 +1,4 @@ -import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, selectors } from 'kea' import { router } from 'kea-router' import posthog from 'posthog-js' @@ -8,6 +8,7 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { removeProjectIdIfPresent } from 'lib/utils/kea-router' +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' import type { FeatureFlagsSet } from '../../../../frontend/src/lib/logic/featureFlagLogic' import type { IntegrationType } from '../../../../frontend/src/types' import { CATALOG_CHANNELS, ChannelSummary, PUSH_CHANNELS, summarizeChannel } from './channelSummary' @@ -17,27 +18,33 @@ import type { ChannelType } from './MessageChannels' export interface channelCatalogLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic + integrationsLoading: boolean // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic activeCount: number channels: ChannelSummary[] | null - integrationsLoadFailed: boolean + sandboxSetupError: string | null + sandboxSetupLoading: boolean } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicActions { - loadIntegrationsFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any + ensureSandboxEmailSender: () => { + value: true } // integrationsLogic - loadIntegrationsSuccess: ( - integrations: IntegrationType[], + loadCurrentTeamSuccess: ( + currentTeam: null | import('../../../../frontend/src/types').TeamPublicType, payload?: any ) => { - integrations: IntegrationType[] + currentTeam: null | import('../../../../frontend/src/types').TeamPublicType payload?: any } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic openSetupModal: ( integration?: IntegrationType | undefined, channelType?: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined @@ -45,6 +52,12 @@ export interface channelCatalogLogicActions { channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined integration: IntegrationType | undefined } // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic + retrySandboxSenderSetup: () => { + value: true + } startChannelSetup: (kind: ChannelType) => { kind: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' } @@ -53,6 +66,11 @@ export interface channelCatalogLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface channelCatalogLogicMeta { __keaTypeGenInternalSelectorTypes: { + sandboxSetupLoading: ( + integrationsLoading: boolean, + sandboxEmailSenderProvisionResultLoading: boolean + ) => boolean + sandboxSetupError: (integrationsLoadFailed: boolean, sandboxEmailSenderSetupError: any) => string | null channels: (integrations: IntegrationType[] | null, featureFlags: FeatureFlagsSet) => ChannelSummary[] | null activeCount: (channels: ChannelSummary[] | null) => number } @@ -68,22 +86,46 @@ export type channelCatalogLogicType = MakeLogicType< export const channelCatalogLogic = kea([ path(['products', 'workflows', 'frontend', 'Channels', 'channelCatalogLogic']), connect(() => ({ - values: [integrationsLogic, ['integrations'], featureFlagLogic, ['featureFlags']], - actions: [integrationsLogic, ['openSetupModal', 'loadIntegrationsFailure', 'loadIntegrationsSuccess']], + values: [ + integrationsLogic, + [ + 'integrations', + 'integrationsLoadError', + 'integrationsLoadFailed', + 'sandboxEmailSenderSetupError', + 'integrationsLoading', + 'sandboxEmailSenderEnabled', + 'sandboxEmailSenderProvisionResult', + 'sandboxEmailSenderProvisionError', + 'sandboxEmailSenderProvisionResultLoading', + ], + featureFlagLogic, + ['featureFlags'], + ], + actions: [ + integrationsLogic, + [ + 'openSetupModal', + 'ensureSandboxEmailSender', + 'retrySandboxEmailSender', + 'loadCurrentTeamSuccess', + 'loadIntegrations', + ], + ], })), actions({ startChannelSetup: (kind: ChannelType) => ({ kind }), - }), - reducers({ - integrationsLoadFailed: [ - false, - { - loadIntegrationsFailure: () => true, - loadIntegrationsSuccess: () => false, - }, - ], + retrySandboxSenderSetup: true, }), selectors({ + sandboxSetupLoading: [ + (s) => [s.integrationsLoading, s.sandboxEmailSenderProvisionResultLoading], + (loading: boolean, provisioning: boolean): boolean => loading || provisioning, + ], + sandboxSetupError: [ + (s) => [s.integrationsLoadFailed, s.sandboxEmailSenderSetupError], + (loadFailed: boolean, setupError: string | null): string | null => (loadFailed ? null : setupError), + ], /** `null` until the integrations load, so the catalog never shows a channel as not set up by mistake. */ channels: [ (s) => [s.integrations, s.featureFlags], @@ -103,6 +145,14 @@ export const channelCatalogLogic = kea([ ], }), listeners(({ actions, values }) => ({ + loadCurrentTeamSuccess: () => actions.ensureSandboxEmailSender(), + retrySandboxSenderSetup: () => { + if (values.sandboxSetupLoading) { + return + } + actions.loadIntegrations() + actions.retrySandboxEmailSender() + }, startChannelSetup: ({ kind }) => { // pinned: analytics event name - renaming breaks dashboards posthog.capture('workflows channel setup started', { @@ -120,4 +170,5 @@ export const channelCatalogLogic = kea([ actions.openSetupModal(undefined, kind) }, })), + afterMount(({ actions }) => actions.ensureSandboxEmailSender()), ]) diff --git a/products/workflows/frontend/Channels/channelSummary.test.ts b/products/workflows/frontend/Channels/channelSummary.test.ts index da3118f48f50..9fc451b7f5cc 100644 --- a/products/workflows/frontend/Channels/channelSummary.test.ts +++ b/products/workflows/frontend/Channels/channelSummary.test.ts @@ -4,10 +4,13 @@ import type { ChannelType } from './MessageChannels' const email = (verified: boolean): any => ({ kind: 'email', config: { verified } }) const slack = (errors?: string): any => ({ kind: 'slack', config: {}, errors }) const github: any = { kind: 'github', config: {} } +const sandbox: any = { kind: 'email', config: { provider: 'sandbox', verified: true } } describe('summarizeChannel', () => { test.each<[string, ChannelType, any[], ChannelStatus, number]>([ ['nothing connected', 'email', [], 'not-set-up', 0], + ['sandbox is separate from own email setup', 'email', [sandbox], 'not-set-up', 0], + ['sandbox does not verify an own sender', 'email', [sandbox, email(false)], 'needs-verification', 1], ['other kinds do not count', 'slack', [github, email(true)], 'not-set-up', 0], ['an unverified email address needs verification', 'email', [email(false)], 'needs-verification', 1], ['one verified address makes email active', 'email', [email(false), email(true)], 'active', 2], diff --git a/products/workflows/frontend/Channels/channelSummary.ts b/products/workflows/frontend/Channels/channelSummary.ts index 1b32d0d360f9..ca139d53f1df 100644 --- a/products/workflows/frontend/Channels/channelSummary.ts +++ b/products/workflows/frontend/Channels/channelSummary.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import type { IntegrationType } from '~/types' import type { ChannelType } from './MessageChannels' @@ -31,7 +33,9 @@ export function summarizeChannel( kind: ChannelType, integrations: Pick[] ): ChannelSummary { - const connected = integrations.filter((integration) => integration.kind === kind) + const connected = integrations.filter( + (integration) => integration.kind === kind && !isSandboxEmailSender(integration) + ) const count = connected.length if (count === 0) { return { kind, status: 'not-set-up', count } diff --git a/products/workflows/frontend/Channels/defaultEmailSender.test.ts b/products/workflows/frontend/Channels/defaultEmailSender.test.ts index ce1b0d280f58..c5c5999cc209 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.test.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.test.ts @@ -5,8 +5,18 @@ import { resolveDefaultEmailSender } from './defaultEmailSender' const sender = (id: number, verified: boolean, kind = 'email'): IntegrationType => ({ id, kind, config: { verified } }) as unknown as IntegrationType +const sandboxSender = { ...sender(7, true), config: { verified: true, provider: 'sandbox' } } + describe('resolveDefaultEmailSender', () => { it.each([ + { case: 'nothing when only sandbox exists', integrations: [sandboxSender], defaultId: null, expected: null }, + { + case: 'the own sender beside sandbox', + integrations: [sandboxSender, sender(1, true)], + defaultId: null, + expected: 1, + }, + { case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null }, { case: 'the project default', integrations: [sender(1, true), sender(2, true)], defaultId: 2, expected: 2 }, { case: 'the only verified sender', diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 82f05e42c8f8..063d45406654 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -1,3 +1,5 @@ +import { isSandboxEmailSender } from 'lib/integrations/utils' + import { IntegrationType } from '~/types' export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' @@ -12,7 +14,8 @@ export function resolveDefaultEmailSender( defaultIntegrationId: number | null | undefined ): DefaultEmailSender | null { const verified = (integrations ?? []).filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true + (integration) => + integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true ) const projectDefault = verified.find((integration) => integration.id === defaultIntegrationId) if (projectDefault) { diff --git a/products/workflows/frontend/Channels/emailSendingMessages.ts b/products/workflows/frontend/Channels/emailSendingMessages.ts index 1573829036cf..52fb836d018e 100644 --- a/products/workflows/frontend/Channels/emailSendingMessages.ts +++ b/products/workflows/frontend/Channels/emailSendingMessages.ts @@ -4,3 +4,6 @@ export const EMAIL_SENDING_SUSPENDED_MESSAGE = export const UNVERIFIED_SENDER_MESSAGE = "Verify the sender's domain before sending" export const NO_EMAIL_SENDER_MESSAGE = 'Set up an email sender before sending' + +export const SANDBOX_ONLY_SENDER_MESSAGE = + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.' diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts index 5ac223a19584..7c892e04bdef 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.test.ts @@ -17,6 +17,7 @@ const LIST_MATCHING = '"Signups" is still matching people. You can launch when i const verified = { kind: 'email', config: { verified: true } } const unverified = { kind: 'email', config: { verified: false } } +const sandbox = { kind: 'email', config: { provider: 'sandbox', verified: true } } const matchingList = { name: 'Signups', is_static: true, is_calculating: true, last_calculation: null } const audience: MessageAudience = { source: 'cohort', @@ -51,6 +52,14 @@ describe('loadMessageAudienceWarnings', () => { senders: [unverified], expected: ["Verify the sender's domain before sending"], }, + ...(['broadcast', 'workflow'] as const).map((destination) => ({ + case: `only the sandbox sender (${destination})`, + destination, + senders: [sandbox], + expected: [ + 'The sandbox sender only delivers to verified members of your organization. Set up your own email sender to reach this audience.', + ], + })), { case: 'unverified senders on the first of several pages', destination: 'broadcast', diff --git a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx index 03758588a586..205e7c21312c 100644 --- a/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx +++ b/products/workflows/frontend/MessageAudience/messageAudienceWarnings.tsx @@ -3,6 +3,7 @@ import { router } from 'kea-router' import { LemonDialog } from '@posthog/lemon-ui' import { ApiConfig } from 'lib/api' +import { isSandboxEmailSender } from 'lib/integrations/utils' import { urls } from 'scenes/urls' import { cohortsRetrieve } from 'products/cohorts/frontend/generated/api' @@ -14,14 +15,16 @@ import { audienceCohortIds, audienceCohortLaunchError, toAudienceCohort } from ' import { EMAIL_SENDING_SUSPENDED_MESSAGE, NO_EMAIL_SENDER_MESSAGE, + SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE, } from '../Channels/emailSendingMessages' import { MessageAudience, MessageAudienceDestination, messageAudienceUrl } from './messageAudience' export function emailSenderWarning(integrations: Pick[]): string | null { - const senders = integrations.filter((integration) => integration.kind === 'email') + const emailIntegrations = integrations.filter((integration) => integration.kind === 'email') + const senders = emailIntegrations.filter((integration) => !isSandboxEmailSender(integration)) if (senders.length === 0) { - return NO_EMAIL_SENDER_MESSAGE + return emailIntegrations.length > 0 ? SANDBOX_ONLY_SENDER_MESSAGE : NO_EMAIL_SENDER_MESSAGE } const verified = senders.some( (integration) => (integration.config as { verified?: boolean } | undefined)?.verified === true @@ -111,10 +114,11 @@ export async function openMessageAudience( onClick: navigate, 'data-attr': `message-audience-${audience.source}-continue`, }, - secondaryButton: - warnings.includes(NO_EMAIL_SENDER_MESSAGE) || warnings.includes(UNVERIFIED_SENDER_MESSAGE) - ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } - : { children: 'Cancel' }, + secondaryButton: [NO_EMAIL_SENDER_MESSAGE, SANDBOX_ONLY_SENDER_MESSAGE, UNVERIFIED_SENDER_MESSAGE].some( + (message) => warnings.includes(message) + ) + ? { children: 'Set up email', onClick: () => router.actions.push(urls.workflows('channels')) } + : { children: 'Cancel' }, }) } finally { opening = false diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index 7cb2788dbe9c..c16d7b727ba1 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -269,6 +269,7 @@ export function HogFlowFunctionConfiguration({ // (into the staged draft on active workflows), so the editor needs no save step. emailLiveChanges emailSaveIndicator={} + emailSandboxSenderAllowed={isEmailStep && workflow?.origin_product !== 'broadcasts'} configuration={{ inputs: inputs as Record, inputs_schema: schema }} showSource={false} sampleGlobalsWithInputs={sampleGlobals} diff --git a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx index 59b20a30429c..bce67c310fba 100644 --- a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx +++ b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx @@ -10,7 +10,12 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { ChannelSetupModal } from '../../Channels/ChannelSetupModal' export function WizardChannelStep(): JSX.Element { - const { integrations, setupModalOpen, setupModalType, selectedIntegration } = useValues(integrationsLogic) + const { + userManagedIntegrations: integrations, + setupModalOpen, + setupModalType, + selectedIntegration, + } = useValues(integrationsLogic) const { openSetupModal, closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic) const hasEmailChannel = !!integrations?.some((integration) => integration.kind === 'email') diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts index 09b48e850579..429ffc206fff 100644 --- a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts +++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts @@ -257,7 +257,7 @@ export const workflowsOnboardingWizardLogic = kea([ projectLogic, ['currentProjectId'], integrationsLogic, - ['integrations'], + ['userManagedIntegrations as integrations'], optOutCategoriesLogic, ['categories', 'categoriesLoading'], messageTemplatesLogic,