diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md index 315eff6dc6d0..46e4d4b9d7aa 100644 --- a/docs/internal/workflows-email-sending-tiers.md +++ b/docs/internal/workflows-email-sending-tiers.md @@ -30,7 +30,7 @@ The `workflows-sandbox-sender` flag enables a PostHog-managed email integration The endpoint requires `integration:write` and project membership. Repeated requests keep the same integration and refresh its display name from the organization name. Set `WORKFLOWS_SANDBOX_SENDER_DOMAIN`, `SES_SANDBOX_FROM_ADDRESS`, and `SES_SANDBOX_TENANT_NAME` in Django; any empty value leaves the endpoint unavailable. The worker sends from the same `SES_SANDBOX_FROM_ADDRESS`, so set it once for Django and the worker. The address the UI shows is then the address the worker sends from. -The workflow email step does not offer it. +Workflow email steps and test sends offer it as a sender. Users cannot edit, verify, or remove the managed integration, or register their own integration on the shared domain. The managed integration is excluded from project SES tenant migration, identity cleanup, and the reputation page's domain breakdown. Deploy the schema, recipient restrictions, daily caps, and tenant pause checks first. Configure the shared SES route, then enable `WORKFLOWS_SANDBOX_SENDER_ENABLED` on the worker and CDP API. Enable `workflows-sandbox-sender` last so provisioning and sender selection follow delivery readiness. diff --git a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx index 8639c256a962..66cf48e39d7f 100644 --- a/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx +++ b/frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx @@ -140,6 +140,7 @@ export type CyclotronJobInputsProps = { // (the workflow builder's auto-save). Only the email input types read these. emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean parentConfiguration?: CyclotronJobInputConfiguration onInputSchemaChange?: (schema: CyclotronJobInputSchemaType[]) => void // Classes for the column the inputs are laid out in, so a host with height to spare can let @@ -160,6 +161,7 @@ export function CyclotronJobInputs({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, showSource, sampleGlobalsWithInputs, className, @@ -205,6 +207,7 @@ export function CyclotronJobInputs({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> ) })} @@ -308,6 +311,7 @@ function EmailTemplateField({ fieldErrors, liveChanges, saveIndicator, + sandboxSenderAllowed, }: { schema: CyclotronJobInputSchemaType value: any @@ -316,6 +320,7 @@ function EmailTemplateField({ fieldErrors?: EmailFieldErrors liveChanges?: boolean saveIndicator?: ReactNode + sandboxSenderAllowed?: boolean }): JSX.Element { return ( ) } @@ -717,6 +723,7 @@ type CyclotronJobInputProps = { emailFieldErrors?: EmailFieldErrors emailLiveChanges?: boolean emailSaveIndicator?: ReactNode + emailSandboxSenderAllowed?: boolean } function NonFailureStatusCodesField({ @@ -772,6 +779,7 @@ function CyclotronJobInputRenderer({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputProps): JSX.Element { const templating = schema.templating ?? true @@ -917,6 +925,7 @@ function CyclotronJobInputRenderer({ fieldErrors={emailFieldErrors} liveChanges={emailLiveChanges} saveIndicator={emailSaveIndicator} + sandboxSenderAllowed={emailSandboxSenderAllowed} /> ) case 'non_failure_status_codes': @@ -1106,6 +1115,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors, emailLiveChanges, emailSaveIndicator, + emailSandboxSenderAllowed, }: CyclotronJobInputWithSchemaProps): JSX.Element | null { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key }) const [editing, setEditing] = useState(false) @@ -1276,6 +1286,7 @@ function CyclotronJobInputWithSchema({ emailFieldErrors={emailFieldErrors} emailLiveChanges={emailLiveChanges} emailSaveIndicator={emailSaveIndicator} + emailSandboxSenderAllowed={emailSandboxSenderAllowed} /> )} {warning && !value?.secret ? ( diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx new file mode 100644 index 000000000000..e0bbe575df94 --- /dev/null +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.test.tsx @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { EmailSenderSummary } from './EmailSenderSummary' +import { EmailTemplaterLogicProps, emailTemplaterLogic } from './emailTemplaterLogic' + +const OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Acme ', + config: { name: 'Acme', email: 'hello@acme.example.com', verified: true }, +} +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} + +describe('EmailSenderSummary', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/messaging_templates/': { results: [] }, + '/api/projects/:team_id/property_definitions/': { results: [] }, + '/api/projects/:team_id/integrations/': { results: [OWN_SENDER, SANDBOX_SENDER] }, + }, + }) + initKeaTests() + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(cleanup) + + it.each([ + ['the own sender', OWN_SENDER.id, 'Acme'], + ['the sandbox sender', SANDBOX_SENDER.id, 'Choose who this email comes from'], + ])('shows a transactional template sending from %s', async (_name, integrationId, expectedText) => { + const props: EmailTemplaterLogicProps = { + type: 'native_email_transactional_template', + value: { subject: 'Hello', text: 'Hello', html: '
Hello
', from: { integrationId } } as any, + onChange: jest.fn(), + } + const logic = emailTemplaterLogic(props) + logic.mount() + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + + render( + + + + ) + + expect(await screen.findByText(expectedText)).toBeInTheDocument() + expect(screen.queryByText('Acme via PostHog')).not.toBeInTheDocument() + logic.unmount() + }) +}) diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx index a59fe39c9031..e21998f57a1e 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx @@ -4,19 +4,15 @@ import { useState } from 'react' import { IconPencil } from '@posthog/icons' import { LemonButton, LemonDropdown, LemonLabel } from '@posthog/lemon-ui' -import { integrationsLogic } from 'lib/integrations/integrationsLogic' - import { senderAddressError, senderFrom, senderIntegrationFor } from './emailSenderFields' import { EmailSenderForm } from './EmailSenderForm' import { emailTemplaterLogic } from './emailTemplaterLogic' export function EmailSenderSummary(): JSX.Element { - const { emailTemplate } = useValues(emailTemplaterLogic) + const { emailTemplate, senderIntegrations: senders } = useValues(emailTemplaterLogic) const { setEmailTemplateValue } = useActions(emailTemplaterLogic) - const { integrations } = useValues(integrationsLogic) const [isEditing, setIsEditing] = useState(false) - const senders = (integrations ?? []).filter((integration) => integration.kind === 'email') const from = senderFrom(emailTemplate) const sender = senderIntegrationFor(emailTemplate, senders) const name = from.name || sender?.config?.name diff --git a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx index 02710a4045bb..679d5617ef65 100644 --- a/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx +++ b/frontend/src/scenes/hog-functions/email-templater/EmailTemplater.test.tsx @@ -1,11 +1,12 @@ import '@testing-library/jest-dom' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { useValues } from 'kea' import { NativeEmailIntegrationChoice } from './EmailTemplater' let mockSenderRotationEnabled = false +const mockChooseSenders = jest.fn() jest.mock('lib/hooks/useFeatureFlag', () => ({ useFeatureFlag: () => mockSenderRotationEnabled, @@ -14,23 +15,42 @@ jest.mock('lib/hooks/useFeatureFlag', () => ({ jest.mock('kea', () => ({ ...jest.requireActual('kea'), useValues: jest.fn(), + useActions: () => ({ chooseSenders: mockChooseSenders }), })) jest.mock('@posthog/lemon-ui', () => ({ ...jest.requireActual('@posthog/lemon-ui'), - LemonInputSelect: ({ options }: { options: { key: string; label: string }[] }) => ( + LemonInputSelect: ({ + options, + value, + onChange, + }: { + options: { key: string; label: string; value: number }[] + value: number[] + onChange: (value: number[]) => void + }) => (
{options.map((option) => ( - {option.label} + ))}
), - LemonSelect: ({ options }: { options: { options: { value: number; label: string }[] }[] }) => ( + LemonSelect: ({ + options, + onChange, + }: { + options: { options: { value: number; label: string }[] }[] + onChange: (value: number) => void + }) => (
{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}