diff --git a/products/workflows/frontend/Workflows/firstRunSender.ts b/products/workflows/frontend/Workflows/firstRunSender.ts index 30f028ad56e2..4fbf69f9c477 100644 --- a/products/workflows/frontend/Workflows/firstRunSender.ts +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -75,18 +75,50 @@ async function provisionSandboxSender(): Promise { return sandbox.id } -async function resolveFirstRunSender(allowSandbox: boolean): Promise { +function teamDefaultSenderId(): number | null | undefined { const team = teamLogic.findMounted()?.values.currentTeam - const integrations = await loadAllIntegrations() - const ownSender = resolveDefaultEmailSender( - integrations, - team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null - ) - if (ownSender || !allowSandbox || !sandboxSenderEnabled() || verifiedOwnEmailSenders(integrations).length > 0) { - return ownSender - } + return team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null +} + +function sandboxApplies(integrations: IntegrationType[], sandboxAllowed: boolean): boolean { + return sandboxAllowed && sandboxSenderEnabled() && verifiedOwnEmailSenders(integrations).length === 0 +} + +function chooseSender(integrations: IntegrationType[], sandboxAllowed: boolean): DefaultEmailSender | null { const sandbox = integrations.find(isSandboxEmailSender) - return { integrationId: sandbox?.id ?? (await provisionSandboxSender()), reason: 'sandbox_sender' } + return sandbox && sandboxApplies(integrations, sandboxAllowed) + ? { integrationId: sandbox.id, reason: 'sandbox_sender' } + : resolveDefaultEmailSender(integrations, teamDefaultSenderId()) +} + +async function resolveFirstRunSender(allowSandbox: boolean): Promise { + const unmountIntegrations = integrationsLogic.mount() + try { + const integrations = await loadAllIntegrations() + if (!sandboxApplies(integrations, allowSandbox) || integrations.some(isSandboxEmailSender)) { + return chooseSender(integrations, allowSandbox) + } + return { integrationId: await provisionSandboxSender(), reason: 'sandbox_sender' } + } finally { + unmountIntegrations() + } +} + +export async function prepareSandboxSender(): Promise { + if (sandboxSenderEnabled() && !integrationsLogic.findMounted()?.values.sandboxEmailSender) { + await resolveFirstRunSender(true) + } +} + +export function withInsertedStepSender( + action: HogFlowAction, + workflow: HogFlow, + integrations: IntegrationType[] | null +): { action: HogFlowAction; sender: DefaultEmailSender | null } { + const sender = takesOwnSender(action) + ? chooseSender(integrations ?? [], takesSandboxSender(action, workflow)) + : null + return { action: sender ? withSender(action, sender.integrationId) : action, sender } } export async function withFirstRunSender( @@ -95,10 +127,7 @@ export async function withFirstRunSender( if (!workflow.actions.some(takesOwnSender)) { return { workflow, sender: null } } - const unmountIntegrations = integrationsLogic.mount() - const sender = await resolveFirstRunSender( - workflow.actions.some((action) => takesSandboxSender(action, workflow)) - ).finally(unmountIntegrations) + const sender = await resolveFirstRunSender(workflow.actions.some((action) => takesSandboxSender(action, workflow))) if (!sender) { return { workflow, sender: null } } diff --git a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx index c66aa6e1c3bc..68e4f0569681 100644 --- a/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx +++ b/products/workflows/frontend/Workflows/hogflows/hogFlowEditorLogic.tsx @@ -41,9 +41,9 @@ import type { TeamType, UserBasicType, } from '../../../../../frontend/src/types' -import { resolveDefaultEmailSender } from '../../Channels/defaultEmailSender' import { optOutCategoriesLogic } from '../../OptOuts/optOutCategoriesLogic' import type { MessageCategory } from '../../OptOuts/optOutCategoriesLogic' +import { prepareSandboxSender, withInsertedStepSender } from '../firstRunSender' import { EXIT_NODE_ID, TRIGGER_NODE_ID, WorkflowLogicProps, workflowLogic } from '../workflowLogic' import { getEdgeId } from './edgeId' import { getFormattedNodes } from './react_flow_utils/autolayout' @@ -2074,6 +2074,13 @@ export interface hogFlowEditorLogicActions { } payload?: any } // workflowLogic + setFeatureFlags: ( + flags: string[], + variants: Record + ) => { + flags: string[] + variants: Record + } // featureFlagLogic setWorkflowAction: ( actionId: string, action: HogFlowAction @@ -2192,6 +2199,12 @@ export interface hogFlowEditorLogicActions { onNodesDelete: (deleted: HogFlowActionNode[]) => { deleted: HogFlowActionNode[] } + prepareFirstRunSender: () => { + value: true + } + assignInsertedStepSender: (actionId: string) => { + actionId: string + } resetFlowFromHogFlow: (hogFlow: HogFlow) => { hogFlow: HogFlow } @@ -2314,12 +2327,16 @@ export const hogFlowEditorLogic = kea([ ['setWorkflowInfo', 'setWorkflowAction', 'setWorkflowActionEdges', 'loadWorkflowSuccess'], optOutCategoriesLogic(), ['loadCategories'], + featureFlagLogic, + ['setFeatureFlags'], ], })), actions({ onEdgesChange: (edges: EdgeChange[]) => ({ edges }), onNodesChange: (nodes: NodeChange[]) => ({ nodes }), onNodesDelete: (deleted: HogFlowActionNode[]) => ({ deleted }), + prepareFirstRunSender: true, + assignInsertedStepSender: (actionId: string) => ({ actionId }), setNodes: (nodes: HogFlowActionNode[]) => ({ nodes }), setDropzoneNodes: (dropzoneNodes: DropzoneNode[]) => ({ dropzoneNodes, @@ -2589,7 +2606,7 @@ export const hogFlowEditorLogic = kea([ }, ], })), - listeners(({ values, actions }) => { + listeners(({ values, actions, cache }) => { let animationTimeout: ReturnType | null = null return { onEdgesChange: ({ edges }) => { @@ -2724,6 +2741,34 @@ export const hogFlowEditorLogic = kea([ actions.setNodesRaw(reconcileById(values.nodes, formattedNodes, (node) => node.id)) }, + prepareFirstRunSender: async () => { + // A second setup while one runs would cancel its sender list load, so later calls share the running one. + cache.senderPreparation ??= prepareSandboxSender() + .catch(() => undefined) + .finally(() => { + cache.senderPreparation = null + }) + await cache.senderPreparation + }, + assignInsertedStepSender: async ({ actionId }) => { + await cache.senderPreparation + if (cache.disposables.isDisposed) { + return + } + const step = values.workflow.actions.find((action) => action.id === actionId) + if (!step) { + return + } + const { action, sender } = withInsertedStepSender(step, values.workflow, values.integrations) + if (sender) { + actions.setWorkflowAction(actionId, action) + // pinned: analytics event name + posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + } + }, + setFeatureFlags: () => { + actions.prepareFirstRunSender() + }, onNodesDelete: ({ deleted }) => { if (deleted.some((node) => node.id === values.selectedNodeId)) { actions.setSelectedNodeId(null) @@ -2914,35 +2959,7 @@ export const hogFlowEditorLogic = kea([ } } - if (!isHogFlowActionNode && 'template_id' in config && config.template_id === 'template-email') { - const emailInput = config.inputs?.email - const team = values.currentTeam - const sender = resolveDefaultEmailSender( - values.integrations, - team && 'workflows_config' in team - ? team.workflows_config?.default_email_integration_id - : null - ) - if (sender && emailInput?.value && !emailInput.value.from?.integrationId) { - config = { - ...config, - inputs: { - ...config.inputs, - email: { - ...emailInput, - value: { - ...emailInput.value, - from: { ...emailInput.value.from, integrationId: sender.integrationId }, - }, - }, - }, - } - // pinned: analytics event name - posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) - } - } - - const newAction = { + const builtAction = { id: isHogFlowActionNode ? (values.nodeToBeAdded as HogFlowActionNode).id : `action_${partialNewAction.type}_${uuid()}`, @@ -2960,6 +2977,14 @@ export const hogFlowEditorLogic = kea([ : {}), } as HogFlowAction + const { action: newAction, sender } = isHogFlowActionNode + ? { action: builtAction, sender: null } + : withInsertedStepSender(builtAction, values.workflow, values.integrations) + if (sender) { + // pinned: analytics event name + posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + } + const step = getHogFlowStep(newAction, values.hogFunctionTemplatesById) const branchEdges = isHogFlowActionNode @@ -3059,6 +3084,9 @@ export const hogFlowEditorLogic = kea([ actions.setWorkflowInfo({ actions: newActions, edges: newEdges, variables: updatedVariables }) actions.setNodeToBeAdded(null) actions.setSelectedNodeId(newAction.id) + if (!sender && !isHogFlowActionNode) { + actions.assignInsertedStepSender(newAction.id) + } } // We can clear the dropzones now actions.hideDropzones() @@ -3287,6 +3315,7 @@ export const hogFlowEditorLogic = kea([ actions.setEditorLayout('advanced') } actions.resetFlowFromHogFlow(values.workflow) + actions.prepareFirstRunSender() const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts index a8845901b144..eb8135daf25c 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -10,6 +10,7 @@ import { initKeaTests } from '~/test/init' import { IntegrationType } from '~/types' import { draftMessage } from '../MessageAudience/messageDrafts' +import { hogFlowEditorLogic } from './hogflows/hogFlowEditorLogic' import { HogFlowAction } from './hogflows/types' import { NEW_WORKFLOW, workflowLogic } from './workflowLogic' @@ -294,4 +295,164 @@ describe('workflow first-run sender', () => { expect(ensureCalls).toBe(2) logic.unmount() }) + it.each<{ + name: string + step: HogFlowAction + senders: Partial[] + flag: boolean + originProduct?: 'broadcasts' + flagArrivesAfterOpen?: boolean + setupFails?: boolean + expectedFrom: Record + expectedEnsureCalls: number + }>([ + { + name: 'an inserted email step gets the sandbox sender set up when the editor opened', + step: emailAction('function_email'), + senders: [], + flag: true, + expectedFrom: { integrationId: SANDBOX.id }, + expectedEnsureCalls: 1, + }, + { + name: 'an inserted email step gets the verified own sender over the sandbox sender', + step: emailAction('function_email'), + senders: [OWN_VERIFIED, SANDBOX], + flag: true, + expectedFrom: { integrationId: OWN_VERIFIED.id }, + expectedEnsureCalls: 0, + }, + { + name: 'an inserted broadcast email step never gets the sandbox sender', + step: emailAction('function_email'), + senders: [SANDBOX], + flag: true, + originProduct: 'broadcasts', + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'an inserted generic email function never gets the sandbox sender', + step: emailAction('function'), + senders: [SANDBOX], + flag: true, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'an inserted email step gets the sandbox sender when the flag arrives after the editor opened', + step: emailAction('function_email'), + senders: [], + flag: true, + flagArrivesAfterOpen: true, + expectedFrom: { integrationId: SANDBOX.id }, + expectedEnsureCalls: 1, + }, + { + name: 'an inserted email step stays empty when the sandbox sender cannot be set up', + step: emailAction('function_email'), + senders: [], + flag: true, + setupFails: true, + expectedFrom: {}, + expectedEnsureCalls: 1, + }, + { + name: 'flag off sets nothing up and leaves an inserted email step empty', + step: emailAction('function_email'), + senders: [], + flag: false, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + ])( + '$name', + async ({ + step, + senders, + flag, + originProduct, + flagArrivesAfterOpen, + setupFails, + expectedFrom, + expectedEnsureCalls, + }) => { + integrations = senders + ensureFails = setupFails ? 1 : 0 + setSandboxFlag(flagArrivesAfterOpen ? false : flag) + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + if (originProduct) { + workflow.actions.setWorkflowInfo({ origin_product: originProduct }) + } + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + await expectLogic(editor).toFinishAllListeners() + if (flagArrivesAfterOpen) { + setSandboxFlag(flag) + await expectLogic(editor).toFinishAllListeners() + } + + editor.actions.setNodeToBeAdded({ ...step, id: undefined } as any) + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + + const inserted = workflow.values.workflow.actions.find((action) => action.type === step.type) + expect((inserted?.config as any)?.inputs?.email?.value?.from).toEqual(expectedFrom) + expect(ensureCalls).toBe(expectedEnsureCalls) + editor.unmount() + workflow.unmount() + } + ) + + it.each([ + ['', false], + [' and the flags refresh meanwhile', true], + ])( + 'gives an email step inserted during the sandbox sender setup that sender once it finishes%s', + async (_case, flagsRefresh) => { + let finishSetup = (): void => undefined + const setupFinished = new Promise((resolve) => { + finishSetup = resolve + }) + useMocks({ + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': async () => { + await setupFinished + ensureCalls++ + integrations = [...integrations, SANDBOX] + return [200, SANDBOX] + }, + }, + }) + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + const workflow = workflowLogic({ id: 'new' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + const editor = hogFlowEditorLogic({ id: 'new' }) + editor.mount() + + editor.actions.setNodeToBeAdded({ ...emailAction('function_email'), id: undefined } as any) + editor.actions.onDrop(undefined, NEW_WORKFLOW.edges[0]) + const insertedFrom = (): unknown => + (workflow.values.workflow.actions.find((action) => action.type === 'function_email')?.config as any) + ?.inputs?.email?.value?.from + expect(insertedFrom()).toEqual({}) + if (flagsRefresh) { + setSandboxFlag(true) + } + + finishSetup() + await expectLogic(editor).toFinishAllListeners() + + expect(insertedFrom()).toEqual({ integrationId: SANDBOX.id }) + expect(ensureCalls).toBe(1) + expect(capture.mock.calls.filter(([event]) => event === 'email sender preselected')).toEqual([ + ['email sender preselected', { surface: 'workflow', reason: 'sandbox_sender' }], + ]) + capture.mockRestore() + editor.unmount() + workflow.unmount() + } + ) })