+ {sandboxSetupError && (
+
+ {sandboxSetupError}
+
+ )}
+ {sandboxEmailSender &&
}
{supabaseAuthEmailsEnabled &&
}
- {allWorkflowIntegrations.length > 0 && (
+ {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..73b74a1adc3e
--- /dev/null
+++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts
@@ -0,0 +1,183 @@
+import '@testing-library/jest-dom'
+
+import { act, cleanup, fireEvent, 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'
+import { MessageChannels } from './MessageChannels'
+
+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 recovers from a %s through Try again', async (failureStatus) => {
+ let status = failureStatus
+ let requests = 0
+ const sandbox = {
+ id: 9,
+ kind: 'email',
+ config: {
+ provider: 'sandbox',
+ verified: true,
+ name: 'Acme via PostHog',
+ email: 'hello@sandbox.example.com',
+ },
+ }
+ 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()
+ render(createElement(MessageChannels))
+ expect(await screen.findByText('Could not set up your sandbox sender.')).toBeInTheDocument()
+
+ status = 200
+ fireEvent.click(screen.getAllByText('Try again')[0])
+ expect(await screen.findByText('Acme via PostHog ')).toBeInTheDocument()
+ expect(requests).toBe(2)
+ expect(screen.queryByText('Could not set up your sandbox sender.')).not.toBeInTheDocument()
+ })
+
+ it('keeps a channel load failure separate from sandbox setup and reloads on retry', async () => {
+ let loadStatus = 500
+ const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } }
+ useMocks({
+ get: {
+ '/api/projects/:team_id/integrations/': () =>
+ loadStatus === 200
+ ? [200, { results: [sandbox] }]
+ : [500, { detail: 'Could not load your channels.' }],
+ },
+ post: { '/api/projects/:team_id/integrations/email_sandbox_sender/': [200, sandbox] },
+ })
+ featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
+ [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
+ })
+ logic = channelCatalogLogic()
+ logic.mount()
+ await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsFailure'])
+ await expectLogic(integrationsLogic).toFinishAllListeners()
+ expect(logic.values.channels).toBeNull()
+ expect(logic.values.integrationsLoadFailed).toBe(true)
+ expect(logic.values.sandboxSetupError).toBeNull()
+
+ render(createElement(ChannelCatalog))
+ expect(screen.getByText('Could not load your channels.')).toBeInTheDocument()
+ cleanup()
+
+ loadStatus = 200
+ await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners()
+ await expectLogic(integrationsLogic).toFinishAllListeners()
+ expect(logic.values.integrationsLoadFailed).toBe(false)
+ expect(logic.values.channels).not.toBeNull()
+ expect(logic.values.sandboxSetupError).toBeNull()
+ render(createElement(ChannelCatalog))
+ expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument()
+
+ loadStatus = 500
+ await act(async () => {
+ await expectLogic(integrationsLogic, () =>
+ integrationsLogic.actions.loadIntegrations()
+ ).toFinishAllListeners()
+ })
+ expect(screen.getByTestId('workflows-channel-catalog-email')).toBeInTheDocument()
+ expect(screen.getByText(/Couldn't refresh your channels/)).toBeInTheDocument()
+ })
+
+ it('ignores a setup failure from an earlier visit after Channels opens again', async () => {
+ let failFirstRequest: () => void = () => {}
+ let requests = 0
+ const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } }
+ useMocks({
+ get: { '/api/projects/:team_id/integrations/': () => [200, { results: requests > 1 ? [sandbox] : [] }] },
+ post: {
+ '/api/projects/:team_id/integrations/email_sandbox_sender/': () => {
+ requests++
+ if (requests > 1) {
+ return [200, sandbox]
+ }
+ return new Promise((resolve) => {
+ failFirstRequest = () => resolve([500, { detail: 'Could not set up your sandbox sender.' }])
+ })
+ },
+ },
+ })
+ featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
+ [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
+ })
+ logic = channelCatalogLogic()
+ logic.mount()
+ await waitFor(() => expect(requests).toBe(1))
+ logic.unmount()
+
+ logic = channelCatalogLogic()
+ logic.mount()
+ await waitFor(() => expect(integrationsLogic.values.sandboxEmailSender).not.toBeNull())
+ failFirstRequest()
+ await expectLogic(logic).toFinishAllListeners()
+
+ expect(requests).toBe(2)
+ expect(logic.values.sandboxSetupError).toBeNull()
+ })
+
+ it.each([
+ { when: 'the flag is on at mount', flagAtMount: true },
+ { when: 'the flag arrives after mount', flagAtMount: false },
+ ])('sets up the sandbox sender once when $when and shows the new row', async ({ flagAtMount }) => {
+ let requests = 0
+ const sandbox = { id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } }
+ useMocks({
+ get: { '/api/projects/:team_id/integrations/': () => [200, { results: requests > 0 ? [sandbox] : [] }] },
+ post: {
+ '/api/projects/:team_id/integrations/email_sandbox_sender/': () => {
+ requests++
+ return [200, sandbox]
+ },
+ },
+ })
+ const enableFlag = (): void =>
+ featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
+ [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
+ })
+ featureFlagLogic.actions.setFeatureFlags([], {})
+ if (flagAtMount) {
+ enableFlag()
+ }
+ logic = channelCatalogLogic()
+ logic.mount()
+ await expectLogic(integrationsLogic).toFinishAllListeners()
+ expect(requests).toBe(flagAtMount ? 1 : 0)
+
+ enableFlag()
+ await waitFor(() => expect(integrationsLogic.values.sandboxEmailSender).not.toBeNull())
+ await expectLogic(logic).toFinishAllListeners()
+ expect(requests).toBe(1)
+ })
+})
diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts
index b0881131a1f9..194dcaec6be0 100644
--- a/products/workflows/frontend/Channels/channelCatalogLogic.ts
+++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts
@@ -1,12 +1,16 @@
-import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
+import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, selectors } from 'kea'
+import { loaders } from 'kea-loaders'
import { router } from 'kea-router'
import posthog from 'posthog-js'
-import api from 'lib/api'
+import api, { ApiError } from 'lib/api'
import { FEATURE_FLAGS } from 'lib/constants'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { removeProjectIdIfPresent } from 'lib/utils/kea-router'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { integrationsEmailSandboxSenderCreate } from 'products/integrations/frontend/generated/api'
import type { FeatureFlagsSet } from '../../../../frontend/src/lib/logic/featureFlagLogic'
import type { IntegrationType } from '../../../../frontend/src/types'
@@ -18,27 +22,30 @@ import type { ChannelType } from './MessageChannels'
export interface channelCatalogLogicValues {
featureFlags: FeatureFlagsSet // featureFlagLogic
integrations: IntegrationType[] | null // integrationsLogic
+ integrationsLoadError: string | null // integrationsLogic
+ integrationsLoadFailed: boolean // integrationsLogic
+ integrationsLoading: boolean // integrationsLogic
+ sandboxEmailSender: IntegrationType | null // integrationsLogic
+ sandboxEmailSenderEnabled: boolean // integrationsLogic
+ currentProjectId: number | null // teamLogic
activeCount: number
channels: ChannelSummary[] | null
- integrationsLoadFailed: boolean
+ sandboxProvisionError: string | null
+ sandboxProvisionErrorLoading: boolean
+ sandboxSetupError: string | null
+ sandboxSetupLoading: boolean
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface channelCatalogLogicActions {
- loadIntegrationsFailure: (
- error: string,
- errorObject?: any
+ setFeatureFlags: (
+ flags: string[],
+ variants: Record
) => {
- error: string
- errorObject?: any
- } // integrationsLogic
- loadIntegrationsSuccess: (
- integrations: IntegrationType[],
- payload?: any
- ) => {
- integrations: IntegrationType[]
- payload?: any
- } // integrationsLogic
+ flags: string[]
+ variants: Record
+ } // featureFlagLogic
+ loadIntegrations: (_?: any) => any // integrationsLogic
openSetupModal: (
integration?: IntegrationType | undefined,
channelType?: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined
@@ -46,6 +53,24 @@ export interface channelCatalogLogicActions {
channelType: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio' | undefined
integration: IntegrationType | undefined
} // integrationsLogic
+ retrySandboxSenderSetup: () => {
+ value: true
+ }
+ setUpSandboxSender: (_?: any) => any
+ setUpSandboxSenderFailure: (
+ error: string,
+ errorObject?: any
+ ) => {
+ error: string
+ errorObject?: any
+ }
+ setUpSandboxSenderSuccess: (
+ sandboxProvisionError: string | null,
+ payload?: any
+ ) => {
+ sandboxProvisionError: string | null
+ payload?: any
+ }
startChannelSetup: (kind: ChannelType) => {
kind: 'apns' | 'email' | 'firebase' | 'slack' | 'twilio'
}
@@ -54,6 +79,8 @@ export interface channelCatalogLogicActions {
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface channelCatalogLogicMeta {
__keaTypeGenInternalSelectorTypes: {
+ sandboxSetupLoading: (integrationsLoading: boolean, sandboxProvisionErrorLoading: boolean) => boolean
+ sandboxSetupError: (integrationsLoadFailed: boolean, sandboxProvisionError: string | null) => string | null
channels: (integrations: IntegrationType[] | null, featureFlags: FeatureFlagsSet) => ChannelSummary[] | null
activeCount: (channels: ChannelSummary[] | null) => number
}
@@ -69,22 +96,57 @@ 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',
+ 'integrationsLoading',
+ 'sandboxEmailSender',
+ 'sandboxEmailSenderEnabled',
+ ],
+ featureFlagLogic,
+ ['featureFlags'],
+ teamLogic,
+ ['currentProjectId'],
+ ],
+ actions: [integrationsLogic, ['openSetupModal', 'loadIntegrations'], featureFlagLogic, ['setFeatureFlags']],
})),
actions({
startChannelSetup: (kind: ChannelType) => ({ kind }),
+ retrySandboxSenderSetup: true,
}),
- reducers({
- integrationsLoadFailed: [
- false,
+ loaders(({ actions, values }) => ({
+ // The endpoint is idempotent, so the page asks for the sandbox sender on every visit.
+ sandboxProvisionError: [
+ null as string | null,
{
- loadIntegrationsFailure: () => true,
- loadIntegrationsSuccess: () => false,
+ setUpSandboxSender: async (_, breakpoint): Promise => {
+ let failure: string | null = null
+ try {
+ await integrationsEmailSandboxSenderCreate(String(values.currentProjectId))
+ } catch (error) {
+ failure = (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.'
+ }
+ breakpoint()
+ if (!failure) {
+ actions.loadIntegrations()
+ }
+ return failure
+ },
},
],
- }),
+ })),
selectors({
+ sandboxSetupLoading: [
+ (s) => [s.integrationsLoading, s.sandboxProvisionErrorLoading],
+ (loading: boolean, provisioning: boolean): boolean => loading || provisioning,
+ ],
+ sandboxSetupError: [
+ (s) => [s.integrationsLoadFailed, s.sandboxProvisionError],
+ (loadFailed: boolean, setupError: string | null): string | null => (loadFailed ? null : setupError),
+ ],
/** `null` until the integrations load, so the catalog never shows a channel as not set up by mistake. */
channels: [
(s) => [s.integrations, s.featureFlags],
@@ -104,6 +166,24 @@ export const channelCatalogLogic = kea([
],
}),
listeners(({ actions, values }) => ({
+ setFeatureFlags: () => {
+ if (
+ values.sandboxEmailSenderEnabled &&
+ !values.sandboxEmailSender &&
+ !values.sandboxProvisionErrorLoading
+ ) {
+ actions.setUpSandboxSender()
+ }
+ },
+ retrySandboxSenderSetup: () => {
+ if (values.sandboxSetupLoading) {
+ return
+ }
+ actions.loadIntegrations()
+ if (values.sandboxEmailSenderEnabled) {
+ actions.setUpSandboxSender()
+ }
+ },
startChannelSetup: ({ kind }) => {
// pinned: analytics event name - renaming breaks dashboards
posthog.capture('workflows channel setup started', {
@@ -125,4 +205,9 @@ export const channelCatalogLogic = kea([
actions.openSetupModal(undefined, kind)
},
})),
+ afterMount(({ actions, values }) => {
+ if (values.sandboxEmailSenderEnabled) {
+ actions.setUpSandboxSender()
+ }
+ }),
])
diff --git a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx
index 5f2d947ad8a4..6420ca4d64d5 100644
--- a/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx
+++ b/products/workflows/frontend/setupGuide/wizard/WizardChannelStep.tsx
@@ -10,7 +10,12 @@ import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { ChannelSetupModal } from '../../Channels/ChannelSetupModal'
export function WizardChannelStep(): JSX.Element {
- const { integrations, setupModalOpen, setupModalType, selectedIntegration } = useValues(integrationsLogic)
+ const {
+ userManagedIntegrations: integrations,
+ setupModalOpen,
+ setupModalType,
+ selectedIntegration,
+ } = useValues(integrationsLogic)
const { openSetupModal, closeSetupModal, markTaskAsCompleted } = useActions(integrationsLogic)
const hasEmailChannel = !!integrations?.some((integration) => integration.kind === 'email')
diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts
new file mode 100644
index 000000000000..e58833a3dbec
--- /dev/null
+++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts
@@ -0,0 +1,36 @@
+import { waitFor } from '@testing-library/react'
+
+import { useMocks } from '~/mocks/jest'
+import { initKeaTests } from '~/test/init'
+
+import { workflowsSetupGuideLogic } from '../workflowsSetupGuideLogic'
+import { workflowsOnboardingWizardLogic } from './workflowsOnboardingWizardLogic'
+
+describe('workflowsOnboardingWizardLogic', () => {
+ let logic: ReturnType
+
+ beforeEach(() => {
+ initKeaTests()
+ useMocks({
+ get: {
+ '/api/projects/:team_id/integrations/': {
+ results: [{ id: 9, kind: 'email', config: { provider: 'sandbox', verified: true } }],
+ },
+ '/api/projects/:project_id/messaging_categories/': { results: [], count: 0 },
+ '/api/environments/:team_id/messaging_templates/': { results: [] },
+ '/api/projects/:project_id/hog_flows/': { results: [], count: 0 },
+ },
+ })
+ logic = workflowsOnboardingWizardLogic()
+ logic.mount()
+ })
+
+ afterEach(() => logic.unmount())
+
+ it('leaves the email channel and domain steps open when only the sandbox sender exists', async () => {
+ await waitFor(() => expect(workflowsSetupGuideLogic.values.steps).not.toBeNull())
+
+ expect(workflowsSetupGuideLogic.values.steps?.filter((step) => step.done).map((step) => step.key)).toEqual([])
+ expect(logic.values.stepDone).toMatchObject({ channel: false, domain: false })
+ })
+})
diff --git a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts
index 7e63da79620d..80eec4409343 100644
--- a/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts
+++ b/products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.ts
@@ -294,7 +294,7 @@ export const workflowsOnboardingWizardLogic = kea([
projectLogic,
['currentProjectId'],
integrationsLogic,
- ['integrations'],
+ ['userManagedIntegrations as integrations'],
optOutCategoriesLogic,
['categories', 'categoriesLoading'],
messageTemplatesLogic,