diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 36933d1b3a12..5a689b4ad125 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -144,6 +144,8 @@ export const DARK_MODE_INVERT_ICON_KINDS = new Set(['apns', 'ap 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 => { switch (kind) { case 'google-pubsub': diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx new file mode 100644 index 000000000000..042af246f516 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx @@ -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 ', + 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 = { + title: 'Products/Workflows/Send test email modal', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + }, +} +export default meta + +const Template: StoryFn = ({ 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 +} + +export const SandboxSenderPreselected: StoryFn = Template.bind({}) +SandboxSenderPreselected.args = { recipient: MEMBER_EMAIL } + +export const RecipientOutsideOrganization: StoryFn = Template.bind({}) +RecipientOutsideOrganization.args = { recipient: 'outsider@example.com' } diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx new file mode 100644 index 000000000000..90ec50d2b277 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx @@ -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 ', + config: { provider: 'sandbox', verified: true }, +} + +describe('SendTestEmailModal', () => { + let logic: ReturnType + let template: ReturnType + + 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() + } + + 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((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() + }) +}) diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx index 22671911dbed..b620a646ba1c 100644 --- a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx @@ -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 { + const option = { label: integration.display_name, value: integration.id } + if (integration.id !== sandboxEmailSender?.id) { + return option + } + return { + ...option, + labelInMenu: ( + + {integration.display_name} + Sandbox + + ), + } +} + export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen: boolean }): JSX.Element { const { isOpen, ...logicProps } = props const logic = messageTemplateTestSendLogic(logicProps) const { recipientEmail, + recipientSuggestions, + recipientIsUnverifiedSelf, + recipientNotSuggested, + membersLoading, senderIntegrationId, + sandboxEmailSender, + sandboxEmailSenderEnabled, + isSandboxSenderSelected, emailIntegrations, integrationsLoading, sendDisabledReason, @@ -48,31 +87,69 @@ export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen:
Send to - + {sandboxEmailSenderEnabled ? ( + 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" + /> + ) : ( + + )} + {recipientIsUnverifiedSelf ? ( + + Verify your email address to send test emails to yourself. + + ) : recipientNotSuggested ? ( + + + {recipientEmail} + {' '} + isn't among the verified members you can see. The sandbox sender may not deliver to it. Add + your own sender to email anyone. + + ) : null}
From {integrationsLoading && emailIntegrations.length === 0 ? ( ) : emailIntegrations.length > 0 ? ( - setSenderIntegrationId(id)} - options={emailIntegrations.map((integration) => ({ - label: integration.display_name, - value: integration.id, - }))} - data-attr="send-test-email-sender" - fullWidth - /> + <> + setSenderIntegrationId(id)} + options={emailIntegrations.map((integration) => + senderOption(integration, sandboxEmailSender) + )} + data-attr="send-test-email-sender" + fullWidth + /> + {isSandboxSenderSelected && ( + {SANDBOX_EMAIL_SENDER_NOTE} + )} + ) : ( -
+
No email senders configured yet ({ const mockToast = require('lib/lemon-ui/LemonToast').lemonToast +const VERIFIED_MEMBERS = [MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_SECOND_ORGANIZATION_MEMBER].map((member) => ({ + ...member, + user: { ...member.user, is_email_verified: true }, +})) + describe('messageTemplateTestSendLogic', () => { let logic: ReturnType let templateLogic: ReturnType @@ -29,6 +39,7 @@ describe('messageTemplateTestSendLogic', () => { useMocks({ get: { + '/api/organizations/:organization_id/members/': { results: VERIFIED_MEMBERS }, '/api/projects/:team_id/integrations/': { results: [ { @@ -91,6 +102,22 @@ describe('messageTemplateTestSendLogic', () => { }).toMatchValues({ recipientEmail: 'john.doe@posthog.com' }) }) + it('leaves the recipient empty with the sandbox flag on, so the member suggestions show with the user first', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + try { + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ recipientEmail: '' }) + expect(logic.values.recipientSuggestions[0]).toBe('john.doe@posthog.com') + } finally { + featureFlagLogic.actions.setFeatureFlags([], {}) + } + }) + it('defaults the sender to the first verified email integration, excluding unverified, sandbox and non-email kinds', async () => { await expectLogic(logic).toMatchValues({ senderIntegrationId: 5, @@ -153,6 +180,207 @@ describe('messageTemplateTestSendLogic', () => { expect(logic.values.testSendResult?.status).toBe('skipped') }) + describe('sandbox sender', () => { + 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 }, + } + const UNVERIFIED_OWN_SENDER = { + id: 4, + kind: 'email', + display_name: 'Unverified sender ', + config: { verified: false }, + } + const VERIFIED_OWN_SENDER = { + id: 5, + kind: 'email', + display_name: 'Sender ', + config: { verified: true }, + } + + let integrationsPayload: Record[] + + const loadIntegrations = async (integrations: Record[]): Promise => { + integrationsPayload = integrations + await expectLogic(integrationsLogic, () => { + integrationsLogic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess']) + } + + beforeEach(() => { + integrationsPayload = [] + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }] }, + }) + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }) + + afterEach(() => { + featureFlagLogic.actions.setFeatureFlags([], {}) + }) + + it.each([ + { + description: 'preselects the sandbox sender when the project has no verified own sender', + integrations: [UNVERIFIED_OWN_SENDER, SANDBOX_SENDER], + expectedSenderId: 7, + expectedSenderIds: [7], + }, + { + description: 'prefers a verified own sender and lists the sandbox sender last', + integrations: [SANDBOX_SENDER, VERIFIED_OWN_SENDER], + expectedSenderId: 5, + expectedSenderIds: [5, 7], + }, + ])('$description', async ({ integrations, expectedSenderId, expectedSenderIds }) => { + await loadIntegrations(integrations) + + await expectLogic(logic).toMatchValues({ senderIntegrationId: expectedSenderId }) + expect(logic.values.emailIntegrations.map((integration) => integration.id)).toEqual(expectedSenderIds) + }) + + it('lists the sandbox sender only while the flag is on, and drops a selection that is no longer listed', async () => { + await loadIntegrations([SANDBOX_SENDER, VERIFIED_OWN_SENDER]) + logic.actions.setSenderIntegrationId(7) + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + featureFlagLogic.actions.setFeatureFlags([], {}) + + await expectLogic(logic).toMatchValues({ + senderIntegrationId: 5, + emailIntegrations: [expect.objectContaining({ id: 5 })], + }) + }) + + it('loads the member suggestions once when the flag lands on an open modal', async () => { + featureFlagLogic.actions.setFeatureFlags([], {}) + await loadIntegrations([SANDBOX_SENDER]) + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toNotHaveDispatchedActions([membersLogic.actionTypes.loadAllMembers]) + + await expectLogic(logic, () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }).toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + + await expectLogic(logic).toMatchValues({ + senderIntegrationId: 7, + recipientSuggestions: expect.arrayContaining(['john.doe@posthog.com']), + }) + + await expectLogic(logic, () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER, 'some-other-flag'], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + 'some-other-flag': true, + }) + }).toNotHaveDispatchedActions([membersLogic.actionTypes.loadAllMembers]) + }) + + it('sends a sandbox test without the template Reply-To and custom From, which the sandbox sender ignores', async () => { + await loadIntegrations([SANDBOX_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + templateLogic.actions.setTemplateValue('content.email.replyTo', 'replies@example.com') + templateLogic.actions.setTemplateValue('content.email.from', { + integrationId: 7, + name: 'Security team', + email: 'alerts@example.com', + }) + logic.actions.setRecipientEmail('rose.dawson@posthog.com') + + await expectLogic(logic, () => { + logic.actions.sendTestEmail() + }).toDispatchActions(['sendTestEmailSuccess']) + + const emailAction = capturedBody.configuration.actions.find((a: any) => a.type === 'function_email') + expect(emailAction.config.inputs.email.value.from).toEqual({ integrationId: 7 }) + expect(emailAction.config.inputs.email.value.replyTo).toBe('') + }) + + it('warns about addresses absent from visible verified suggestions without blocking server authorization', async () => { + useMocks({ + get: { + '/api/organizations/:organization_id/members/': { + results: [ + ...VERIFIED_MEMBERS, + { + ...MOCK_SECOND_ORGANIZATION_MEMBER, + id: 'unverified-member', + user: { + ...MOCK_SECOND_BASIC_USER, + id: 303, + email: 'unverified@example.com', + is_email_verified: false, + }, + }, + { + ...MOCK_SECOND_ORGANIZATION_MEMBER, + id: 'verification-unknown-member', + user: { + ...MOCK_SECOND_BASIC_USER, + id: 304, + email: 'unknown@example.com', + is_email_verified: undefined, + }, + }, + ], + }, + }, + }) + await loadIntegrations([SANDBOX_SENDER, VERIFIED_OWN_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + logic.actions.setSenderIntegrationId(7) + logic.actions.setRecipientEmail('outsider@example.com') + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ + recipientNotSuggested: true, + sendDisabledReason: undefined, + recipientSuggestions: expect.arrayContaining(['rose.dawson@posthog.com']), + }) + + logic.actions.setRecipientEmail('Rose.Dawson@posthog.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: false, sendDisabledReason: undefined }) + + logic.actions.setRecipientEmail('unverified@example.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) + expect(logic.values.recipientSuggestions).not.toContain('unverified@example.com') + + logic.actions.setRecipientEmail('unknown@example.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) + expect(logic.values.recipientSuggestions).not.toContain('unknown@example.com') + + logic.actions.setRecipientEmail('outsider@example.com') + logic.actions.setSenderIntegrationId(5) + await expectLogic(logic).toMatchValues({ recipientNotSuggested: false, sendDisabledReason: undefined }) + }) + + it('holds the send while the member list loads, and does not warn before it has loaded', async () => { + await loadIntegrations([SANDBOX_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + logic.actions.setRecipientEmail('outsider@example.com') + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }) + .toDispatchActions(membersLogic, ['loadAllMembers']) + .toMatchValues({ + recipientNotSuggested: false, + sendDisabledReason: 'Checking your organization members', + }) + .toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + .toMatchValues({ recipientNotSuggested: true }) + }) + }) + it('keeps the modal open and surfaces the error when the send fails', async () => { useMocks({ post: { diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index d132a15f2e14..faf9e24cd7ec 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -1,15 +1,16 @@ import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' +import { subscriptions } from 'kea-subscriptions' 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 { isEmail } from 'lib/utils/url' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' -import { IntegrationType, UserType } from '~/types' +import { membersLogic } from '~/models/membersLogic' +import { IntegrationType, OrganizationMemberType, UserType } from '~/types' import { senderFrom, @@ -26,16 +27,28 @@ import { MessageTemplate } from './types' // below and passed as `current_action_id` so the CDP worker executes just this one node. const TEST_EMAIL_ACTION_ID = 'send_test_email' +const sameEmail = (a: string, b: string): boolean => a.trim().toLowerCase() === b.trim().toLowerCase() + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicValues { - integrations: IntegrationType[] | null // integrationsLogic integrationsLoading: boolean // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + meFirstMembers: OrganizationMemberType[] // membersLogic + members: OrganizationMemberType[] | null // membersLogic + membersLoading: boolean // membersLogic template: MessageTemplate // messageTemplateLogic currentTeamId: number | null // teamLogic user: UserType | null // userLogic emailIntegrations: IntegrationType[] isModalOpen: boolean + isSandboxSenderSelected: boolean + membersNeeded: boolean recipientEmail: string + recipientIsUnverifiedSelf: boolean + recipientNotSuggested: boolean + recipientSuggestions: string[] sendDisabledReason: string | undefined senderIntegrationId: number | null senderIntegrationIdOverride: number | null @@ -45,6 +58,9 @@ export interface messageTemplateTestSendLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicActions { + loadAllMembers: () => { + value: true + } // membersLogic clearTestSendResult: () => { value: true } @@ -96,16 +112,38 @@ export interface messageTemplateTestSendLogicActions { export interface messageTemplateTestSendLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { - emailIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] + emailIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ) => IntegrationType[] senderIntegrationId: ( senderIntegrationIdOverride: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate ) => number | null + membersNeeded: (isModalOpen: boolean, sandboxEmailSenderEnabled: boolean) => boolean + isSandboxSenderSelected: ( + senderIntegrationId: number | null, + sandboxEmailSender: IntegrationType | null + ) => boolean + recipientSuggestions: (meFirstMembers: OrganizationMemberType[]) => string[] + recipientIsUnverifiedSelf: ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + user: UserType | null + ) => boolean + recipientNotSuggested: ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + members: OrganizationMemberType[] | null, + recipientSuggestions: string[] + ) => boolean sendDisabledReason: ( template: MessageTemplate, recipientEmail: string, - senderIntegrationId: number | null + senderIntegrationId: number | null, + isSandboxSenderSelected: boolean, + membersLoading: boolean ) => string | undefined } } @@ -130,8 +168,11 @@ export const messageTemplateTestSendLogic = kea ({ open }), @@ -150,7 +191,8 @@ export const messageTemplateTestSendLogic = kea null, sendTestEmail: async (): Promise => { - const { template, recipientEmail, senderIntegrationId, currentTeamId } = values + const { template, recipientEmail, senderIntegrationId, currentTeamId, isSandboxSenderSelected } = + values const emailAction: HogFlowAction = { id: TEST_EMAIL_ACTION_ID, @@ -169,12 +211,16 @@ export const messageTemplateTestSendLogic = kea [s.integrations], - (integrations: IntegrationType[] | null): IntegrationType[] => - integrations?.filter( - (integration) => - integration.kind === 'email' && - integration.config?.verified === true && - !isSandboxEmailSender(integration) - ) ?? [], + (s) => [s.ownEmailIntegrations, s.sandboxEmailSender], + ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ): IntegrationType[] => { + const verifiedOwnSenders = ownEmailIntegrations.filter( + (integration) => integration.config?.verified === true + ) + return sandboxEmailSender ? [...verifiedOwnSenders, sandboxEmailSender] : verifiedOwnSenders + }, ], senderIntegrationId: [ (s) => [s.senderIntegrationIdOverride, s.emailIntegrations, s.template], (override: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate): number | null => - override ?? + emailIntegrations.find((integration) => integration.id === override)?.id ?? senderIntegrationFor(template.content.email ?? {}, emailIntegrations)?.id ?? emailIntegrations[0]?.id ?? null, ], + membersNeeded: [ + (s) => [s.isModalOpen, s.sandboxEmailSenderEnabled], + (isModalOpen: boolean, sandboxEmailSenderEnabled: boolean): boolean => + isModalOpen && sandboxEmailSenderEnabled, + ], + isSandboxSenderSelected: [ + (s) => [s.senderIntegrationId, s.sandboxEmailSender], + (senderIntegrationId: number | null, sandboxEmailSender: IntegrationType | null): boolean => + sandboxEmailSender !== null && senderIntegrationId === sandboxEmailSender.id, + ], + recipientSuggestions: [ + (s) => [s.meFirstMembers], + (meFirstMembers: OrganizationMemberType[]): string[] => + meFirstMembers + .filter((member) => member.user.is_email_verified === true) + .map((member) => member.user.email), + ], + recipientIsUnverifiedSelf: [ + (s) => [s.isSandboxSenderSelected, s.recipientEmail, s.user], + (isSandboxSenderSelected: boolean, recipientEmail: string, user: UserType | null): boolean => + isSandboxSenderSelected && + !!user && + user.is_email_verified !== true && + sameEmail(user.email, recipientEmail), + ], + recipientNotSuggested: [ + (s) => [s.isSandboxSenderSelected, s.recipientEmail, s.members, s.recipientSuggestions], + ( + isSandboxSenderSelected: boolean, + recipientEmail: string, + members: OrganizationMemberType[] | null, + recipientSuggestions: string[] + ): boolean => + isSandboxSenderSelected && + members !== null && + isEmail(recipientEmail) && + !recipientSuggestions.some((suggestion) => sameEmail(suggestion, recipientEmail)), + ], sendDisabledReason: [ - (s) => [s.template, s.recipientEmail, s.senderIntegrationId], + (s) => [s.template, s.recipientEmail, s.senderIntegrationId, s.isSandboxSenderSelected, s.membersLoading], ( template: MessageTemplate, recipientEmail: string, - senderIntegrationId: number | null + senderIntegrationId: number | null, + isSandboxSenderSelected: boolean, + membersLoading: boolean ): string | undefined => { if (!template.content.email?.subject) { return 'Add a subject first' @@ -249,6 +337,9 @@ export const messageTemplateTestSendLogic = kea ({ + membersNeeded: (membersNeeded: boolean, wereNeeded: boolean | undefined) => { + if (membersNeeded && !wereNeeded) { + actions.loadAllMembers() + } + }, + })), ])