Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d761252
feat(workflows): warn when broadcast recipients lack the to-field ema…
dmarchuk Oct 8, 2026
24fea54
feat(workflows): warn on batch workflows when recipients lack the to-…
dmarchuk Oct 8, 2026
3afbf90
fix(workflows): use the generated client for the missing recipient count
dmarchuk Oct 8, 2026
771d6e0
perf(workflows): check recipient emails only before launch and skip u…
dmarchuk Oct 8, 2026
ec11678
fix(workflows): suggest liquid-safe $email syntax and read bracket To…
dmarchuk Oct 9, 2026
edc6fed
fix(workflows): space the missing recipient warning and show it with …
dmarchuk Oct 9, 2026
93ae446
Merge remote-tracking branch 'origin/master' into dmarchuk/broadcast-…
dmarchuk Oct 9, 2026
7e37ce1
feat(workflows): open entry-point broadcasts and workflows ready to send
dmarchuk Oct 9, 2026
49c94c4
chore(workflows): keep the loadWorkflow return type so the generated …
dmarchuk Oct 9, 2026
e06081e
fix(workflows): keep the entry-point link in the url so a reload befo…
dmarchuk Oct 10, 2026
1c8c834
Merge remote-tracking branch 'origin/dmarchuk/broadcast-missing-recip…
dmarchuk Oct 11, 2026
a62a37e
fix(workflows): print liquid delimiters in prefilled draft text as typed
dmarchuk Oct 11, 2026
1aabe9c
chore(visual): update storybook baselines
posthog[bot] Oct 11, 2026
6cf15c8
Merge remote-tracking branch 'origin/master' into dmarchuk/messaging-…
dmarchuk Oct 12, 2026
97e63e5
chore(visual): restore master baseline for flaky oauth authorize snap…
dmarchuk Oct 12, 2026
d154209
Merge remote-tracking branch 'origin/master' into dmarchuk/messaging-…
dmarchuk Oct 12, 2026
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 @@ -7,6 +7,7 @@ import { ButtonPrimitive } from 'lib/ui/Button/ButtonPrimitives'
import { SurveyEventName } from '~/types'

import { captureMessageAudienceClicked } from 'products/workflows/frontend/MessageAudience/messageAudience'
import { draftMessage } from 'products/workflows/frontend/MessageAudience/messageDrafts'
import {
type WorkflowTriggerConfig,
urlForNewWorkflowWithTrigger,
Expand Down Expand Up @@ -34,7 +35,13 @@ export function SurveyFollowUpWorkflowButton({ surveyId }: { surveyId: string })
tooltip="Open a workflow that emails each person who responds to this survey"
onClick={() => {
captureMessageAudienceClicked(SOURCE, 'workflow')
router.actions.push(urlForNewWorkflowWithTrigger(surveyFollowUpTrigger(surveyId), SOURCE))
router.actions.push(
urlForNewWorkflowWithTrigger(
surveyFollowUpTrigger(surveyId),
SOURCE,
draftMessage({ kind: 'survey_responded' })
)
)
}}
data-attr="survey-start-follow-up-workflow"
>
Expand Down
11 changes: 11 additions & 0 deletions products/early_access_features/frontend/EarlyAccessFeature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { AssigneeSelect } from 'products/error_tracking/frontend/components/Assi
import { PersonDisplay } from 'products/persons/frontend/components/PersonDisplay'
import { MessageAudience } from 'products/workflows/frontend/MessageAudience/messageAudience'
import { MessageAudienceButton } from 'products/workflows/frontend/MessageAudience/MessageAudienceButton'
import { draftMessage } from 'products/workflows/frontend/MessageAudience/messageDrafts'

import { EarlyAccessFeatureLogicProps, earlyAccessFeatureLogic } from './earlyAccessFeatureLogic'
import { InstructionsModal } from './InstructionsModal'
Expand Down Expand Up @@ -161,6 +162,16 @@ function enrolledPeopleAudience(earlyAccessFeature: EarlyAccessFeatureType): Mes
},
],
broadcastName: `${earlyAccessFeature.name} is now available`,
broadcastEmail: draftMessage({
kind: 'feature_available',
featureName: earlyAccessFeature.name,
featureDescription: earlyAccessFeature.description,
}),
workflowEmail: draftMessage({
kind: 'feature_enrolled',
featureName: earlyAccessFeature.name,
featureDescription: earlyAccessFeature.description,
}),
workflowTrigger: {
type: 'event',
filters: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { IconSend } from '@posthog/icons'
import { ButtonPrimitive } from 'lib/ui/Button/ButtonPrimitives'

import { captureMessageAudienceClicked } from 'products/workflows/frontend/MessageAudience/messageAudience'
import { draftMessage } from 'products/workflows/frontend/MessageAudience/messageDrafts'
import {
type WorkflowTriggerConfig,
urlForNewWorkflowWithTrigger,
Expand All @@ -30,7 +31,13 @@ export function IssueWorkflowButton({ issueId }: { issueId: string }): JSX.Eleme
tooltip="Open a workflow that emails people each time they hit this issue"
onClick={() => {
captureMessageAudienceClicked(SOURCE, 'workflow')
router.actions.push(urlForNewWorkflowWithTrigger(issueWorkflowTrigger(issueId), SOURCE))
router.actions.push(
urlForNewWorkflowWithTrigger(
issueWorkflowTrigger(issueId),
SOURCE,
draftMessage({ kind: 'issue_hit' })
)
)
}}
data-attr="issue-panel-start-workflow"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
cohortAudienceProperties,
messageAudienceUrl,
} from 'products/workflows/frontend/MessageAudience/messageAudience'
import { draftMessage } from 'products/workflows/frontend/MessageAudience/messageDrafts'

export const AFFECTED_LOOKBACK_DAYS = 30
// pinned: sent as the broadcast's entry_source and the click event's source, so renaming it splits the history
Expand Down Expand Up @@ -188,6 +189,7 @@ export const issueAffectedBroadcastLogic = kea<issueAffectedBroadcastLogicType>(
properties: cohortAudienceProperties(audienceCohort),
source: SOURCE,
broadcastName: truncate(`We fixed ${props.issueName || 'an error'}`, MAX_NAME_LENGTH),
broadcastEmail: draftMessage({ kind: 'issue_fixed' }),
},
'broadcast'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { urls } from 'scenes/urls'

import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'

import { EMAIL_PREFILL_PARAM, MessageDraft } from '../MessageAudience/messageDrafts'

// pinned: URL search params, other products link to /broadcasts/new with these
export const AUDIENCE_PREFILL_PARAM = 'audience'
export const NAME_PREFILL_PARAM = 'name'
Expand All @@ -14,13 +16,15 @@ export interface BroadcastPrefill {
name?: string
/** The product surface the person came from, reported on the launch event. */
source?: string
email?: MessageDraft
}

export function urlForNewBroadcastWithAudience({ properties, name, source }: BroadcastPrefill): string {
export function urlForNewBroadcastWithAudience({ properties, name, source, email }: BroadcastPrefill): string {
return combineUrl(urls.broadcastNew(), {
[AUDIENCE_PREFILL_PARAM]: JSON.stringify(properties),
...(name ? { [NAME_PREFILL_PARAM]: name } : {}),
...(source ? { [SOURCE_PREFILL_PARAM]: source } : {}),
...(email ? { [EMAIL_PREFILL_PARAM]: JSON.stringify(email) } : {}),
}).url
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types

import type { HogFlowApi } from 'products/workflows/frontend/generated/api.schemas'

import { draftMessage } from '../MessageAudience/messageDrafts'
import { urlForNewBroadcastWithAudience } from './broadcastAudiencePrefill'
import { DEFAULT_BROADCAST_EMAIL, DELETED_SENDER_ERROR, broadcastWizardLogic } from './broadcastWizardLogic'

Expand Down Expand Up @@ -144,7 +145,56 @@ describe('broadcastWizardLogic', () => {
expect(logic.values.audienceProperties).toEqual(properties)
expect(logic.values.name).toEqual('Fix shipped')
expect(logic.values.entrySource).toEqual('cohort')
expect(router.values.searchParams).toEqual({})

// A reload remounts the wizard from the same URL, and nothing was saved yet.
logic.unmount()
logic = broadcastWizardLogic({ id: 'new' })
logic.mount()
await expectLogic(logic).toDispatchActions(['prefillFromLink'])
expect(logic.values.audienceProperties).toEqual(properties)
expect(logic.values.name).toEqual('Fix shipped')
})

it.each([
{
link: 'a draft email and one verified sender',
email: draftMessage({ kind: 'issue_fixed' }),
verified: true,
step: 'review',
},
{
link: 'a draft email but no verified sender',
email: draftMessage({ kind: 'issue_fixed' }),
verified: false,
step: 'content',
},
{ link: 'no email', email: undefined, verified: true, step: 'content' },
])('opens a link with $link on the $step step without creating a draft', async ({ email, verified, step }) => {
const createDraft = jest.fn()
useMocks({
get: {
'/api/projects/:team_id/integrations/': {
results: [{ id: 1, kind: 'email', config: { verified } }],
count: 1,
},
},
post: { '/api/projects/:team_id/hog_flows/': () => createDraft() },
})
logic.unmount()
router.actions.push(
urlForNewBroadcastWithAudience({ properties: LOCAL_AUDIENCE, source: 'error_tracking', email })
)
logic = broadcastWizardLogic({ id: 'new' })
logic.mount()
integrationsLogic.mount()

await expectLogic(logic, () => {
integrationsLogic.actions.loadIntegrations()
}).toDispatchActions(['landOnStep'])

expect(logic.values.currentStep).toEqual(step)
expect(logic.values.email.subject).toEqual(email?.subject ?? '')
expect(createDraft).not.toHaveBeenCalled()
})

it('blocks the recipients step until the person chooses, when a link has an audience it cannot use', async () => {
Expand Down
76 changes: 68 additions & 8 deletions products/workflows/frontend/Broadcasts/broadcastWizardLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
} from 'products/workflows/frontend/generated/api.schemas'

import { resolveDefaultEmailSender } from '../Channels/defaultEmailSender'
import { EMAIL_PREFILL_PARAM, messageDraftEmail, parseMessageDraftPrefill } from '../MessageAudience/messageDrafts'
import type { MissingRecipientEmailBannerProps } from '../Workflows/hogflows/steps/components/MissingRecipientEmailBanner'
import {
DEFAULT_STATE,
Expand Down Expand Up @@ -322,6 +323,12 @@ export interface broadcastWizardLogicActions {
hydrateFromBroadcast: (broadcast: HogFlowApi) => {
broadcast: HogFlowApi
}
landOnPrefilledStep: () => {
value: true
}
landOnStep: (step: BroadcastWizardStep) => {
step: BroadcastWizardStep
}
launchBroadcast: () => {
value: true
}
Expand Down Expand Up @@ -593,6 +600,8 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([

actions({
setStep: (step: BroadcastWizardStep) => ({ step }),
landOnStep: (step: BroadcastWizardStep) => ({ step }),
landOnPrefilledStep: true,
nextStep: true,
prevStep: true,
continueStep: true,
Expand Down Expand Up @@ -744,6 +753,7 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
'recipients' as BroadcastWizardStep,
{
setStep: (_, { step }) => step,
landOnStep: (_, { step }) => step,
nextStep: (state) => {
const index = BROADCAST_WIZARD_STEPS.indexOf(state)
return BROADCAST_WIZARD_STEPS[Math.min(index + 1, BROADCAST_WIZARD_STEPS.length - 1)]
Expand Down Expand Up @@ -854,6 +864,8 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
DEFAULT_BROADCAST_EMAIL,
{
setEmail: (_, { email }) => email,
prefillFromLink: (state, { prefill }) =>
prefill.email ? { ...state, ...messageDraftEmail(prefill.email) } : state,
defaultSenderApplied: (state, { integrationId }) =>
state.from?.integrationId ? state : { ...state, from: { ...state.from, integrationId } },
applyExternalEdit: (state, { broadcast }) => {
Expand Down Expand Up @@ -1256,8 +1268,41 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
// pinned: analytics event name
posthog.capture('email sender preselected', { surface: 'broadcast', reason: sender.reason })
},
[integrationsLogic.actionTypes.loadIntegrationsSuccess]: () => actions.applyDefaultSender(),
[teamLogic.actionTypes.loadCurrentTeamSuccess]: () => actions.applyDefaultSender(),
[integrationsLogic.actionTypes.loadIntegrationsSuccess]: () => {
actions.applyDefaultSender()
actions.landOnPrefilledStep()
},
[teamLogic.actionTypes.loadCurrentTeamSuccess]: () => {
actions.applyDefaultSender()
actions.landOnPrefilledStep()
},
landOnPrefilledStep: () => {
// A link that filled the audience skips ahead to the first step that still needs input,
// or to Review when nothing does. It waits for the senders, because the default sender
// decides whether the email is complete, and it runs once.
if (!cache.landOnPrefilledStep || values.integrations === null || values.integrationsLoading) {
return
}
if (!values.currentTeam) {
return
}
cache.landOnPrefilledStep = false
if (values.currentStep !== 'recipients') {
return
}
const step = values.firstInvalidStep ?? 'review'
if (step !== 'recipients') {
actions.landOnStep(step)
}
},
landOnStep: ({ step }) => {
// Unlike setStep, this doesn't create a draft on the content step: following a link must
// not leave one behind. Continue or launch saves it.
if (step === 'review') {
actions.loadBlastRadius()
}
actions.reportReviewVisit()
},
expandRun: ({ runId }) => {
actions.setExpandedRunOverride([...values.expandedRunIds.filter((id) => id !== runId), runId])
},
Expand Down Expand Up @@ -1721,6 +1766,9 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
audience_filter_count: values.audienceProperties.length,
has_goal: values.goalEnabled,
entry_source: values.entrySource ?? loadEntrySource(broadcastId),
...(cache.prefilledEmail
? { prefilled_email_edited: !prefilledEmailUnchanged(cache.prefilledEmail, values.email) }
: {}),
seconds_since_created: activated
? Math.round((Date.now() - new Date(activated.created_at).getTime()) / 1000)
: null,
Expand Down Expand Up @@ -1891,7 +1939,7 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
},
})),

afterMount(({ actions, props }) => {
afterMount(({ actions, props, cache }) => {
if (props.id !== 'new') {
actions.loadBroadcast()
return
Expand All @@ -1901,32 +1949,40 @@ export const broadcastWizardLogic = kea<broadcastWizardLogicType>([
[AUDIENCE_PREFILL_PARAM]: audience,
[NAME_PREFILL_PARAM]: name,
[SOURCE_PREFILL_PARAM]: source,
...searchParams
[EMAIL_PREFILL_PARAM]: emailParam,
} = router.values.searchParams
const properties = parseBroadcastAudiencePrefill(audience)
if (audience !== undefined || name !== undefined || source !== undefined) {
const email = parseMessageDraftPrefill(emailParam)
const fromLink =
audience !== undefined || name !== undefined || source !== undefined || emailParam !== undefined
if (fromLink) {
const prefill: BroadcastPrefill = {
properties: properties ?? [],
name: typeof name === 'string' ? name : undefined,
source: typeof source === 'string' ? source : undefined,
email: email ?? undefined,
}
// Not setAudienceProperties: opening /broadcasts/new must not create a draft.
actions.prefillFromLink(prefill)
if (email) {
cache.prefilledEmail = { ...DEFAULT_BROADCAST_EMAIL, ...messageDraftEmail(email) }
}
if (properties) {
cache.landOnPrefilledStep = true
// pinned: analytics event name
posthog.capture('broadcast prefilled from link', {
entry_source: prefill.source ?? null,
audience_filter_count: properties.length,
has_email: !!email,
})
}
}
if (audience !== undefined && !properties) {
actions.rejectLinkAudience()
}
if (audience !== undefined || name !== undefined || source !== undefined) {
router.actions.replace(router.values.location.pathname, searchParams, router.values.hashParams)
}
// The link stays in the URL until the draft exists, so a reload before Continue opens the same prefill.
actions.loadBlastRadius()
actions.landOnPrefilledStep()
}),
])

Expand Down Expand Up @@ -2154,3 +2210,7 @@ export function buildBroadcastPayload(values: {
],
}
}

function prefilledEmailUnchanged(prefilled: BroadcastEmailValue, current: BroadcastEmailValue): boolean {
return prefilled.subject === current.subject && prefilled.html === current.html && prefilled.text === current.text
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types

import { urlForNewBroadcastWithAudience } from '../Broadcasts/broadcastAudiencePrefill'
import { type WorkflowTriggerConfig, urlForNewWorkflowWithTrigger } from '../Workflows/workflowTriggerPrefill'
import { MessageDraft } from './messageDrafts'

export type MessageAudienceDestination = 'broadcast' | 'workflow'

Expand All @@ -13,6 +14,8 @@ export interface MessageAudience {
broadcastName?: string
/** The workflow to start. Without one, the workflow sends to the same people with a batch trigger. */
workflowTrigger?: WorkflowTriggerConfig
broadcastEmail?: MessageDraft
workflowEmail?: MessageDraft
}

export function cohortAudienceProperties(cohort: { id: number; name?: string | null }): AnyPropertyFilter[] {
Expand All @@ -33,11 +36,13 @@ export function messageAudienceUrl(audience: MessageAudience, destination: Messa
properties: audience.properties,
name: audience.broadcastName,
source: audience.source,
email: audience.broadcastEmail,
})
}
return urlForNewWorkflowWithTrigger(
audience.workflowTrigger ?? { type: 'batch', filters: { properties: audience.properties } },
audience.source
audience.source,
audience.workflowEmail
)
}

Expand Down
Loading