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: 2 additions & 0 deletions frontend/src/lib/integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@
export const isSandboxEmailSender = (integration: { kind: string; config?: unknown }): boolean =>
integration.kind === 'email' && (integration.config as { provider?: unknown } | undefined)?.provider === 'sandbox'

export const SANDBOX_EMAIL_SENDER_NOTE = 'Delivers only to verified members of your organization.'

export const getIntegrationNameFromKind = (kind: string): string => {

Check warning on line 149 in frontend/src/lib/integrations/utils.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`getIntegrationNameFromKind` has cyclomatic complexity 26 (warn >10)
switch (kind) {
case 'google-pubsub':
return 'Google Cloud Pub/Sub'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Meta, StoryFn } from '@storybook/react'
import { useActions } from 'kea'
import { useEffect } from 'react'

import { FEATURE_FLAGS } from 'lib/constants'

import { useStorybookMocks } from '~/mocks/browser'

import { messageTemplateLogic } from './messageTemplateLogic'
import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic'
import { SendTestEmailModal } from './SendTestEmailModal'

const LOGIC_PROPS = { id: 'new' }

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 },
created_at: '2026-08-18T00:00:00Z',
}

const MEMBER_EMAIL = 'teammate@example.com'

type StoryArgs = {
recipient?: string
}

const meta: Meta<StoryArgs> = {
title: 'Products/Workflows/Send test email modal',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER],
},
}
export default meta

const Template: StoryFn<StoryArgs> = ({ recipient }) => {
useStorybookMocks({
get: {
'/api/organizations/:organization_id/members/': {
results: [{ id: 'sandbox-teammate', user: { id: 42, email: MEMBER_EMAIL, is_email_verified: true } }],
},
'/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER] },
'/api/environments/:team_id/messaging_templates/': { results: [] },
},
})
const { setTemplateValue } = useActions(messageTemplateLogic(LOGIC_PROPS))
const { setModalOpen, setRecipientEmail } = useActions(messageTemplateTestSendLogic(LOGIC_PROPS))

useEffect(() => {
setTemplateValue('content.email.subject', 'Welcome to Acme')
setTemplateValue('content.email.text', 'Hello from the team.')
setModalOpen(true)
if (recipient) {
setRecipientEmail(recipient)
}
}, [recipient, setTemplateValue, setModalOpen, setRecipientEmail])

return <SendTestEmailModal {...LOGIC_PROPS} isOpen />
}

export const SandboxSenderPreselected: StoryFn<StoryArgs> = Template.bind({})
SandboxSenderPreselected.args = { recipient: MEMBER_EMAIL }

export const RecipientOutsideOrganization: StoryFn<StoryArgs> = Template.bind({})
RecipientOutsideOrganization.args = { recipient: 'outsider@example.com' }
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_DEFAULT_USER } from 'lib/api.mock'

import '@testing-library/jest-dom'

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { expectLogic } from 'kea-test-utils'

import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { userLogic } from 'scenes/userLogic'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'

import { messageTemplateLogic } from './messageTemplateLogic'
import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic'
import { SendTestEmailModal } from './SendTestEmailModal'

const sandbox = {
id: 9,
kind: 'email',
display_name: 'Sandbox <sender@example.com>',
config: { provider: 'sandbox', verified: true },
}

describe('SendTestEmailModal', () => {
let logic: ReturnType<typeof messageTemplateTestSendLogic.build>
let template: ReturnType<typeof messageTemplateLogic.build>

beforeEach(() => {
initKeaTests()
featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], {
[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true,
})
})
afterEach(() => {
cleanup()
logic?.unmount()
template?.unmount()
})

const openModal = (): void => {
template = messageTemplateLogic({ id: 'new' })
template.mount()
template.actions.setTemplateValue('content.email.subject', 'Welcome')
logic = messageTemplateTestSendLogic({ id: 'new' })
logic.mount()
logic.actions.setModalOpen(true)
render(<SendTestEmailModal id="new" isOpen />)
}

it.each([
[false, 'Verify your email address to send test emails to yourself.'],
[true, null],
])('asks a signed-in user with verified=%s to verify their own address', async (verified, expected) => {
useMocks({
get: {
'/api/projects/:team_id/integrations/': { results: [sandbox] },
'/api/organizations/:organization_id/members/': { results: [] },
},
})
userLogic.actions.loadUserSuccess({ ...MOCK_DEFAULT_USER, is_email_verified: verified })
openModal()
await expectLogic(logic).toFinishAllListeners()
logic.actions.setRecipientEmail(MOCK_DEFAULT_USER.email.toUpperCase())

await waitFor(() =>
expect(screen.queryByText(/isn't among the verified members you can see/) !== null).toBe(verified)
)
expect(
screen.queryByText('Verify your email address to send test emails to yourself.')?.textContent ?? null
).toBe(expected)
})

it.each([
[
'a suggested teammate',
'teammate@example.com',
() => fireEvent.click(screen.getByText('teammate@example.com')),
],
[
'a typed address',
'someone@example.com',
(input: HTMLElement) => {
fireEvent.change(input, { target: { value: 'someone@example.com' } })
fireEvent.keyDown(input, { key: 'Enter' })
},
],
])('sends the test to %s', async (_, expectedRecipient, pickRecipient) => {
let sentTo: string | null = null
useMocks({
get: {
'/api/projects/:team_id/integrations/': { results: [sandbox] },
'/api/organizations/:organization_id/members/': {
results: [
{
...MOCK_DEFAULT_ORGANIZATION_MEMBER,
user: {
...MOCK_DEFAULT_ORGANIZATION_MEMBER.user,
email: 'teammate@example.com',
is_email_verified: true,
},
},
],
},
},
post: {
'/api/environments/:team_id/hog_flows/:id/invocations/': async ({ request }: { request: Request }) => {
const body = await request.json()
const emailAction = body.configuration.actions.find((a: any) => a.type === 'function_email')
sentTo = emailAction.config.inputs.email.value.to.email
return [200, { status: 'success', nextActionId: null }]
},
},
})
openModal()
await expectLogic(logic).toFinishAllListeners()

const input = screen.getByPlaceholderText('you@example.com')
fireEvent.focus(input)
pickRecipient(input)
fireEvent.click(screen.getByText('Send test email', { selector: 'button *' }))
await expectLogic(logic).toFinishAllListeners()

expect(sentTo).toBe(expectedRecipient)
})

it.each([true, false])('distinguishes member loading from an empty list (%s)', async (loading) => {
let releaseMembers: () => void = () => {}
const gate = new Promise<void>((resolve) => {
releaseMembers = resolve
})
useMocks({
get: {
'/api/projects/:team_id/integrations/': { results: [sandbox] },
'/api/organizations/:organization_id/members/': async () => {
if (loading) {
await gate
}
return [200, { results: [] }]
},
},
})
openModal()
if (!loading) {
await expectLogic(logic).toFinishAllListeners()
}
fireEvent.focus(screen.getByPlaceholderText('you@example.com'))
expect(document.querySelector('.LemonSkeleton') !== null).toBe(loading)
expect(screen.queryByText('Type an email address and press Enter') !== null).toBe(!loading)
releaseMembers()
await expectLogic(logic).toFinishAllListeners()
})
})
115 changes: 96 additions & 19 deletions products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,59 @@
import { useActions, useValues } from 'kea'

import { IconExternal } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonInput, LemonLabel, LemonSelect, Spinner } from '@posthog/lemon-ui'
import {
LemonBanner,
LemonButton,
LemonInput,
LemonInputSelect,
LemonLabel,
LemonSelect,
type LemonSelectOption,
LemonTag,
Spinner,
} from '@posthog/lemon-ui'

import { SANDBOX_EMAIL_SENDER_NOTE } from 'lib/integrations/utils'
import { LemonModal } from 'lib/lemon-ui/LemonModal'
import { urls } from 'scenes/urls'

import { IntegrationType } from '~/types'

import { MessageTemplateLogicProps } from './messageTemplateLogic'
import { messageTemplateTestSendLogic } from './messageTemplateTestSendLogic'

function senderOption(
integration: IntegrationType,
sandboxEmailSender: IntegrationType | null
): LemonSelectOption<number> {
const option = { label: integration.display_name, value: integration.id }
if (integration.id !== sandboxEmailSender?.id) {
return option
}
return {
...option,
labelInMenu: (
<span className="flex items-center gap-2">
<span translate="no">{integration.display_name}</span>
<LemonTag type="highlight">Sandbox</LemonTag>
</span>
),
}
}

export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen: boolean }): JSX.Element {

Check warning on line 44 in products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`SendTestEmailModal` has cyclomatic complexity 15 (warn >10)
const { isOpen, ...logicProps } = props
const logic = messageTemplateTestSendLogic(logicProps)
const {
recipientEmail,
recipientSuggestions,
recipientIsUnverifiedSelf,
recipientNotSuggested,
membersLoading,
senderIntegrationId,
sandboxEmailSender,
sandboxEmailSenderEnabled,
isSandboxSenderSelected,
emailIntegrations,
integrationsLoading,
sendDisabledReason,
Expand Down Expand Up @@ -48,31 +87,69 @@
<div className="flex flex-col gap-4 min-w-100">
<div className="flex flex-col gap-1">
<LemonLabel>Send to</LemonLabel>
<LemonInput
type="email"
value={recipientEmail}
onChange={setRecipientEmail}
placeholder="you@example.com"
data-attr="send-test-email-recipient"
/>
{sandboxEmailSenderEnabled ? (
<LemonInputSelect
mode="single"
allowCustomValues
value={recipientEmail ? [recipientEmail] : []}
onChange={(values) => setRecipientEmail(values[0] ?? '')}
options={recipientSuggestions.map((email) => ({ key: email, label: email }))}
loading={membersLoading}
placeholder="you@example.com"
emptyStateComponent="Type an email address and press Enter"
data-attr="send-test-email-recipient"
/>
) : (
<LemonInput
type="email"
value={recipientEmail}
onChange={setRecipientEmail}
placeholder="you@example.com"
data-attr="send-test-email-recipient"
/>
)}
{recipientIsUnverifiedSelf ? (
<LemonBanner type="warning">
Verify your email address to send test emails to yourself.
</LemonBanner>
) : recipientNotSuggested ? (
<LemonBanner
type="warning"
action={{
children: 'Add your own sender',
to: urls.workflows('channels'),
targetBlank: true,
}}
>
<span translate="no" className="break-all">
{recipientEmail}
</span>{' '}
isn't among the verified members you can see. The sandbox sender may not deliver to it. Add
your own sender to email anyone.
</LemonBanner>
) : null}
</div>
<div className="flex flex-col gap-1">
<LemonLabel>From</LemonLabel>
{integrationsLoading && emailIntegrations.length === 0 ? (
<Spinner />
) : emailIntegrations.length > 0 ? (
<LemonSelect
value={senderIntegrationId}
onChange={(id) => setSenderIntegrationId(id)}
options={emailIntegrations.map((integration) => ({
label: integration.display_name,
value: integration.id,
}))}
data-attr="send-test-email-sender"
fullWidth
/>
<>
<LemonSelect
value={senderIntegrationId}
onChange={(id) => setSenderIntegrationId(id)}
options={emailIntegrations.map((integration) =>
senderOption(integration, sandboxEmailSender)
)}
data-attr="send-test-email-sender"
fullWidth
/>
{isSandboxSenderSelected && (
<span className="text-xs text-secondary">{SANDBOX_EMAIL_SENDER_NOTE}</span>
)}
</>
) : (
<div className="flex gap-2 items-center">
<div className="flex flex-wrap gap-2 items-center">
<span className="text-muted">No email senders configured yet</span>
<LemonButton
size="small"
Expand Down
Loading
Loading