{options
.flatMap((section) => section.options)
.map((option) => (
- {option.label}
+
))}
),
@@ -48,18 +68,23 @@ const SANDBOX = {
describe('NativeEmailIntegrationChoice', () => {
afterEach(cleanup)
- beforeEach(() => {
+ function mockPicker(values: Record): void {
jest.mocked(useValues).mockReturnValue({
integrationsLoading: false,
- integrations: [ALICE, BOB, SANDBOX],
+ senderIntegrations: [ALICE, BOB],
+ sandboxSenderId: undefined,
+ isSandboxSenderSelected: false,
+ logicProps: {},
+ ...values,
})
- })
+ }
it.each([
[false, 'single-email-sender-select'],
[true, 'multiple-email-sender-select'],
])('renders the expected sender picker when the rotation flag is %s', (flagEnabled, expectedPicker) => {
mockSenderRotationEnabled = flagEnabled
+ mockPicker({})
render()
@@ -68,19 +93,35 @@ describe('NativeEmailIntegrationChoice', () => {
})
it.each([false, true])(
- 'leaves the sandbox sender out of the picker when the rotation flag is %s',
+ 'offers the sandbox sender the step lists, with its members-only note once selected, when the rotation flag is %s',
(flagEnabled) => {
mockSenderRotationEnabled = flagEnabled
+ mockPicker({ senderIntegrations: [ALICE, SANDBOX], sandboxSenderId: 3, isSandboxSenderSelected: true })
- render()
+ render()
- expect(screen.getByText(ALICE.display_name)).toBeInTheDocument()
- expect(screen.queryByText(SANDBOX.display_name)).not.toBeInTheDocument()
+ expect(screen.getByText(SANDBOX.display_name)).toBeInTheDocument()
+ expect(screen.getByText('Delivers only to verified members of your organization.')).toBeInTheDocument()
+ expect(screen.queryByText('Custom sender')).not.toBeInTheDocument()
}
)
- it('treats a project with only the sandbox sender as having no email sender', () => {
- jest.mocked(useValues).mockReturnValue({ integrationsLoading: false, integrations: [SANDBOX] })
+ it.each([
+ [false, [3]],
+ [true, [1, 3]],
+ ])('sends a pick through chooseSenders when the rotation flag is %s', (flagEnabled, expectedSenderIds) => {
+ mockSenderRotationEnabled = flagEnabled
+ mockPicker({ senderIntegrations: [ALICE, SANDBOX], sandboxSenderId: 3 })
+ mockChooseSenders.mockClear()
+
+ render()
+ fireEvent.click(screen.getByText(SANDBOX.display_name))
+
+ expect(mockChooseSenders).toHaveBeenCalledWith(expectedSenderIds)
+ })
+
+ it('treats a step with no listed sender as having no email sender', () => {
+ mockPicker({ senderIntegrations: [] })
render()
diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx
index f8db9a4db5c0..d1d497ad52af 100644
--- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx
+++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.tsx
@@ -14,6 +14,7 @@ import {
LemonModal,
LemonSegmentedButton,
LemonSelect,
+ LemonTag,
} from '@posthog/lemon-ui'
import {
@@ -27,7 +28,7 @@ import {
import { TagSelect } from 'lib/components/TagSelect'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
-import { isSandboxEmailSender } from 'lib/integrations/utils'
+import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea'
@@ -37,6 +38,7 @@ import { sceneAgentPanelLogic } from 'scenes/max/sceneAgentPanelLogic'
import { urls } from 'scenes/urls'
import { sceneLayoutLogic } from '~/layout/scenes/sceneLayoutLogic'
+import { IntegrationType } from '~/types'
import 'products/workflows/frontend/TemplateLibrary/MessageTemplatesGrid.scss'
import { filterMessageTemplates } from 'products/workflows/frontend/TemplateLibrary/filterMessageTemplates'
@@ -48,6 +50,7 @@ import { previewLinkTargetCustomJs } from './custom-tools/previewLinkTarget'
import { unsubscribeLinkToolCustomJs } from './custom-tools/unsubscribeLinkTool'
import { EmailSenderSummary } from './EmailSenderSummary'
import { EMAIL_TYPE_SUPPORTED_FIELDS, EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic'
+import { selectedSenderIds } from './selectSenders'
import { EmailFieldErrors, EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types'
export type EmailEditorMode = 'full' | 'preview'
@@ -283,40 +286,35 @@ export function NativeEmailIntegrationChoice({
onChange: (value: EmailTemplateFrom) => void
value?: EmailTemplateFrom
}): JSX.Element {
- const { integrationsLoading, integrations } = useValues(integrationsLogic)
- const { logicProps } = useValues(emailTemplaterLogic)
+ const { integrationsLoading } = useValues(integrationsLogic)
+ const { logicProps, senderIntegrations, sandboxSenderId, isSandboxSenderSelected } = useValues(emailTemplaterLogic)
+ const { chooseSenders } = useActions(emailTemplaterLogic)
const senderRotationEnabled = useFeatureFlag('WORKFLOWS_EMAIL_SENDER_ROTATION')
- const integrationsOfKind = integrations?.filter((x) => x.kind === 'email' && !isSandboxEmailSender(x))
- const selectedIntegrationIds = value?.integrationIds?.length
- ? value.integrationIds
- : value?.integrationId
- ? [value.integrationId]
- : []
// Presence of the override keys is what reveals the inputs, so a saved override is
// visible again on reopen without any separate reveal state.
- const overridesVisible = value?.email !== undefined || value?.name !== undefined
+ const overridesVisible = !isSandboxSenderSelected && (value?.email !== undefined || value?.name !== undefined)
const onChangeIntegration = (integrationId: number): void => {
if (integrationId === -1) {
window.open(urls.workflows('channels'), '_blank')
return
}
- onChange({ ...value, integrationId, integrationIds: undefined })
+ chooseSenders([integrationId])
}
- const onChangeIntegrations = (integrationIds: number[]): void => {
- if (integrationIds.length > MAX_WORKFLOW_EMAIL_SENDERS) {
- return
- }
- onChange({
- ...value,
- integrationId: integrationIds[0],
- integrationIds: integrationIds.length > 1 ? integrationIds : undefined,
- })
- }
+ const senderLabel = (integration: IntegrationType): JSX.Element => (
+ <>
+ {integration.display_name}
+ {integration.id === sandboxSenderId && (
+
+ Sandbox
+
+ )}
+ >
+ )
- if (!integrationsLoading && integrationsOfKind?.length === 0) {
+ if (!integrationsLoading && senderIntegrations.length === 0) {
return (
{label}
@@ -348,16 +346,17 @@ export function NativeEmailIntegrationChoice({
mode="multiple"
placeholder="Choose email senders"
loading={integrationsLoading}
- options={(integrationsOfKind || []).map((integration) => ({
+ options={senderIntegrations.map((integration) => ({
key: String(integration.id),
label: integration.display_name,
+ labelComponent: senderLabel(integration),
value: integration.id,
}))}
- value={selectedIntegrationIds}
+ value={selectedSenderIds(value)}
size="small"
fullWidth
autoWidth={false}
- onChange={onChangeIntegrations}
+ onChange={chooseSenders}
data-attr="workflow-email-sender-select"
action={{
children: 'Add new email sender',
@@ -366,42 +365,49 @@ export function NativeEmailIntegrationChoice({
}}
/>
- Choose up to {MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from
- this list.
+ {isSandboxSenderSelected
+ ? SANDBOX_EMAIL_SENDER_NOTE
+ : `Choose up to ${MAX_WORKFLOW_EMAIL_SENDERS} senders. Each workflow run uses one sender from this list.`}
) : (
- ({
- label: integration.display_name,
- value: integration.id,
- })),
- },
- {
- options: [
- {
- label: 'Add new email sender',
- icon: ,
- value: -1,
- },
- ],
- },
- ]}
- value={value?.integrationId}
- size="small"
- fullWidth
- onChange={onChangeIntegration}
- data-attr="workflow-email-sender-select"
- />
+
+ ({
+ label: integration.display_name,
+ labelInMenu: senderLabel(integration),
+ value: integration.id,
+ })),
+ },
+ {
+ options: [
+ {
+ label: 'Add new email sender',
+ icon: ,
+ value: -1,
+ },
+ ],
+ },
+ ]}
+ value={value?.integrationId}
+ size="small"
+ fullWidth
+ onChange={onChangeIntegration}
+ data-attr="workflow-email-sender-select"
+ />
+ {isSandboxSenderSelected && (
+ {SANDBOX_EMAIL_SENDER_NOTE}
+ )}
+
)}
- {!overridesVisible && (
+ {!overridesVisible && !isSandboxSenderSelected && (
{
jest.useRealTimers()
})
+ describe('sender picker', () => {
+ const OWN_SENDER = { id: 5, kind: 'email', display_name: 'Acme ', config: {} }
+ const SANDBOX_SENDER = {
+ id: 7,
+ kind: 'email',
+ display_name: 'Acme via PostHog ',
+ config: { provider: 'sandbox', verified: true },
+ }
+
+ function enableSandboxFlag(): void {
+ featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
+ [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
+ })
+ }
+
+ async function mountWithSenders(overrides?: Partial): Promise {
+ logic = emailTemplaterLogic(makeProps(overrides))
+ logic.mount()
+ await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess'])
+ }
+
+ beforeEach(() => {
+ useMocks({ get: { '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, OWN_SENDER] } } })
+ })
+
+ afterEach(() => {
+ featureFlagLogic.actions.setFeatureFlags([], {})
+ })
+
+ it.each([
+ { surface: 'a workflow email step', sandboxSenderAllowed: true, flag: true, expectedIds: [5, 7] },
+ {
+ surface: 'a workflow email step with the flag off',
+ sandboxSenderAllowed: true,
+ flag: false,
+ expectedIds: [5],
+ },
+ { surface: 'a broadcast or destination', sandboxSenderAllowed: false, flag: true, expectedIds: [5] },
+ ])('lists the sandbox sender last only on $surface', async ({ sandboxSenderAllowed, flag, expectedIds }) => {
+ if (flag) {
+ enableSandboxFlag()
+ }
+ await mountWithSenders({ sandboxSenderAllowed })
+
+ expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual(expectedIds)
+ })
+
+ it('keeps a saved sandbox sender listed after the flag turns off, until another sender replaces it', async () => {
+ const onChange = jest.fn()
+ await mountWithSenders({
+ sandboxSenderAllowed: true,
+ liveChanges: true,
+ onChange,
+ value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 } },
+ })
+
+ expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5, 7])
+ expect(logic.values.isSandboxSenderSelected).toBe(true)
+
+ logic.actions.chooseSenders([7, 5])
+
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ from: { integrationId: 5, integrationIds: undefined } })
+ )
+ expect(logic.values.senderIntegrations.map((integration) => integration.id)).toEqual([5])
+ })
+
+ it('picking the sandbox sender replaces the selection and clears the custom sender and Reply-To', async () => {
+ enableSandboxFlag()
+ const onChange = jest.fn()
+ await mountWithSenders({
+ sandboxSenderAllowed: true,
+ liveChanges: true,
+ onChange,
+ value: {
+ ...DEFAULT_EMAIL_TEMPLATE,
+ from: { integrationId: 5, email: 'hello@acme.example.com', name: 'Acme' },
+ replyTo: 'replies@acme.example.com',
+ },
+ })
+
+ logic.actions.chooseSenders([5, 7])
+
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ from: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined },
+ replyTo: '',
+ })
+ )
+ expect(logic.values.isSandboxSenderSelected).toBe(true)
+ })
+
+ it('offers no Reply-To while the sandbox sender is selected', async () => {
+ enableSandboxFlag()
+ await mountWithSenders({
+ sandboxSenderAllowed: true,
+ value: { ...DEFAULT_EMAIL_TEMPLATE, from: { integrationId: 7 }, replyTo: 'replies@example.com' },
+ })
+
+ expect(logic.values.visibleFields.map((field) => field.key)).not.toContain('replyTo')
+ expect(logic.values.hiddenAdvancedFields.map((field) => field.key)).not.toContain('replyTo')
+
+ logic.actions.setEmailTemplateValue('from', { integrationId: 5 })
+
+ expect(logic.values.isSandboxSenderSelected).toBe(false)
+ expect(logic.values.visibleFields.map((field) => field.key)).toContain('replyTo')
+ })
+ })
+
describe('advanced fields', () => {
it('hides advanced fields by default', async () => {
logic = emailTemplaterLogic(makeProps())
diff --git a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
index 074288c3cd6b..13564c41c320 100644
--- a/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
+++ b/frontend/src/scenes/hog-functions/email-templater/emailTemplaterLogic.tsx
@@ -21,16 +21,20 @@ import { Editor, EmailEditorProps, EditorRef as _EditorRef } from 'react-email-e
import { LemonDialog } from '@posthog/lemon-ui'
import api from 'lib/api'
+import { integrationsLogic } from 'lib/integrations/integrationsLogic'
+import { isSandboxEmailSender } from 'lib/integrations/utils'
import { lemonToast } from 'lib/lemon-ui/LemonToast'
import { preflightLogic } from 'lib/logic/preflightLogic'
import { objectsEqual } from 'lib/utils/objects'
import { teamLogic } from 'scenes/teamLogic'
-import { PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types'
+import { IntegrationType, PreflightStatus, PropertyDefinition, PropertyDefinitionType, Realm } from '~/types'
import { MessageTemplate } from 'products/workflows/frontend/TemplateLibrary/types'
import { registerEmailMediaLibrary } from './emailMediaLibrary'
+import { senderFrom } from './emailSenderFields'
+import { selectSenders, selectedSenderIds } from './selectSenders'
import type { EmailFieldErrors, EmailTemplate } from './types'
export type { EmailTemplate }
@@ -253,6 +257,8 @@ export interface EmailTemplaterLogicProps {
// not compute these itself; a caller that validates the email step (e.g. the workflow builder)
// decides what and when to show.
fieldErrors?: EmailFieldErrors
+ // Only workflow email steps can send through the sandbox sender; every other surface hides it.
+ sandboxSenderAllowed?: boolean
}
function autoRevealAdvancedFields(
@@ -274,6 +280,9 @@ function autoRevealAdvancedFields(
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface emailTemplaterLogicValues {
+ integrations: IntegrationType[] | null // integrationsLogic
+ ownEmailIntegrations: IntegrationType[] // integrationsLogic
+ sandboxEmailSender: IntegrationType | null // integrationsLogic
preflight: PreflightStatus | null // preflightLogic
currentProjectId: number | string // teamLogic
activeContentTab: 'plaintext' | 'visual'
@@ -294,6 +303,7 @@ export interface emailTemplaterLogicValues {
isEmailTemplateSubmitting: boolean
isEmailTemplateValid: boolean
isModalOpen: boolean
+ isSandboxSenderSelected: boolean
isSaveTemplateModalOpen: boolean
isTemplatePickerOpen: boolean
logicProps: EmailTemplaterLogicProps
@@ -301,7 +311,11 @@ export interface emailTemplaterLogicValues {
personPropertyDefinitions: PropertyDefinition[]
personPropertyDefinitionsLoading: boolean
revealedAdvancedFields: EmailMetaFieldKey[]
+ sandboxSenderId: number | undefined
+ selectedSandboxSender: IntegrationType | null
+ senderIntegrations: IntegrationType[]
showEmailTemplateErrors: boolean
+ supportedFields: EmailMetaField[]
templates: MessageTemplate[]
templatesLoading: boolean
templatingEngine: 'hog' | 'liquid'
@@ -319,6 +333,9 @@ export interface emailTemplaterLogicActions {
applyTemplate: (template: MessageTemplate) => {
template: MessageTemplate
}
+ chooseSenders: (integrationIds: number[]) => {
+ integrationIds: number[]
+ }
closeWithConfirmation: () => {
value: true
}
@@ -448,8 +465,24 @@ export interface emailTemplaterLogicMeta {
id: string
}
| undefined
- visibleFields: (arg: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]) => EmailMetaField[]
- hiddenAdvancedFields: (arg: EmailTemplaterType, visibleFields: EmailMetaField[]) => EmailMetaField[]
+ selectedSandboxSender: (
+ integrations: IntegrationType[] | null,
+ emailTemplate: EmailTemplate
+ ) => IntegrationType | null
+ senderIntegrations: (
+ ownEmailIntegrations: IntegrationType[],
+ sandboxEmailSender: IntegrationType | null,
+ selectedSandboxSender: IntegrationType | null,
+ arg: boolean
+ ) => IntegrationType[]
+ sandboxSenderId: (senderIntegrations: IntegrationType[]) => number | undefined
+ isSandboxSenderSelected: (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined) => boolean
+ supportedFields: (arg: EmailTemplaterType, isSandboxSenderSelected: boolean) => EmailMetaField[]
+ visibleFields: (
+ supportedFields: EmailMetaField[],
+ revealedAdvancedFields: EmailMetaFieldKey[]
+ ) => EmailMetaField[]
+ hiddenAdvancedFields: (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]) => EmailMetaField[]
}
}
@@ -464,7 +497,14 @@ export const emailTemplaterLogic = kea([
props({} as EmailTemplaterLogicProps),
path(['scenes', 'hog-functions', 'email-templater', 'emailTemplaterLogic']),
connect(() => ({
- values: [preflightLogic, ['preflight'], teamLogic, ['currentProjectId']],
+ values: [
+ preflightLogic,
+ ['preflight'],
+ integrationsLogic,
+ ['integrations', 'ownEmailIntegrations', 'sandboxEmailSender'],
+ teamLogic,
+ ['currentProjectId'],
+ ],
})),
actions({
setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }),
@@ -481,6 +521,7 @@ export const emailTemplaterLogic = kea([
setActiveContentTab: (tab: 'visual' | 'plaintext') => ({ tab }),
revealAdvancedField: (key: EmailMetaFieldKey) => ({ key }),
hideAdvancedField: (key: EmailMetaFieldKey) => ({ key }),
+ chooseSenders: (integrationIds: number[]) => ({ integrationIds }),
}),
reducers({
emailEditorRef: [
@@ -609,18 +650,6 @@ export const emailTemplaterLogic = kea([
// shared by the whole project, so the project is the user it sees.
emailMediaLibraryEnabled ? { id: `project-${currentProjectId}` } : undefined,
],
- visibleFields: [
- (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.revealedAdvancedFields],
- (type: EmailTemplaterType, revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] =>
- EMAIL_TYPE_SUPPORTED_FIELDS[type].filter(
- (field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key)
- ),
- ],
- hiddenAdvancedFields: [
- (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.visibleFields],
- (type: EmailTemplaterType, visibleFields: EmailMetaField[]): EmailMetaField[] =>
- EMAIL_TYPE_SUPPORTED_FIELDS[type].filter((f) => f.isAdvancedField && !visibleFields.includes(f)),
- ],
}),
forms(({ actions, values, props, cache }) => ({
@@ -676,6 +705,69 @@ export const emailTemplaterLogic = kea([
},
})),
+ selectors({
+ // A step saved with the sandbox sender keeps showing it after the flag turns off, so the
+ // picker can replace it instead of showing a bare id.
+ selectedSandboxSender: [
+ (s) => [s.integrations, s.emailTemplate],
+ (integrations: IntegrationType[] | null, emailTemplate: EmailTemplate): IntegrationType | null => {
+ const ids = selectedSenderIds(senderFrom(emailTemplate))
+ return (
+ integrations?.find(
+ (integration) => isSandboxEmailSender(integration) && ids.includes(integration.id)
+ ) ?? null
+ )
+ },
+ ],
+ senderIntegrations: [
+ (s) => [
+ s.ownEmailIntegrations,
+ s.sandboxEmailSender,
+ s.selectedSandboxSender,
+ (_, props: EmailTemplaterLogicProps) => !!props.sandboxSenderAllowed,
+ ],
+ (
+ ownEmailIntegrations: IntegrationType[],
+ sandboxEmailSender: IntegrationType | null,
+ selectedSandboxSender: IntegrationType | null,
+ sandboxSenderAllowed: boolean
+ ): IntegrationType[] => {
+ const sandboxSender = sandboxEmailSender ?? selectedSandboxSender
+ return sandboxSenderAllowed && sandboxSender
+ ? [...ownEmailIntegrations, sandboxSender]
+ : ownEmailIntegrations
+ },
+ ],
+ sandboxSenderId: [
+ (s) => [s.senderIntegrations],
+ (senderIntegrations: IntegrationType[]): number | undefined =>
+ senderIntegrations.find(isSandboxEmailSender)?.id,
+ ],
+ isSandboxSenderSelected: [
+ (s) => [s.emailTemplate, s.sandboxSenderId],
+ (emailTemplate: EmailTemplate, sandboxSenderId: number | undefined): boolean =>
+ sandboxSenderId !== undefined && selectedSenderIds(senderFrom(emailTemplate)).includes(sandboxSenderId),
+ ],
+ // The sandbox sender always replies from its own identity, so it offers no Reply-To.
+ supportedFields: [
+ (s) => [(_, props: EmailTemplaterLogicProps) => props.type, s.isSandboxSenderSelected],
+ (type: EmailTemplaterType, isSandboxSenderSelected: boolean): EmailMetaField[] =>
+ EMAIL_TYPE_SUPPORTED_FIELDS[type].filter(
+ (field) => !isSandboxSenderSelected || field.key !== EMAIL_META_FIELDS.REPLY_TO.key
+ ),
+ ],
+ visibleFields: [
+ (s) => [s.supportedFields, s.revealedAdvancedFields],
+ (supportedFields: EmailMetaField[], revealedAdvancedFields: EmailMetaFieldKey[]): EmailMetaField[] =>
+ supportedFields.filter((field) => !field.isAdvancedField || revealedAdvancedFields.includes(field.key)),
+ ],
+ hiddenAdvancedFields: [
+ (s) => [s.supportedFields, s.visibleFields],
+ (supportedFields: EmailMetaField[], visibleFields: EmailMetaField[]): EmailMetaField[] =>
+ supportedFields.filter((f) => f.isAdvancedField && !visibleFields.includes(f)),
+ ],
+ }),
+
listeners(({ props, values, actions, cache }) => ({
// Person properties load after the editor starts, and the editor only reads its options once.
loadPersonPropertyDefinitionsSuccess: () => {
@@ -685,6 +777,19 @@ export const emailTemplaterLogic = kea([
values.emailEditorRef?.editor?.setMergeTags?.(mergeTags)
}
},
+ chooseSenders: ({ integrationIds }) => {
+ const { sandboxSenderId } = values
+ const next = selectSenders(senderFrom(values.emailTemplate), integrationIds, sandboxSenderId)
+ if (!next) {
+ return
+ }
+ if (sandboxSenderId !== undefined && next.integrationId === sandboxSenderId) {
+ actions.setEmailTemplateValues({ from: next, replyTo: '' })
+ actions.hideAdvancedField('replyTo')
+ return
+ }
+ actions.setEmailTemplateValue('from', next)
+ },
onEmailEditorReady: () => {
const editor = values.emailEditorRef?.editor
if (editor && values.emailMediaLibraryEnabled) {
diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts
new file mode 100644
index 000000000000..18ad53cd434c
--- /dev/null
+++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.test.ts
@@ -0,0 +1,49 @@
+import { selectSenders } from './selectSenders'
+
+describe('selectSenders', () => {
+ const current = { integrationId: 1, email: 'custom@example.com', name: 'Custom' }
+
+ it.each([
+ {
+ case: 'picking the sandbox sender keeps only it and drops the custom sender values',
+ ids: [1, 2, 7],
+ sandboxId: 7,
+ expected: { integrationId: 7, integrationIds: undefined, email: undefined, name: undefined },
+ },
+ {
+ case: 'adding an own sender while the sandbox sender is selected drops the sandbox sender',
+ value: { integrationId: 7 },
+ ids: [7, 1],
+ sandboxId: 7,
+ expected: { integrationId: 1, integrationIds: undefined },
+ },
+ {
+ case: 'own senders rotate and keep the custom sender values',
+ ids: [1, 2],
+ sandboxId: 7,
+ expected: { integrationId: 1, integrationIds: [1, 2], email: 'custom@example.com', name: 'Custom' },
+ },
+ {
+ case: 'picking the sandbox sender over a full rotation still works',
+ ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7],
+ sandboxId: 7,
+ expected: { integrationId: 7, integrationIds: undefined },
+ },
+ {
+ case: 'a surface without the sandbox sender treats its id like any other',
+ ids: [1, 7],
+ sandboxId: undefined,
+ expected: { integrationId: 1, integrationIds: [1, 7] },
+ },
+ {
+ case: 'more senders than the limit are refused',
+ ids: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12],
+ sandboxId: 7,
+ expected: null,
+ },
+ ])('$case', ({ value = current, ids, sandboxId, expected }) => {
+ expect(selectSenders(value, ids, sandboxId)).toEqual(
+ expected === null ? null : expect.objectContaining(expected)
+ )
+ })
+})
diff --git a/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts
new file mode 100644
index 000000000000..d822a83691cd
--- /dev/null
+++ b/frontend/src/scenes/hog-functions/email-templater/selectSenders.ts
@@ -0,0 +1,38 @@
+import { EmailTemplateFrom, MAX_WORKFLOW_EMAIL_SENDERS } from './types'
+
+export function selectedSenderIds(value: EmailTemplateFrom | undefined): number[] {
+ if (value?.integrationIds?.length) {
+ return value.integrationIds
+ }
+ return value?.integrationId ? [value.integrationId] : []
+}
+
+function exclusiveSandboxSenders(
+ integrationIds: number[],
+ sandboxSenderId: number | undefined,
+ sandboxSelected: boolean
+): number[] {
+ if (sandboxSenderId === undefined || !integrationIds.includes(sandboxSenderId)) {
+ return integrationIds
+ }
+ return sandboxSelected ? integrationIds.filter((id) => id !== sandboxSenderId) : [sandboxSenderId]
+}
+
+export function selectSenders(
+ value: EmailTemplateFrom | undefined,
+ integrationIds: number[],
+ sandboxSenderId: number | undefined
+): EmailTemplateFrom | null {
+ const sandboxSelected = sandboxSenderId !== undefined && selectedSenderIds(value).includes(sandboxSenderId)
+ const senders = exclusiveSandboxSenders(integrationIds, sandboxSenderId, sandboxSelected)
+ if (senders.length > MAX_WORKFLOW_EMAIL_SENDERS) {
+ return null
+ }
+ const sandboxChosen = sandboxSenderId !== undefined && senders.includes(sandboxSenderId)
+ return {
+ ...value,
+ integrationId: senders[0],
+ integrationIds: senders.length > 1 ? senders : undefined,
+ ...(sandboxChosen ? { email: undefined, name: undefined } : {}),
+ }
+}
diff --git a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx
index e2ad5442a586..5a7ee8c20c5a 100644
--- a/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx
+++ b/products/workflows/frontend/Broadcasts/BroadcastsEmptyState.tsx
@@ -14,7 +14,7 @@ import { newBroadcastAgentLogic } from './newBroadcastAgentLogic'
/** The Broadcasts tab before the first broadcast: set up a verified sender first, then write one. */
export function BroadcastsEmptyState(): JSX.Element {
- const { integrations } = useValues(integrationsLogic)
+ const { userManagedIntegrations: integrations } = useValues(integrationsLogic)
const { startNewBroadcast } = useActions(newBroadcastAgentLogic)
const editorDisabledReason = getAccessControlDisabledReason(
AccessControlResourceType.Workflow,
diff --git a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx
index dbec2b503f7e..2ef1e1da5c62 100644
--- a/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx
+++ b/products/workflows/frontend/Broadcasts/steps/BroadcastContentStep.tsx
@@ -38,13 +38,13 @@ export function BroadcastContentStep(): JSX.Element {
channel,
} = useValues(broadcastWizardLogic)
const { setEmail, setEmailSettings } = useActions(broadcastWizardLogic)
- const { integrations, integrationsLoading } = useValues(integrationsLogic)
+ const { integrations, integrationsLoading, ownEmailIntegrations } = useValues(integrationsLogic)
const { loadIntegrations } = useActions(integrationsLogic)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
// null: closed. 'new': set up a sender. An integration: finish verifying that one.
const [senderSetup, setSenderSetup] = useState<'new' | IntegrationType | null>(null)
- const hasSenders = !!integrations?.some((integration) => integration.kind === 'email')
+ const hasSenders = ownEmailIntegrations.length > 0
const senderUnverified = !!selectedSender && selectedSender.config?.verified !== true
// A deleted sender would otherwise show as its bare id, so the picker leaves it out.
const missingSenderIds = integrations ? getMissingSenderIds(email.from, integrations) : []
diff --git a/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx b/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx
index 49321f3fa801..e5419bcb0311 100644
--- a/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx
+++ b/products/workflows/frontend/Workflows/hogflows/steps/StepFunction.tsx
@@ -40,6 +40,7 @@ export function StepFunctionConfiguration({ node }: { node: StepFunctionNode }):
// another step of the same template must remount to show the newly selected node's inputs.
key={node.id}
templateId={templateId}
+ actionType={node.data.type}
inputs={inputs}
setInputs={(inputs) => partialSetWorkflowActionConfig(node.id, { inputs })}
mappings={mappings}
diff --git a/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx b/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx
index db9002e04bbe..7300412c7f85 100644
--- a/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx
+++ b/products/workflows/frontend/Workflows/hogflows/steps/StepTrigger.tsx
@@ -516,6 +516,7 @@ function StepTriggerConfigurationWebhook({
/>
setWorkflowActionConfig(action.id, {
@@ -881,6 +882,7 @@ function StepTriggerConfigurationTrackingPixel({
setWorkflowActionConfig(action.id, {
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 81488de1516b..0a500199fc5c 100644
--- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts
+++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.test.ts
@@ -1,6 +1,7 @@
import { CyclotronJobInvocationGlobals } from '~/types'
-import { buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration'
+import type { HogFlowAction } from '../../types'
+import { allowsSandboxSender, 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.
@@ -15,6 +16,22 @@ 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 86c25fff7192..e2f2838895e5 100644
--- a/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx
+++ b/products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx
@@ -17,6 +17,7 @@ import { workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorTestLogic } from '../../panel/testing/hogFlowEditorTestLogic'
import { isGithubEventTriggerConfig } from '../../registry/triggers/githubTriggerFilters'
import { isSlackMessageTriggerConfig } from '../../registry/triggers/slackTriggerFilters'
+import type { HogFlowAction } from '../../types'
import { CustomerTaskWorkflowReferenceInput } from './CustomerTaskWorkflowReferenceInput'
import { HogFlowFunctionMappings } from './HogFlowFunctionMappings'
import { WorkflowAutoSaveIndicator } from './WorkflowAutoSaveIndicator'
@@ -191,8 +192,16 @@ 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,
inputs,
setInputs,
mappings,
@@ -202,6 +211,7 @@ export function HogFlowFunctionConfiguration({
emailFieldErrors,
}: {
templateId: string
+ actionType: HogFlowAction['type']
inputs: Record
mappings?: HogFunctionMappingType[]
setInputs: (inputs: Record) => void
@@ -281,6 +291,7 @@ export function HogFlowFunctionConfiguration({
// (into the staged draft on active workflows), so the editor needs no save step.
emailLiveChanges
emailSaveIndicator={}
+ emailSandboxSenderAllowed={allowsSandboxSender(actionType, workflow?.origin_product)}
configuration={{ inputs: inputs as Record, inputs_schema: schema }}
showSource={false}
sampleGlobalsWithInputs={sampleGlobals}