Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -86,6 +87,7 @@ export function EmailDomainSetupModal({
next,
onComplete,
onClose,
onSenderCreated,
}: EmailDomainSetupModalProps): JSX.Element {
const [integrationId, setIntegrationId] = useState<number | null>(initialIntegrationId ?? null)

Expand All @@ -112,7 +114,13 @@ export function EmailDomainSetupModal({
/>
) : (
<div className="@container">
<DomainStep logicKey="modal" onCreated={(created) => setIntegrationId(created.id)} />
<DomainStep
logicKey="modal"
onCreated={(created) => {
setIntegrationId(created.id)
onSenderCreated?.(created.id)
}}
/>
</div>
)}
</LemonModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +42,11 @@ export function WorkflowDomainSetupActions(): JSX.Element | null {
<p className="m-0 text-sm text-secondary break-words">
{lead(enabled, enableFromSenderDomainSetupDisabledReason, verifying, domain ?? 'the domain')}
</p>
{senderDomainSetup?.filledEmptySenders && fromAddress && (
<p className="m-0 text-sm text-secondary break-words">
Your workflow now sends from <strong>{fromAddress}</strong>.
</p>
)}
</div>
<div className="flex flex-col gap-2">
{offersEnable && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HogFlow>, from: Record<string, unknown>): HogFlow =>
({
Expand Down Expand Up @@ -99,6 +100,9 @@ const flush = async (ms = 0): Promise<void> => {
})
}

const emailSenderOf = (workflow: Partial<HogFlow>, 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
Expand Down Expand Up @@ -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 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +20,7 @@ export function WorkflowSenderDomainSetupModal(): JSX.Element | null {
next={<WorkflowDomainSetupActions />}
onComplete={closeSenderDomainSetup}
onClose={closeSenderDomainSetup}
onSenderCreated={fillEmptySendersFromDomainSetup}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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
Expand Down
5 changes: 4 additions & 1 deletion products/workflows/frontend/Workflows/resourceSaveQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function withEmailSender(workflow: HogFlow, integrationId: number): { wor
const actions = workflow.actions.map((action) => {
const config = action.config as Record<string, any>
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -47,6 +48,9 @@ const emailStep = (from: Record<string, unknown>): HogFlow['actions'][number] =>
},
}) as HogFlow['actions'][number]

const senderOf = (workflow: Partial<HogFlow>, stepId: string): unknown =>
(workflow.actions?.find(({ id }) => id === stepId)?.config as any)?.inputs?.email?.value?.from

const withEmailStep = (workflow: HogFlow, from: Record<string, unknown>): HogFlow => ({
...workflow,
actions: [workflow.actions[0], emailStep(from), workflow.actions[1]],
Expand Down Expand Up @@ -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, () => {
Expand All @@ -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<string, unknown>[]): 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<HogFlow>
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<void>((resolve) => {
releaseFirstSave = resolve
})
useMocks({
patch: {
'/api/environments/:team_id/hog_flows/:id/': async ({ request }) => {
const body = (await request.json()) as Partial<HogFlow>
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<HogFlow>; reason: string }>([
{
name: 'a step has errors',
Expand Down
Loading
Loading