diff --git a/products/workflows/frontend/Channels/defaultEmailSender.test.ts b/products/workflows/frontend/Channels/defaultEmailSender.test.ts index c5c5999cc209..fa26619d8b5d 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.test.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.test.ts @@ -1,49 +1,83 @@ import { IntegrationType } from '~/types' -import { resolveDefaultEmailSender } from './defaultEmailSender' +import type { HogFlowAction } from '../Workflows/hogflows/types' +import { allowsSandboxSender, resolveDefaultEmailSender } from './defaultEmailSender' const sender = (id: number, verified: boolean, kind = 'email'): IntegrationType => ({ id, kind, config: { verified } }) as unknown as IntegrationType const sandboxSender = { ...sender(7, true), config: { verified: true, provider: 'sandbox' } } -describe('resolveDefaultEmailSender', () => { - it.each([ - { case: 'nothing when only sandbox exists', integrations: [sandboxSender], defaultId: null, expected: null }, - { - case: 'the own sender beside sandbox', - integrations: [sandboxSender, sender(1, true)], - defaultId: null, - expected: 1, - }, - { case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null }, - { case: 'the project default', integrations: [sender(1, true), sender(2, true)], defaultId: 2, expected: 2 }, - { - case: 'the only verified sender', - integrations: [sender(1, true), sender(2, false)], - defaultId: null, - expected: 1, - }, - { - case: 'nothing when several are verified', - integrations: [sender(1, true), sender(2, true)], - defaultId: null, - expected: null, - }, - { case: 'nothing for an unverified default', integrations: [sender(1, false)], defaultId: 1, expected: null }, - { - case: 'the only verified sender when the default was deleted', - integrations: [sender(1, true)], - defaultId: 9, - expected: 1, - }, - { - case: 'nothing for a verified non-email integration', - integrations: [sender(1, true, 'slack')], - defaultId: 1, - expected: null, - }, - ])('picks $case', ({ integrations, defaultId, expected }) => { - expect(resolveDefaultEmailSender(integrations, defaultId)?.integrationId ?? null).toEqual(expected) +describe('defaultEmailSender', () => { + describe('resolveDefaultEmailSender', () => { + it.each([ + { + case: 'nothing when only sandbox exists', + integrations: [sandboxSender], + defaultId: null, + expected: null, + }, + { + case: 'the own sender beside sandbox', + integrations: [sandboxSender, sender(1, true)], + defaultId: null, + expected: 1, + }, + { case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null }, + { + case: 'the project default', + integrations: [sender(1, true), sender(2, true)], + defaultId: 2, + expected: 2, + }, + { + case: 'the only verified sender', + integrations: [sender(1, true), sender(2, false)], + defaultId: null, + expected: 1, + }, + { + case: 'nothing when several are verified', + integrations: [sender(1, true), sender(2, true)], + defaultId: null, + expected: null, + }, + { + case: 'nothing for an unverified default', + integrations: [sender(1, false)], + defaultId: 1, + expected: null, + }, + { + case: 'the only verified sender when the default was deleted', + integrations: [sender(1, true)], + defaultId: 9, + expected: 1, + }, + { + case: 'nothing for a verified non-email integration', + integrations: [sender(1, true, 'slack')], + defaultId: 1, + expected: null, + }, + ])('picks $case', ({ integrations, defaultId, expected }) => { + expect(resolveDefaultEmailSender(integrations, defaultId)?.integrationId ?? null).toEqual(expected) + }) + }) + + describe('allowsSandboxSender', () => { + it.each<{ step: string; actionType: HogFlowAction['type']; originProduct: string | null; expected: boolean }>([ + { step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true }, + { + step: 'a broadcast email step', + actionType: 'function_email', + originProduct: 'broadcasts', + expected: false, + }, + { step: 'a generic function step', actionType: 'function', originProduct: null, expected: false }, + { step: 'a trigger', actionType: 'trigger', originProduct: null, expected: false }, + ])('is $expected for $step', ({ actionType, originProduct, expected }) => { + expect(allowsSandboxSender(actionType, originProduct)).toBe(expected) + }) }) }) diff --git a/products/workflows/frontend/Channels/defaultEmailSender.ts b/products/workflows/frontend/Channels/defaultEmailSender.ts index 063d45406654..ec9750665279 100644 --- a/products/workflows/frontend/Channels/defaultEmailSender.ts +++ b/products/workflows/frontend/Channels/defaultEmailSender.ts @@ -2,21 +2,27 @@ import { isSandboxEmailSender } from 'lib/integrations/utils' import { IntegrationType } from '~/types' -export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' +import type { HogFlowAction } from '../Workflows/hogflows/types' + +export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' | 'sandbox_sender' export interface DefaultEmailSender { integrationId: number reason: DefaultEmailSenderReason } +export function verifiedOwnEmailSenders(integrations: IntegrationType[] | null | undefined): IntegrationType[] { + return (integrations ?? []).filter( + (integration) => + integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true + ) +} + export function resolveDefaultEmailSender( integrations: IntegrationType[] | null | undefined, defaultIntegrationId: number | null | undefined ): DefaultEmailSender | null { - const verified = (integrations ?? []).filter( - (integration) => - integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true - ) + const verified = verifiedOwnEmailSenders(integrations) const projectDefault = verified.find((integration) => integration.id === defaultIntegrationId) if (projectDefault) { return { integrationId: projectDefault.id, reason: 'project_default' } @@ -26,3 +32,10 @@ export function resolveDefaultEmailSender( } return null } + +export function allowsSandboxSender( + actionType: HogFlowAction['type'], + originProduct: string | null | undefined +): boolean { + return actionType === 'function_email' && originProduct !== 'broadcasts' +} diff --git a/products/workflows/frontend/Workflows/firstRunSender.ts b/products/workflows/frontend/Workflows/firstRunSender.ts new file mode 100644 index 000000000000..30f028ad56e2 --- /dev/null +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -0,0 +1,116 @@ +import { ApiConfig } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { isSandboxEmailSender } from 'lib/integrations/utils' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { teamLogic } from 'scenes/teamLogic' + +import type { IntegrationType } from '~/types' + +import { integrationsEmailSandboxSenderCreate } from 'products/integrations/frontend/generated/api' + +import { + DefaultEmailSender, + allowsSandboxSender, + resolveDefaultEmailSender, + verifiedOwnEmailSenders, +} from '../Channels/defaultEmailSender' +import type { HogFlow, HogFlowAction } from './hogflows/types' + +type EmailStepValue = { from?: { integrationId?: number; email?: string; name?: string }; replyTo?: string } + +function emailStepValue(action: HogFlowAction): EmailStepValue | null { + const config = action.config as Record | undefined + return config?.template_id === 'template-email' ? (config.inputs?.email?.value ?? null) : null +} + +function takesOwnSender(action: HogFlowAction): boolean { + const value = emailStepValue(action) + return !!value && !value.from?.integrationId +} + +function takesSandboxSender(action: HogFlowAction, workflow: HogFlow): boolean { + const value = emailStepValue(action) + return ( + !!value && + allowsSandboxSender(action.type, workflow.origin_product) && + !value.from?.integrationId && + !value.from?.email && + !value.from?.name && + !value.replyTo + ) +} + +function withSender(action: HogFlowAction, integrationId: number): HogFlowAction { + const config = action.config as Record + const email = config.inputs.email + return { + ...action, + config: { + ...config, + inputs: { + ...config.inputs, + email: { ...email, value: { ...email.value, from: { ...email.value.from, integrationId } } }, + }, + }, + } as HogFlowAction +} + +function sandboxSenderEnabled(): boolean { + return !!featureFlagLogic.findMounted()?.values.featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] +} + +async function loadAllIntegrations(): Promise { + await integrationsLogic.asyncActions.loadIntegrations() + const integrations = integrationsLogic.values.integrations + if (!integrations || integrationsLogic.values.integrationsLoadFailed) { + throw new Error('The sender list could not load.') + } + return integrations +} + +async function provisionSandboxSender(): Promise { + const sandbox = await integrationsEmailSandboxSenderCreate(String(ApiConfig.getCurrentProjectId())) + await integrationsLogic.asyncActions.loadIntegrations() + return sandbox.id +} + +async function resolveFirstRunSender(allowSandbox: boolean): Promise { + 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 + } + const sandbox = integrations.find(isSandboxEmailSender) + return { integrationId: sandbox?.id ?? (await provisionSandboxSender()), reason: 'sandbox_sender' } +} + +export async function withFirstRunSender( + workflow: HogFlow +): Promise<{ workflow: HogFlow; sender: DefaultEmailSender | null }> { + 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) + if (!sender) { + return { workflow, sender: null } + } + const takesSender = (action: HogFlowAction): boolean => + sender.reason === 'sandbox_sender' ? takesSandboxSender(action, workflow) : takesOwnSender(action) + return { + workflow: { + ...workflow, + actions: workflow.actions.map((action) => + takesSender(action) ? withSender(action, sender.integrationId) : action + ), + }, + sender, + } +} diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts index 0a500199fc5c..81488de1516b 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts @@ -1,7 +1,6 @@ import { CyclotronJobInvocationGlobals } from '~/types' -import type { HogFlowAction } from '../../types' -import { allowsSandboxSender, buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration' +import { buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration' // The stored shape a Slack message workflow carries. The trigger type alone no longer identifies // it, so every case below passes the whole config. @@ -16,22 +15,6 @@ const githubTrigger = { } describe('HogFlowFunctionConfiguration', () => { - describe('allowsSandboxSender', () => { - it.each<{ step: string; actionType: HogFlowAction['type']; originProduct: string | null; expected: boolean }>([ - { step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true }, - { - step: 'a broadcast email step', - actionType: 'function_email', - originProduct: 'broadcasts', - expected: false, - }, - { step: 'a generic function step', actionType: 'function', originProduct: null, expected: false }, - { step: 'a trigger', actionType: 'trigger', originProduct: null, expected: false }, - ])('is $expected for $step', ({ actionType, originProduct, expected }) => { - expect(allowsSandboxSender(actionType, originProduct)).toBe(expected) - }) - }) - describe('buildSampleGlobals', () => { const realSampleGlobals: CyclotronJobInvocationGlobals = { project: { id: 2, name: 'Real project', url: 'https://app.example.com/project/2' }, diff --git a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx index e2f2838895e5..24a2f4dc07d4 100644 --- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx +++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx @@ -13,6 +13,7 @@ import { urls } from 'scenes/urls' import { CyclotronJobInputType, CyclotronJobInvocationGlobals, HogFunctionMappingType } from '~/types' +import { allowsSandboxSender } from '../../../../Channels/defaultEmailSender' import { workflowLogic } from '../../../workflowLogic' import { hogFlowEditorTestLogic } from '../../panel/testing/hogFlowEditorTestLogic' import { isGithubEventTriggerConfig } from '../../registry/triggers/githubTriggerFilters' @@ -192,13 +193,6 @@ export function filterInputsSchemaForTrigger( return inputsSchema } -export function allowsSandboxSender( - actionType: HogFlowAction['type'], - originProduct: string | null | undefined -): boolean { - return actionType === 'function_email' && originProduct !== 'broadcasts' -} - export function HogFlowFunctionConfiguration({ templateId, actionType, diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts index d67b71e79f1c..d019be45b674 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.test.ts @@ -1,5 +1,5 @@ import { draftMessage } from '../MessageAudience/messageDrafts' -import { prefilledWorkflow, withEmailSender } from './workflowEmailPrefill' +import { prefilledWorkflow } from './workflowEmailPrefill' import { NEW_WORKFLOW } from './workflowLogic' import type { WorkflowTriggerConfig } from './workflowTriggerPrefill' @@ -31,14 +31,4 @@ describe('workflowEmailPrefill', () => { }) expect(workflow.trigger_masking).toEqual(masking) }) - - it('keeps a sender the email step already has and fills the ones without', () => { - const workflow = prefilledWorkflow(NEW_WORKFLOW, EVENT_TRIGGER, draftMessage({ kind: 'issue_hit' })) - const filled = withEmailSender(workflow, 5) - const refilled = withEmailSender(filled.workflow, 9) - - expect(filled.filled).toBe(true) - expect((filled.workflow.actions[1].config as any).inputs.email.value.from).toEqual({ integrationId: 5 }) - expect(refilled).toEqual({ workflow: filled.workflow, filled: false }) - }) }) diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts index 126bf022e72c..2550b9ea80eb 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts @@ -94,29 +94,6 @@ export function withPrefilledEmail(workflow: HogFlow, email: MessageDraft): HogF } } -export function withEmailSender(workflow: HogFlow, integrationId: number): { workflow: HogFlow; filled: boolean } { - let filled = false - const actions = workflow.actions.map((action) => { - const config = action.config as Record - const value = config?.template_id === 'template-email' ? config.inputs?.email?.value : null - if (!value || value.from?.integrationId) { - return action - } - filled = true - return { - ...action, - config: { - ...config, - inputs: { - ...config.inputs, - email: { ...config.inputs.email, value: { ...value, from: { ...value.from, integrationId } } }, - }, - }, - } as HogFlowAction - }) - return { workflow: filled ? { ...workflow, actions } : workflow, filled } -} - /** The prefilled email step inside the brand's design, or the workflow unchanged when the brand doesn't fit. */ export function withEmailBrand(workflow: HogFlow, brand: EmailBrand | null): HogFlow { if (!brand) { diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts new file mode 100644 index 000000000000..a8845901b144 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -0,0 +1,297 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +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 { IntegrationType } from '~/types' + +import { draftMessage } from '../MessageAudience/messageDrafts' +import { HogFlowAction } from './hogflows/types' +import { NEW_WORKFLOW, workflowLogic } from './workflowLogic' + +const SANDBOX: Partial = { + id: 7, + kind: 'email', + display_name: 'Example via PostHog ', + config: { provider: 'sandbox', verified: true, email: 'sandbox@example.com' }, +} +const OWN_VERIFIED: Partial = { + id: 5, + kind: 'email', + config: { provider: 'ses', verified: true, email: 'own@example.com' }, +} + +type EmailValue = { from?: Record; replyTo?: string } + +const emailAction = (type: 'function_email' | 'function', value: EmailValue = {}): HogFlowAction => + ({ + id: 'email', + type, + name: 'Send email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + from: {}, + to: { email: 'member@example.com' }, + subject: 'Welcome', + html: '

Your account is ready.

', + ...value, + }, + templating: 'liquid', + }, + }, + }, + }) as HogFlowAction + +const emailValueOf = (logic: ReturnType, id = 'email'): Record | undefined => + (logic.values.workflow.actions.find((step) => step.id === id)?.config as any)?.inputs?.email?.value + +describe('workflow first-run sender', () => { + let ensureCalls: number + let ensureFails: number + let integrations: Partial[] + let action: HogFlowAction + + const setSandboxFlag = (enabled: boolean): void => { + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: enabled }) + } + + const loadTemplate = async (): Promise> => { + const logic = workflowLogic({ id: 'new', templateId: 'first-run-example' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + return logic + } + + beforeEach(() => { + integrations = [] + ensureCalls = 0 + ensureFails = 0 + action = emailAction('function_email') + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: integrations }], + '/api/projects/:team_id/hog_flow_templates/:id/': () => [ + 200, + { ...NEW_WORKFLOW, actions: [NEW_WORKFLOW.actions[0], action, NEW_WORKFLOW.actions[1]] }, + ], + '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls++ + if (ensureFails > 0) { + ensureFails-- + return [500, { detail: 'Unavailable' }] + } + integrations = [...integrations, SANDBOX] + return [200, SANDBOX] + }, + }, + }) + initKeaTests() + setSandboxFlag(true) + }) + + it.each<{ + name: string + step: HogFlowAction + senders: Partial[] + flag: boolean + expectedFrom: Record + expectedEnsureCalls: number + }>([ + { + name: 'an email step in a project without senders gets the sandbox sender', + step: emailAction('function_email'), + senders: [], + flag: true, + expectedFrom: { integrationId: SANDBOX.id }, + expectedEnsureCalls: 1, + }, + { + name: 'an existing sandbox sender is reused', + step: emailAction('function_email'), + senders: [SANDBOX], + flag: true, + expectedFrom: { integrationId: SANDBOX.id }, + expectedEnsureCalls: 0, + }, + { + name: 'a verified own sender wins over the sandbox sender', + step: emailAction('function_email'), + senders: [OWN_VERIFIED, SANDBOX], + flag: true, + expectedFrom: { integrationId: OWN_VERIFIED.id }, + expectedEnsureCalls: 0, + }, + { + name: 'several verified own senders leave the choice to the person', + step: emailAction('function_email'), + senders: [OWN_VERIFIED, { ...OWN_VERIFIED, id: 6 }, SANDBOX], + flag: true, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'a generic email function never gets the sandbox sender', + step: emailAction('function'), + senders: [], + flag: true, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'a configured Reply-To keeps the sandbox sender off', + step: emailAction('function_email', { replyTo: 'reply@example.com' }), + senders: [], + flag: true, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'a custom From address keeps the sandbox sender off', + step: emailAction('function_email', { from: { email: 'hello@example.com' } }), + senders: [], + flag: true, + expectedFrom: { email: 'hello@example.com' }, + expectedEnsureCalls: 0, + }, + { + name: 'a configured sender stays', + step: emailAction('function_email', { from: { integrationId: 12 } }), + senders: [OWN_VERIFIED], + flag: true, + expectedFrom: { integrationId: 12 }, + expectedEnsureCalls: 0, + }, + { + name: 'flag off leaves a step without own senders empty', + step: emailAction('function_email'), + senders: [SANDBOX], + flag: false, + expectedFrom: {}, + expectedEnsureCalls: 0, + }, + { + name: 'flag off fills a custom From address with the own default sender, as before', + step: emailAction('function_email', { from: { email: 'hello@example.com' } }), + senders: [OWN_VERIFIED], + flag: false, + expectedFrom: { email: 'hello@example.com', integrationId: OWN_VERIFIED.id }, + expectedEnsureCalls: 0, + }, + { + name: 'flag on fills a custom From address with the own default sender too', + step: emailAction('function_email', { from: { email: 'hello@example.com' } }), + senders: [OWN_VERIFIED], + flag: true, + expectedFrom: { email: 'hello@example.com', integrationId: OWN_VERIFIED.id }, + expectedEnsureCalls: 0, + }, + ])('$name', async ({ step, senders, flag, expectedFrom, expectedEnsureCalls }) => { + action = step + integrations = senders + setSandboxFlag(flag) + + const logic = await loadTemplate() + + expect(emailValueOf(logic)).toMatchObject({ subject: 'Welcome', html: '

Your account is ready.

' }) + expect(emailValueOf(logic)?.from).toEqual(expectedFrom) + expect(ensureCalls).toBe(expectedEnsureCalls) + logic.unmount() + }) + + it.each([ + ['a template', { templateId: 'first-run-example' }], + [ + 'a trigger prefill', + { + triggerPrefill: JSON.stringify({ + type: 'event', + filters: { events: [{ id: '$pageview', name: '$pageview', type: 'events' }] }, + }), + emailPrefill: JSON.stringify(draftMessage({ kind: 'issue_hit' })), + }, + ], + ])('reports the sandbox preselection once for a workflow from %s', async (_source, prefillProps) => { + const capture = jest.spyOn(posthog, 'capture').mockImplementation() + const logic = workflowLogic({ id: 'new', ...prefillProps }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + + const email = logic.values.workflow.actions.find((step) => step.type === 'function_email') + expect((email?.config as any)?.inputs?.email?.value?.from).toEqual({ integrationId: SANDBOX.id }) + expect(capture.mock.calls.filter(([event]) => event === 'email sender preselected')).toEqual([ + ['email sender preselected', { surface: 'workflow', reason: 'sandbox_sender' }], + ]) + capture.mockRestore() + logic.unmount() + }) + + it('finds a verified own sender on a later page of the sender list', async () => { + const unverifiedOwn = (id: number): Partial => ({ + id, + kind: 'email', + config: { provider: 'ses', verified: false, email: `own${id}@example.com` }, + }) + useMocks({ + get: { + '/api/projects/:team_id/integrations/': ({ request }) => + new URL(request.url).searchParams.get('offset') === '100' + ? [200, { results: [OWN_VERIFIED], next: null }] + : [ + 200, + { + results: Array.from({ length: 100 }, (_, index) => unverifiedOwn(100 + index)), + next: 'next-page', + }, + ], + }, + }) + + const logic = await loadTemplate() + + expect(emailValueOf(logic)?.from).toEqual({ integrationId: OWN_VERIFIED.id }) + expect(ensureCalls).toBe(0) + logic.unmount() + }) + + it.each([ + ['nothing is cached', false], + ['an earlier list is cached', true], + ])('opens the editor without a sender when the sender list cannot load and %s', async (_case, cached) => { + integrations = [OWN_VERIFIED] + const unmountIntegrations = cached ? integrationsLogic.mount() : () => undefined + if (cached) { + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + } + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [500, { detail: 'Unavailable' }] } }) + + const logic = await loadTemplate() + unmountIntegrations() + + expect(emailValueOf(logic)?.from).toEqual({}) + expect(ensureCalls).toBe(0) + logic.unmount() + }) + + it('tries to set up the sandbox sender again on the next load after a failure', async () => { + ensureFails = 1 + + const logic = await loadTemplate() + expect(emailValueOf(logic)?.from).toEqual({}) + + await expectLogic(logic, () => logic.actions.loadWorkflow()).toDispatchActions(['loadWorkflowSuccess']) + expect(emailValueOf(logic)?.from).toEqual({ integrationId: SANDBOX.id }) + expect(ensureCalls).toBe(2) + logic.unmount() + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index d843b1caeca6..76bf66ba3ffd 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -20,7 +20,6 @@ import { objectsEqual } from 'lib/utils/objects' import { sanitizeInputs } from 'scenes/hog-functions/configuration/hogFunctionConfigurationLogic' import type { EmailFieldErrors } from 'scenes/hog-functions/email-templater/types' import { projectLogic } from 'scenes/projectLogic' -import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' @@ -44,7 +43,6 @@ import { } from '../Broadcasts/broadcastLifecycle' import { canEditInWizard, isBroadcastShaped } from '../Broadcasts/broadcastsLogic' import { loadEntrySource, saveEntrySource } from '../Broadcasts/broadcastUsage' -import { resolveDefaultEmailSender } from '../Channels/defaultEmailSender' import { AiEmailDraftResult, aiEmailDraftFailureMessage, @@ -55,6 +53,7 @@ import { import type { AiEmailDraftStatusProps } from '../MessageAudience/AiEmailDraftStatus' import { loadEmailBrand } from '../MessageAudience/emailBrand' import { parseMessageDraftPrefill } from '../MessageAudience/messageDrafts' +import { withFirstRunSender } from './firstRunSender' import { getRegisteredTriggerTypes } from './hogflows/registry/triggers/triggerTypeRegistry' import { DEFAULT_STATE, @@ -81,13 +80,7 @@ import { import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' import { prepareWorkflowDuplicate } from './workflowDuplication' -import { - prefilledEmailContent, - prefilledWorkflow, - withEmailBrand, - withEmailSender, - withPrefilledEmail, -} from './workflowEmailPrefill' +import { prefilledEmailContent, prefilledWorkflow, withEmailBrand, withPrefilledEmail } from './workflowEmailPrefill' import { workflowSceneLogic } from './workflowSceneLogic' import { workflowsLogic } from './workflowsLogic' import { parseWorkflowTriggerPrefill } from './workflowTriggerPrefill' @@ -162,18 +155,10 @@ export const ROW_SCOPED_TRIGGER_TYPES = new Set(['data-warehouse-table', 'data-w async function withDefaultEmailSender(workflow: HogFlow): Promise { try { - const team = teamLogic.findMounted()?.values.currentTeam - const sender = resolveDefaultEmailSender( - (await api.integrations.list()).results, - team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null - ) - if (!sender) { - return workflow - } - const result = withEmailSender(workflow, sender.integrationId) - if (result.filled) { + const result = await withFirstRunSender(workflow) + if (result.sender) { // pinned: analytics event name - posthog.capture('email sender preselected', { surface: 'workflow', reason: sender.reason }) + posthog.capture('email sender preselected', { surface: 'workflow', reason: result.sender.reason }) } return result.workflow } catch {