Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
eddc828
wip: sandbox sender ensure endpoint test
Silthus Oct 3, 2026
f314725
wip(workflows): sandbox sender row, ensure endpoint and lifecycle exc…
Silthus Oct 3, 2026
02b8599
feat(workflows): tidy sandbox sender service and pin edit test body
Silthus Oct 3, 2026
9c0ee89
feat(workflows): regenerate integrations api client for the sandbox s…
Silthus Oct 3, 2026
85c2d19
fix(workflows): guard the sandbox sender before edit validation and r…
Silthus Oct 3, 2026
1465e4c
chore(workflows): tighten sandbox sender test assertions and annotations
Silthus Oct 3, 2026
72ded8d
chore(workflows): keep the sandbox sender scope entry clear of the re…
Silthus Oct 3, 2026
ec1bb13
chore(workflows): extract SES sender route selection
Silthus Oct 3, 2026
7b73ca1
feat(workflows): route sandbox email with a fixed sender identity
Silthus Oct 3, 2026
5fd964a
fix(workflows): preserve sandbox email snapshots and retries
Silthus Oct 3, 2026
e63e981
fix(workflows): skip disabled sandbox sends before sender validation
Silthus Oct 3, 2026
cad864b
fix(workflows): append sandbox footer to the parsed email body
Silthus Oct 3, 2026
8dc512c
fix(workflows): preserve sandbox footers in fallback HTML
Silthus Oct 3, 2026
cf10809
fix(workflows): finalize sandbox HTML before footer serialization
Silthus Oct 4, 2026
489b630
fix(workflows): reject sandbox HTML without a delivered footer
Silthus Oct 4, 2026
12d5fa2
chore(workflows): release sandbox send test Redis clients
Silthus Oct 4, 2026
0007a4f
fix(workflows): reject empty sandbox email content
Silthus Oct 4, 2026
48d00b4
feat(workflows): restrict sandbox email to verified org members
Silthus Oct 4, 2026
67d5d12
fix(workflows): bound sandbox membership freshness
Silthus Oct 4, 2026
e0fc341
fix(workflows): recheck sandbox recipients before delivery
Silthus Oct 4, 2026
c64bd97
fix(workflows): preserve sandbox recipient diagnostics
Silthus Oct 4, 2026
7206328
fix(workflows): read sandbox cc and bcc mailboxes with quoted angle c…
Silthus Oct 4, 2026
c267c6d
fix(workflows): pack blocked sandbox addresses into few log rows
Silthus Oct 5, 2026
754efd7
chore(workflows): test that a minute-long member query blocks the san…
Silthus Oct 5, 2026
9c2ca60
chore(workflows): specify sandbox daily caps at the send path
Silthus Oct 5, 2026
d04082f
feat(workflows): cap sandbox email sends per project and recipient pe…
Silthus Oct 5, 2026
5d64942
chore(workflows): document the sandbox daily cap settings
Silthus Oct 5, 2026
fce4e20
fix(workflows): parse sandbox caps strictly and claim them after the …
Silthus Oct 5, 2026
7bf827d
chore(workflows): cover repeated and several capped sandbox recipients
Silthus Oct 5, 2026
1bdf140
fix(workflows): let cdp-api test sends reach the sandbox cap buckets
Silthus Oct 5, 2026
08d9a89
chore(workflows): read SES tenant reputation by tenant name
Silthus Oct 5, 2026
5d88fad
feat(workflows): mirror the sandbox SES tenant state into postgres
Silthus Oct 5, 2026
389dd0e
feat(workflows): skip sandbox sends while the sandbox tenant is paused
Silthus Oct 5, 2026
66a1cd0
chore(workflows): freeze the clock in the sandbox tenant sync test
Silthus Oct 5, 2026
046d6ad
fix(workflows): read the sandbox tenant under its row lock and withou…
Silthus Oct 5, 2026
b0cba84
fix(workflows): read the sandbox pause state from the primary
Silthus Oct 5, 2026
a66bb20
chore(workflows): name the sandbox tenant in the tenant events docstring
Silthus Oct 5, 2026
e692ca7
chore(workflows): specify sandbox sends outside tier history and the …
Silthus Oct 5, 2026
ca72786
feat(workflows): keep sandbox sends out of tier history and record sa…
Silthus Oct 5, 2026
bc81df2
chore(workflows): document sandbox sends in tier history and freeze t…
Silthus Oct 5, 2026
d9ef8d1
test(workflows): pin the sandbox delivery code gate and per-day tier …
Silthus Oct 5, 2026
b070682
fix(workflows): skip sandbox sends when the sandbox route settings ar…
Silthus Oct 5, 2026
949b105
fix(workflows): return sandbox daily cap tokens when SES throttles a …
Silthus Oct 5, 2026
b706c59
fix(workflows): return a sandbox cap claim at most once
Silthus Oct 5, 2026
b6e1ba7
fix(workflows): drop a sandbox cap claim return that arrives late
Silthus Oct 5, 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
1 change: 1 addition & 0 deletions .github/scripts/check-idor-model-coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ def get_scoped_models() -> tuple[dict[str, set[str]], set[str], set[str], set[st
"AsyncMigrationError",
"InstanceSetting",
"License",
"SandboxSenderTenantState", # instance-global mirror of the shared sandbox SES tenant state
# --- Deprecated ---
"Prompt",
"PromptSequence",
Expand Down
10 changes: 10 additions & 0 deletions docs/internal/workflows-email-sending-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ None of them are wired there yet; the charts change is part of turning the rollo

In order: staff suspension or an AWS-paused tenant drops to tier 0; a dirty 7-day window, a workflow auto-pause, or a HIGH tenant reputation impact demotes one tier (at most once per cooldown, anchored on the last rate demotion); a tier above 0 with zero sends for the decay period drops one step per period; promotion needs the dwell served, real use of the tier (at least the use ratio of the daily cap on the minimum number of separate days, counted only after the tier anchor), a clean 30-day window, and a clean tenant reputation.
Rates only count on a meaningful denominator; below the complaint floor, the absolute complaint backstop still applies, including in windows with no sends.
Sandbox sender sends never count as sends: the sweep subtracts `email_sandbox_sent` from `email_sent` per day, floored at zero. Their bounces and complaints still count.
Pinned teams never move automatically.

While the tier is enforced, the sweep notifies teams it moved: a rate demotion sends an in-app notification plus an email to project admins, and an earned promotion sends an in-app notification only.
Expand All @@ -57,6 +58,15 @@ Decay, suspension drops, admin recomputes, and the backfill stay silent.

- The batch audience cap is decided when the batch is dispatched. Adding an email step to the workflow while a batch is queued does not re-cap it; the send-time buckets still cap every email at execution. This is why enforcement requires the worker caps to be deployed (see the rollout order).
- Test-panel sends bypass the team buckets on purpose, matching the per-workflow rate limit.
- Sandbox sender sends bypass the team buckets without spending their tokens. Sandbox limits run independently.
The worker holds every sandbox send, test sends included, to a daily cap per project (`WORKFLOWS_SANDBOX_DAILY_TEAM_CAP`, one token per recipient copy) and a daily cap per recipient address (`WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP`).
A reached cap skips the send instead of delaying it. A limiter error or an empty or invalid cap value skips every sandbox send.
The SES tenant-state webhook and daily reconciliation mirror `SES_SANDBOX_TENANT_NAME` into `SandboxSenderTenantState` without emailing project admins.
Every sandbox send, including test sends, skips when that tenant's mirrored sending status is `DISABLED` or the pause lookup fails. A missing row allows sending, with SES enforcing its own state.
A committed state change reaches the workers through the `reload-sandbox-sender-state` message. Own-sender sends do not read this state.
Test sends run inline in the CDP API, so the API reads the same `SES_RATE_LIMITER_VALKEY_*` settings as the email worker. Without them, every sandbox test send is skipped.
The CDP API's SES webhook captures `workflows sandbox email delivered` for a delivery on `SES_SANDBOX_CONFIGURATION_SET`, test sends included. Without that setting on the CDP API, the event never fires.
Keep `WORKFLOWS_SANDBOX_SENDER_ENABLED` off until the recipient membership checks, sandbox caps and sandbox pause gate are deployed.
- The buckets are token buckets: a full idle bucket plus refill allows up to roughly twice the stated cap in the very first period. The bucket TTLs exceed the refill periods so this does not recur from idling.
- A denied send parks until every short bucket has refilled enough to cover it, instead of retrying on a fixed few-minute cadence.
The computed wait is capped at one hour and then jittered 1x to 2x, so a parked send can wait just under two hours between attempts.
Expand Down
1 change: 1 addition & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"node-schedule": "^2.1.0",
"nodemailer": "^8.0.8",
"p-limit": "3.1.0",
"parse5": "7.1.2",
"pg": "^8.6.0",
"pino": "^8.6.0",
"posthog-node": "5.54.1",
Expand Down
3 changes: 2 additions & 1 deletion nodejs/src/cdp/cdp-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ export class CdpApi {
services.capturedEventsService,
services.teamWorkflowsConfigService,
new EmailTrackingCodeSigner(config.ENCRYPTION_SALT_KEYS, config.CDP_EMAIL_TRACKING_URL),
services.emailSuppressionService
services.emailSuppressionService,
services.sandboxEmailSender
)
this.groupsManager = new GroupsManagerService(deps.teamManager, deps.groupRepository)
this.batchExportHogFunctionService = new BatchExportHogFunctionService(
Expand Down
31 changes: 30 additions & 1 deletion nodejs/src/cdp/cdp-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import { InvocationResultsService } from './services/invocation-results.service'
import { HogFunctionManagerService } from './services/managers/hog-function-manager.service'
import { HogFunctionTemplateManagerService } from './services/managers/hog-function-template-manager.service'
import { IntegrationManagerService } from './services/managers/integration-manager.service'
import { OrganizationMembersService } from './services/managers/organization-members.service'
import { RecipientsManagerService } from './services/managers/recipients-manager.service'
import { SandboxSenderStateService } from './services/managers/sandbox-sender-state.service'
import { TeamWorkflowsConfigService } from './services/managers/team-workflows-config.service'
import { EmailSuppressionService } from './services/messaging/email-suppression.service'
import { EmailValidationService } from './services/messaging/email-validation.service'
Expand All @@ -40,6 +42,7 @@ 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 { SandboxEmailSender } from './services/messaging/sandbox-email-sender'
import { HogFunctionMonitoringService } from './services/monitoring/hog-function-monitoring.service'
import { HogInvocationResultsService } from './services/monitoring/hog-invocation-results.service'
import { HogWatcherService } from './services/monitoring/hog-watcher.service'
Expand Down Expand Up @@ -107,6 +110,7 @@ export interface CdpCoreServices {
/** Resolved outputs shared across every CDP service/consumer. */
outputs: CdpOutputs
emailService: EmailService
sandboxEmailSender: SandboxEmailSender
/** Buffers rendered-email asset rows and bulk-flushes them at the batch boundary. */
messageAssetsService: MessageAssetsService
}
Expand Down Expand Up @@ -159,6 +163,12 @@ export type CdpCoreServicesConfig = Pick<
| 'SES_ENDPOINT'
| 'SES_TRACKED_CONFIGURATION_SET'
| 'SES_UNTRACKED_CONFIGURATION_SET'
| 'WORKFLOWS_SANDBOX_SENDER_ENABLED'
| 'WORKFLOWS_SANDBOX_DAILY_TEAM_CAP'
| 'WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP'
| 'SES_SANDBOX_TENANT_NAME'
| 'SES_SANDBOX_CONFIGURATION_SET'
| 'SES_SANDBOX_FROM_ADDRESS'
| 'EMAIL_SUPPRESSION_TRANSIENT_BOUNCE_THRESHOLD'
| 'EMAIL_TEAM_SENDING_CAP_MODE'
| 'EMAIL_TEAM_SENDING_CAP_HOURLY_BY_TIER'
Expand Down Expand Up @@ -433,6 +443,22 @@ 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 sandboxEmailSender = new SandboxEmailSender(
{
enabled: config.WORKFLOWS_SANDBOX_SENDER_ENABLED,
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,
sandboxEmailRateLimiter,
new SandboxSenderStateService(deps.postgres, deps.pubSub)
)
const emailService = new EmailService(
{
sesAccessKeyId: config.SES_ACCESS_KEY_ID,
Expand All @@ -454,7 +480,9 @@ export function createCdpCoreServices(
recipientsManager,
messageAssetsService,
workflowEmailRateLimiter,
teamEmailRateLimiter
teamEmailRateLimiter,
sandboxEmailSender,
new OrganizationMembersService(deps.postgres)
)
const recipientTokensService = new RecipientTokensService(config.ENCRYPTION_SALT_KEYS, config.SITE_URL)
const hogInputsService = new HogInputsService(deps.integrationManager, recipientTokensService, deps.encryptedFields)
Expand Down Expand Up @@ -578,6 +606,7 @@ export function createCdpCoreServices(
recipientTokensService,
outputs,
emailService,
sandboxEmailSender,
messageAssetsService,
}
}
13 changes: 13 additions & 0 deletions nodejs/src/cdp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ export type CdpConfig = ClickhouseConfig & {
// Configuration set without open/click tracking (same delivery/bounce/complaint event destination).
// Empty means not provisioned: tracking-off sends fall back to the tracked set with a warning.
SES_UNTRACKED_CONFIGURATION_SET: string
WORKFLOWS_SANDBOX_SENDER_ENABLED: boolean
// An empty or invalid value blocks every sandbox send.
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: string
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: string
SES_SANDBOX_TENANT_NAME: string
SES_SANDBOX_CONFIGURATION_SET: string
SES_SANDBOX_FROM_ADDRESS: 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 @@ -344,6 +351,12 @@ export function getDefaultCdpConfig(): CdpConfig {
SES_REGION: isTestEnv() || isDevEnv() ? 'us-east-1' : '',
SES_TRACKED_CONFIGURATION_SET: 'posthog-messaging',
SES_UNTRACKED_CONFIGURATION_SET: '',
WORKFLOWS_SANDBOX_SENDER_ENABLED: false,
WORKFLOWS_SANDBOX_DAILY_TEAM_CAP: '25',
WORKFLOWS_SANDBOX_DAILY_RECIPIENT_CAP: '5',
SES_SANDBOX_TENANT_NAME: '',
SES_SANDBOX_CONFIGURATION_SET: '',
SES_SANDBOX_FROM_ADDRESS: '',
SES_ALLOWED_SNS_TOPIC_ARNS: '',
EMAIL_SUPPRESSION_TRANSIENT_BOUNCE_THRESHOLD: 5,

Expand Down
73 changes: 73 additions & 0 deletions nodejs/src/cdp/services/managers/organization-members.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres'
import { LazyLoader } from '~/common/utils/lazy-loader'

type OrganizationMemberSnapshot = {
emails: ReadonlySet<string>
expiresAt: number
}

const MAX_SNAPSHOT_AGE_MS = 60_000
const REFRESH_AGE_MS = MAX_SNAPSHOT_AGE_MS - 1_000

export class OrganizationMembersService {
private snapshots: LazyLoader<OrganizationMemberSnapshot>

constructor(private postgres: PostgresRouter) {
this.snapshots = new LazyLoader({
name: 'organization_email_members',
refreshAgeMs: REFRESH_AGE_MS,
refreshJitterMs: 0,
bufferMs: 0,
loader: async (organizationIds) => {
const expiresAt = Date.now() + MAX_SNAPSHOT_AGE_MS
const { rows } = await postgres.query<{ organization_id: string; email: string }>(
PostgresUse.COMMON_WRITE,
`SELECT membership.organization_id, users.email
FROM posthog_organizationmembership AS membership
JOIN posthog_user AS users ON users.id = membership.user_id
WHERE membership.organization_id = ANY($1::uuid[])
AND users.is_active = true AND users.is_email_verified = true`,
[organizationIds],
'fetch-organization-email-members'
)
const emailsByOrganization: Record<string, Set<string>> = Object.fromEntries(
organizationIds.map((id) => [id, new Set<string>()])
)
for (const row of rows) {
emailsByOrganization[row.organization_id].add(row.email.trim().toLowerCase())
}
return Object.fromEntries(
Object.entries(emailsByOrganization).map(([id, emails]) => [id, { emails, expiresAt }])
)
},
})
}

public async getBlockedRecipients(teamId: number, recipients: string[]): Promise<string[]> {
const expiresAt = Date.now() + MAX_SNAPSHOT_AGE_MS
const {
rows: [team],
} = await this.postgres.query<{ organization_id: string }>(
PostgresUse.COMMON_WRITE,
'SELECT organization_id FROM posthog_team WHERE id = $1',
[teamId],
'fetch-sandbox-team-organization'
)
if (!team) {
throw new Error('Could not identify the organization for this team')
}
let snapshot = await this.snapshots.get(team.organization_id)
if (snapshot && Date.now() >= snapshot.expiresAt && Date.now() < expiresAt) {
this.snapshots.markForRefresh(team.organization_id)
snapshot = await this.snapshots.get(team.organization_id)
}
if (!snapshot) {
throw new Error('Could not check organization members')
}
if (Date.now() >= Math.min(expiresAt, snapshot.expiresAt)) {
this.snapshots.markForRefresh(team.organization_id)
throw new Error('The organization member check expired')
}
return recipients.filter((email) => !snapshot.emails.has(email.trim().toLowerCase()))
}
}
40 changes: 40 additions & 0 deletions nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { PostgresRouter, PostgresUse } from '~/common/utils/db/postgres'
import { LazyLoader } from '~/common/utils/lazy-loader'
import { PubSub } from '~/common/utils/pubsub'

type SandboxSenderState = { sendingStatus: string }

export class SandboxSenderStateService {
private lazyLoader: LazyLoader<SandboxSenderState>

constructor(
private postgres: PostgresRouter,
pubSub: PubSub
) {
this.lazyLoader = new LazyLoader({
name: 'sandbox_sender_state',
refreshAgeMs: 30_000,
refreshJitterMs: 5_000,
loader: async (tenantNames) => await this.fetchStates(tenantNames),
})
pubSub.on<{ tenantName: string }>('reload-sandbox-sender-state', ({ tenantName }): void => {
this.lazyLoader.markForRefresh(tenantName)
})
}

public async isPaused(tenantName: string): Promise<boolean> {
// No row means never synced; SES stays the final word.
return (await this.lazyLoader.get(tenantName))?.sendingStatus === 'DISABLED'
}

private async fetchStates(tenantNames: string[]): Promise<Record<string, SandboxSenderState>> {
const { rows } = await this.postgres.query<{ tenant_name: string; sending_status: string }>(
// The reload message fires right after the commit, so a lagging replica would cache the old state again.
PostgresUse.COMMON_WRITE,
'SELECT tenant_name, sending_status FROM workflows_sandboxsendertenantstate WHERE tenant_name = ANY($1)',
[tenantNames],
'fetch-sandbox-sender-state'
)
return Object.fromEntries(rows.map((row) => [row.tenant_name, { sendingStatus: row.sending_status }]))
}
}
Loading