Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
1013dbb
feat(cdp): add sandbox email delivery policy
Silthus Nov 9, 2026
d17f6fb
fix(cdp): bound sandbox authorization connections and block unread op…
Silthus Nov 9, 2026
5e40619
fix(cdp): retry sandbox sends when the suppression read fails
Silthus Nov 9, 2026
2c9dcb5
chore(cdp): test that a strict suppression retry rereads the global list
Silthus Nov 9, 2026
c3364fb
feat(cdp): route sandbox email steps to their own queue
Silthus Nov 9, 2026
98fbfe7
fix(cdp): run only the sandbox email step on the sandbox queue
Silthus Nov 9, 2026
51cb399
fix(cdp): keep a sandbox job alive when on_error continues past a throw
Silthus Nov 9, 2026
b4a236c
Merge remote-tracking branch 'origin/workflows-sandbox/n2-delivery-po…
Silthus Nov 9, 2026
778458d
feat(cdp): send sandbox email from the sandbox worker
Silthus Nov 9, 2026
359a51c
fix(cdp): harden sandbox test sends and sandbox SES calls
Silthus Nov 9, 2026
8033197
fix(cdp): keep sandbox caps across retries and tag parent runs
Silthus Nov 9, 2026
a29be1a
fix(cdp): hold sandbox caps only after a throttle, per recipient
Silthus Nov 9, 2026
e1588a4
fix(cdp): keep a held sandbox cap claim through refusals before ses
Silthus Nov 9, 2026
5435c1f
fix(cdp): outlast sandbox worker deadlines in tests and keep caps poo…
Silthus Nov 9, 2026
7e0252b
fix(cdp): refuse sandbox sends whose action id ses tags cannot carry
Silthus Nov 9, 2026
2440fcc
fix(cdp): claim both sandbox cap buckets in one atomic step
Silthus Nov 10, 2026
999b4ac
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
c82d290
fix(cdp): reject an invalid ttl before charging any sandbox cap bucket
Silthus Nov 10, 2026
4a4bd93
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
26686ad
Merge master into workflows-sandbox/n1-route-sandbox-steps
Silthus Nov 10, 2026
88f9fd4
Merge master into workflows-sandbox/n2-delivery-policy
Silthus Nov 10, 2026
ef8285e
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
3822f4a
Merge workflows-sandbox/n1-route-sandbox-steps into workflows-sandbox…
Silthus Nov 10, 2026
ac1ec8b
fix(cdp): reject a ttl past the expiry range before charging any bucket
Silthus Nov 10, 2026
5b2c31b
chore(cdp): test sandbox routing through a real queue and drop added …
Silthus Nov 10, 2026
34be9df
chore(cdp): assert sandbox step outcomes from job state, not calls
Silthus Nov 10, 2026
8d0e981
fix(cdp): cap sandbox authorization reads waiting for a connection
Silthus Nov 10, 2026
0c5db6f
chore(cdp): assert sandbox deliveries through a fake mailbox
Silthus Nov 10, 2026
4f9420c
Merge workflows-sandbox/n1-route-sandbox-steps into workflows-sandbox…
Silthus Nov 10, 2026
85a4b57
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
6aaef42
chore(cdp): check that timed-out sandbox reads keep holding the queue…
Silthus Nov 10, 2026
ff076f2
chore(cdp): run the sandbox recipient checks against real suppression…
Silthus Nov 10, 2026
3b70954
chore(cdp): fail sandbox recipient reads through an unreachable database
Silthus Nov 10, 2026
97ee783
chore(cdp): drain the refill test's buckets with refill off
Silthus Nov 10, 2026
8e23454
chore(cdp): fail the sandbox cap claim against a real unreachable valkey
Silthus Nov 10, 2026
5eb231a
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
85bfdde
fix(cdp): check sandbox opt-outs under the stored member spelling
Silthus Nov 10, 2026
190446e
chore(cdp): assert sandbox opt-out and frequency cap hand-off by send…
Silthus Nov 10, 2026
1d0a683
chore(cdp): type the sandbox recipient-check test helpers
Silthus Nov 10, 2026
0610ac8
chore(cdp): assert sandbox opt-out sends by the delivered mailbox
Silthus Nov 10, 2026
8489b53
chore(cdp): prove an opted-out sandbox send leaves the daily allowance
Silthus Nov 10, 2026
869f7d0
fix(cdp): spend the sandbox frequency cap only after every opt-out check
Silthus Nov 10, 2026
6f52ec5
chore(cdp): drop comments from the sandbox sender code
Silthus Nov 10, 2026
3cd5253
fix(cdp): honour sandbox opt-outs across address spellings
Silthus Nov 10, 2026
3f1826f
fix(cdp): hold sandbox test sends to the shared SES send rate
Silthus Nov 10, 2026
c090a07
chore(cdp): type the sandbox test world fixtures
Silthus Nov 10, 2026
abdb459
fix(cdp): derive the sandbox test-send wait from the worker deadlines
Silthus Nov 10, 2026
0ab36fc
chore(cdp): read workflow billing from the real usage reporter in tests
Silthus Nov 10, 2026
5bfc5ad
chore(cdp): read workflow billing from a local usage-ingestion endpoi…
Silthus Nov 10, 2026
d62307a
fix(cdp): charge a retried sandbox cap claim once
Silthus Nov 10, 2026
01e85d4
fix(cdp): give sandbox recipient reads a deadline
Silthus Nov 10, 2026
eb37634
fix(cdp): bound sandbox recipient reads at the query
Silthus Nov 10, 2026
a27f5ef
fix(cdp): scope a sandbox cap claim id to its member
Silthus Nov 10, 2026
7fbe90e
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
6942ce2
chore(cdp): cover a sandbox claim id reused across projects
Silthus Nov 10, 2026
d7df77b
fix(cdp): claim sandbox caps once per send and bound recipient reads
Silthus Nov 10, 2026
21f8883
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
8472677
fix(cdp): charge a sandbox cap claim again after its send
Silthus Nov 10, 2026
db1bdc9
Merge branch 'workflows-sandbox/n2-delivery-policy' into workflows-sa…
Silthus Nov 10, 2026
b12d9de
chore(cdp): format the sandbox authorization merge
Silthus Nov 10, 2026
995550b
fix(cdp): charge a sandbox send replayed after ses accepted it
Silthus Nov 10, 2026
7307976
fix(cdp): scope a sandbox cap sent mark to its claim attempt
Silthus Nov 10, 2026
4d87be9
Merge branch 'workflows-sandbox/n2-delivery-policy' into workflows-sa…
Silthus Nov 10, 2026
be3df59
fix(cdp): give each sandbox send its own cap claim attempt
Silthus Nov 10, 2026
1d81a62
fix(cdp): keep a sandbox cap sent mark with every attempt of its charge
Silthus Nov 10, 2026
7b41fa0
Merge branch 'workflows-sandbox/n2-delivery-policy' into workflows-sa…
Silthus Nov 10, 2026
f80c464
fix(cdp): mark a sandbox send through its cap claim
Silthus Nov 10, 2026
f1dc894
chore(cdp): rerun checks on the sandbox sender
Silthus Nov 10, 2026
09dbca2
fix(cdp): give the sandbox frequency cap check a deadline
Silthus Nov 10, 2026
c937291
fix(cdp): release a sandbox frequency cap slot for every finished check
Silthus Nov 10, 2026
ef42356
test(cdp): cover a sandbox frequency cap check that throws at once
Silthus Nov 10, 2026
98d4390
test(cdp): check sandbox recipients against the real frequency cap
Silthus Nov 10, 2026
21bc277
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
3254f59
test(cdp): check the sandbox test route's auth over real requests
Silthus Nov 10, 2026
5016cda
fix(cdp): skip the sandbox frequency cap only behind late checks
Silthus Nov 10, 2026
bc0b245
fix(cdp): run at most eight sandbox frequency cap checks at once
Silthus Nov 10, 2026
2ae3156
test(cdp): show the sandbox cap bound through the next send's reads
Silthus Nov 10, 2026
a180df8
test(cdp): read an uncached recipient after the stalled cap batch
Silthus Nov 10, 2026
d86b461
test(cdp): show the timed-out read frees its pooled connection
Silthus Nov 10, 2026
ebeb51b
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
15e67f1
test(cdp): show a late authorization read frees the one pooled connec…
Silthus Nov 10, 2026
387715c
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
fcf3ffb
fix(cdp): bound the sandbox test route's SES bucket and test the sand…
Silthus Nov 10, 2026
133d0a1
fix(cdp): give sandbox cap claims and sent marks a real deadline
Silthus Nov 10, 2026
971ec93
fix(cdp): refuse cap claims at once while abandoned ones wait for a c…
Silthus Nov 10, 2026
57c65df
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
5e24bc1
chore(cdp): share one stalling Valkey proxy between the sandbox tests
Silthus Nov 10, 2026
b4e96cd
fix(cdp): charge a sandbox cap claim before its send, release it only…
Silthus Nov 10, 2026
8c90889
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
f0e85f9
fix(cdp): charge the sandbox cap claim before calling SES
Silthus Nov 10, 2026
81a7fc6
chore(cdp): settle the stalled release acquisition in the caps test
Silthus Nov 10, 2026
4aec545
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
cb1bea5
chore(cdp): pin the overlapping-attempt caps test to caps of one
Silthus Nov 10, 2026
e6f61e2
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
bdb88ed
chore(cdp): prove overlapping sandbox sends on one claim send one email
Silthus Nov 10, 2026
1c815bd
fix(cdp): refuse sandbox reads and cap claims only after waiters gave up
Silthus Nov 10, 2026
c117265
fix(cdp): hold at most 8 pending acquisitions per pool for deadline c…
Silthus Nov 10, 2026
e2aba6d
chore(cdp): drain the stalled burst's acquisitions before the proxy s…
Silthus Nov 10, 2026
d6e3b40
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
9c090dd
fix(cdp): give the sandbox queue's SES bucket claim a real deadline
Silthus Nov 10, 2026
f2fa7e6
fix(cdp): end sandbox read connections that stall in the startup hand…
Silthus Nov 10, 2026
3aa1057
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
d4a7817
fix(cdp): keep an ended router's sandbox reads refused
Silthus Nov 10, 2026
cadfd7e
fix(cdp): keep the sandbox email worker off the fatal MX validation pool
Silthus Nov 10, 2026
d16bc6c
fix(cdp): drop a stalled connection from the test send SES bucket pool
Silthus Nov 10, 2026
d34dcbf
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 10, 2026
487e656
chore(cdp): type the reference Valkey client in the sandbox worker test
Silthus Nov 10, 2026
2ea53e8
Merge master into workflows-sandbox/n1-route-sandbox-steps
Silthus Nov 11, 2026
771aeb0
Merge master into workflows-sandbox/n2-delivery-policy
Silthus Nov 11, 2026
e6f5433
Merge workflows-sandbox/n1-route-sandbox-steps into workflows-sandbox…
Silthus Nov 11, 2026
5032247
Merge workflows-sandbox/n2-delivery-policy into workflows-sandbox/n3-…
Silthus Nov 11, 2026
bf612ec
fix(cdp): keep the sandbox email worker off a fatal frequency cap pool
Silthus Nov 11, 2026
ef3ac75
test(cdp): prove the sandbox email worker process survives refused va…
Silthus Nov 11, 2026
b1c4b98
Merge master into workflows-sandbox/n3-sandbox-sender
Silthus Nov 11, 2026
6e6a5cb
chore(cdp): observe sandbox email events at the analytics sdk
Silthus Nov 11, 2026
33d0f16
chore(cdp): prove a redelivered sandbox job cannot resend after a los…
Silthus Nov 11, 2026
68029af
Merge master into workflows-sandbox/n3-sandbox-sender
Silthus Nov 11, 2026
07d3d54
fix(cdp): settle stalled sandbox sends and key unsubscribes to the de…
Silthus Nov 11, 2026
d2944c4
chore(cdp): assert the stalled sandbox read by behavior, not duration
Silthus Nov 11, 2026
e120fc0
chore(cdp): drop job-state assertions from the stalled sandbox read test
Silthus Nov 11, 2026
32748e9
fix(cdp): claim the shared ses slot only for admitted sandbox sends
Silthus Nov 11, 2026
d68188c
fix(cdp): refund the daily caps of a sandbox send the ses bucket refuses
Silthus Nov 11, 2026
ce8fa95
chore(cdp): hold the refund test to both daily caps
Silthus Nov 11, 2026
94cd5d9
fix(cdp): honour the team email suspension and workflow pause in sand…
Silthus Nov 11, 2026
4495d7c
fix(cdp): bound the sandbox suspension read and retry when it times out
Silthus Nov 11, 2026
8cba180
fix(cdp): refund both daily caps when SES confirms an editor test unsent
Silthus Nov 12, 2026
2c15289
Merge master into workflows-sandbox/n3-sandbox-sender
Silthus Nov 12, 2026
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
33 changes: 32 additions & 1 deletion nodejs/src/cdp/cdp-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import { MessageAssetsService } from './services/messaging/message-assets.servic
import { PushNotificationService } from './services/messaging/push-notification.service'
import { RecipientPreferencesService } from './services/messaging/recipient-preferences.service'
import { RecipientTokensService } from './services/messaging/recipient-tokens.service'
import { SandboxEmailRuntime } from './services/messaging/sandbox/sandbox-email-runtime'
import { SandboxEmailSendStep } from './services/messaging/sandbox/sandbox-email-step'
import { SandboxEmailTester } from './services/messaging/sandbox/sandbox-email-tester'
import { deliveredAddressReader } from './services/messaging/sandbox/sandbox-render'
import {
HogFunctionMonitoringService,
parseInternalLogsSources,
Expand Down Expand Up @@ -177,6 +181,8 @@ export type CdpCoreServicesConfig = Pick<
| 'CDP_WATCHER_OBSERVE_RESULTS_BUFFER_MAX_RESULTS'
| 'CDP_HOGFLOW_AWAITED_STEPS_ENABLED'
| 'WORKFLOWS_SANDBOX_SENDER_ENABLED'
| 'WORKFLOWS_SANDBOX_EMAIL_TEST_URL'
| 'WORKFLOWS_SANDBOX_EMAIL_TEST_JWT_SECRET'
| 'CDP_INTERNAL_LOGS_SOURCES'
| 'SES_ACCESS_KEY_ID'
| 'SES_SECRET_ACCESS_KEY'
Expand Down Expand Up @@ -253,6 +259,7 @@ export interface CdpCoreServicesDeps {
* `null` elsewhere, and when no host is configured, which turns the cap off.
*/
frequencyCapValkey: RedisV2 | null
sandboxEmail?: SandboxEmailRuntime | null
}

/**
Expand Down Expand Up @@ -598,6 +605,7 @@ export function createCdpCoreServices(
createUsageIngestionClient(config, 'cdp'),
usageReportTeamMatcher(config)
)
const sandboxDeliveredAddress = deliveredAddressReader(deps.postgres)
const hogFlowExecutor = new HogFlowExecutorService(
hogFlowFunctionsService,
recipientPreferencesService,
Expand All @@ -609,7 +617,30 @@ export function createCdpCoreServices(
cdpUsageReporter,
{
awaitedStepsEnabled: config.CDP_HOGFLOW_AWAITED_STEPS_ENABLED,
sandboxEmail: { enabled: config.WORKFLOWS_SANDBOX_SENDER_ENABLED },
sandboxEmail: {
enabled: config.WORKFLOWS_SANDBOX_SENDER_ENABLED,
send: deps.sandboxEmail
? new SandboxEmailSendStep({
service: deps.sandboxEmail.service,
events: deps.sandboxEmail.events,
hogInputsService,
deliveredAddress: sandboxDeliveredAddress,
siteUrl: config.SITE_URL,
recipientPreferences: recipientPreferencesService,
sesBucket: deps.sandboxEmail.sesBucket,
})
: null,
testClient: new SandboxEmailTester({
hogInputsService,
deliveredAddress: sandboxDeliveredAddress,
siteUrl: config.SITE_URL,
endpoint: config.WORKFLOWS_SANDBOX_EMAIL_TEST_URL,
jwt: new ScopedServiceJwt(
PosthogJwtAudience.WORKFLOWS_SANDBOX_EMAIL_TEST,
config.WORKFLOWS_SANDBOX_EMAIL_TEST_JWT_SECRET
),
}),
},
}
)

Expand Down
20 changes: 20 additions & 0 deletions nodejs/src/cdp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export type CdpConfig = ClickhouseConfig & {
// Off until Django emits `$workflow_step_resume`, or parked steps never wake.
CDP_HOGFLOW_AWAITED_STEPS_ENABLED: boolean
WORKFLOWS_SANDBOX_SENDER_ENABLED: boolean
SES_SANDBOX_CONFIGURATION_SET: string
SES_SANDBOX_TENANT_NAME: string
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: number
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: number
WORKFLOWS_SANDBOX_CAPS_VALKEY_HOST: string
WORKFLOWS_SANDBOX_CAPS_VALKEY_PORT: number
WORKFLOWS_SANDBOX_CAPS_VALKEY_PASSWORD: string
WORKFLOWS_SANDBOX_CAPS_VALKEY_TLS: boolean
WORKFLOWS_SANDBOX_EMAIL_TEST_URL: string
WORKFLOWS_SANDBOX_EMAIL_TEST_JWT_SECRET: string
// Comma-separated log sources, such as `hog_flow`, that also send their logs to the logs product. Empty sends none.
CDP_INTERNAL_LOGS_SOURCES: string
// Django produces cdp_internal_events to the cyclotron cluster; empty inherits the consumer default.
Expand Down Expand Up @@ -362,6 +372,16 @@ export function getDefaultCdpConfig(): CdpConfig {
CDP_CYCLOTRON_COMPRESS_KAFKA_DATA: true,
CDP_HOGFLOW_AWAITED_STEPS_ENABLED: isProdEnv() ? false : true,
WORKFLOWS_SANDBOX_SENDER_ENABLED: false,
SES_SANDBOX_CONFIGURATION_SET: '',
SES_SANDBOX_TENANT_NAME: '',
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: 0,
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: 0,
WORKFLOWS_SANDBOX_CAPS_VALKEY_HOST: '',
WORKFLOWS_SANDBOX_CAPS_VALKEY_PORT: 6379,
WORKFLOWS_SANDBOX_CAPS_VALKEY_PASSWORD: '',
WORKFLOWS_SANDBOX_CAPS_VALKEY_TLS: false,
WORKFLOWS_SANDBOX_EMAIL_TEST_URL: '',
WORKFLOWS_SANDBOX_EMAIL_TEST_JWT_SECRET: '',
CDP_INTERNAL_LOGS_SOURCES: '',
CDP_INTERNAL_EVENTS_CONSUMER_METADATA_BROKER_LIST: '',
CDP_REDIS_HOST: '127.0.0.1',
Expand Down
23 changes: 19 additions & 4 deletions nodejs/src/cdp/services/hog-inputs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const accessTokenPlaceholderBase = (integrationId: number): string => `${ACCESS_

const signedAccessTokenPlaceholder = (base: string, signature: string): string => `${base}_${signature.slice(0, 32)}`

export type BuildInputsOptions = { recipientIdentifier?: (email: string) => string | Promise<string> }

export class HogInputsService {
constructor(
private integrationManager: IntegrationManagerService,
Expand All @@ -64,7 +66,8 @@ export class HogInputsService {
globals: HogFunctionInvocationGlobals,
additionalInputs?: Record<string, any>,
preferencesTokenSource?: PreferencesTokenSource,
signal?: AbortSignal
signal?: AbortSignal,
{ recipientIdentifier = (email: string) => email }: BuildInputsOptions = {}
): Promise<Record<string, any>> {
// TODO: Load the values from the integrationManager
const newGlobals: HogFunctionInvocationGlobalsWithInputs = {
Expand Down Expand Up @@ -145,7 +148,11 @@ export class HogInputsService {
// If we have an email value then we template it out to get the email address
const emailValue = await _formatInput(emailInput, emailInputSchema.key)
if (emailValue?.to?.email) {
const recipient = { team_id: hogFunction.team_id, identifier: emailValue.to.email }
const identifier = recipientIdentifier(emailValue.to.email)
const recipient = {
team_id: hogFunction.team_id,
identifier: typeof identifier === 'string' ? identifier : await identifier,
}
newGlobals.unsubscribe_url = this.recipientTokensService.generatePreferencesUrl(
recipient,
preferencesTokenSource
Expand Down Expand Up @@ -186,11 +193,19 @@ export class HogInputsService {
globals: HogFunctionInvocationGlobals,
additionalInputs?: Record<string, any>,
preferencesTokenSource?: PreferencesTokenSource,
signal?: AbortSignal
signal?: AbortSignal,
options?: BuildInputsOptions
): Promise<HogFunctionInvocationGlobalsWithInputs> {
return {
...globals,
inputs: await this.buildInputs(hogFunction, globals, additionalInputs, preferencesTokenSource, signal),
inputs: await this.buildInputs(
hogFunction,
globals,
additionalInputs,
preferencesTokenSource,
signal,
options
),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ describe('SandboxEmailActionHandler', () => {
{ status: 'pending', reason: 'pause_state_unavailable' },
undefined,
],
[
'a real test over the shared send rate asks the editor to try again',
{ isTest: true },
{ send: () => Promise.resolve({ status: 'failed', reason: 'send_rate_limited' }) },
{ status: 'failed', reason: 'send_rate_limited' },
'Too many emails are being sent right now. Send the test again in a moment.',
],
])('runs a test inline: %s', async (_name, hogExecutorOptions, testClient, execResult, error) => {
const mailbox = new FakeMailbox()

Expand Down
23 changes: 16 additions & 7 deletions nodejs/src/cdp/services/hogflows/actions/email_sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ export class SandboxEmailActionHandler implements ActionHandler {
}

private async runTest(options: ActionHandlerOptions<SandboxEmailAction>): Promise<ActionHandlerResult> {
if (options.hogExecutorOptions?.functions) {
this.log(options, 'Mocked the sandbox email send')
return this.finish(options, { status: 'accepted' })
}
const mocked = !!options.hogExecutorOptions?.functions
if (!this.testClient) {
return this.finish(options, toStepResult(WORKER_UNAVAILABLE))
return this.finish(options, mocked ? { status: 'accepted' } : toStepResult(WORKER_UNAVAILABLE))
}
const stepResult = await this.testClient.send(options)
if (mocked && stepResult.status === 'accepted') {
this.log(options, 'Mocked the sandbox email send')
}
return this.finish(options, await this.testClient.send(options))
return this.finish(options, stepResult)
}

private routeToSandboxQueue(
Expand Down Expand Up @@ -111,7 +112,7 @@ export class SandboxEmailActionHandler implements ActionHandler {
stepResult: SandboxEmailStepResult
): ActionHandlerResult {
if (stepResult.status === 'failed') {
return { result: stepResult, error: new Error(`Sandbox email was not sent: ${stepResult.reason}`) }
return { result: stepResult, error: new Error(failureMessage(stepResult.reason)) }
}
this.log(
options,
Expand All @@ -131,6 +132,14 @@ export class SandboxEmailActionHandler implements ActionHandler {
}
}

const FAILURE_MESSAGES: Record<string, string> = {
send_rate_limited: 'Too many emails are being sent right now. Send the test again in a moment.',
}

function failureMessage(reason: string | undefined): string {
return (reason && FAILURE_MESSAGES[reason]) ?? `Sandbox email was not sent: ${reason}`
}

function toStepResult({ outcome, reason, messageId }: SandboxEmailOutcome): SandboxEmailStepResult {
return { status: outcome, ...(reason ? { reason } : {}), ...(messageId ? { message_id: messageId } : {}) }
}
Expand Down
111 changes: 107 additions & 4 deletions nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { template as setVariableTemplate } from '~/cdp/templates/_destinations/p
import { compileHog } from '~/cdp/templates/compiler'
import { convertToHogFunctionFilterGlobal } from '~/cdp/utils/hog-function-filtering'
import { createRedisV2PoolFromConfig } from '~/common/redis/redis-v2'
import { FakeUsageIngestion } from '~/common/usage-ingestion/_tests/fake-usage-ingestion'
import { createHub } from '~/common/utils/db/hub'
import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres'
import { parseJSON } from '~/common/utils/json-parse'
Expand Down Expand Up @@ -50,7 +51,11 @@ import { EmailTrackingCodeSigner } from '../messaging/helpers/tracking-code'
import { PushNotificationFetchUtils, PushNotificationService } from '../messaging/push-notification.service'
import { RecipientPreferencesService } from '../messaging/recipient-preferences.service'
import { RecipientTokensService } from '../messaging/recipient-tokens.service'
import { SandboxEmailService } from '../messaging/sandbox/sandbox-email.service'
import { SandboxEmailSendStep } from '../messaging/sandbox/sandbox-email-step'
import { SandboxMessage } from '../messaging/sandbox/sandbox-message'
import { RateLimiterService } from '../rate-limiter/rate-limiter.service'
import { CdpUsageReporterService } from '../usage/cdp-usage-reporter.service'
import { HogFlowExecutorService, createHogFlowInvocation } from './hogflow-executor.service'
import { HogFlowFunctionsService } from './hogflow-functions.service'
import { TRANSACTIONAL_EMAIL_EVENT } from './hogflow-sensitivity'
Expand Down Expand Up @@ -183,6 +188,8 @@ describe('Hogflow Executor', () => {
let executor: HogFlowExecutorService
let cohortMembershipRepository: CohortMembershipRepository
let hogFlowFunctionsService: HogFlowFunctionsService
let recipientPreferencesService: RecipientPreferencesService
let emailValidationService: EmailValidationService
let hub: Hub
let team: Team
let integrationId: number
Expand Down Expand Up @@ -300,14 +307,14 @@ describe('Hogflow Executor', () => {
hogExecutor
)
const recipientsManager = new RecipientsManagerService(hub.postgres)
const recipientPreferencesService = new RecipientPreferencesService(
recipientPreferencesService = new RecipientPreferencesService(
recipientsManager,
new EmailSuppressionService(hub.postgres, emailSuppressionConfigFromEnv())
)
// Stubbed to always allow: this suite covers executor routing and flow control,
// not MX validation (email-validation.service.test.ts does), and the real
// service would fire live DNS lookups for the fixture recipients here.
const emailValidationService = {
emailValidationService = {
getSkipReason: () => Promise.resolve(null),
} as unknown as EmailValidationService
return {
Expand Down Expand Up @@ -3290,7 +3297,74 @@ describe('Hogflow Executor', () => {
.build()
}

it('should record billing metrics for both regular hog functions and email functions', async () => {
it('bills hog functions and own-domain email, and never a sandbox email in the same run', async () => {
const usageIngestion = new FakeUsageIngestion()
const usageReporter = new CdpUsageReporterService(await usageIngestion.start(), () => true)
const sandboxMailbox: SandboxMessage[] = []
const sandboxSes = {
send: (message: SandboxMessage) => {
sandboxMailbox.push(message)
return Promise.resolve({ status: 'accepted' as const, messageId: 'ses-1' })
},
}
executor = new HogFlowExecutorService(
hogFlowFunctionsService,
recipientPreferencesService,
emailValidationService,
cohortMembershipRepository,
hub.integrationManager,
null,
undefined,
usageReporter,
{
sandboxEmail: {
enabled: true,
send: new SandboxEmailSendStep({
service: new SandboxEmailService({
enabled: true,
fromAddress: 'sandbox@send.example.com',
tenantName: 'sandbox-tenant',
isProvisioned: () => Promise.resolve(true),
checkRecipient: () => Promise.resolve({ ok: true }),
authorize: () =>
Promise.resolve({
ok: true,
organizationId: 'org-1',
organizationName: 'Acme',
storedAddress: 'member@example.com',
tenant: { sendingStatus: 'ENABLED', syncedAt: new Date(Date.now()) },
}),
claimCaps: () =>
Promise.resolve({
ok: true,
markSending: () => Promise.resolve(true),
releaseUnsent: () => Promise.resolve(true),
refundUnsent: () => Promise.resolve(true),
}),
teamWorkflowsConfig: new TeamWorkflowsConfigService(hub.postgres, hub.pubSub),
ses: sandboxSes,
now: () => new Date(Date.now()),
}),
hogInputsService: new HogInputsService(
hub.integrationManager,
new RecipientTokensService(hub.ENCRYPTION_SALT_KEYS, hub.SITE_URL),
hub.encryptedFields
),
deliveredAddress: () => Promise.resolve('member@example.com'),
siteUrl: hub.SITE_URL,
recipientPreferences: recipientPreferencesService,
events: { capture: () => Promise.resolve() },
sesBucket: {
limiter: { claimUpTo: () => Promise.resolve(1) } as unknown as RateLimiterService,
key: '@posthog/ses/billing-test',
capacity: 1,
refillPerSecond: 1,
},
}),
},
}
)

await insertIntegration(hub.postgres, team.id, {
id: integrationId,
kind: 'email',
Expand Down Expand Up @@ -3398,13 +3472,24 @@ describe('Hogflow Executor', () => {
},
},
},
sandbox_email: {
type: 'email_sandbox',
config: {
inputs: {
email: {
value: { to: { email: 'member@example.com' }, subject: 'Sandbox', text: 'Hi' },
},
},
},
},
},
edges: [
{ from: 'trigger', to: 'function_1', type: 'continue' },
{ from: 'function_1', to: 'function_2', type: 'continue' },
{ from: 'function_2', to: 'email_1', type: 'continue' },
{ from: 'email_1', to: 'email_2', type: 'continue' },
{ from: 'email_2', to: 'exit', type: 'continue' },
{ from: 'email_2', to: 'sandbox_email', type: 'continue' },
{ from: 'sandbox_email', to: 'exit', type: 'continue' },
],
})

Expand Down Expand Up @@ -3440,6 +3525,24 @@ describe('Hogflow Executor', () => {
(m) => m.metric_kind === 'email' && m.metric_name === 'billable_invocation'
)
expect(emailBilling).toHaveLength(2)

expect(sandboxMailbox.map((message) => message.to.address)).toEqual(['member@example.com'])
await usageReporter.shutdown()
await usageIngestion.stop()
expect(
usageIngestion.accepted.map(({ teamId, usageKey, quantity }) => [teamId, usageKey, quantity]).sort()
).toEqual([
[invocation.teamId, 'workflow_billable_invocations', 1],
[invocation.teamId, 'workflow_billable_invocations', 1],
[invocation.teamId, 'workflow_emails_sent', 1],
[invocation.teamId, 'workflow_emails_sent', 1],
])
expect(
metrics.filter((m) => m.instance_id === 'sandbox_email').map((m) => [m.metric_kind, m.metric_name])
).toEqual([
['email', 'email_sandbox_sent'],
['success', 'succeeded'],
])
})

const suspendEmailSending = async (): Promise<void> => {
Expand Down
Loading
Loading