From 3d4348c1d0031518e3d639b265cdd2187c1e23b6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:25:49 +0000 Subject: [PATCH 1/4] feat(workflows): give a new workflow its sender before the editor opens A new workflow from a template or a trigger prefill gets the project's verified default sender, else the sandbox sender when the flag is on and an email step can use it. Own senders fill steps as before. Co-Authored-By: Claude Opus 5.5 --- .../Channels/defaultEmailSender.test.ts | 112 +++++--- .../frontend/Channels/defaultEmailSender.ts | 23 +- .../frontend/Workflows/firstRunSender.ts | 102 +++++++ .../HogFlowFunctionConfiguration.test.ts | 19 +- .../HogFlowFunctionConfiguration.tsx | 8 +- .../Workflows/workflowEmailPrefill.test.ts | 12 +- .../Workflows/workflowEmailPrefill.ts | 23 -- .../workflowLogic.firstRunSender.test.ts | 259 ++++++++++++++++++ .../frontend/Workflows/workflowLogic.ts | 25 +- 9 files changed, 460 insertions(+), 123 deletions(-) create mode 100644 products/workflows/frontend/Workflows/firstRunSender.ts create mode 100644 products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts 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..0ea56db77e04 --- /dev/null +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -0,0 +1,102 @@ +import api, { 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 { 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 provisionSandboxSender(): Promise { + const sandbox = await integrationsEmailSandboxSenderCreate(String(ApiConfig.getCurrentProjectId())) + integrationsLogic.findMounted()?.actions.loadIntegrations() + return sandbox.id +} + +async function resolveFirstRunSender(allowSandbox: boolean): Promise { + const team = teamLogic.findMounted()?.values.currentTeam + const integrations = (await api.integrations.list()).results + 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 sender = await resolveFirstRunSender(workflow.actions.some((action) => takesSandboxSender(action, workflow))) + const takesSender = (action: HogFlowAction): boolean => + sender?.reason === 'sandbox_sender' ? takesSandboxSender(action, workflow) : takesOwnSender(action) + if (!sender || !workflow.actions.some(takesSender)) { + return { workflow, sender: null } + } + 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 27abee39fa55..94bbf8dbdefe 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..ac69076dd03e --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -0,0 +1,259 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +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('opens the editor without a sender when the sender list cannot load', async () => { + useMocks({ get: { '/api/projects/:team_id/integrations/': () => [500, { detail: 'Unavailable' }] } }) + + const logic = await loadTemplate() + + 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 6eeb3f1c01f9..69b294072270 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -21,7 +21,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' @@ -45,7 +44,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, @@ -56,6 +54,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 { From aeca792f13f83b54dd36d281ea0f7d1289f492cc Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:30:47 +0000 Subject: [PATCH 2/4] refactor(workflows): drop a redundant first-run sender check Co-Authored-By: Claude Opus 5.5 --- products/workflows/frontend/Workflows/firstRunSender.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/products/workflows/frontend/Workflows/firstRunSender.ts b/products/workflows/frontend/Workflows/firstRunSender.ts index 0ea56db77e04..f3c98d35f8a5 100644 --- a/products/workflows/frontend/Workflows/firstRunSender.ts +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -85,11 +85,11 @@ export async function withFirstRunSender( return { workflow, sender: null } } const sender = await resolveFirstRunSender(workflow.actions.some((action) => takesSandboxSender(action, workflow))) - const takesSender = (action: HogFlowAction): boolean => - sender?.reason === 'sandbox_sender' ? takesSandboxSender(action, workflow) : takesOwnSender(action) - if (!sender || !workflow.actions.some(takesSender)) { + if (!sender) { return { workflow, sender: null } } + const takesSender = (action: HogFlowAction): boolean => + sender.reason === 'sandbox_sender' ? takesSandboxSender(action, workflow) : takesOwnSender(action) return { workflow: { ...workflow, From a9566a0c1bbcf28c33581946b33a48f1b0469390 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:36:11 +0000 Subject: [PATCH 3/4] fix(workflows): read every page of senders before choosing the first-run sender Co-Authored-By: Claude Opus 5.5 --- .../frontend/Workflows/firstRunSender.ts | 22 ++++++++++++--- .../workflowLogic.firstRunSender.test.ts | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/products/workflows/frontend/Workflows/firstRunSender.ts b/products/workflows/frontend/Workflows/firstRunSender.ts index f3c98d35f8a5..e7f744814f43 100644 --- a/products/workflows/frontend/Workflows/firstRunSender.ts +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -1,10 +1,12 @@ -import api, { ApiConfig } from 'lib/api' +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 { @@ -58,15 +60,24 @@ 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) { + throw new Error('The sender list could not load.') + } + return integrations +} + async function provisionSandboxSender(): Promise { const sandbox = await integrationsEmailSandboxSenderCreate(String(ApiConfig.getCurrentProjectId())) - integrationsLogic.findMounted()?.actions.loadIntegrations() + await integrationsLogic.asyncActions.loadIntegrations() return sandbox.id } async function resolveFirstRunSender(allowSandbox: boolean): Promise { const team = teamLogic.findMounted()?.values.currentTeam - const integrations = (await api.integrations.list()).results + const integrations = await loadAllIntegrations() const ownSender = resolveDefaultEmailSender( integrations, team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null @@ -84,7 +95,10 @@ export async function withFirstRunSender( if (!workflow.actions.some(takesOwnSender)) { return { workflow, sender: null } } - const sender = await resolveFirstRunSender(workflow.actions.some((action) => takesSandboxSender(action, workflow))) + const unmountIntegrations = integrationsLogic.mount() + const sender = await resolveFirstRunSender( + workflow.actions.some((action) => takesSandboxSender(action, workflow)) + ).finally(unmountIntegrations) if (!sender) { return { workflow, sender: null } } diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts index ac69076dd03e..5eee37e7c7b2 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -235,6 +235,34 @@ describe('workflow first-run sender', () => { 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('opens the editor without a sender when the sender list cannot load', async () => { useMocks({ get: { '/api/projects/:team_id/integrations/': () => [500, { detail: 'Unavailable' }] } }) From 9704a2f9c748dec9529ec5dd36869425715d0ab3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:40:14 +0000 Subject: [PATCH 4/4] fix(workflows): leave the first-run sender empty when the sender list fails to refresh Co-Authored-By: Claude Opus 5.5 --- .../workflows/frontend/Workflows/firstRunSender.ts | 2 +- .../Workflows/workflowLogic.firstRunSender.test.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/products/workflows/frontend/Workflows/firstRunSender.ts b/products/workflows/frontend/Workflows/firstRunSender.ts index e7f744814f43..30f028ad56e2 100644 --- a/products/workflows/frontend/Workflows/firstRunSender.ts +++ b/products/workflows/frontend/Workflows/firstRunSender.ts @@ -63,7 +63,7 @@ function sandboxSenderEnabled(): boolean { async function loadAllIntegrations(): Promise { await integrationsLogic.asyncActions.loadIntegrations() const integrations = integrationsLogic.values.integrations - if (!integrations) { + if (!integrations || integrationsLogic.values.integrationsLoadFailed) { throw new Error('The sender list could not load.') } return integrations diff --git a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts index 5eee37e7c7b2..a8845901b144 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.firstRunSender.test.ts @@ -2,6 +2,7 @@ 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' @@ -263,10 +264,19 @@ describe('workflow first-run sender', () => { logic.unmount() }) - it('opens the editor without a sender when the sender list cannot load', async () => { + 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)