From f1e44aa71164b5e2be31b73c7ecafdf2786da049 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:43:11 +0000 Subject: [PATCH] feat(workflows): test templates with the sandbox sender Co-Authored-By: Claude Opus 5.5 --- nodejs/src/cdp/cdp-api.serial.test.ts | 38 +++ nodejs/src/cdp/cdp-api.ts | 2 +- .../services/hogflows/actions/hog_function.ts | 4 + .../services/messaging/email.service.test.ts | 27 +- .../cdp/services/messaging/email.service.ts | 6 + .../SendTestEmailModal.stories.tsx | 84 +++++ .../SendTestEmailModal.test.tsx | 166 ++++++++++ .../TemplateLibrary/SendTestEmailModal.tsx | 151 +++++++-- .../messageTemplateTestSendLogic.test.ts | 305 +++++++++++++++++- .../messageTemplateTestSendLogic.ts | 303 +++++++++++++---- .../HogFlowEditorNotificationPanelTest.tsx | 7 +- .../panel/testing/HogFlowEditorPanelTest.tsx | 7 +- .../getHogFlowTestResultMessage.test.ts | 130 ++++++++ .../testing/getHogFlowTestResultMessage.ts | 26 ++ 14 files changed, 1154 insertions(+), 102 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx create mode 100644 products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts create mode 100644 products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts diff --git a/nodejs/src/cdp/cdp-api.serial.test.ts b/nodejs/src/cdp/cdp-api.serial.test.ts index 6a5914f4fcc1..a48bd30a8240 100644 --- a/nodejs/src/cdp/cdp-api.serial.test.ts +++ b/nodejs/src/cdp/cdp-api.serial.test.ts @@ -4,6 +4,7 @@ import { mockFetch } from '../../tests/helpers/mocks/request.mock' import { Server } from 'http' import jwt from 'jsonwebtoken' +import { DateTime } from 'luxon' import supertest from 'supertest' import express from 'ultimate-express' @@ -2721,6 +2722,43 @@ describe('CDP API', () => { emailSpy.mockRestore() }) + it('reports a skipped email delivery and preserves step-through continuation', async () => { + emailSpy.mockImplementation((invocation: any) => + Promise.resolve({ + invocation, + finished: true, + skipped: true, + logs: [ + { + level: 'warn', + timestamp: DateTime.now(), + message: 'Skipping send: Sandbox daily cap reached.', + }, + ], + metrics: [], + capturedPostHogEvents: [], + warehouseWebhookPayloads: [], + messageAssets: [], + conversionWatchers: [], + }) + ) + + const res = await supertest(app).post(`/api/projects/${team.id}/hog_flows/${hogFlowId}/invocations`).send({ + globals, + configuration: {}, + current_action_id: 'email_1', + }) + + expect(res.status).toBe(200) + expect(res.body.status).toBe('skipped') + expect(res.body.nextActionId).toBe('exit') + expect(res.body.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('Sandbox daily cap reached.') }), + ]) + ) + }) + it('sends the email inline via EmailService instead of routing to the email queue', async () => { const res = await supertest(app).post(`/api/projects/${team.id}/hog_flows/${hogFlowId}/invocations`).send({ globals, diff --git a/nodejs/src/cdp/cdp-api.ts b/nodejs/src/cdp/cdp-api.ts index 5868cd1dcb56..abc282c22f5b 100644 --- a/nodejs/src/cdp/cdp-api.ts +++ b/nodejs/src/cdp/cdp-api.ts @@ -930,7 +930,7 @@ export class CdpApi { const result = await this.hogFlowExecutor.executeCurrentAction(invocation, { hogExecutorOptions: options }) res.json({ - nextActionId: result.skipped ? null : result.invocation.state.currentAction?.id, + nextActionId: result.skipped && result.finished ? null : result.invocation.state.currentAction?.id, status: result.error ? 'error' : result.skipped ? 'skipped' : 'success', errors: result.error ? [result.error] : [], logs: [...result.logs, ...logs], diff --git a/nodejs/src/cdp/services/hogflows/actions/hog_function.ts b/nodejs/src/cdp/services/hogflows/actions/hog_function.ts index 931326f1882f..7d1efd7418bf 100644 --- a/nodejs/src/cdp/services/hogflows/actions/hog_function.ts +++ b/nodejs/src/cdp/services/hogflows/actions/hog_function.ts @@ -241,6 +241,10 @@ export class HogFunctionHandler implements ActionHandler { ) } + if (hogExecutorOptions?.isTest && functionResult.skipped) { + result.skipped = true + } + // Add billable_invocation metric only if the function actually executed (not skipped) if (!functionResult.skipped) { // A step that does not send leaves this undefined and bills as before. diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 4343e576e8bf..8d4d5d9f773e 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -765,6 +765,7 @@ describe('EmailService', () => { expect(result.error).toBeUndefined() expect(result.invocation.state.vmState?.stack).toEqual([{ success: !suppressed }]) if (suppressed) { + expect(result.skipped).toBe(true) expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.logs).toContainEqual( expect.objectContaining({ message: expect.stringContaining(email) }) @@ -2588,6 +2589,18 @@ describe('EmailService', () => { expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics.map((m) => m.metric_name)).toContain('email_suppressed') expect(result.metrics.map((m) => m.metric_name)).not.toContain('email_sent') + expect(result.skipped).not.toBe(true) + }) + + it('reports editor test sends to a suppressed recipient as skipped without recording metrics', async () => { + jest.spyOn(service['emailSuppressionService'], 'isSuppressed').mockResolvedValue(true) + sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) + + const result = await service.executeSendEmail(invocation, true) + + expect(sendEmailSpy).not.toHaveBeenCalled() + expect(result.metrics).toEqual([]) + expect(result.skipped).toBe(true) }) it('calls SES when the recipient is not suppressed', async () => { @@ -2663,18 +2676,27 @@ describe('EmailService', () => { ) } - it('does not call SES while the team is suspended and records email_suspended', async () => { + it.each([false, true])('does not call SES while the team is suspended (sandbox=%s)', async (isSandbox) => { await suspendTeam() + if (isSandbox) { + await insertIntegration(hub.postgres, team.id, { + id: getIntegrationId(4), + kind: 'email', + config: { provider: 'sandbox', verified: true }, + }) + invocation.queueParameters = createEmailParams({ from: { integrationId: 4 } }) + } sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) const result = await service.executeSendEmail(invocation) expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics.map((m) => m.metric_name)).toEqual(['email_suspended']) + expect(result.skipped === true).toBe(isSandbox) expect(invocation.state.vmState?.stack).toEqual([{ success: false }]) }) - it('blocks editor test sends while suspended without recording metrics', async () => { + it('reports editor test sends as skipped while suspended without recording metrics', async () => { await suspendTeam() sendEmailSpy.mockResolvedValue({ MessageId: 'test-message-id' }) @@ -2682,6 +2704,7 @@ describe('EmailService', () => { expect(sendEmailSpy).not.toHaveBeenCalled() expect(result.metrics).toEqual([]) + expect(result.skipped).toBe(true) }) // A per-workflow pause holds one workflow's email while the rest of the project keeps diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index 8094da1eb54a..39a05cdf3288 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -541,6 +541,9 @@ export class EmailService { // gating here turns wasted invocations into one clear skip. const suspensionCause = await this.teamWorkflowsConfigService.getEmailSendingSuspension(invocation.teamId) if (suspensionCause) { + if (isSandbox || isTest) { + result.skipped = true + } addLog( 'warn', suspensionCause === 'staff' @@ -694,6 +697,9 @@ export class EmailService { sandboxRecipients ) if (skipReason) { + if (isSandbox || isTest) { + result.skipped = true + } addLog('info', skipReason) if (!isTest) { result.metrics.push({ diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx new file mode 100644 index 000000000000..0b989b5768fb --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx @@ -0,0 +1,84 @@ +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' + +const SANDBOX_BLOCK_LOG = `Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: ${MEMBER_EMAIL}. Verify your own domain to send to anyone.` + +type StoryArgs = { + recipient?: string + sendResult?: Record +} + +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, sendResult }) => { + 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: [] }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': SANDBOX_SENDER, + '/api/environments/:team_id/hog_flows/:id/invocations/': sendResult ?? { status: 'success' }, + }, + }) + const { setTemplateValue } = useActions(messageTemplateLogic(LOGIC_PROPS)) + const { setModalOpen, setRecipientEmail, sendTestEmail } = 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) + } + if (sendResult) { + sendTestEmail() + } + }, [recipient, sendResult, setTemplateValue, setModalOpen, setRecipientEmail, sendTestEmail]) + + return +} + +export const SandboxSenderPreselected: StoryFn = Template.bind({}) +SandboxSenderPreselected.args = { recipient: MEMBER_EMAIL } + +export const RecipientOutsideOrganization: StoryFn = Template.bind({}) +RecipientOutsideOrganization.args = { recipient: 'outsider@example.com' } + +export const SendBlockedByServer: StoryFn = Template.bind({}) +SendBlockedByServer.args = { + recipient: MEMBER_EMAIL, + sendResult: { status: 'skipped', nextActionId: null, logs: [{ level: 'info', message: SANDBOX_BLOCK_LOG }] }, +} diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx new file mode 100644 index 000000000000..592f91724334 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx @@ -0,0 +1,166 @@ +import { 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 { integrationsLogic } from 'lib/integrations/integrationsLogic' +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([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() + }) + + it.each([404, 500])( + 'shows a %s setup error detail and retries through the integrations API', + async (failureStatus) => { + let requests = 0 + let status = failureStatus + const detail = 'Sandbox sending is not configured. Try again.' + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => [200, { results: status === 200 ? [sandbox] : [] }], + '/api/organizations/:organization_id/members/': { results: [] }, + }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + requests++ + return status === 200 ? [200, sandbox] : [status, { detail }] + }, + }, + }) + openModal() + await expectLogic(integrationsLogic).toFinishAllListeners() + await waitFor(() => expect(screen.getByText(detail)).toBeInTheDocument()) + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute( + 'href', + '/project/997/workflows/channels' + ) + status = 200 + fireEvent.click(screen.getAllByText('Try again')[0]) + await waitFor(() => expect(requests).toBe(2)) + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(screen.queryByText(detail)).not.toBeInTheDocument() + logic.actions.setRecipientEmail('outside@example.com') + await waitFor(() => + expect( + screen.getByText( + /isn't among the verified members you can see\. The sandbox sender may not deliver to it\. Add your own sender to email anyone\./ + ) + ).toBeInTheDocument() + ) + } + ) + it('shows an integrations load failure with a retry instead of an empty sender list', async () => { + let status = 500 + const detail = 'Could not load your senders. Try again.' + useMocks({ + get: { + '/api/projects/:team_id/integrations/': () => + status === 200 ? [200, { results: [sandbox] }] : [status, { detail }], + '/api/organizations/:organization_id/members/': { results: [] }, + }, + }) + openModal() + await waitFor(() => expect(screen.getByText(detail)).toBeInTheDocument()) + expect(screen.queryByText('No email senders configured yet')).not.toBeInTheDocument() + expect(screen.getByText('Connect email sender').closest('a')).toHaveAttribute( + 'href', + '/project/997/workflows/channels' + ) + status = 200 + fireEvent.click(screen.getAllByText('Try again')[0]) + await expectLogic(integrationsLogic).toFinishAllListeners() + await waitFor(() => expect(screen.queryByText(detail)).not.toBeInTheDocument()) + }) +}) diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx index 22671911dbed..7303ed078ffd 100644 --- a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.tsx @@ -1,27 +1,72 @@ 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, + 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 +): { + label: string + value: number + labelInMenu?: JSX.Element +} { + 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, + sandboxEmailSenderEnabled, + recipientNotSuggested, senderIntegrationId, + sandboxEmailSender, + isSandboxSenderSelected, emailIntegrations, - integrationsLoading, + emailIntegrationsLoading, + emailSendersError, sendDisabledReason, testSendResult, testSendResultLoading, + testSendSkipMessage, + membersLoading, } = useValues(logic) - const { setModalOpen, setRecipientEmail, setSenderIntegrationId, sendTestEmail } = useActions(logic) + const { setModalOpen, setRecipientEmail, setSenderIntegrationId, sendTestEmail, retryEmailSenders } = + useActions(logic) return (
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 ? ( + {emailSendersError && ( + + {emailSendersError} + + )} + {emailIntegrationsLoading && 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 +
+ {!emailSendersError && No email senders configured yet} )}
- {testSendResult && testSendResult.status === 'skipped' ? ( + {testSendSkipMessage ? ( - {testSendResult.logs?.map((log) => log.message).join(' ') || - 'The send was skipped for this recipient.'} +
This test email was not sent
+
{testSendSkipMessage}
) : testSendResult && testSendResult.status === 'error' ? ( diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts index 81a4245849aa..c7328a89ef3c 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts @@ -1,6 +1,11 @@ +import { MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_SECOND_BASIC_USER, MOCK_SECOND_ORGANIZATION_MEMBER } from 'lib/api.mock' + 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 { membersLogic } from 'scenes/organization/membersLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' @@ -18,6 +23,11 @@ jest.mock('lib/lemon-ui/LemonToast', () => ({ 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: [ { @@ -85,6 +96,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 and non-email kinds', async () => { await expectLogic(logic).toMatchValues({ senderIntegrationId: 5, @@ -129,7 +156,13 @@ describe('messageTemplateTestSendLogic', () => { post: { '/api/environments/:team_id/hog_flows/:id/invocations/': () => [ 200, - { status: 'skipped', logs: [{ message: 'Recipient has opted out.' }] }, + { + status: 'skipped', + logs: [ + { message: 'Executing action [Action:send_test_email]' }, + { message: '[Action:send_test_email] Recipient has opted out, skipping message delivery.' }, + ], + }, ], }, }) @@ -145,6 +178,276 @@ describe('messageTemplateTestSendLogic', () => { expect(mockToast.error).not.toHaveBeenCalled() expect(logic.values.isModalOpen).toBe(true) expect(logic.values.testSendResult?.status).toBe('skipped') + expect(logic.values.testSendSkipMessage).toBe('Recipient has opted out, skipping message delivery.') + }) + + it.each(['recipient', 'sender'])('clears the previous send outcome when changing the %s', async (field) => { + logic.actions.sendTestEmailSuccess({ + status: 'skipped', + logs: [], + nextActionId: null, + errors: [], + variables: {}, + }) + expect(logic.values.testSendResult?.status).toBe('skipped') + + if (field === 'recipient') { + logic.actions.setRecipientEmail('teammate@example.com') + } else { + logic.actions.setSenderIntegrationId(5) + } + + await expectLogic(logic).toMatchValues({ testSendResult: null }) + }) + + it.each(['recipient', 'sender'])('keeps the send in flight when the %s changes', async (field) => { + let releaseSend: () => void = () => {} + const pendingSend = new Promise((resolve) => { + releaseSend = resolve + }) + useMocks({ + post: { + '/api/environments/:team_id/hog_flows/:id/invocations/': async () => { + await pendingSend + return [200, { status: 'success', nextActionId: null }] + }, + }, + }) + logic.actions.setRecipientEmail('recipient@example.com') + logic.actions.sendTestEmail() + try { + await expectLogic(logic).toMatchValues({ testSendResultLoading: true }) + if (field === 'recipient') { + logic.actions.setRecipientEmail('teammate@example.com') + } else { + logic.actions.setSenderIntegrationId(5) + } + await expectLogic(logic).toMatchValues({ testSendResultLoading: true }) + } finally { + releaseSend() + await expectLogic(logic).toFinishAllListeners() + } + }) + + 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 ensureCalls: number + let integrationsPayload: Record[] + + const loadIntegrations = async (integrations: Record[]): Promise => { + integrationsPayload = integrations + await expectLogic(integrationsLogic, () => { + integrationsLogic.actions.loadIntegrations() + }).toDispatchActions(['loadIntegrationsSuccess']) + } + + beforeEach(() => { + ensureCalls = 0 + integrationsPayload = [] + useMocks({ + get: { '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }] }, + post: { + '/api/projects/:team_id/integrations/email_sandbox_sender/': () => { + ensureCalls += 1 + integrationsPayload = [...integrationsPayload, SANDBOX_SENDER] + return [200, SANDBOX_SENDER] + }, + }, + }) + 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('opening the modal creates a missing sandbox sender once and preselects it', async () => { + await loadIntegrations([UNVERIFIED_OWN_SENDER]) + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toDispatchActions(integrationsLogic, ['provisionSandboxEmailSender', 'loadIntegrationsSuccess']) + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + logic.actions.setModalOpen(false) + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + + expect(ensureCalls).toBe(1) + }) + + it('does not create the sandbox sender while the flag is off, and catches up when the flag lands on an open modal', async () => { + featureFlagLogic.actions.setFeatureFlags([], {}) + await loadIntegrations([UNVERIFIED_OWN_SENDER]) + + await expectLogic(logic, () => { + logic.actions.setModalOpen(true) + }).toFinishAllListeners() + await expectLogic(integrationsLogic).toFinishAllListeners() + expect(ensureCalls).toBe(0) + + await expectLogic(logic, () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], { + [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true, + }) + }).toDispatchActions(membersLogic, ['loadAllMembersSuccess']) + await expectLogic(integrationsLogic).toFinishAllListeners() + await expectLogic(logic).toMatchValues({ senderIntegrationId: 7 }) + + expect(ensureCalls).toBe(1) + }) + + it('sends a sandbox test without the template Reply-To, which the sandbox sender does not support', async () => { + await loadIntegrations([SANDBOX_SENDER]) + templateLogic.actions.setTemplateValue('content.email.text', 'Hello!') + templateLogic.actions.setTemplateValue('content.email.replyTo', 'replies@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).toMatchObject({ from: { integrationId: 7 }, replyTo: '' }) + }) + + 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@posthog.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@posthog.com') + await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) + expect(logic.values.recipientSuggestions).not.toContain('unverified@posthog.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 () => { diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index dc1d9315665d..f381bc3ebaf7 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -4,16 +4,20 @@ import { loaders } from 'kea-loaders' import api from 'lib/api' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isEmail } from 'lib/utils/url' +import { membersLogic } from 'scenes/organization/membersLogic' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' -import { IntegrationType, UserType } from '~/types' +import { IntegrationType, OrganizationMemberType, UserType } from '~/types' +import type { SandboxEmailSenderProvisionResult } from '../../../../frontend/src/lib/integrations/integrationsLogic' import { senderFrom, senderIntegrationFor, } from '../../../../frontend/src/scenes/hog-functions/email-templater/emailSenderFields' +import { skippedSendReasons } from '../Workflows/hogflows/panel/testing/getHogFlowTestResultMessage' import { HogflowTestResult } from '../Workflows/hogflows/steps/types' import { createExampleEvent } from '../Workflows/hogflows/testEventFactory' import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types' @@ -21,49 +25,69 @@ import { EXIT_NODE_ID, NEW_WORKFLOW, TRIGGER_NODE_ID } from '../Workflows/workfl import { MessageTemplateLogicProps, messageTemplateLogic } from './messageTemplateLogic' import { MessageTemplate } from './types' -// Fixed id for the single email action in the synthetic test flow - referenced by the edges -// below and passed as `current_action_id` so the CDP worker executes just this one node. +// The edges and current_action_id must identify the same synthetic email action. const TEST_EMAIL_ACTION_ID = 'send_test_email' // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicValues { integrations: IntegrationType[] | null // integrationsLogic + integrationsLoadError: string | null // integrationsLogic + integrationsLoadFailed: boolean // integrationsLogic integrationsLoading: boolean // integrationsLogic + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + sandboxEmailSenderEnabled: boolean // integrationsLogic + sandboxEmailSenderProvisionError: string | null // integrationsLogic + sandboxEmailSenderProvisionResult: SandboxEmailSenderProvisionResult | null // integrationsLogic + sandboxEmailSenderProvisionResultLoading: boolean // integrationsLogic + sandboxEmailSenderSetupError: string | null // integrationsLogic + meFirstMembers: OrganizationMemberType[] // membersLogic + members: OrganizationMemberType[] | null // membersLogic + membersLoading: boolean // membersLogic template: MessageTemplate // messageTemplateLogic currentTeamId: number | null // teamLogic user: UserType | null // userLogic emailIntegrations: IntegrationType[] + emailIntegrationsLoading: boolean + emailSendersError: string | null isModalOpen: boolean + isSandboxSenderSelected: boolean recipientEmail: string + recipientIsUnverifiedSelf: boolean + recipientNotSuggested: boolean + recipientSuggestions: string[] sendDisabledReason: string | undefined senderIntegrationId: number | null senderIntegrationIdOverride: number | null testSendResult: HogflowTestResult | null testSendResultLoading: boolean + testSendSkipMessage: string | null } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface messageTemplateTestSendLogicActions { + setFeatureFlags: ( + flags: string[], + variants: Record + ) => { + flags: string[] + variants: Record + } // featureFlagLogic + ensureSandboxEmailSender: () => { + value: true + } // integrationsLogic + loadIntegrations: (_?: any) => any // integrationsLogic + retrySandboxEmailSender: () => { + value: true + } // integrationsLogic + loadAllMembers: () => { + value: true + } // membersLogic clearTestSendResult: () => { value: true } - clearTestSendResultFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any - } - clearTestSendResultSuccess: ( - testSendResult: null, - payload?: { - value: true - } - ) => { - testSendResult: null - payload?: { - value: true - } + retryEmailSenders: () => { + value: true } sendTestEmail: () => any sendTestEmailFailure: ( @@ -95,17 +119,50 @@ export interface messageTemplateTestSendLogicActions { export interface messageTemplateTestSendLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { - emailIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] + emailSendersError: ( + integrationsLoadError: string | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderSetupError: string | null + ) => string | null + emailIntegrationsLoading: ( + integrationsLoading: boolean, + integrations: IntegrationType[] | null, + integrationsLoadFailed: boolean, + sandboxEmailSenderProvisionResultLoading: boolean + ) => boolean + emailIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ) => IntegrationType[] senderIntegrationId: ( senderIntegrationIdOverride: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate ) => number | null + 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 + testSendSkipMessage: (testSendResult: HogflowTestResult | null) => string | null } } @@ -129,7 +186,29 @@ export const messageTemplateTestSendLogic = kea ({ email }), setSenderIntegrationId: (id: number | null) => ({ id }), clearTestSendResult: true, + retryEmailSenders: true, }), reducers({ isModalOpen: [false, { setModalOpen: (_, { open }) => open }], recipientEmail: ['', { setRecipientEmail: (_, { email }) => email }], senderIntegrationIdOverride: [null as number | null, { setSenderIntegrationId: (_, { id }) => id }], + testSendResult: [ + null as HogflowTestResult | null, + { clearTestSendResult: () => null, setRecipientEmail: () => null, setSenderIntegrationId: () => null }, + ], }), loaders(({ values }) => ({ testSendResult: [ null as HogflowTestResult | null, { - clearTestSendResult: () => 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, @@ -164,16 +248,19 @@ export const messageTemplateTestSendLogic = kea [s.integrationsLoadError, s.integrationsLoadFailed, s.sandboxEmailSenderSetupError], + (loadError: string | null, loadFailed: boolean, sandboxSetupError: string | null): string | null => + loadError || (loadFailed ? 'Could not load email senders.' : null) || sandboxSetupError, + ], + emailIntegrationsLoading: [ + (s) => [ + s.integrationsLoading, + s.integrations, + s.integrationsLoadFailed, + s.sandboxEmailSenderProvisionResultLoading, + ], + ( + loading: boolean, + integrations: IntegrationType[] | null, + loadFailed: boolean, + provisioning: boolean + ): boolean => loading || (integrations === null && !loadFailed) || provisioning, + ], emailIntegrations: [ - (s) => [s.integrations], - (integrations: IntegrationType[] | null): IntegrationType[] => - integrations?.filter( - (integration) => integration.kind === 'email' && integration.config?.verified === true - ) ?? [], + (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, ], + 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 && + user.email.toLowerCase() === recipientEmail.trim().toLowerCase(), + ], + 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) => suggestion.toLowerCase() === recipientEmail.trim().toLowerCase() + ), + ], 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' @@ -245,33 +393,64 @@ export const messageTemplateTestSendLogic = kea [s.testSendResult], + (testSendResult: HogflowTestResult | null): string | null => { + if (testSendResult?.status !== 'skipped') { + return null + } + return skippedSendReasons(testSendResult).join(' ') || 'The send was skipped for this recipient.' + }, + ], }), - listeners(({ actions, values }) => ({ - setModalOpen: ({ open }) => { - if (!open) { - return + listeners(({ actions, values }) => { + const prepareSandboxSender = (): void => { + if (values.isModalOpen && values.sandboxEmailSenderEnabled) { + actions.ensureSandboxEmailSender() + actions.loadAllMembers() } - actions.clearTestSendResult() - if (!values.recipientEmail && values.user?.email) { - actions.setRecipientEmail(values.user.email) - } - }, - sendTestEmailSuccess: ({ testSendResult }) => { - if (testSendResult.status === 'success') { - lemonToast.success(`Test email sent to ${values.recipientEmail}`) - actions.setModalOpen(false) + } + return { + retryEmailSenders: () => { + if (values.emailIntegrationsLoading || values.testSendResultLoading) { + return + } + actions.loadIntegrations() + if (values.sandboxEmailSenderEnabled) { + actions.retrySandboxEmailSender() + } + }, + setModalOpen: ({ open }) => { + if (!open) { + return + } actions.clearTestSendResult() - } else if (testSendResult.status === 'skipped') { - lemonToast.warning('Test email skipped, see details below') - } else { + if (!values.recipientEmail && values.user?.email && !values.sandboxEmailSenderEnabled) { + actions.setRecipientEmail(values.user.email) + } + prepareSandboxSender() + }, + setFeatureFlags: prepareSandboxSender, + sendTestEmailSuccess: ({ testSendResult }) => { + if (testSendResult.status === 'success') { + lemonToast.success(`Test email sent to ${values.recipientEmail}`) + actions.setModalOpen(false) + actions.clearTestSendResult() + } else if (testSendResult.status === 'skipped') { + lemonToast.warning('Test email skipped, see details below') + } else { + lemonToast.error('Failed to send test email') + } + }, + sendTestEmailFailure: () => { lemonToast.error('Failed to send test email') - } - }, - sendTestEmailFailure: () => { - lemonToast.error('Failed to send test email') - }, - })), + }, + } + }), ]) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx index 6c5c34ceda8e..c9c13dca1109 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorNotificationPanelTest.tsx @@ -29,6 +29,7 @@ import { asDisplay } from 'products/persons/frontend/person-utils' import { renderWorkflowLogMessage } from '../../../logs/log-utils' import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic' import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' import { hogFlowEditorNotificationTestLogic } from './hogFlowEditorNotificationTestLogic' import { reorderGlobalsForEmailAction } from './hogFlowEditorNotificationTestLogic' @@ -368,11 +369,7 @@ export function EmailActionTestContent(): JSX.Element | null { : 'error' } > - {testResult.status === 'success' - ? 'Success' - : testResult.status === 'skipped' - ? 'Workflow was skipped because the event did not match the filter criteria' - : 'Error: ' + testResult.errors?.join(', ')} + {getHogFlowTestResultMessage(testResult)}
diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx index 923aeaf0cd93..f53bedb1dd5b 100644 --- a/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/HogFlowEditorPanelTest.tsx @@ -11,6 +11,7 @@ import { LogsViewerTable } from 'scenes/hog-functions/logs/LogsViewer' import { renderWorkflowLogMessage } from '../../../logs/log-utils' import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic' import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' import { HogFlowEditorTestEvent } from './HogFlowEditorTestEvent' import { hogFlowEditorTestLogic } from './hogFlowEditorTestLogic' @@ -157,11 +158,7 @@ export function HogFlowEditorPanelTest(): JSX.Element | null { : 'error' } > - {testResult.status === 'success' - ? 'Success' - : testResult.status === 'skipped' - ? 'Workflow was skipped because the event did not match the filter criteria' - : 'Error: ' + testResult.errors?.join(', ')} + {getHogFlowTestResultMessage(testResult)}
diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts new file mode 100644 index 000000000000..761d744d966d --- /dev/null +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.test.ts @@ -0,0 +1,130 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render } from '@testing-library/react' +import { BindLogic } from 'kea' +import { expectLogic } from 'kea-test-utils' +import { createElement } from 'react' + +import { dayjs } from 'lib/dayjs' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { workflowLogic } from '../../../workflowLogic' +import { hogFlowEditorLogic } from '../../hogFlowEditorLogic' +import { HogflowTestResult } from '../../steps/types' +import { getHogFlowTestResultMessage } from './getHogFlowTestResultMessage' +import { EmailActionTestContent } from './HogFlowEditorNotificationPanelTest' +import { hogFlowEditorNotificationTestLogic } from './hogFlowEditorNotificationTestLogic' + +jest.mock('scenes/hog-functions/configuration/HogFunctionTest', () => ({ HogFunctionTestEditor: () => null })) + +describe('rendered workflow test result message', () => { + let workflow: ReturnType + let editor: ReturnType + let logic: ReturnType + + beforeEach(async () => { + initKeaTests() + useMocks({ + get: { + '/api/environments/:team_id/persons/': { results: [] }, + '/api/environments/@current/messaging_categories': { results: [] }, + '/api/projects/:team_id/hog_flows/test-workflow-id/': { + id: 'test-workflow-id', + team_id: 997, + name: 'Test workflow', + status: 'draft', + actions: [ + { + id: 'send-email', + type: 'function_email', + name: 'Send email', + config: { template_id: 'template-email', inputs: {} }, + }, + ], + edges: [], + }, + }, + }) + workflow = workflowLogic({ id: 'test-workflow-id' }) + workflow.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']).toFinishAllListeners() + editor = hogFlowEditorLogic({ id: 'test-workflow-id' }) + editor.mount() + editor.actions.setSelectedNodeId('send-email') + logic = hogFlowEditorNotificationTestLogic({ id: 'test-workflow-id' }) + logic.mount() + await expectLogic(logic).toFinishAllListeners() + }) + + afterEach(() => { + cleanup() + logic?.unmount() + editor?.unmount() + workflow?.unmount() + }) + + it.each([ + [ + "Skipping send: this email would go over the sandbox sender's daily limit for this project. Verify your own domain to send more.", + 'next-step', + ], + [ + 'Skipping send: the sandbox sender only sends to active organization members with verified email addresses. Blocked addresses: outsider@example.com.', + 'next-step', + ], + [ + 'Skipping send: could not check organization members for these addresses: member@example.com. Try again, or verify your own domain to send to anyone.', + null, + ], + ['Skipping send: recipient is on the suppression list.', 'next-step'], + ['Recipient has opted out, skipping message delivery.', 'next-step'], + ['Skipping send: the sandbox sender is paused. Try again later.', 'next-step'], + ])('shows the action-prefixed delivery skip reason above the logs: %s', (reason, nextActionId) => { + const result: HogflowTestResult = { + status: 'skipped', + nextActionId, + logs: [ + 'Sandbox sender selected.', + reason, + 'Email recipients: sandbox-user@example.com', + 'Function finished.', + ].map((message) => ({ + instanceId: 'test-run', + timestamp: dayjs('2026-05-01T00:00:00Z'), + rawTimestamp: '2026-05-01T00:00:00Z', + level: 'INFO', + message: `[Action:send-email] ${message}`, + })), + } + const { container } = render( + createElement(BindLogic, { + logic: workflowLogic, + props: { id: 'test-workflow-id' }, + children: createElement(BindLogic, { + logic: hogFlowEditorLogic, + props: { id: 'test-workflow-id' }, + children: createElement(EmailActionTestContent), + }), + }) + ) + act(() => logic.actions.setTestResult(result)) + expect(container.querySelector('.LemonBanner')).toHaveTextContent(reason) + expect(container.querySelector('.LemonBanner')).not.toHaveTextContent('sandbox-user@example.com') + }) +}) + +describe('workflow test result message', () => { + it.each([ + [{ status: 'success', nextActionId: 'next-step' }, 'Success'], + [{ status: 'error', nextActionId: null, errors: ['Invalid input'] }, 'Error: Invalid input'], + [{ status: 'skipped', nextActionId: 'next-step' }, 'This step was skipped. See the logs for details.'], + [ + { status: 'skipped', nextActionId: null }, + 'Workflow was skipped because the event did not match the filter criteria', + ], + ] as [HogflowTestResult, string][])('preserves the existing outcome message for %j', (result, expected) => { + expect(getHogFlowTestResultMessage(result)).toBe(expected) + }) +}) diff --git a/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts new file mode 100644 index 000000000000..eb46b3fc6b97 --- /dev/null +++ b/products/workflows/frontend/Workflows/hogflows/panel/testing/getHogFlowTestResultMessage.ts @@ -0,0 +1,26 @@ +import { HogflowTestResult } from '../../steps/types' + +export function skippedSendReasons(result: HogflowTestResult): string[] { + return ( + result.logs + ?.map((log) => log.message.replace(/^\[Action:[^\]]+\]\s*/, '')) + .filter( + (message) => message.startsWith('Skipping send:') || message.includes('skipping message delivery.') + ) ?? [] + ) +} + +export function getHogFlowTestResultMessage(result: HogflowTestResult): string { + if (result.status === 'success') { + return 'Success' + } + if (result.status === 'error') { + return 'Error: ' + result.errors?.join(', ') + } + return ( + skippedSendReasons(result).at(-1) ?? + (result.nextActionId + ? 'This step was skipped. See the logs for details.' + : 'Workflow was skipped because the event did not match the filter criteria') + ) +}