diff --git a/products/workflows/frontend/Channels/EmailDomain/EmailDomainSetupModal.tsx b/products/workflows/frontend/Channels/EmailDomain/EmailDomainSetupModal.tsx index 5450813da58a..f56bb0e2a13a 100644 --- a/products/workflows/frontend/Channels/EmailDomain/EmailDomainSetupModal.tsx +++ b/products/workflows/frontend/Channels/EmailDomain/EmailDomainSetupModal.tsx @@ -25,6 +25,7 @@ export interface EmailDomainSetupModalProps { next?: ReactNode onComplete: (integrationId?: number) => void onClose: (savedIntegrationId?: number) => void + onSenderCreated?: (integrationId: number) => void } function ModalStep({ next }: { next?: ReactNode }): JSX.Element { @@ -86,6 +87,7 @@ export function EmailDomainSetupModal({ next, onComplete, onClose, + onSenderCreated, }: EmailDomainSetupModalProps): JSX.Element { const [integrationId, setIntegrationId] = useState(initialIntegrationId ?? null) @@ -112,7 +114,13 @@ export function EmailDomainSetupModal({ /> ) : (
- setIntegrationId(created.id)} /> + { + setIntegrationId(created.id) + onSenderCreated?.(created.id) + }} + />
)} diff --git a/products/workflows/frontend/Workflows/WorkflowDomainSetupActions.tsx b/products/workflows/frontend/Workflows/WorkflowDomainSetupActions.tsx index 6e9e92b3955c..3af4296bb593 100644 --- a/products/workflows/frontend/Workflows/WorkflowDomainSetupActions.tsx +++ b/products/workflows/frontend/Workflows/WorkflowDomainSetupActions.tsx @@ -23,10 +23,10 @@ function lead(enabled: boolean, disabledReason: string | null, verifying: boolea /** The workflow's own next step inside the domain setup it opened, in place of the workflow suggestions. */ export function WorkflowDomainSetupActions(): JSX.Element | null { - const { originalWorkflow, enableFromSenderDomainSetupDisabledReason } = useValues(workflowLogic) + const { originalWorkflow, enableFromSenderDomainSetupDisabledReason, senderDomainSetup } = useValues(workflowLogic) const { enableFromSenderDomainSetup, closeSenderDomainSetup } = useActions(workflowLogic) const { phase } = useValues(emailDomainLogic) - const { domain } = useValues(emailDomainSenderLogic) + const { domain, fromAddress } = useValues(emailDomainSenderLogic) if (!originalWorkflow) { return null @@ -42,6 +42,11 @@ export function WorkflowDomainSetupActions(): JSX.Element | null {

{lead(enabled, enableFromSenderDomainSetupDisabledReason, verifying, domain ?? 'the domain')}

+ {senderDomainSetup?.filledEmptySenders && fromAddress && ( +

+ Your workflow now sends from {fromAddress}. +

+ )}
{offersEnable && ( diff --git a/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.test.tsx b/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.test.tsx index e5b32b46c8fb..82b13ed8b156 100644 --- a/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.test.tsx +++ b/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.test.tsx @@ -19,6 +19,7 @@ import { WorkflowStatusBar } from './WorkflowStatusBar' const WORKFLOW_ID = 'wf-domain-setup-1' const DOMAIN = 'mail.acme.com' const SENDER_ID = 42 +const POLL_MS = 10_000 const workflowWith = (overrides: Partial, from: Record): HogFlow => ({ @@ -99,6 +100,9 @@ const flush = async (ms = 0): Promise => { }) } +const emailSenderOf = (workflow: Partial, stepId: string): unknown => + (workflow.actions?.find(({ id }) => id === stepId)?.config as any)?.inputs?.email?.value?.from + describe('the email domain setup opened from a workflow', () => { let backend: FakeEmailDomainBackend let storedWorkflow: HogFlow @@ -260,4 +264,29 @@ describe('the email domain setup opened from a workflow', () => { expect(element('email-domain-workflow-enable')).toHaveTextContent('Enable workflow') expect(document.querySelector('[data-attr="email-domain-create-blank-workflow"]')).toBeNull() }) + + it('fills the empty sender with the one created in the setup, so the fresh workflow enables in one step', async () => { + await openFromStatusBar(workflowWith({}, {}), 'workflow-sender-domain-set-up') + + fireEvent.change(document.getElementById('email-domain-another-domain') as HTMLInputElement, { + target: { value: 'acme.com' }, + }) + fireEvent.click(element('email-domain-use-custom-domain')) + fireEvent.click(element('email-domain-continue')) + await flush() + + expect(savedBodies.map((body) => emailSenderOf(body, 'email_node'))).toEqual([{ integrationId: SENDER_ID }]) + + backend.publishRecords(DOMAIN) + await flush(POLL_MS) + const fillNotice = `Your workflow now sends from hello@${DOMAIN}.` + expect(screen.getByText((_, node) => node?.tagName === 'P' && node.textContent === fillNotice)).toBeVisible() + expect(element('email-domain-workflow-enable')).toHaveTextContent('Enable and send once verified') + + fireEvent.click(element('email-domain-workflow-enable')) + await flush() + + expect(savedBodies.map((body) => body.status)).toEqual([undefined, 'active']) + expect(emailSenderOf(storedWorkflow, 'email_node')).toEqual({ integrationId: SENDER_ID }) + }) }) diff --git a/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.tsx b/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.tsx index b45b1b963736..c30ccdfce3dc 100644 --- a/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.tsx +++ b/products/workflows/frontend/Workflows/WorkflowSenderDomainSetupModal.tsx @@ -7,7 +7,7 @@ import { workflowLogic } from './workflowLogic' /** The email domain setup, over the workflow whose status bar opened it. */ export function WorkflowSenderDomainSetupModal(): JSX.Element | null { const { senderDomainSetup, originalWorkflow } = useValues(workflowLogic) - const { closeSenderDomainSetup } = useActions(workflowLogic) + const { closeSenderDomainSetup, fillEmptySendersFromDomainSetup } = useActions(workflowLogic) if (!senderDomainSetup || !originalWorkflow) { return null @@ -20,6 +20,7 @@ export function WorkflowSenderDomainSetupModal(): JSX.Element | null { next={} onComplete={closeSenderDomainSetup} onClose={closeSenderDomainSetup} + onSenderCreated={fillEmptySendersFromDomainSetup} /> ) } diff --git a/products/workflows/frontend/Workflows/resourceSaveQueue.test.ts b/products/workflows/frontend/Workflows/resourceSaveQueue.test.ts index f67c673a3465..0e6f9fd12e28 100644 --- a/products/workflows/frontend/Workflows/resourceSaveQueue.test.ts +++ b/products/workflows/frontend/Workflows/resourceSaveQueue.test.ts @@ -62,13 +62,14 @@ describe('ResourceSaveQueue', () => { expect(queue(null).classify(event())).toBe('ignore') }) - it('holds an event that lands mid-save and hands the latest one over once', async () => { + it('holds an event that lands mid-save and hands the newest one over once', async () => { const saves = queue() let finish: () => void = () => {} const saving = saves.run(() => new Promise((resolve) => (finish = resolve))) expect(saves.classify(event({ updated_at: '2026-09-25T10:00:03Z' }))).toBe('defer') expect(saves.classify(event())).toBe('defer') + expect(saves.classify(event({ updated_at: '2026-09-25T10:00:04Z' }))).toBe('defer') await Promise.resolve() finish() await saving diff --git a/products/workflows/frontend/Workflows/resourceSaveQueue.ts b/products/workflows/frontend/Workflows/resourceSaveQueue.ts index 1d7342ce5280..1dde28454200 100644 --- a/products/workflows/frontend/Workflows/resourceSaveQueue.ts +++ b/products/workflows/frontend/Workflows/resourceSaveQueue.ts @@ -52,7 +52,10 @@ export class ResourceSaveQueue { return 'ignore' } if (this.pending > 0 || this.options.isBusy?.()) { - this.deferred = event + // Events can arrive out of order, so a held older copy never replaces a newer one. + if (!this.deferred || !dayjs(event.updated_at).isBefore(dayjs(this.deferred.updated_at))) { + this.deferred = event + } return 'defer' } const loaded = this.options.getLoadedStamp() diff --git a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts index 1ae94083ed01..0ec0ef367649 100644 --- a/products/workflows/frontend/Workflows/workflowEmailPrefill.ts +++ b/products/workflows/frontend/Workflows/workflowEmailPrefill.ts @@ -98,7 +98,7 @@ export function withEmailSender(workflow: HogFlow, integrationId: number): { wor 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) { + if (!value || value.from?.integrationId || value.from?.integrationIds?.length) { return action } filled = true diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts index d492ccc4589e..5dd06a9ebf80 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -1,4 +1,5 @@ import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' import { FEATURE_FLAGS } from 'lib/constants' import { integrationsLogic } from 'lib/integrations/integrationsLogic' @@ -47,6 +48,9 @@ const emailStep = (from: Record): HogFlow['actions'][number] => }, }) as HogFlow['actions'][number] +const senderOf = (workflow: Partial, stepId: string): unknown => + (workflow.actions?.find(({ id }) => id === stepId)?.config as any)?.inputs?.email?.value?.from + const withEmailStep = (workflow: HogFlow, from: Record): HogFlow => ({ ...workflow, actions: [workflow.actions[0], emailStep(from), workflow.actions[1]], @@ -458,7 +462,7 @@ describe('workflowLogic send once verified', () => { expect(logic.values.senderDomainNotice).toEqual({ stage: 'no_domain' }) logic.actions.openSenderDomainSetup(null) - expect(logic.values.senderDomainSetup).toEqual({ integrationId: null }) + expect(logic.values.senderDomainSetup).toEqual({ integrationId: null, filledEmptySenders: false }) projectSenders = [PENDING_PROJECT_SENDER] await expectLogic(integrationsLogic, () => { @@ -472,6 +476,109 @@ describe('workflowLogic send once verified', () => { }) }) + describe('a sender created in the domain setup', () => { + const CREATED_SENDER_ID = 9 + + const withEmailSteps = (...senders: Record[]): HogFlow => { + const workflow = makeWorkflow() + const steps = senders.map((from, index) => ({ ...emailStep(from), id: `email_${index}` })) + return { ...workflow, actions: [workflow.actions[0], ...steps, workflow.actions[1]] } + } + + it('fills only the steps without a sender, and saves them with the unsaved edits', async () => { + await mountWith(withEmailSteps({}, { integrationId: 8 }, {}), { senders: [VERIFIED_PROJECT_SENDER] }) + logic.actions.openSenderDomainSetup(null) + logic.actions.setWorkflowValue('name', 'Welcome, renamed') + const capture = jest.spyOn(posthog, 'capture') + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + const body = (await request.json()) as Partial + patchBodies.push(body) + storedWorkflow = { ...storedWorkflow, ...body, updated_at: '2026-05-04T00:00:00.000Z' } + return [200, storedWorkflow] + }, + }, + }) + + await expectLogic(logic, () => { + logic.actions.fillEmptySendersFromDomainSetup(CREATED_SENDER_ID) + }) + .toDispatchActions(['saveWorkflowSuccess']) + .toFinishAllListeners() + + expect(patchBodies).toHaveLength(1) + expect(logic.values.senderDomainSetup?.filledEmptySenders).toBe(true) + expect(logic.values.hasUnsavedChanges).toBe(false) + expect(logic.values.workflow.name).toEqual('Welcome, renamed') + expect(['email_0', 'email_1', 'email_2'].map((id) => senderOf(logic.values.workflow, id))).toEqual([ + { integrationId: CREATED_SENDER_ID }, + { integrationId: 8 }, + { integrationId: CREATED_SENDER_ID }, + ]) + expect(capture).toHaveBeenCalledWith('email sender preselected', { + surface: 'workflow', + reason: 'created_in_domain_setup', + }) + }) + + it('keeps the fill when it saves behind an earlier save and its own save fails', async () => { + await mountWith(withEmailSteps({}), { senders: [VERIFIED_PROJECT_SENDER] }) + logic.actions.openSenderDomainSetup(null) + let releaseFirstSave!: () => void + const firstSaveHeld = new Promise((resolve) => { + releaseFirstSave = resolve + }) + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + const body = (await request.json()) as Partial + patchBodies.push(body) + if (patchBodies.length === 1) { + await firstSaveHeld + } + if (patchBodies.length === 2) { + return [500, { detail: 'Server error' }] + } + storedWorkflow = { + ...storedWorkflow, + ...body, + updated_at: `2026-05-0${patchBodies.length + 3}`, + } + return [200, storedWorkflow] + }, + }, + }) + logic.actions.setWorkflowValue('name', 'Welcome, renamed') + logic.actions.flushAutoSave() + await expectLogic(logic).toDispatchActions(['saveWorkflow']) + + logic.actions.fillEmptySendersFromDomainSetup(CREATED_SENDER_ID) + releaseFirstSave() + await expectLogic(logic).toDispatchActions(['saveWorkflowSuccess', 'saveWorkflowFailure']) + + expect(senderOf(logic.values.workflow, 'email_0')).toEqual({ integrationId: CREATED_SENDER_ID }) + expect(logic.values.hasUnsavedChanges).toBe(true) + }) + + it.each([ + ['one sender', { integrationId: 8 }], + ['a sender rotation', { integrationIds: [8, 7] }], + ])('leaves a workflow whose steps all have %s alone', async (_name, from) => { + await mountWith(withEmailSteps(from), { senders: [VERIFIED_PROJECT_SENDER] }) + logic.actions.openSenderDomainSetup(null) + + await expectLogic(logic, () => { + logic.actions.fillEmptySendersFromDomainSetup(CREATED_SENDER_ID) + }) + .toFinishAllListeners() + .toNotHaveDispatchedActions(['saveWorkflow']) + .toMatchValues({ hasUnsavedChanges: false }) + expect(patchBodies).toEqual([]) + expect(logic.values.senderDomainSetup?.filledEmptySenders).toBe(false) + }) + }) + it.each<{ name: string; edit: (workflow: HogFlow) => Partial; reason: string }>([ { name: 'a step has errors', diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index b8c997ef8e35..561951775f12 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -311,6 +311,7 @@ function applyWorkflowLifecycle( /** The email domain setup opened over the workflow, for one sender or for a new domain. */ interface SenderDomainSetup { integrationId: number | null + filledEmptySenders: boolean } /** What a save was dispatched with, kept per save because a later save moves the shared state. */ @@ -324,6 +325,7 @@ interface SaveContext { // A status change made while the form had unsaved edits. The edits stay in the form, unsaved. keepsFormEdits: boolean enablesWorkflow: boolean + editVersion: number } /** The schedule editor state, kept across a status change so unsaved schedule edits survive it. */ @@ -450,6 +452,15 @@ export interface workflowLogicActions { enableFromSenderDomainSetup: () => { value: true } + fillEmptySendersFromDomainSetup: (integrationId: number) => { + integrationId: number + } + emptySendersFilledFromDomainSetup: () => { + value: true + } + flushAutoSave: () => { + value: true + } loadIntegrations: () => { value: true } // integrationsLogic @@ -3440,6 +3451,9 @@ export const workflowLogic = kea([ openSenderDomainSetup: (integrationId: number | null) => ({ integrationId }), closeSenderDomainSetup: true, enableFromSenderDomainSetup: true, + fillEmptySendersFromDomainSetup: (integrationId: number) => ({ integrationId }), + emptySendersFilledFromDomainSetup: true, + flushAutoSave: true, dismissStartedSendingNotice: true, setStartedSendingNoticeDismissed: (noticeKey: string) => ({ noticeKey }), checkWaitingStatus: true, @@ -3553,6 +3567,7 @@ export const workflowLogic = kea([ const keepsFormEdits = cache.nextSaveKeepsFormEdits === true const saveContext: SaveContext = { initiatedByAutoSave, + editVersion: values.workflowEditVersion, // A status change keeps schedule edits unsaved, like the other edits in the form. pendingSchedule: keepsFormEdits ? false : values.pendingSchedule, moveToBroadcastIds: (cache.nextSaveMovesWorkflowIds ?? []) as string[], @@ -3984,7 +3999,8 @@ export const workflowLogic = kea([ senderDomainSetup: [ null as SenderDomainSetup | null, { - openSenderDomainSetup: (_, { integrationId }) => ({ integrationId }), + openSenderDomainSetup: (_, { integrationId }) => ({ integrationId, filledEmptySenders: false }), + emptySendersFilledFromDomainSetup: (setup) => setup && { ...setup, filledEmptySenders: true }, closeSenderDomainSetup: () => null, enableFromSenderDomainSetup: () => null, }, @@ -4771,6 +4787,18 @@ export const workflowLogic = kea([ actions.loadIntegrations() actions.saveWorkflowPartial({ status: 'active' }) }, + fillEmptySendersFromDomainSetup: ({ integrationId }) => { + const { workflow, filled } = withEmailSender(values.workflow, integrationId) + if (!filled) { + return + } + actions.setWorkflowValues({ actions: workflow.actions }) + actions.emptySendersFilledFromDomainSetup() + // pinned: analytics event name + posthog.capture('email sender preselected', { surface: 'workflow', reason: 'created_in_domain_setup' }) + // Saved now rather than after the auto-save pause, so the setup can offer to enable the workflow. + actions.flushAutoSave() + }, closeSenderDomainSetup: async () => { actions.loadIntegrations() if (!props.id || props.id === 'new') { @@ -4826,9 +4854,6 @@ export const workflowLogic = kea([ actions.setStartedSendingNoticeDismissed(values.startedSendingNoticeKey) } }, - saveWorkflow: () => { - cache.saveEditVersion = values.workflowEditVersion - }, saveWorkflowFailure: () => { // Keep the queue aligned with the saves still in flight. ;(cache.saveContexts as SaveContext[] | undefined)?.shift() @@ -4972,7 +4997,7 @@ export const workflowLogic = kea([ // form on the merged view, or the reset would wipe the just-saved edits off the canvas. const editedDuringSave = !!saveContext?.keepsFormEdits || - (cache.saveEditVersion !== undefined && values.workflowEditVersion !== cache.saveEditVersion) + (!!saveContext && values.workflowEditVersion !== saveContext.editVersion) const editsDuringSave = editedDuringSave ? pickWorkflowEdits(values.workflow) : null const scheduleEdits: ScheduleEdits | null = saveContext?.keepsFormEdits ? { @@ -5097,7 +5122,9 @@ export const workflowLogic = kea([ }, autoSaveWorkflow: async (_, breakpoint) => { await breakpoint(3000) - + actions.flushAutoSave() + }, + flushAutoSave: () => { // Active workflows auto-save too: their content edits route into the staged draft // (stage_draft in the saveWorkflow loader), so nothing deploys without an explicit publish. const shouldSkip =