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/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 92f80a876be8..fa9f1b62ea51 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -50,6 +50,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' @@ -253,6 +254,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { {isSavedWorkflow && } {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..a2b8b89b4d3a --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -0,0 +1,389 @@ +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[] + let savedPatch: Partial & { stage_draft?: boolean } + + 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 }) => { + savedPatch = (await request.json()) as typeof savedPatch + return [200, { ...savedWorkflow, ...savedPatch }] + }, + }, + }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + successToast = jest.spyOn(lemonToast, 'success').mockImplementation() + }) + + afterEach(() => { + jest.useRealTimers() + 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', + 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) + await jest.advanceTimersByTimeAsync(3500) + await expectLogic(workflowLogic({ id: WORKFLOW_ID })).toDispatchActions(['saveWorkflowSuccess']) + + 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({ + 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() + } + }, + })), +])