diff --git a/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.stories.tsx b/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.stories.tsx new file mode 100644 index 000000000000..fde96801799f --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { WorkflowSenderDomainNotice } from './WorkflowSenderDomainNotice' + +const meta: Meta = { + title: 'Products/Workflows/Sender domain notice', + component: WorkflowSenderDomainNotice, + parameters: { + testOptions: { waitForSelector: '[data-attr^="workflow-sender-domain"]' }, + }, +} +export default meta + +type Story = StoryObj + +export const NoDomain: Story = { args: { notice: { stage: 'no_domain', url: '/workflows/channels' } } } + +export const UnverifiedDomain: Story = { + args: { notice: { stage: 'unverified', domains: ['mail.example.com'], url: '/workflows/channels' } }, +} + +export const WaitingForSeveralDomains: Story = { + args: { + notice: { stage: 'waiting', domains: ['mail.example.com', 'news.example.org'], url: '/workflows/channels' }, + }, +} diff --git a/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.tsx b/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.tsx new file mode 100644 index 000000000000..763eb674cd09 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSenderDomainNotice.tsx @@ -0,0 +1,33 @@ +import { Link } from '@posthog/lemon-ui' + +import type { SenderDomainNotice } from './senderDomainNotice' + +const domainList = new Intl.ListFormat('en', { type: 'conjunction' }) + +function noticeMessage(notice: SenderDomainNotice): string { + if (notice.stage === 'no_domain') { + return 'No domain configured.' + } + const domains = domainList.format(notice.domains) + const several = notice.domains.length > 1 + return notice.stage === 'waiting' + ? `Sending starts once ${domains} ${several ? 'are' : 'is'} verified.` + : `${domains} ${several ? "aren't" : "isn't"} verified yet.` +} + +export function WorkflowSenderDomainNotice({ notice }: { notice: SenderDomainNotice }): JSX.Element { + const setsUpDomain = notice.stage === 'no_domain' + return ( + + {noticeMessage(notice)} + + {setsUpDomain ? 'Set up a domain' : 'Check domain status'} + + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx b/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx index 78c879c4e16a..1da941bed272 100644 --- a/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx @@ -9,6 +9,7 @@ import { urls } from 'scenes/urls' import type { HogFlowEditorLayout } from './hogflows/hogFlowEditorLogic' import { WorkflowLogicProps, workflowLogic } from './workflowLogic' +import { WorkflowSenderDomainNotice } from './WorkflowSenderDomainNotice' type WorkflowStatusBarProps = WorkflowLogicProps & { editorLayout: HogFlowEditorLayout @@ -34,6 +35,7 @@ export function WorkflowStatusBar({ isAutoSavePending, autoSaveEnabled, lastSavedAt, + senderDomainNotice, } = useValues(logic) const { setAutoSaveEnabled } = useActions(logic) const showSaving = useDebouncedValue(isAutoSavePending || workflowLoading, 1000) @@ -94,9 +96,14 @@ export function WorkflowStatusBar({ ) : ( Draft ))} - {showWorkflowStatus && statusExplanation && ( - {statusExplanation} - )} + {showWorkflowStatus && + (senderDomainNotice ? ( + + ) : ( + statusExplanation && ( + {statusExplanation} + ) + ))} {commands} {/* Interactive controls sit right-anchored with variable-width text leftmost, so the diff --git a/products/workflows/frontend/Workflows/senderDomainNotice.ts b/products/workflows/frontend/Workflows/senderDomainNotice.ts new file mode 100644 index 000000000000..c7a3f4823ded --- /dev/null +++ b/products/workflows/frontend/Workflows/senderDomainNotice.ts @@ -0,0 +1,48 @@ +import type { EmailTemplateFrom } from 'scenes/hog-functions/email-templater/types' +import { urls } from 'scenes/urls' + +import type { IntegrationType } from '~/types' + +import { isEmailAction } from './hogflows/steps/types' +import type { HogFlow } from './hogflows/types' +import { unverifiedSenderDomains } from './unverifiedSenderDomains' + +export type SenderDomainNotice = + | { stage: 'no_domain'; url: string } + | { stage: 'unverified' | 'waiting'; domains: string[]; url: string } + +function chosenSenderIds(sender: EmailTemplateFrom | undefined): number[] { + return sender?.integrationIds?.length ? sender.integrationIds : sender?.integrationId ? [sender.integrationId] : [] +} + +function hasEmailStepWithoutSender(workflow: HogFlow, emailSenders: IntegrationType[]): boolean { + const existingSenderIds = new Set(emailSenders.map(({ id }) => id)) + return workflow.actions.filter(isEmailAction).some((action) => { + const senderIds = chosenSenderIds(action.config.inputs.email?.value?.from) + return !senderIds.some((senderId) => existingSenderIds.has(senderId)) + }) +} + +function projectDomainNotice(workflow: HogFlow, emailSenders: IntegrationType[]): SenderDomainNotice | null { + const hasVerifiedSender = emailSenders.some(({ config }) => config.verified === true) + if (hasVerifiedSender || !hasEmailStepWithoutSender(workflow, emailSenders)) { + return null + } + const pendingDomains: string[] = [...new Set(emailSenders.map(({ config }) => config.domain))] + return pendingDomains.length > 0 + ? { stage: 'unverified', domains: pendingDomains, url: urls.workflows('channels') } + : { stage: 'no_domain', url: urls.workflows('channels') } +} + +export function senderDomainNotice( + workflow: HogFlow, + savedWorkflow: HogFlow, + emailSenders: IntegrationType[] | null +): SenderDomainNotice | null { + const domains = unverifiedSenderDomains(savedWorkflow) + if (domains.length > 0) { + const stage = savedWorkflow.status === 'waiting_for_domain' ? 'waiting' : 'unverified' + return { stage, domains, url: urls.workflows('channels') } + } + return emailSenders ? projectDomainNotice(workflow, emailSenders) : null +} diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts index 83c611bd9e42..0064a27cbee8 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -1,18 +1,44 @@ import { expectLogic } from 'kea-test-utils' import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import { HogFlow } from './hogflows/types' +import type { SenderDomainNotice } from './senderDomainNotice' import { workflowLogic } from './workflowLogic' const WORKFLOW_ID = 'wf-send-once-verified-1' const UNVERIFIED_SENDER = { id: 7, domain: 'example.com' } const WEBHOOK_TEMPLATE = { id: 'template-webhook', inputs_schema: [{ key: 'url', type: 'string', templating: true }] } +interface ProjectSender { + id: number + domain: string + verified: boolean +} +const PENDING_PROJECT_SENDER: ProjectSender = { ...UNVERIFIED_SENDER, verified: false } +const VERIFIED_PROJECT_SENDER: ProjectSender = { id: 8, domain: 'mail.example.org', verified: true } + +const emailStep = (from: Record): HogFlow['actions'][number] => + ({ + id: 'email_node', + type: 'function_email', + name: 'Welcome email', + description: '', + created_at: 0, + updated_at: 0, + config: { template_id: 'template-email', inputs: { email: { value: { from, to: {}, subject: 'Hi' } } } }, + }) as HogFlow['actions'][number] + +const withEmailStep = (workflow: HogFlow, from: Record): HogFlow => ({ + ...workflow, + actions: [workflow.actions[0], emailStep(from), workflow.actions[1]], +}) + const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ id: WORKFLOW_ID, name: 'Welcome', @@ -62,16 +88,22 @@ describe('workflowLogic send once verified', () => { let logic: ReturnType let patchBodies: Record[] let storedWorkflow: HogFlow + let projectSenders: ProjectSender[] let retrieveCount: number - const mountWith = async (workflow: HogFlow, { flagOn = true }: { flagOn?: boolean } = {}): Promise => { + const mountWith = async ( + workflow: HogFlow, + { flagOn = true, senders = [PENDING_PROJECT_SENDER] }: { flagOn?: boolean; senders?: ProjectSender[] } = {} + ): Promise => { storedWorkflow = workflow + projectSenders = senders initKeaTests() featureFlagLogic.mount() featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: flagOn }) logic = workflowLogic({ id: WORKFLOW_ID }) logic.mount() await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + await expectLogic(integrationsLogic).toFinishAllListeners() } beforeEach(() => { @@ -85,6 +117,18 @@ describe('workflowLogic send once verified', () => { return [200, storedWorkflow] }, '/api/projects/:team_id/hog_function_templates/': { results: [WEBHOOK_TEMPLATE], count: 1 }, + '/api/projects/:team_id/integrations/': () => [ + 200, + { + results: projectSenders.map(({ id, domain, verified }) => ({ + id, + kind: 'email', + config: { domain, verified }, + })), + count: projectSenders.length, + next: null, + }, + ], }, patch: { '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { @@ -288,6 +332,95 @@ describe('workflowLogic send once verified', () => { expect(logic.values.showStartedSendingNotice).toBe(false) }) + it.each<{ + name: string + status: HogFlow['status'] + from: Record + unverified: { id: number; domain: string }[] + senders: ProjectSender[] + flagOn?: boolean + notice: SenderDomainNotice | null + }>([ + { + name: 'a step has no sender and the project has a verified sender', + status: 'draft', + from: {}, + unverified: [], + senders: [PENDING_PROJECT_SENDER, VERIFIED_PROJECT_SENDER], + notice: null, + }, + { + name: 'a step has no sender and the project has no sender', + status: 'draft', + from: {}, + unverified: [], + senders: [], + notice: { stage: 'no_domain', url: '/workflows/channels' }, + }, + { + name: 'a step has no sender and the project only has a pending domain', + status: 'draft', + from: {}, + unverified: [], + senders: [PENDING_PROJECT_SENDER], + notice: { stage: 'unverified', domains: ['example.com'], url: '/workflows/channels' }, + }, + { + name: 'the sender of a step was deleted and the project has no other sender', + status: 'draft', + from: { integrationId: 99 }, + unverified: [], + senders: [], + notice: { stage: 'no_domain', url: '/workflows/channels' }, + }, + { + name: 'a draft sends from an unverified domain', + status: 'draft', + from: { integrationId: 7 }, + unverified: [UNVERIFIED_SENDER], + senders: [PENDING_PROJECT_SENDER, VERIFIED_PROJECT_SENDER], + notice: { stage: 'unverified', domains: ['example.com'], url: '/workflows/channels' }, + }, + { + name: 'an enabled workflow waits for its domain', + status: 'waiting_for_domain', + from: { integrationId: 7 }, + unverified: [UNVERIFIED_SENDER], + senders: [PENDING_PROJECT_SENDER], + notice: { stage: 'waiting', domains: ['example.com'], url: '/workflows/channels' }, + }, + { + name: 'every sender is verified', + status: 'active', + from: { integrationId: 8 }, + unverified: [], + senders: [VERIFIED_PROJECT_SENDER], + notice: null, + }, + { + name: 'the flag is off', + status: 'draft', + from: {}, + unverified: [], + senders: [], + flagOn: false, + notice: null, + }, + ])('shows the sender domain notice when $name', async ({ status, from, unverified, senders, flagOn, notice }) => { + await mountWith(withEmailStep(makeWorkflow({ status, unverified_email_senders: unverified }), from), { + flagOn, + senders, + }) + + expect(logic.values.senderDomainNotice).toEqual(notice) + }) + + it('shows no sender domain notice for a workflow without an email step', async () => { + await mountWith(makeWorkflow()) + + expect(logic.values.senderDomainNotice).toBeNull() + }) + describe('while the workflow waits for its domain', () => { const waiting = makeWorkflow({ status: 'waiting_for_domain', @@ -339,6 +472,7 @@ describe('workflowLogic send once verified', () => { expect(logic.values.originalWorkflow?.status).toEqual('active') expect(logic.values.showStartedSendingNotice).toBe(true) + expect(logic.values.senderDomainNotice).toBeNull() expect(logic.values.workflow.name).toEqual('Renamed while waiting') expect(logic.values.hasUnsavedChanges).toBe(true) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index 49629f189a4c..0e5ea7ac92a8 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -88,6 +88,7 @@ import { } from './hogflows/types' import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' +import { SenderDomainNotice, senderDomainNotice } from './senderDomainNotice' import { unverifiedSenderDomains } from './unverifiedSenderDomains' import { checkWaitingStatusWhileVisible } from './waitingStatusCheck' import { prepareWorkflowDuplicate } from './workflowDuplication' @@ -351,6 +352,7 @@ export interface workflowLogicValues { dismissedStartedSendingNotices: Record enablePromptDomains: string[] | null enablePromptOpen: boolean + senderDomainNotice: SenderDomainNotice | null showStartedSendingNotice: boolean isWaitingForDomain: boolean startedSendingNoticeKey: string | null @@ -3105,6 +3107,12 @@ export interface workflowLogicMeta { logicProps: (arg: WorkflowLogicProps) => WorkflowLogicProps unverifiedSenderDomains: (originalWorkflow: HogFlow | null, featureFlags: FeatureFlagsSet) => string[] enablePromptDomains: (enablePromptOpen: boolean, unverifiedSenderDomains: string[]) => string[] | null + senderDomainNotice: ( + workflow: HogFlow, + originalWorkflow: HogFlow | null, + integrations: IntegrationType[] | null, + featureFlags: FeatureFlagsSet + ) => SenderDomainNotice | null startedSendingNoticeKey: (originalWorkflow: HogFlow | null) => string | null isWaitingForDomain: (originalWorkflow: HogFlow | null) => boolean showStartedSendingNotice: ( @@ -3973,6 +3981,21 @@ export const workflowLogic = kea([ (enablePromptOpen: boolean, unverifiedSenderDomains: string[]): string[] | null => enablePromptOpen ? unverifiedSenderDomains : null, ], + senderDomainNotice: [ + (s) => [s.workflow, s.originalWorkflow, s.integrations, s.featureFlags], + ( + workflow: HogFlow, + originalWorkflow: HogFlow | null, + integrations: IntegrationType[] | null, + featureFlags: FeatureFlagsSet + ): SenderDomainNotice | null => { + if (!featureFlags[FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD] || !originalWorkflow) { + return null + } + const emailSenders = integrations?.filter(({ kind }) => kind === 'email') ?? null + return senderDomainNotice(workflow, originalWorkflow, emailSenders) + }, + ], isWaitingForDomain: [ (s) => [s.originalWorkflow], (originalWorkflow: HogFlow | null): boolean => originalWorkflow?.status === 'waiting_for_domain',