From f913cb0a2751cf07af134fe9c415a873b628228b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:41:15 +0000 Subject: [PATCH] feat(workflows): fill the empty sender with the one created in the domain setup A domain set up from a workflow's notice fills the email steps that have no sender with the new sender, saves at once, and says so in the setup, so the workflow can be enabled in the same place. Each save keeps the edit version it was dispatched with, a held edit event never gives way to an older copy, and a step with a sender rotation counts as having a sender. Ported from PostHog/posthog#123661 with its tests. Co-Authored-By: Claude Opus 5.5 --- .../EmailDomain/EmailDomainSetupModal.tsx | 10 +- .../Workflows/WorkflowDomainSetupActions.tsx | 9 +- .../WorkflowSenderDomainSetupModal.test.tsx | 29 +++++ .../WorkflowSenderDomainSetupModal.tsx | 3 +- .../Workflows/resourceSaveQueue.test.ts | 3 +- .../frontend/Workflows/resourceSaveQueue.ts | 5 +- .../Workflows/workflowEmailPrefill.ts | 2 +- .../workflowLogic.sendOnceVerified.test.ts | 109 +++++++++++++++++- .../frontend/Workflows/workflowLogic.ts | 39 ++++++- 9 files changed, 195 insertions(+), 14 deletions(-) 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 ed0e4cc1f61c..152c4a846e33 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) + }) + }) + describe('a domain that verified while the setup was open', () => { const waiting = (): HogFlow => withEmailStep( 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 =