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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/internal/workflows-email-sending-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
// (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
Expand All @@ -160,6 +161,7 @@
emailFieldErrors,
emailLiveChanges,
emailSaveIndicator,
emailSandboxSenderAllowed,
showSource,
sampleGlobalsWithInputs,
className,
Expand Down Expand Up @@ -205,6 +207,7 @@
emailFieldErrors={emailFieldErrors}
emailLiveChanges={emailLiveChanges}
emailSaveIndicator={emailSaveIndicator}
emailSandboxSenderAllowed={emailSandboxSenderAllowed}
/>
)
})}
Expand Down Expand Up @@ -308,6 +311,7 @@
fieldErrors,
liveChanges,
saveIndicator,
sandboxSenderAllowed,
}: {
schema: CyclotronJobInputSchemaType
value: any
Expand All @@ -316,6 +320,7 @@
fieldErrors?: EmailFieldErrors
liveChanges?: boolean
saveIndicator?: ReactNode
sandboxSenderAllowed?: boolean
}): JSX.Element {
return (
<EmailTemplater
Expand All @@ -328,6 +333,7 @@
fieldErrors={fieldErrors}
liveChanges={liveChanges}
saveIndicator={saveIndicator}
sandboxSenderAllowed={sandboxSenderAllowed}
/>
)
}
Expand Down Expand Up @@ -717,6 +723,7 @@
emailFieldErrors?: EmailFieldErrors
emailLiveChanges?: boolean
emailSaveIndicator?: ReactNode
emailSandboxSenderAllowed?: boolean
}

function NonFailureStatusCodesField({
Expand Down Expand Up @@ -759,7 +766,7 @@
)
}

function CyclotronJobInputRenderer({

Check warning on line 769 in frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`CyclotronJobInputRenderer` has cyclomatic complexity 26 (warn >10)
onChange,
onInputChange,
onInputsChange,
Expand All @@ -772,6 +779,7 @@
emailFieldErrors,
emailLiveChanges,
emailSaveIndicator,
emailSandboxSenderAllowed,
}: CyclotronJobInputProps): JSX.Element {
const templating = schema.templating ?? true

Expand Down Expand Up @@ -917,6 +925,7 @@
fieldErrors={emailFieldErrors}
liveChanges={emailLiveChanges}
saveIndicator={emailSaveIndicator}
sandboxSenderAllowed={emailSandboxSenderAllowed}
/>
)
case 'non_failure_status_codes':
Expand Down Expand Up @@ -1092,7 +1101,7 @@
sampleGlobalsWithInputs: CyclotronJobInvocationGlobalsWithInputs | null
}

function CyclotronJobInputWithSchema({

Check warning on line 1104 in frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`CyclotronJobInputWithSchema` has cyclomatic complexity 25 (warn >10)
schema,
configuration,
parentConfiguration,
Expand All @@ -1106,6 +1115,7 @@
emailFieldErrors,
emailLiveChanges,
emailSaveIndicator,
emailSandboxSenderAllowed,
}: CyclotronJobInputWithSchemaProps): JSX.Element | null {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key })
const [editing, setEditing] = useState(false)
Expand Down Expand Up @@ -1276,6 +1286,7 @@
emailFieldErrors={emailFieldErrors}
emailLiveChanges={emailLiveChanges}
emailSaveIndicator={emailSaveIndicator}
emailSandboxSenderAllowed={emailSandboxSenderAllowed}
/>
)}
{warning && !value?.secret ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <hello@acme.example.com>',
config: { name: 'Acme', email: 'hello@acme.example.com', verified: true },
}
const SANDBOX_SENDER = {
id: 7,
kind: 'email',
display_name: 'Acme via PostHog <sandbox@example.com>',
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: '<div>Hello</div>', from: { integrationId } } as any,
onChange: jest.fn(),
}
const logic = emailTemplaterLogic(props)
logic.mount()
await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess'])

render(
<BindLogic logic={emailTemplaterLogic} props={props}>
<EmailSenderSummary />
</BindLogic>
)

expect(await screen.findByText(expectedText)).toBeInTheDocument()
expect(screen.queryByText('Acme via PostHog')).not.toBeInTheDocument()
logic.unmount()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,15 @@
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 {

Check warning on line 11 in frontend/src/scenes/hog-functions/email-templater/EmailSenderSummary.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`EmailSenderSummary` has cyclomatic complexity 11 (warn >10)
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
}) => (
<div data-attr="multiple-email-sender-select">
{options.map((option) => (
<span key={option.key}>{option.label}</span>
<button key={option.key} onClick={() => onChange([...value, option.value])}>
{option.label}
</button>
))}
</div>
),
LemonSelect: ({ options }: { options: { options: { value: number; label: string }[] }[] }) => (
LemonSelect: ({
options,
onChange,
}: {
options: { options: { value: number; label: string }[] }[]
onChange: (value: number) => void
}) => (
<div data-attr="single-email-sender-select">
{options
.flatMap((section) => section.options)
.map((option) => (
<span key={option.value}>{option.label}</span>
<button key={option.value} onClick={() => onChange(option.value)}>
{option.label}
</button>
))}
</div>
),
Expand All @@ -48,18 +68,23 @@ const SANDBOX = {
describe('NativeEmailIntegrationChoice', () => {
afterEach(cleanup)

beforeEach(() => {
function mockPicker(values: Record<string, unknown>): 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(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />)

Expand All @@ -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(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />)
render(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 3 }} onChange={jest.fn()} />)

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(<NativeEmailIntegrationChoice label="From" value={{ integrationId: 1 }} onChange={jest.fn()} />)
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(<NativeEmailIntegrationChoice label="From" value={{}} onChange={jest.fn()} />)

Expand Down
Loading
Loading