From 968b927cf955026cd6e5cead08cb69f806040794 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:30:30 +0000 Subject: [PATCH 1/4] feat(workflows): test templates with the sandbox sender The template test send lists the sandbox sender last, with a Sandbox tag and the members-only note, and preselects it when the project has no verified own sender. With the workflows-sandbox-sender flag on, the recipient field suggests verified teammates, signed-in user first, and warns about addresses outside them. A sandbox test drops the template's custom From and Reply-To, which the sandbox sender ignores. Refs Silthus/personal#202 Co-Authored-By: Claude Opus 5.5 --- .../SendTestEmailModal.stories.tsx | 68 ++++++ .../SendTestEmailModal.test.tsx | 101 ++++++++ .../TemplateLibrary/SendTestEmailModal.tsx | 115 +++++++-- .../messageTemplateTestSendLogic.test.ts | 221 ++++++++++++++++++ .../messageTemplateTestSendLogic.ts | 140 +++++++++-- 5 files changed, 602 insertions(+), 43 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx 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..8bdb64e77b2e --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx @@ -0,0 +1,101 @@ +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 { 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() + }) +}) 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,200 @@ 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 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']), + }) + }) + + 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@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 () => { useMocks({ post: { diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index d132a15f2e14..4113ea186a73 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -3,13 +3,14 @@ import { loaders } from 'kea-loaders' 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 { 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 { senderFrom, @@ -26,16 +27,27 @@ 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 recipientEmail: string + recipientIsUnverifiedSelf: boolean + recipientNotSuggested: boolean + recipientSuggestions: string[] sendDisabledReason: string | undefined senderIntegrationId: number | null senderIntegrationIdOverride: number | null @@ -45,6 +57,16 @@ export interface messageTemplateTestSendLogicValues { // 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 + loadAllMembers: () => { + value: true + } // membersLogic clearTestSendResult: () => { value: true } @@ -96,16 +118,31 @@ export interface messageTemplateTestSendLogicActions { export interface messageTemplateTestSendLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { - emailIntegrations: (integrations: IntegrationType[] | null) => IntegrationType[] + emailIntegrations: (ownEmailIntegrations: any, sandboxEmailSender: any) => IntegrationType[] senderIntegrationId: ( senderIntegrationIdOverride: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate ) => number | null + isSandboxSenderSelected: (senderIntegrationId: number | null, sandboxEmailSender: any) => boolean + recipientSuggestions: (meFirstMembers: any) => string[] + recipientIsUnverifiedSelf: ( + isSandboxSenderSelected: any, + recipientEmail: string, + user: UserType | null + ) => boolean + recipientNotSuggested: ( + isSandboxSenderSelected: any, + recipientEmail: string, + members: any, + recipientSuggestions: any + ) => boolean sendDisabledReason: ( template: MessageTemplate, recipientEmail: string, - senderIntegrationId: number | null + senderIntegrationId: number | null, + isSandboxSenderSelected: any, + membersLoading: any ) => string | undefined } } @@ -130,8 +167,11 @@ export const messageTemplateTestSendLogic = kea ({ open }), @@ -150,7 +190,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 +210,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, ], + 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 +331,9 @@ export const messageTemplateTestSendLogic = kea { + if (values.isModalOpen && values.sandboxEmailSenderEnabled) { + actions.loadAllMembers() + } + }, sendTestEmailSuccess: ({ testSendResult }) => { if (testSendResult.status === 'success') { lemonToast.success(`Test email sent to ${values.recipientEmail}`) From 2a44506d8f9ba05e0d297b7f0f5fcc7f0f76ab0d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:54:24 +0000 Subject: [PATCH 2/4] fix(workflows): load test-send members only when the modal needs them Members load when the modal opens with the sandbox sender flag on, or when the flag turns on while the modal is open. Other feature flag updates no longer reload the member list. Refs Silthus/personal#202 Co-Authored-By: Claude Opus 5.5 --- .../messageTemplateTestSendLogic.test.ts | 9 ++- .../messageTemplateTestSendLogic.ts | 58 ++++++++++--------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts index 98113e019733..b10994299b3a 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts @@ -256,7 +256,7 @@ describe('messageTemplateTestSendLogic', () => { }) }) - it('loads the member suggestions when the flag lands on an open modal', async () => { + 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, () => { @@ -273,6 +273,13 @@ describe('messageTemplateTestSendLogic', () => { 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 () => { diff --git a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts index 4113ea186a73..a7da9f0af6f9 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.ts @@ -1,10 +1,10 @@ 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 { 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' @@ -44,6 +44,7 @@ export interface messageTemplateTestSendLogicValues { emailIntegrations: IntegrationType[] isModalOpen: boolean isSandboxSenderSelected: boolean + membersNeeded: boolean recipientEmail: string recipientIsUnverifiedSelf: boolean recipientNotSuggested: boolean @@ -57,13 +58,6 @@ export interface messageTemplateTestSendLogicValues { // 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 loadAllMembers: () => { value: true } // membersLogic @@ -118,31 +112,38 @@ export interface messageTemplateTestSendLogicActions { export interface messageTemplateTestSendLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { - emailIntegrations: (ownEmailIntegrations: any, sandboxEmailSender: any) => IntegrationType[] + emailIntegrations: ( + ownEmailIntegrations: IntegrationType[], + sandboxEmailSender: IntegrationType | null + ) => IntegrationType[] senderIntegrationId: ( senderIntegrationIdOverride: number | null, emailIntegrations: IntegrationType[], template: MessageTemplate ) => number | null - isSandboxSenderSelected: (senderIntegrationId: number | null, sandboxEmailSender: any) => boolean - recipientSuggestions: (meFirstMembers: any) => string[] + membersNeeded: (isModalOpen: boolean, sandboxEmailSenderEnabled: boolean) => boolean + isSandboxSenderSelected: ( + senderIntegrationId: number | null, + sandboxEmailSender: IntegrationType | null + ) => boolean + recipientSuggestions: (meFirstMembers: OrganizationMemberType[]) => string[] recipientIsUnverifiedSelf: ( - isSandboxSenderSelected: any, + isSandboxSenderSelected: boolean, recipientEmail: string, user: UserType | null ) => boolean recipientNotSuggested: ( - isSandboxSenderSelected: any, + isSandboxSenderSelected: boolean, recipientEmail: string, - members: any, - recipientSuggestions: any + members: OrganizationMemberType[] | null, + recipientSuggestions: string[] ) => boolean sendDisabledReason: ( template: MessageTemplate, recipientEmail: string, senderIntegrationId: number | null, - isSandboxSenderSelected: any, - membersLoading: any + isSandboxSenderSelected: boolean, + membersLoading: boolean ) => string | undefined } } @@ -171,7 +172,7 @@ export const messageTemplateTestSendLogic = kea ({ open }), @@ -277,6 +278,11 @@ export const messageTemplateTestSendLogic = kea [s.isModalOpen, s.sandboxEmailSenderEnabled], + (isModalOpen: boolean, sandboxEmailSenderEnabled: boolean): boolean => + isModalOpen && sandboxEmailSenderEnabled, + ], isSandboxSenderSelected: [ (s) => [s.senderIntegrationId, s.sandboxEmailSender], (senderIntegrationId: number | null, sandboxEmailSender: IntegrationType | null): boolean => @@ -344,17 +350,10 @@ export const messageTemplateTestSendLogic = kea { - if (values.isModalOpen && values.sandboxEmailSenderEnabled) { - actions.loadAllMembers() - } - }, sendTestEmailSuccess: ({ testSendResult }) => { if (testSendResult.status === 'success') { lemonToast.success(`Test email sent to ${values.recipientEmail}`) @@ -370,4 +369,11 @@ export const messageTemplateTestSendLogic = kea ({ + membersNeeded: (membersNeeded: boolean, wereNeeded: boolean | undefined) => { + if (membersNeeded && !wereNeeded) { + actions.loadAllMembers() + } + }, + })), ]) From e04c2e8573db9cd92910092b02e018f5c19ac4b6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:03:00 +0000 Subject: [PATCH 3/4] test(workflows): cover picking and typing a test-send recipient Co-Authored-By: Claude Opus 5.5 --- .../SendTestEmailModal.test.tsx | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx index 8bdb64e77b2e..90ec50d2b277 100644 --- a/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx +++ b/products/workflows/frontend/TemplateLibrary/SendTestEmailModal.test.tsx @@ -1,4 +1,4 @@ -import { MOCK_DEFAULT_USER } from 'lib/api.mock' +import { MOCK_DEFAULT_ORGANIZATION_MEMBER, MOCK_DEFAULT_USER } from 'lib/api.mock' import '@testing-library/jest-dom' @@ -72,6 +72,59 @@ describe('SendTestEmailModal', () => { ).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) => { From 1c08de0b9b480e5edcb18844a70fb46f132ce024 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 28 Oct 2026 09:29:33 +0000 Subject: [PATCH 4/4] chore(workflows): own the sandbox sender note and use a reserved test domain The test send modal is now the first place that shows the note, so it defines it. The unverified member fixture uses example.com. Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/integrations/utils.ts | 2 ++ .../TemplateLibrary/messageTemplateTestSendLogic.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/integrations/utils.ts b/frontend/src/lib/integrations/utils.ts index 251ee6214f1c..1d9ef34222e7 100644 --- a/frontend/src/lib/integrations/utils.ts +++ b/frontend/src/lib/integrations/utils.ts @@ -142,6 +142,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/messageTemplateTestSendLogic.test.ts b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts index b10994299b3a..86935570cdc7 100644 --- a/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts +++ b/products/workflows/frontend/TemplateLibrary/messageTemplateTestSendLogic.test.ts @@ -314,7 +314,7 @@ describe('messageTemplateTestSendLogic', () => { user: { ...MOCK_SECOND_BASIC_USER, id: 303, - email: 'unverified@posthog.com', + email: 'unverified@example.com', is_email_verified: false, }, }, @@ -350,9 +350,9 @@ describe('messageTemplateTestSendLogic', () => { logic.actions.setRecipientEmail('Rose.Dawson@posthog.com') await expectLogic(logic).toMatchValues({ recipientNotSuggested: false, sendDisabledReason: undefined }) - logic.actions.setRecipientEmail('unverified@posthog.com') + logic.actions.setRecipientEmail('unverified@example.com') await expectLogic(logic).toMatchValues({ recipientNotSuggested: true }) - expect(logic.values.recipientSuggestions).not.toContain('unverified@posthog.com') + expect(logic.values.recipientSuggestions).not.toContain('unverified@example.com') logic.actions.setRecipientEmail('unknown@example.com') await expectLogic(logic).toMatchValues({ recipientNotSuggested: true })