From 4c197d8324210a28a09754c3e6fa728e74353de6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:18:16 +0000 Subject: [PATCH 1/3] feat(workflows): show the managed sandbox sender in channels Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/constants.tsx | 1 + .../src/lib/integrations/IntegrationsList.tsx | 6 +- .../integrations/integrationsLogic.test.ts | 96 +++++++++++++ .../src/lib/integrations/integrationsLogic.ts | 95 +++++++++--- .../frontend/Channels/ChannelCatalog.tsx | 44 ++++-- .../Channels/MessageChannels.stories.tsx | 63 ++++++++ .../frontend/Channels/MessageChannels.tsx | 30 +++- .../Channels/SandboxEmailSenderRow.tsx | 51 +++++++ .../Channels/channelCatalogLogic.test.ts | 136 ++++++++++++++++++ .../frontend/Channels/channelCatalogLogic.ts | 129 +++++++++++++---- .../setupGuide/wizard/WizardChannelStep.tsx | 7 +- .../workflowsOnboardingWizardLogic.test.ts | 36 +++++ .../wizard/workflowsOnboardingWizardLogic.ts | 2 +- .../setupGuide/workflowsSetupGuideLogic.ts | 2 +- 14 files changed, 637 insertions(+), 61 deletions(-) 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 create mode 100644 products/workflows/frontend/setupGuide/wizard/workflowsOnboardingWizardLogic.test.ts diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index c02eaca10a25..30ea56408c3c 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -622,6 +622,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows + WORKFLOWS_SANDBOX_SENDER: 'workflows-sandbox-sender', // owner: #team-workflows WORKFLOWS_SENDING_TIER_REQUESTS: 'workflows-sending-tier-requests', // owner: @luke-belton #team-workflows, lets customers request a higher email sending tier 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..c6ea32ab4fb2 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,100 @@ 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', + } + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + const enableFlag = (): void => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + } + + 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.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..f2286e0f50cc 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' @@ -44,7 +46,7 @@ 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' const INTEGRATIONS_POLL_INTERVAL_MS = 30_000 @@ -105,6 +107,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 +182,25 @@ 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 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. @@ -709,7 +718,7 @@ export interface integrationsLogicMeta { githubAvailableInstallationsResponse: GitHubAvailableInstallationsResponseApi | null ) => boolean | null getIntegrationsByKind: ( - integrations: IntegrationType[] | null + userManagedIntegrations: IntegrationType[] | null ) => ( kinds: ( | 'apns' @@ -770,7 +779,14 @@ export interface integrationsLogicMeta { getGitHubRepositoriesTotal: ( githubRepositoriesTotal: Record ) => (integrationId: number) => number | null - domainGroupedEmailIntegrations: (integrations: IntegrationType[] | null) => EmailIntegrationDomainGroupedType[] + sandboxEmailSenderEnabled: (featureFlags: FeatureFlagsSet) => boolean + 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 +800,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'], @@ -838,6 +861,25 @@ 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, + }, + ], githubDisconnecting: [false, { setGithubDisconnecting: (_, { disconnecting }) => disconnecting }], githubDiscoveryGeneration: [ 0, @@ -1479,7 +1521,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 +1553,40 @@ export const integrationsLogic = kea([ }, ], - domainGroupedEmailIntegrations: [ + sandboxEmailSenderEnabled: [ + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => !!featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + ], + 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/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..569872b3d399 --- /dev/null +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -0,0 +1,136 @@ +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() + }) + + 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..a16a2b0b5c94 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 + 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,53 @@ 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 (): Promise => { + try { + await integrationsEmailSandboxSenderCreate(String(values.currentProjectId)) + } catch (error) { + return (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' + } + actions.loadIntegrations() + return null + }, }, ], - }), + })), 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 +162,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 +201,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, From 87c1210aa924247dc7b085dc95d87c45f18e7885 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:00:18 +0000 Subject: [PATCH 2/3] fix(workflows): drop stale sandbox setup results and cover the retry banner Co-Authored-By: Claude Opus 5.5 --- .../Channels/channelCatalogLogic.test.ts | 27 +++++++++++++------ .../frontend/Channels/channelCatalogLogic.ts | 14 ++++++---- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts index 569872b3d399..872d514bf116 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { expectLogic } from 'kea-test-utils' import { createElement } from 'react' @@ -13,6 +13,7 @@ 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 @@ -23,10 +24,19 @@ describe('sandbox setup in the channel catalog', () => { logic?.unmount() }) - it.each([404, 500])('starts setup on mount and exposes a %s request for retry', async (failureStatus) => { + 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 } } + 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: { @@ -45,13 +55,14 @@ describe('sandbox setup in the channel catalog', () => { logic.mount() await waitFor(() => expect(requests).toBe(1)) await expectLogic(integrationsLogic).toFinishAllListeners() - expect(logic.values.sandboxSetupError).toBe('Could not set up your sandbox sender.') + render(createElement(MessageChannels)) + expect(await screen.findByText('Could not set up your sandbox sender.')).toBeInTheDocument() status = 200 - await expectLogic(logic, () => logic.actions.retrySandboxSenderSetup()).toFinishAllListeners() - await waitFor(() => expect(requests).toBe(2)) - await expectLogic(integrationsLogic).toFinishAllListeners() - expect(logic.values.sandboxSetupError).toBeNull() + 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 () => { diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.ts b/products/workflows/frontend/Channels/channelCatalogLogic.ts index a16a2b0b5c94..194dcaec6be0 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.ts @@ -56,7 +56,7 @@ export interface channelCatalogLogicActions { retrySandboxSenderSetup: () => { value: true } - setUpSandboxSender: () => any + setUpSandboxSender: (_?: any) => any setUpSandboxSenderFailure: ( error: string, errorObject?: any @@ -122,14 +122,18 @@ export const channelCatalogLogic = kea([ sandboxProvisionError: [ null as string | null, { - setUpSandboxSender: async (): Promise => { + setUpSandboxSender: async (_, breakpoint): Promise => { + let failure: string | null = null try { await integrationsEmailSandboxSenderCreate(String(values.currentProjectId)) } catch (error) { - return (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' + failure = (error instanceof ApiError && error.detail) || 'Could not set up the sandbox sender.' } - actions.loadIntegrations() - return null + breakpoint() + if (!failure) { + actions.loadIntegrations() + } + return failure }, }, ], From ed38f61eaff40ad3d67eb037e63d293e2e083ee7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:10:21 +0000 Subject: [PATCH 3/3] test(workflows): cover a stale sandbox setup response after Channels reopens Co-Authored-By: Claude Opus 5.5 --- .../Channels/channelCatalogLogic.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts index 872d514bf116..73b74a1adc3e 100644 --- a/products/workflows/frontend/Channels/channelCatalogLogic.test.ts +++ b/products/workflows/frontend/Channels/channelCatalogLogic.test.ts @@ -111,6 +111,42 @@ describe('sandbox setup in the channel catalog', () => { 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 },