From a6dbd3575f8e79a2542a74b597e492d93b98854e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:21:01 +0000 Subject: [PATCH 1/2] feat(workflows): offer a switch from the sandbox sender A workflow whose email steps use the sandbox sender shows a banner once the project has a verified own sender. The banner switches every sandbox email step to that sender as an unsaved change, behind the workflows-sandbox-sender flag. Refs Silthus/personal#202 Co-Authored-By: Claude Opus 5.5 --- .../WorkflowSandboxSwitchBanner.stories.tsx | 159 ++++++++ .../Workflows/WorkflowSandboxSwitchBanner.tsx | 36 ++ .../WorkflowSandboxSwitchSenderButton.tsx | 43 ++ .../frontend/Workflows/WorkflowScene.tsx | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 375 ++++++++++++++++++ .../workflowSandboxSwitchBannerLogic.ts | 236 +++++++++++ 6 files changed, 851 insertions(+) create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx new file mode 100644 index 000000000000..697302b5664c --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' +import { AccessControlLevel } from '~/types' + +import type { HogFlow } from './hogflows/types' +import { NEW_WORKFLOW, WorkflowLogicProps, workflowLogic } from './workflowLogic' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' + +const LOGIC_PROPS: WorkflowLogicProps = { id: 'storybook-sandbox-switch-banner' } + +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', +} + +const SECOND_OWN_SENDER = { + id: 9, + kind: 'email', + display_name: 'Acme support ', + config: { + provider: 'ses', + name: 'Acme support', + email: 'support@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SANDBOX_WORKFLOW: HogFlow = { + ...NEW_WORKFLOW, + id: LOGIC_PROPS.id!, + name: 'Welcome email', + user_access_level: AccessControlLevel.Editor, + actions: [ + { + id: 'trigger', + type: 'trigger', + name: 'New account created', + description: '', + config: { type: 'event', filters: {} }, + }, + { + id: 'email', + type: 'function_email', + name: 'Send welcome email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: '{{ person.properties.email }}', name: '' }, + from: { integrationId: SANDBOX_SENDER.id }, + subject: 'Welcome aboard', + text: 'Your account is ready.', + html: '

Your account is ready.

', + }, + templating: 'liquid', + }, + }, + }, + }, + { id: 'exit', type: 'exit', name: 'Exit', description: '', config: { reason: 'Completed' } }, + ], + edges: [ + { from: 'trigger', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit', type: 'continue' }, + ], +} + +type StoryArgs = { + ownSenders: (typeof OWN_SENDER)[] + narrow?: boolean + constrained?: boolean +} + +const meta: Meta = { + title: 'Products/Workflows/Sandbox switch banner', + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + testOptions: { waitForSelector: '[data-attr="workflow-sandbox-switch-sender"]' }, + }, +} +export default meta + +const Template: StoryFn = ({ ownSenders, narrow, constrained }): JSX.Element => { + useStorybookMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': SANDBOX_WORKFLOW, + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] }, + '/api/projects/:team_id/hog_function_templates': { count: 0, results: [] }, + '/api/environments/:team_id/messaging_categories': { count: 0, results: [] }, + }, + }) + return ( + +
+
+ + {constrained &&
} +
+
+ + ) +} + +export const OneOwnSender = Template.bind({}) +OneOwnSender.args = { ownSenders: [OWN_SENDER] } + +export const OneOwnSenderNarrow = Template.bind({}) +OneOwnSenderNarrow.args = { ownSenders: [OWN_SENDER], narrow: true } + +export const ConstrainedScene = Template.bind({}) +ConstrainedScene.args = { ownSenders: [OWN_SENDER], constrained: true } + +export const ConstrainedSceneNarrow = Template.bind({}) +ConstrainedSceneNarrow.args = { ownSenders: [OWN_SENDER], constrained: true, narrow: true } + +export const OneOwnSenderLongAddressNarrow = Template.bind({}) +OneOwnSenderLongAddressNarrow.args = { + ownSenders: [ + { + ...OWN_SENDER, + display_name: 'Acme notifications ', + config: { ...OWN_SENDER.config, email: 'notifications-and-billing@mail.acme.example.com' }, + }, + ], + narrow: true, +} + +export const SeveralOwnSenders = Template.bind({}) +SeveralOwnSenders.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER] } + +export const SeveralOwnSendersNarrow = Template.bind({}) +SeveralOwnSendersNarrow.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx new file mode 100644 index 000000000000..33e260f8b2a3 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx @@ -0,0 +1,36 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner } from '@posthog/lemon-ui' + +import { WorkflowLogicProps } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' +import { WorkflowSandboxSwitchSenderButton } from './WorkflowSandboxSwitchSenderButton' + +export function WorkflowSandboxSwitchBanner(props: WorkflowLogicProps): JSX.Element | null { + const logic = workflowSandboxSwitchBannerLogic(props) + const { bannerVisible, verifiedOwnSenders, switchDisabledReason } = useValues(logic) + const { switchToOwnSender } = useActions(logic) + + if (!bannerVisible) { + return null + } + + return ( + +
+ + Your project has a verified email sender. This workflow still sends from the sandbox sender, which + delivers only to verified members of your organization. + + +
+
+ ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx new file mode 100644 index 000000000000..c311249d50e5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx @@ -0,0 +1,43 @@ +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' + +import { IntegrationType } from '~/types' + +export function WorkflowSandboxSwitchSenderButton({ + senders, + disabledReason, + onSwitch, +}: { + senders: IntegrationType[] + disabledReason: string | null + onSwitch: (integrationId: number) => void +}): JSX.Element { + const buttonProps = { + type: 'secondary' as const, + size: 'small' as const, + disabledReason, + 'data-attr': 'workflow-sandbox-switch-sender', + className: 'max-w-full self-start @xl:self-auto', + } + + if (senders.length === 1) { + const label = `Switch to ${senders[0].config.email}` + return ( + onSwitch(senders[0].id)}> + {label} + + ) + } + + return ( + ({ + label: sender.display_name, + onClick: () => onSwitch(sender.id), + 'data-attr': 'workflow-sandbox-switch-sender-option', + }))} + placement="bottom-end" + > + Switch sender + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx index 778b7b8b6d73..62ffa0baf12b 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -49,6 +49,7 @@ import { WorkflowInvocations } from './WorkflowInvocations' import { WorkflowLogicProps, workflowLogic } from './workflowLogic' import { WorkflowMetrics } from './WorkflowMetrics' import { WorkflowRevisions } from './WorkflowRevisions' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' import { WorkflowSceneHeader } from './WorkflowSceneHeader' import { WorkflowSceneLogicProps, WorkflowTab, workflowSceneLogic } from './workflowSceneLogic' import { TRIGGER_PREFILL_PARAM } from './workflowTriggerPrefill' @@ -251,6 +252,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { {isSavedWorkflow && } {!isSavedWorkflow && } + {suggestionsAvailable && isSavedWorkflow && } {/* Only show Logs and Metrics tabs if the workflow has already been created */} {!props.id || props.id === 'new' ? ( diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts new file mode 100644 index 000000000000..d51eed316862 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -0,0 +1,375 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { AccessControlLevel } from '~/types' + +import { resourceEditedLogic } from 'products/notifications/frontend/resourceEditedLogic' + +import { HogFlow, HogFlowAction } from './hogflows/types' +import { workflowLogic } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' + +const WORKFLOW_ID = 'wf-sandbox-switch-1' +const SANDBOX_SENDER_ID = 7 +const OWN_SENDER_ID = 8 +const SECOND_OWN_SENDER_ID = 9 + +const SANDBOX_SENDER = { + id: SANDBOX_SENDER_ID, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} +const OWN_SENDER = { + id: OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme ', + config: { provider: 'ses', domain: 'acme.example.com', email: 'hello@acme.example.com', verified: true }, +} +const SECOND_OWN_SENDER = { + id: SECOND_OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme support ', + config: { provider: 'ses', domain: 'acme.example.com', email: 'support@acme.example.com', verified: true }, +} +const UNVERIFIED_OWN_SENDER = { ...OWN_SENDER, id: 10, config: { ...OWN_SENDER.config, verified: false } } + +const emailStep = (id: string, from: Record): HogFlowAction => ({ + id, + type: 'function_email', + name: 'Send email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: 'recipient@example.com' }, + from, + subject: 'Hello', + html: '

Hi

', + text: 'Hi', + }, + templating: 'liquid', + }, + }, + }, +}) + +const makeWorkflow = (steps: HogFlowAction[]): HogFlow => ({ + id: WORKFLOW_ID, + name: 'Sandbox switch test', + actions: [ + { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { type: 'event', filters: {} }, + }, + ...steps, + { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Default exit' }, + }, + ], + edges: [], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'draft', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', +}) + +const senderOf = (workflow: HogFlow, stepId: string): unknown => { + const action = workflow.actions.find((candidate) => candidate.id === stepId) + return action?.type === 'function_email' ? action.config.inputs.email.value.from : undefined +} + +describe('workflowSandboxSwitchBannerLogic', () => { + let logic: ReturnType + let capture: jest.SpyInstance + let successToast: jest.SpyInstance + let savedWorkflow: HogFlow + let integrationsPayload: unknown[] + + const setSandboxFlag = (enabled: boolean): void => { + featureFlagLogic.actions.setFeatureFlags( + enabled ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] : [], + enabled ? { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true } : {} + ) + } + + const mountWith = async ({ + steps, + integrations, + flag = true, + accessLevel = AccessControlLevel.Editor, + status = 'draft', + }: { + steps: HogFlowAction[] + integrations: unknown[] + flag?: boolean + accessLevel?: AccessControlLevel + status?: HogFlow['status'] + }): Promise => { + savedWorkflow = { ...makeWorkflow(steps), user_access_level: accessLevel, status } + integrationsPayload = integrations + initKeaTests() + featureFlagLogic.mount() + setSandboxFlag(flag) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + workflow.mount() + integrationsLogic.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + logic = workflowSandboxSwitchBannerLogic({ id: WORKFLOW_ID }) + logic.mount() + } + + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => [200, savedWorkflow], + '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }], + '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ + 200, + { ...savedWorkflow, ...((await request.json()) as Partial) }, + ], + }, + }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + successToast = jest.spyOn(lemonToast, 'success').mockImplementation() + }) + + afterEach(() => { + logic?.unmount() + featureFlagLogic.actions.setFeatureFlags([], {}) + capture.mockRestore() + successToast.mockRestore() + }) + + it.each([ + { + case: 'a sandbox email step and a verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: true, + }, + { + case: 'no verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, UNVERIFIED_OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'no email step on the sandbox sender', + steps: [emailStep('email_1', { integrationId: OWN_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'the sandbox sender flag off', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + visible: false, + }, + ])('shows the banner only with $case', async ({ steps, integrations, flag, visible }) => { + await mountWith({ steps, integrations, flag }) + + expect(logic.values.bannerVisible).toBe(visible) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + visible ? 1 : 0 + ) + }) + + it('reports the banner once the flag arrives after the workflow and integrations loaded', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + }) + expect(logic.values.bannerVisible).toBe(false) + + setSandboxFlag(true) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('reports the banner once per workflow view even when it hides and shows again', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + expect(logic.values.bannerVisible).toBe(false) + workflow.actions.setWorkflowValues({ actions: savedWorkflow.actions }) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('switches every sandbox email step to the own sender as an unsaved change', async () => { + await mountWith({ + steps: [ + emailStep('email_1', { integrationId: SANDBOX_SENDER_ID }), + emailStep('email_2', { integrationId: OWN_SENDER_ID, name: 'Acme', email: 'hello@acme.example.com' }), + emailStep('email_3', { integrationId: SANDBOX_SENDER_ID }), + ], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(senderOf(workflow.values.workflow, 'email_2')).toEqual({ + integrationId: OWN_SENDER_ID, + name: 'Acme', + email: 'hello@acme.example.com', + }) + expect(senderOf(workflow.values.workflow, 'email_3')).toEqual({ integrationId: OWN_SENDER_ID }) + const switchedEmail = workflow.values.workflow.actions.find((step) => step.id === 'email_1') + expect(switchedEmail?.type === 'function_email' && switchedEmail.config.inputs.email).toEqual({ + value: { + from: { integrationId: OWN_SENDER_ID }, + to: { email: 'recipient@example.com' }, + subject: 'Hello', + html: '

Hi

', + text: 'Hi', + }, + templating: 'liquid', + }) + expect(senderOf(workflow.values.originalWorkflow!, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(workflow.values.hasUnsavedChanges).toBe(true) + expect(logic.values.bannerVisible).toBe(false) + expect(capture).toHaveBeenCalledWith('workflows sandbox sender switched', { email_step_count: 2 }) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox sender switched')).toHaveLength(1) + }) + + it.each([ + ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], + ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(successToast).toHaveBeenCalledWith(confirmation) + }) + + it('lets the user pick the own sender when several are verified', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, SECOND_OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + + expect(logic.values.verifiedOwnSenders.map((sender) => sender.id)).toEqual([ + OWN_SENDER_ID, + SECOND_OWN_SENDER_ID, + ]) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(SECOND_OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SECOND_OWN_SENDER_ID, + }) + }) + + it('prevents switching while an external edit reloads the workflow', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => new Promise(() => {}), + }, + }) + resourceEditedLogic.actions.resourceEdited({ + notification_type: 'resource_edited', + team_id: 1, + resource_type: 'HogFlow', + resource_id: WORKFLOW_ID, + updated_at: '2026-06-01T00:00:00.000Z', + actor_user_id: 99, + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + expect(workflow.values.isSyncingExternalEdit).toBe(true) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(logic.values.switchDisabledReason).toBe('Wait for the workflow to finish syncing') + expect(successToast).not.toHaveBeenCalled() + }) + + it('leaves the workflow intact for a viewer', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + accessLevel: AccessControlLevel.Viewer, + }) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + }) + + it.each([UNVERIFIED_OWN_SENDER.id, SANDBOX_SENDER_ID, 999])( + 'rejects ineligible switch target %s', + async (targetId) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + logic.actions.switchToOwnSender(targetId) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + } + ) +}) diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts new file mode 100644 index 000000000000..a1a8dc4032bf --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts @@ -0,0 +1,236 @@ +import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' +import posthog from 'posthog-js' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' +import { pluralize } from 'lib/utils/strings' +import { selectedSenderIds } from 'scenes/hog-functions/email-templater/selectSenders' + +import { AccessControlLevel, AccessControlResourceType, IntegrationType } from '~/types' + +import { isEmailAction } from './hogflows/steps/types' +import { HogFlow, HogFlowAction } from './hogflows/types' +import { WorkflowLogicProps, workflowLogic } from './workflowLogic' + +export type EmailStep = Extract + +const usesSender = (step: EmailStep, senderId: number): boolean => + selectedSenderIds(step.config.inputs?.email?.value?.from).includes(senderId) + +const withSender = (step: EmailStep, integrationId: number): EmailStep => { + const email = step.config.inputs.email + return { + ...step, + config: { + ...step.config, + inputs: { + ...step.config.inputs, + email: { ...email, value: { ...email.value, from: { integrationId } } }, + }, + }, + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicValues { + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + isSyncingExternalEdit: boolean // workflowLogic + workflow: HogFlow // workflowLogic + workflowLoading: boolean // workflowLogic + workflowUserAccessLevel: AccessControlLevel | null // workflowLogic + bannerShownReported: boolean + bannerVisible: boolean + sandboxEmailSteps: EmailStep[] + switchDisabledReason: string | null + verifiedOwnSenders: IntegrationType[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicActions { + autoSaveWorkflow: () => { + value: true + } // workflowLogic + setWorkflowValues: (values: import('node_modules/kea-forms/lib').DeepPartial) => { + values: import('node_modules/kea-forms/lib').DeepPartial + } // workflowLogic + reportBannerShown: () => { + value: true + } + switchToOwnSender: (integrationId: number) => { + integrationId: number + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + switchDisabledReason: ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ) => string | null + verifiedOwnSenders: (ownEmailIntegrations: IntegrationType[]) => IntegrationType[] + sandboxEmailSteps: (workflow: HogFlow, sandboxEmailSender: IntegrationType | null) => EmailStep[] + bannerVisible: ( + sandboxEmailSteps: ({ + config: { + inputs: Record< + string, + { + bytecode?: any + order?: number | undefined + secret?: boolean | undefined + templating?: 'hog' | 'liquid' | undefined + value: any + } + > + message_category_id?: string | undefined + message_category_type?: 'marketing' | 'transactional' | undefined + template_id: 'template-email' + template_uuid?: string | undefined + tracking_enabled?: boolean | undefined + utm_params?: + | { + utm_campaign?: string | undefined + utm_content?: string | undefined + utm_medium?: string | undefined + utm_source?: string | undefined + } + | undefined + utm_params_from_default?: + | ('utm_campaign' | 'utm_content' | 'utm_medium' | 'utm_source')[] + | undefined + utm_tags_enabled?: boolean | undefined + } + created_at?: number | undefined + description: string + filters?: + | { + actions?: any[] | undefined + events?: any[] | undefined + properties?: any[] | undefined + } + | null + | undefined + id: string + name: string + on_error?: 'abort' | 'continue' | null | undefined + output_variable?: + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + } + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + }[] + | null + | undefined + type: 'function_email' + updated_at?: number | undefined + } & Record)[], + verifiedOwnSenders: IntegrationType[] + ) => boolean + } +} + +export type workflowSandboxSwitchBannerLogicType = MakeLogicType< + workflowSandboxSwitchBannerLogicValues, + workflowSandboxSwitchBannerLogicActions, + WorkflowLogicProps, + workflowSandboxSwitchBannerLogicMeta +> + +export const workflowSandboxSwitchBannerLogic = kea([ + props({} as WorkflowLogicProps), + key((props) => `${props.id ?? 'new'}-${props.templateId ?? 'default'}-${props.editTemplateId ?? 'default'}`), + path((key) => ['products', 'workflows', 'frontend', 'Workflows', 'workflowSandboxSwitchBannerLogic', key]), + connect((props: WorkflowLogicProps) => ({ + values: [ + workflowLogic(props), + ['workflow', 'workflowUserAccessLevel', 'workflowLoading', 'isSyncingExternalEdit'], + integrationsLogic, + ['ownEmailIntegrations', 'sandboxEmailSender'], + ], + actions: [workflowLogic(props), ['setWorkflowValues', 'autoSaveWorkflow']], + })), + actions({ + switchToOwnSender: (integrationId: number) => ({ integrationId }), + reportBannerShown: true, + }), + reducers({ + bannerShownReported: [false, { reportBannerShown: () => true }], + }), + selectors({ + switchDisabledReason: [ + (s) => [s.workflowUserAccessLevel, s.workflowLoading, s.isSyncingExternalEdit], + ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ): string | null => + getAccessControlDisabledReason( + AccessControlResourceType.Workflow, + AccessControlLevel.Editor, + workflowUserAccessLevel ?? undefined + ) || (workflowLoading || isSyncingExternalEdit ? 'Wait for the workflow to finish syncing' : null), + ], + verifiedOwnSenders: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): IntegrationType[] => + ownEmailIntegrations.filter((integration) => integration.config?.verified === true), + ], + sandboxEmailSteps: [ + (s) => [s.workflow, s.sandboxEmailSender], + (workflow: HogFlow, sandboxEmailSender: IntegrationType | null): EmailStep[] => + sandboxEmailSender + ? workflow.actions.filter(isEmailAction).filter((step) => usesSender(step, sandboxEmailSender.id)) + : [], + ], + bannerVisible: [ + (s) => [s.sandboxEmailSteps, s.verifiedOwnSenders], + (sandboxEmailSteps: EmailStep[], verifiedOwnSenders: IntegrationType[]): boolean => + sandboxEmailSteps.length > 0 && verifiedOwnSenders.length > 0, + ], + }), + listeners(({ actions, values }) => ({ + reportBannerShown: () => { + posthog.capture('workflows sandbox switch banner shown') + }, + switchToOwnSender: ({ integrationId }) => { + const { sandboxEmailSteps } = values + const sender = values.verifiedOwnSenders.find((candidate) => candidate.id === integrationId) + if (sandboxEmailSteps.length === 0 || !sender) { + return + } + if (values.switchDisabledReason) { + return + } + const switched = new Map(sandboxEmailSteps.map((step) => [step.id, withSender(step, integrationId)])) + actions.setWorkflowValues({ + actions: values.workflow.actions.map((action) => switched.get(action.id) ?? action), + }) + actions.autoSaveWorkflow() + posthog.capture('workflows sandbox sender switched', { email_step_count: sandboxEmailSteps.length }) + const nextStep = values.workflow.status === 'active' ? ' Publish the changes to use this sender.' : '' + lemonToast.success( + `Updated ${pluralize(sandboxEmailSteps.length, 'email step')} to ${sender.config.email ?? 'your own sender'}.${nextStep}` + ) + }, + })), + subscriptions(({ actions, values }) => ({ + bannerVisible: (bannerVisible: boolean) => { + if (bannerVisible && !values.bannerShownReported) { + actions.reportBannerShown() + } + }, + })), +]) From ca428c7f5eb298bf62bba884d732c4b293312a24 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:04:33 +0000 Subject: [PATCH 2/2] test(workflows): prove the sandbox switch saves the new sender Also document the switch banner next to the sandbox sender setup. Co-Authored-By: Claude Opus 5.5 --- .../internal/workflows-email-sending-tiers.md | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 44 ++++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 865e62487730..59dfbde66e9a 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -36,6 +36,8 @@ The managed integration is excluded from project SES tenant migration, identity Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. The sandbox sender is available only in workflow email steps and test sends. Broadcasts, destinations, sender rotation, custom From values, and Reply-To overrides are rejected. Unchanged stored sender settings remain editable. Saves check the sandbox sender only while `workflows-sandbox-sender` is on for the project, so projects without the flag skip the integration lookup. +When a project has a verified own sender, a workflow with email steps on the sandbox sender shows a banner that moves all of those steps to the own sender in one click. +The switch saves as a draft change. An active workflow keeps sending from the sandbox sender until someone publishes. ### Sending tiers diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts index d51eed316862..a2b8b89b4d3a 100644 --- a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -111,6 +111,7 @@ describe('workflowSandboxSwitchBannerLogic', () => { let successToast: jest.SpyInstance let savedWorkflow: HogFlow let integrationsPayload: unknown[] + let savedPatch: Partial & { stage_draft?: boolean } const setSandboxFlag = (enabled: boolean): void => { featureFlagLogic.actions.setFeatureFlags( @@ -154,10 +155,10 @@ describe('workflowSandboxSwitchBannerLogic', () => { '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), }, patch: { - '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ - 200, - { ...savedWorkflow, ...((await request.json()) as Partial) }, - ], + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + savedPatch = (await request.json()) as typeof savedPatch + return [200, { ...savedWorkflow, ...savedPatch }] + }, }, }) capture = jest.spyOn(posthog, 'capture').mockImplementation() @@ -165,6 +166,7 @@ describe('workflowSandboxSwitchBannerLogic', () => { }) afterEach(() => { + jest.useRealTimers() logic?.unmount() featureFlagLogic.actions.setFeatureFlags([], {}) capture.mockRestore() @@ -285,19 +287,31 @@ describe('workflowSandboxSwitchBannerLogic', () => { }) it.each([ - ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], - ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], - ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { - await mountWith({ - steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], - integrations: [SANDBOX_SENDER, OWN_SENDER], - status, - }) + [ + 'active', + true, + 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.', + ], + ['draft', false, 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)( + 'saves the switch for a workflow in %s status and explains what remains', + async (status, stagesDraft, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + jest.useFakeTimers() - logic.actions.switchToOwnSender(OWN_SENDER_ID) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + await jest.advanceTimersByTimeAsync(3500) + await expectLogic(workflowLogic({ id: WORKFLOW_ID })).toDispatchActions(['saveWorkflowSuccess']) - expect(successToast).toHaveBeenCalledWith(confirmation) - }) + expect(senderOf(savedPatch as HogFlow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(savedPatch.stage_draft === true).toBe(stagesDraft) + expect(successToast).toHaveBeenCalledWith(confirmation) + } + ) it('lets the user pick the own sender when several are verified', async () => { await mountWith({