Skip to content
Closed
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
38 changes: 38 additions & 0 deletions nodejs/src/cdp/cdp-api.serial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion nodejs/src/cdp/cdp-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
4 changes: 4 additions & 0 deletions nodejs/src/cdp/services/hogflows/actions/hog_function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions nodejs/src/cdp/services/messaging/email.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -2663,25 +2676,35 @@ 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' })

const result = await service.executeSendEmail(invocation, true)

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
Expand Down
6 changes: 6 additions & 0 deletions nodejs/src/cdp/services/messaging/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -694,6 +697,9 @@ export class EmailService {
sandboxRecipients
)
if (skipReason) {
if (isSandbox || isTest) {
result.skipped = true
}
addLog('info', skipReason)
if (!isTest) {
result.metrics.push({
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <sandbox@example.com>',
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<string, unknown>
}

const meta: Meta<StoryArgs> = {
title: 'Products/Workflows/Send test email modal',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER],
},
}
export default meta

const Template: StoryFn<StoryArgs> = ({ 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 <SendTestEmailModal {...LOGIC_PROPS} isOpen />
}

export const SandboxSenderPreselected: StoryFn<StoryArgs> = Template.bind({})
SandboxSenderPreselected.args = { recipient: MEMBER_EMAIL }

export const RecipientOutsideOrganization: StoryFn<StoryArgs> = Template.bind({})
RecipientOutsideOrganization.args = { recipient: 'outsider@example.com' }

export const SendBlockedByServer: StoryFn<StoryArgs> = Template.bind({})
SendBlockedByServer.args = {
recipient: MEMBER_EMAIL,
sendResult: { status: 'skipped', nextActionId: null, logs: [{ level: 'info', message: SANDBOX_BLOCK_LOG }] },
}
Loading
Loading