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
8 changes: 7 additions & 1 deletion docs/internal/workflows-email-sending-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ The worker parses and normalizes the HTML, then appends a footer that names the
It overrides root clipping and visibility styles and keeps the template's typography, so malformed markup may render differently from an own-domain send.
A template whose HTML cannot keep the footer fails the send.
The visible footer is best effort across email clients and template CSS; the signed tracking header still attributes the message.
Right before delivery, every sandbox send, test sends included, claims a daily allowance per project (`WORKFLOWS_SANDBOX_DAILY_TEAM_CAP`, one token per recipient copy) and per recipient address across the organization (`WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP`).
The allowances are token buckets that refill their capacity over 24 hours, not calendar-day counters.
A reached allowance skips the send instead of delaying it. A limiter error, or an empty or invalid cap value, skips every sandbox send.
The worker checks members after the claim and gives the claim back when that check skips the send.
An SES throttle response also gives the claim back before the delivery retry. Returns are deduplicated, and Valkey ignores a return that arrives more than five minutes after it was issued.
Test sends run inline in the CDP API, so a sandbox-enabled CDP API opens the SES Valkey pool (`SES_RATE_LIMITER_VALKEY_*`) for the allowances. Without it, every sandbox test send skips.
Each delivered send emits a `workflows sandbox email sent` event.
Test sends never record `email_sent`, so they do not count toward the sending tier.
Live workflow sends still count as `email_sent`, which the sending tier reads, until sandbox sends get their own metric.
Keep the switch off in production until the sandbox caps are deployed, and use it for test sends only until that metric is deployed.
Use the switch in production for test sends only until that metric is deployed.
26 changes: 18 additions & 8 deletions nodejs/src/cdp/cdp-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export type CdpCoreServicesConfig = Pick<
| 'SES_SANDBOX_TENANT_NAME'
| 'SES_SANDBOX_CONFIGURATION_SET'
| 'SES_SANDBOX_FROM_ADDRESS'
| 'WORKFLOWS_SANDBOX_DAILY_TEAM_CAP'
| 'WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP'
| 'EMAIL_SUPPRESSION_TRANSIENT_BOUNCE_THRESHOLD'
| 'EMAIL_TEAM_SENDING_CAP_MODE'
| 'EMAIL_TEAM_SENDING_CAP_HOURLY_BY_TIER'
Expand Down Expand Up @@ -215,13 +217,15 @@ export interface CdpCoreServicesDeps {
/**
* SES Valkey pool shared with the SES rate limiter, opened only on pods whose
* capabilities actually execute email actions (hogflow/email cyclotron workers).
* `null` on every other CDP consumer and on cdp-api so idle pods don't hold
* open connections against the SES Valkey instance.
* Sandbox-enabled cdp-api opens it too, for the sandbox daily caps of test sends.
* `null` on every other CDP consumer so idle pods don't hold open connections
* against the SES Valkey instance.
*/
emailValidationValkey: RedisV2 | null
/**
* Dedicated frequency cap Valkey pool, opened only on the same pods as `emailValidationValkey`.
* `null` elsewhere, and when no host is configured, which turns the cap off.
* Dedicated frequency cap Valkey pool, opened only on the email workers. `null` elsewhere,
* cdp-api included because its test sends skip the cap, and when no host is configured,
* which turns the cap off.
*/
frequencyCapValkey: RedisV2 | null
}
Expand Down Expand Up @@ -459,6 +463,9 @@ export function createCdpCoreServices(
const teamEmailRateLimiter = deps.emailValidationValkey
? new RateLimiterService(deps.emailValidationValkey, { name: 'team-email' })
: null
const sandboxEmailRateLimiter = deps.emailValidationValkey
? new RateLimiterService(deps.emailValidationValkey, { name: 'sandbox-email' })
: null
const emailService = new EmailService(
{
sesAccessKeyId: config.SES_ACCESS_KEY_ID,
Expand Down Expand Up @@ -487,9 +494,12 @@ export function createCdpCoreServices(
tenantName: config.SES_SANDBOX_TENANT_NAME,
configurationSetName: config.SES_SANDBOX_CONFIGURATION_SET,
fromAddress: config.SES_SANDBOX_FROM_ADDRESS,
dailyTeamCap: config.WORKFLOWS_SANDBOX_DAILY_TEAM_CAP,
dailyRecipientCap: config.WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP,
},
deps.teamManager,
redis
redis,
sandboxEmailRateLimiter
),
new OrganizationMembersService(deps.postgres)
)
Expand Down Expand Up @@ -557,9 +567,9 @@ export function createCdpCoreServices(
: undefined
)
// MX verdicts live on the dedicated SES Valkey (same instance as the SES rate
// limiter, separate pool). The pool is created by the server only on pods
// whose capabilities execute email actions; everywhere else this is null
// and EmailValidationService degrades to the local cache + DNS.
// limiter, separate pool). The server creates the pool only on pods whose
// capabilities execute email actions and on sandbox-enabled cdp-api; everywhere
// else this is null and EmailValidationService degrades to the local cache + DNS.
const emailValidationService = new EmailValidationService(deps.emailValidationValkey)
const cohortMembershipRepository = new PostgresCohortMembershipRepository(deps.postgres)
// Observer writes to both stores; the read source decides which verdict drives the metric.
Expand Down
5 changes: 5 additions & 0 deletions nodejs/src/cdp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export type CdpConfig = ClickhouseConfig & {
SES_SANDBOX_TENANT_NAME: string
SES_SANDBOX_CONFIGURATION_SET: string
SES_SANDBOX_FROM_ADDRESS: string
// Daily sandbox allowances per project and per recipient address. An empty or invalid value skips every sandbox send.
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: string
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: string
// Comma-separated allowlist of SNS Topic ARNs the SES webhook accepts events from. Empty string
// means no restriction (dev/test); production should set this to the workflow SES topic ARN(s).
SES_ALLOWED_SNS_TOPIC_ARNS: string
Expand Down Expand Up @@ -416,6 +419,8 @@ export function getDefaultCdpConfig(): CdpConfig {
SES_SANDBOX_TENANT_NAME: '',
SES_SANDBOX_CONFIGURATION_SET: '',
SES_SANDBOX_FROM_ADDRESS: '',
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: '25',
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: '5',
SES_ALLOWED_SNS_TOPIC_ARNS: '',
EMAIL_SUPPRESSION_TRANSIENT_BOUNCE_THRESHOLD: 5,

Expand Down
Loading
Loading