Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 73 additions & 39 deletions products/workflows/frontend/Channels/defaultEmailSender.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,83 @@
import { IntegrationType } from '~/types'

import { resolveDefaultEmailSender } from './defaultEmailSender'
import type { HogFlowAction } from '../Workflows/hogflows/types'
import { allowsSandboxSender, resolveDefaultEmailSender } from './defaultEmailSender'

const sender = (id: number, verified: boolean, kind = 'email'): IntegrationType =>
({ id, kind, config: { verified } }) as unknown as IntegrationType

const sandboxSender = { ...sender(7, true), config: { verified: true, provider: 'sandbox' } }

describe('resolveDefaultEmailSender', () => {
it.each([
{ case: 'nothing when only sandbox exists', integrations: [sandboxSender], defaultId: null, expected: null },
{
case: 'the own sender beside sandbox',
integrations: [sandboxSender, sender(1, true)],
defaultId: null,
expected: 1,
},
{ case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null },
{ case: 'the project default', integrations: [sender(1, true), sender(2, true)], defaultId: 2, expected: 2 },
{
case: 'the only verified sender',
integrations: [sender(1, true), sender(2, false)],
defaultId: null,
expected: 1,
},
{
case: 'nothing when several are verified',
integrations: [sender(1, true), sender(2, true)],
defaultId: null,
expected: null,
},
{ case: 'nothing for an unverified default', integrations: [sender(1, false)], defaultId: 1, expected: null },
{
case: 'the only verified sender when the default was deleted',
integrations: [sender(1, true)],
defaultId: 9,
expected: 1,
},
{
case: 'nothing for a verified non-email integration',
integrations: [sender(1, true, 'slack')],
defaultId: 1,
expected: null,
},
])('picks $case', ({ integrations, defaultId, expected }) => {
expect(resolveDefaultEmailSender(integrations, defaultId)?.integrationId ?? null).toEqual(expected)
describe('defaultEmailSender', () => {
describe('resolveDefaultEmailSender', () => {
it.each([
{
case: 'nothing when only sandbox exists',
integrations: [sandboxSender],
defaultId: null,
expected: null,
},
{
case: 'the own sender beside sandbox',
integrations: [sandboxSender, sender(1, true)],
defaultId: null,
expected: 1,
},
{ case: 'nothing for a sandbox default', integrations: [sandboxSender], defaultId: 7, expected: null },
{
case: 'the project default',
integrations: [sender(1, true), sender(2, true)],
defaultId: 2,
expected: 2,
},
{
case: 'the only verified sender',
integrations: [sender(1, true), sender(2, false)],
defaultId: null,
expected: 1,
},
{
case: 'nothing when several are verified',
integrations: [sender(1, true), sender(2, true)],
defaultId: null,
expected: null,
},
{
case: 'nothing for an unverified default',
integrations: [sender(1, false)],
defaultId: 1,
expected: null,
},
{
case: 'the only verified sender when the default was deleted',
integrations: [sender(1, true)],
defaultId: 9,
expected: 1,
},
{
case: 'nothing for a verified non-email integration',
integrations: [sender(1, true, 'slack')],
defaultId: 1,
expected: null,
},
])('picks $case', ({ integrations, defaultId, expected }) => {
expect(resolveDefaultEmailSender(integrations, defaultId)?.integrationId ?? null).toEqual(expected)
})
})

describe('allowsSandboxSender', () => {
it.each<{ step: string; actionType: HogFlowAction['type']; originProduct: string | null; expected: boolean }>([
{ step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true },
{
step: 'a broadcast email step',
actionType: 'function_email',
originProduct: 'broadcasts',
expected: false,
},
{ step: 'a generic function step', actionType: 'function', originProduct: null, expected: false },
{ step: 'a trigger', actionType: 'trigger', originProduct: null, expected: false },
])('is $expected for $step', ({ actionType, originProduct, expected }) => {
expect(allowsSandboxSender(actionType, originProduct)).toBe(expected)
})
})
})
23 changes: 18 additions & 5 deletions products/workflows/frontend/Channels/defaultEmailSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ import { isSandboxEmailSender } from 'lib/integrations/utils'

import { IntegrationType } from '~/types'

export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender'
import type { HogFlowAction } from '../Workflows/hogflows/types'

export type DefaultEmailSenderReason = 'project_default' | 'only_verified_sender' | 'sandbox_sender'

export interface DefaultEmailSender {
integrationId: number
reason: DefaultEmailSenderReason
}

export function verifiedOwnEmailSenders(integrations: IntegrationType[] | null | undefined): IntegrationType[] {
return (integrations ?? []).filter(
(integration) =>
integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true
)
}

export function resolveDefaultEmailSender(
integrations: IntegrationType[] | null | undefined,
defaultIntegrationId: number | null | undefined
): DefaultEmailSender | null {
const verified = (integrations ?? []).filter(
(integration) =>
integration.kind === 'email' && !isSandboxEmailSender(integration) && integration.config?.verified === true
)
const verified = verifiedOwnEmailSenders(integrations)
const projectDefault = verified.find((integration) => integration.id === defaultIntegrationId)
if (projectDefault) {
return { integrationId: projectDefault.id, reason: 'project_default' }
Expand All @@ -26,3 +32,10 @@ export function resolveDefaultEmailSender(
}
return null
}

export function allowsSandboxSender(
actionType: HogFlowAction['type'],
originProduct: string | null | undefined
): boolean {
return actionType === 'function_email' && originProduct !== 'broadcasts'
}
116 changes: 116 additions & 0 deletions products/workflows/frontend/Workflows/firstRunSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ApiConfig } from 'lib/api'
import { FEATURE_FLAGS } from 'lib/constants'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { isSandboxEmailSender } from 'lib/integrations/utils'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { teamLogic } from 'scenes/teamLogic'

import type { IntegrationType } from '~/types'

import { integrationsEmailSandboxSenderCreate } from 'products/integrations/frontend/generated/api'

import {
DefaultEmailSender,
allowsSandboxSender,
resolveDefaultEmailSender,
verifiedOwnEmailSenders,
} from '../Channels/defaultEmailSender'
import type { HogFlow, HogFlowAction } from './hogflows/types'

type EmailStepValue = { from?: { integrationId?: number; email?: string; name?: string }; replyTo?: string }

function emailStepValue(action: HogFlowAction): EmailStepValue | null {
const config = action.config as Record<string, any> | undefined
return config?.template_id === 'template-email' ? (config.inputs?.email?.value ?? null) : null
}

function takesOwnSender(action: HogFlowAction): boolean {
const value = emailStepValue(action)
return !!value && !value.from?.integrationId
}

function takesSandboxSender(action: HogFlowAction, workflow: HogFlow): boolean {
const value = emailStepValue(action)
return (
!!value &&
allowsSandboxSender(action.type, workflow.origin_product) &&
!value.from?.integrationId &&
!value.from?.email &&
!value.from?.name &&
!value.replyTo
)
}

function withSender(action: HogFlowAction, integrationId: number): HogFlowAction {
const config = action.config as Record<string, any>
const email = config.inputs.email
return {
...action,
config: {
...config,
inputs: {
...config.inputs,
email: { ...email, value: { ...email.value, from: { ...email.value.from, integrationId } } },
},
},
} as HogFlowAction
}

function sandboxSenderEnabled(): boolean {
return !!featureFlagLogic.findMounted()?.values.featureFlags[FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]
}

async function loadAllIntegrations(): Promise<IntegrationType[]> {
await integrationsLogic.asyncActions.loadIntegrations()
const integrations = integrationsLogic.values.integrations
if (!integrations || integrationsLogic.values.integrationsLoadFailed) {
throw new Error('The sender list could not load.')
}
return integrations
}

async function provisionSandboxSender(): Promise<number> {
const sandbox = await integrationsEmailSandboxSenderCreate(String(ApiConfig.getCurrentProjectId()))
await integrationsLogic.asyncActions.loadIntegrations()
return sandbox.id
}

async function resolveFirstRunSender(allowSandbox: boolean): Promise<DefaultEmailSender | null> {
const team = teamLogic.findMounted()?.values.currentTeam
const integrations = await loadAllIntegrations()
const ownSender = resolveDefaultEmailSender(
integrations,
team && 'workflows_config' in team ? team.workflows_config?.default_email_integration_id : null
)
if (ownSender || !allowSandbox || !sandboxSenderEnabled() || verifiedOwnEmailSenders(integrations).length > 0) {
return ownSender
}
const sandbox = integrations.find(isSandboxEmailSender)
return { integrationId: sandbox?.id ?? (await provisionSandboxSender()), reason: 'sandbox_sender' }
}

export async function withFirstRunSender(
workflow: HogFlow
): Promise<{ workflow: HogFlow; sender: DefaultEmailSender | null }> {
if (!workflow.actions.some(takesOwnSender)) {
return { workflow, sender: null }
}
const unmountIntegrations = integrationsLogic.mount()
const sender = await resolveFirstRunSender(
workflow.actions.some((action) => takesSandboxSender(action, workflow))
).finally(unmountIntegrations)
if (!sender) {
return { workflow, sender: null }
}
const takesSender = (action: HogFlowAction): boolean =>
sender.reason === 'sandbox_sender' ? takesSandboxSender(action, workflow) : takesOwnSender(action)
return {
workflow: {
...workflow,
actions: workflow.actions.map((action) =>
takesSender(action) ? withSender(action, sender.integrationId) : action
),
},
sender,
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { CyclotronJobInvocationGlobals } from '~/types'

import type { HogFlowAction } from '../../types'
import { allowsSandboxSender, buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration'
import { buildSampleGlobals, filterInputsSchemaForTrigger } from './HogFlowFunctionConfiguration'

// The stored shape a Slack message workflow carries. The trigger type alone no longer identifies
// it, so every case below passes the whole config.
Expand All @@ -16,22 +15,6 @@ const githubTrigger = {
}

describe('HogFlowFunctionConfiguration', () => {
describe('allowsSandboxSender', () => {
it.each<{ step: string; actionType: HogFlowAction['type']; originProduct: string | null; expected: boolean }>([
{ step: 'a workflow email step', actionType: 'function_email', originProduct: null, expected: true },
{
step: 'a broadcast email step',
actionType: 'function_email',
originProduct: 'broadcasts',
expected: false,
},
{ step: 'a generic function step', actionType: 'function', originProduct: null, expected: false },
{ step: 'a trigger', actionType: 'trigger', originProduct: null, expected: false },
])('is $expected for $step', ({ actionType, originProduct, expected }) => {
expect(allowsSandboxSender(actionType, originProduct)).toBe(expected)
})
})

describe('buildSampleGlobals', () => {
const realSampleGlobals: CyclotronJobInvocationGlobals = {
project: { id: 2, name: 'Real project', url: 'https://app.example.com/project/2' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { CyclotronJobInputType, CyclotronJobInvocationGlobals, HogFunctionMappingType } from '~/types'

import { allowsSandboxSender } from '../../../../Channels/defaultEmailSender'
import { workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorTestLogic } from '../../panel/testing/hogFlowEditorTestLogic'
import { isGithubEventTriggerConfig } from '../../registry/triggers/githubTriggerFilters'
Expand Down Expand Up @@ -192,14 +193,7 @@
return inputsSchema
}

export function allowsSandboxSender(
actionType: HogFlowAction['type'],
originProduct: string | null | undefined
): boolean {
return actionType === 'function_email' && originProduct !== 'broadcasts'
}

export function HogFlowFunctionConfiguration({

Check warning on line 196 in products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`HogFlowFunctionConfiguration` has cyclomatic complexity 24 (warn >10)
templateId,
actionType,
inputs,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { draftMessage } from '../MessageAudience/messageDrafts'
import { prefilledWorkflow, withEmailSender } from './workflowEmailPrefill'
import { prefilledWorkflow } from './workflowEmailPrefill'
import { NEW_WORKFLOW } from './workflowLogic'
import type { WorkflowTriggerConfig } from './workflowTriggerPrefill'

Expand Down Expand Up @@ -31,14 +31,4 @@ describe('workflowEmailPrefill', () => {
})
expect(workflow.trigger_masking).toEqual(masking)
})

it('keeps a sender the email step already has and fills the ones without', () => {
const workflow = prefilledWorkflow(NEW_WORKFLOW, EVENT_TRIGGER, draftMessage({ kind: 'issue_hit' }))
const filled = withEmailSender(workflow, 5)
const refilled = withEmailSender(filled.workflow, 9)

expect(filled.filled).toBe(true)
expect((filled.workflow.actions[1].config as any).inputs.email.value.from).toEqual({ integrationId: 5 })
expect(refilled).toEqual({ workflow: filled.workflow, filled: false })
})
})
23 changes: 0 additions & 23 deletions products/workflows/frontend/Workflows/workflowEmailPrefill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,29 +94,6 @@ export function withPrefilledEmail(workflow: HogFlow, email: MessageDraft): HogF
}
}

export function withEmailSender(workflow: HogFlow, integrationId: number): { workflow: HogFlow; filled: boolean } {
let filled = false
const actions = workflow.actions.map((action) => {
const config = action.config as Record<string, any>
const value = config?.template_id === 'template-email' ? config.inputs?.email?.value : null
if (!value || value.from?.integrationId) {
return action
}
filled = true
return {
...action,
config: {
...config,
inputs: {
...config.inputs,
email: { ...config.inputs.email, value: { ...value, from: { ...value.from, integrationId } } },
},
},
} as HogFlowAction
})
return { workflow: filled ? { ...workflow, actions } : workflow, filled }
}

/** The prefilled email step inside the brand's design, or the workflow unchanged when the brand doesn't fit. */
export function withEmailBrand(workflow: HogFlow, brand: EmailBrand | null): HogFlow {
if (!brand) {
Expand Down
Loading
Loading