Skip to content

fix(workflows): guard sandbox sends on empty settings and SES throttle - #112071

Closed
Silthus wants to merge 45 commits into
PostHog:masterfrom
Silthus:fix/workflows-sandbox-send-guards
Closed

Silthus wants to merge 45 commits into
PostHog:masterfrom
Silthus:fix/workflows-sandbox-send-guards

Conversation

@Silthus

@Silthus Silthus commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Two sandbox sender sends misbehave when the deployment or SES is not in the expected state:

  • With the sandbox switch on but an empty sandbox tenant, configuration set or From address setting, a sandbox send reaches SES and fails with a provider error instead of a clear skip.
  • When SES throttles a sandbox send, the retry claims the daily cap tokens a second time. Under throttling a project reaches its daily sandbox cap with fewer emails than the cap allows.

Stacked on #111904. This cumulative fork PR targets master. The review delta is d9ef8d19c62...fix/workflows-sandbox-send-guards.

Note

🔵 Merge risk: low (merge-risk rubric)

  • The throttle path now writes to the shared rate limiter Valkey through a new Lua script. A wrong credit would let a project send past its daily sandbox cap.
  • Only sandbox sends call the new method, sandbox sending stays off behind WORKFLOWS_SANDBOX_SENDER_ENABLED, the existing claim scripts are unchanged, and the new paths have focused tests against real Valkey.

Changes

Kind Files Lines
Production 3 +109 −12
Tests 2 +102 −38
Total 5 +211 −50
  • A sandbox send with any empty route setting now skips before SES. The run log shows the same line as a switched-off sender: "the sandbox sender is unavailable right now". The analytics reason stays switch_off, because Django already treats empty sandbox settings as not provisioned.
  • The worker logs which route settings are empty once, at startup, so an operator sees the cause without a per-send flood.
  • A sandbox send that SES throttles now gives its daily cap claim back before it is rescheduled. The retry claims again, so the send spends its tokens once.
  • RateLimiterService.returnClaim adds the tokens back to each bucket that still exists. Reads cap the pool at capacity, so a return can never raise the limit above it.
  • ioredis resends an unanswered command after it reconnects. Each grant carries a claim ID, and the return script sets a marker first, so a replayed return changes nothing.
  • Each return also carries a deadline a few minutes out, checked against Valkey TIME. A replay that arrives after the marker expires is dropped.
  • Own-domain sends are untouched by both changes.

Why return the claim instead of carrying it with the rescheduled send: carrying it needs a marker on the queued email parameters, which the retry must trust. Returning keeps the cap state in Valkey only, and the retry re-runs every sandbox check (pause gate, membership, caps) as before. A return that fails leaves the tokens spent, which only makes the cap stricter.

How did you test this code?

The ticket seams are EmailService.executeSendEmail in email.service.test.ts, with real Postgres fixtures, the real Valkey rate limiter, and SES mocked at sesV2Client.send. The replay guard has its own seam on RateLimiterService against real Redis.

  • Empty settings: the existing switched-off test became a table. New rows set each route setting empty with the switch on. Each row asserts the skip, no SES call, the switch_off event, and that an own-domain send on the same service still goes out. The three new rows failed before the fix.
  • Throttle: a new daily caps case, live and test. SES throttles a two-recipient send once, the retry goes out, and the next send hits the project cap of 2. It catches a double claim (the retry is skipped) and a missing claim (the next send goes out). It failed before the fix.
  • Replay: two returnClaim cases in rate-limiter.service.test.ts. A return sent twice credits once, and a return that reaches Valkey after its deadline credits nothing. Each failed before its fix.

Verify, in nodejs:

pnpm exec jest src/cdp/services/messaging/email.service.test.ts src/cdp/services/rate-limiter/rate-limiter.service.test.ts -t 'skips sandbox sends only when|spends the daily caps once|returnClaim'

Expected at b6e1ba75e0f: Tests: 268 skipped, 11 passed, 279 total (7 empty-settings rows, 2 throttle cases, 2 return cases).

Local gate at b6e1ba75e0f: email.service.test.ts, email-tracking.service.test.ts, the rate limiter suites including the Valkey cluster test, eslint, oxlint, prettier, security semgrep on the touched files, and strict preflight. Node-wide TypeScript reports only the unbuilt @posthog/replay-anonymizer workspace package, which this PR does not touch.

Not checked: a real SES throttle response against the sandbox tenant, or a real ioredis reconnect replay. Fork CI waits for maintainer approval, so no CI run exists yet.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Both changes only affect sandbox sends, which exist only behind workflows-sandbox-sender and send only while WORKFLOWS_SANDBOX_SENDER_ENABLED is on.

Automatic notifications

  • Publish to changelog?

Docs update

None. No new setting. The empty-settings behavior matches the existing rule that empty sandbox settings mean "not provisioned".

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (wrote all code in this PR). Reviews by GPT-6.1 Sol (Codex) and Claude Opus 5.5.

  • Built test-first: each case failed red before its fix. Skills invoked: tdd, /writing-tests, /writing-pr-descriptions, /reviewing-with-coderabbit, qa-swarm, merge-risk.
  • CodeRabbit CLI pass: skipped, the CLI is signed out and no person was available to sign in. The CodeRabbit bot reviewed the PR.
  • QA panel, round 1: GPT-6.1 Sol on correctness, risk and design, Claude Opus 5.5 on fresh eyes. The replayed-return finding was fixed. A failed return leaving tokens spent was rejected, because it only tightens the cap and skipping would drop a throttled email. Reporting empty settings as switch_off was kept, as above.
  • Round 2, risk lens on the fix: a replay after the marker expires. Fixed with the return deadline. CodeRabbit raised the same point and got a reply.
  • CodeRabbit also flagged that own-domain throttle retries do not restore the email parameters. That path is the same on master and outside this PR, so it is a follow-up.
  • No duplicate: an open PR search for the sandbox sender found only the earlier layers of this stack.
  • Public artifact: everything here comes from the public tickets and this repository. Fixture addresses use example.com.

🤖 Generated with Claude Code

Silthus and others added 30 commits October 3, 2026 09:48
…lusions

Unfinished: the red/green proof runs and the OpenAPI regeneration were
interrupted by a devbox rebalance. Tests are written but not yet run green;
generated OpenAPI output is not regenerated. Continue test-first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ender endpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…equire write scope

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…quest_access comment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…haracters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A long blocked CC/BCC list wrote one log row per address. Rows now hold
as many comma-joined addresses as fit under the untruncated log length,
so the row count follows total characters. Every address stays visible.

The case-insensitive membership matrix also stores member emails with
mixed case, which proves the stored-side case fold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dbox send

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r day

Every sandbox send, test sends included, claims one token per recipient copy
from a daily project bucket and one per distinct address from a daily
per-address bucket, all or nothing in one Valkey call. A denied claim skips
the send and spends nothing. A limiter error, a missing limiter, or an unset
or invalid cap setting skips every sandbox send.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…final recipient check

A cap setting with trailing text no longer reads as its numeric prefix, and a
send the final membership recheck blocks no longer spends daily tokens. The
project cap message also reads correctly for a send with more copies than the
whole daily cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test sends run inline in cdp-api, whose deps had no SES Valkey pool, so every
sandbox test send failed closed. cdp-api now gets the same pool as the email
workers and claims the same daily caps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Silthus and others added 4 commits October 5, 2026 12:32
…he clock in the tier test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…subtraction

Cover the signed-code requirement on the sandbox delivered event, the per-day
floor at zero for sandbox sends, and that bounces and complaints of sandbox
sends still count. Move the capture property mapping into a switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e empty

A sandbox send with an empty sandbox tenant name, configuration set or
From address setting went to SES and failed with a provider error. It
now skips before SES with the same message as the switched-off sender,
and the worker logs which settings are empty once at startup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…send

A sandbox send claims its daily cap tokens right before the SES call.
When SES throttled that call, the send was rescheduled and the retry
claimed the tokens again, so a project under SES throttling reached its
daily sandbox cap with fewer emails than the cap allows. The send now
gives its claim back before it is rescheduled, so it spends its tokens
once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 5, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@Silthus Silthus changed the title fix(workflows): guard sandbox sends on empty route settings and throttling fix(workflows): guard sandbox sends on empty settings and SES throttle Oct 5, 2026
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The change adds sandbox email sender provisioning and management, mirrors sandbox SES tenant state to workers, and adds recipient eligibility checks, daily caps, pause checks, and sandbox SES routing to CDP email delivery. It also captures trusted sandbox delivery events and excludes sandbox sends from tier-qualifying send counts.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to b706c

A delayed claim-return replay could allow sandbox sends beyond their daily cap. Extend replay protection to the bucket’s active lifetime before merging, unless that bounded risk is explicitly accepted.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to b706c

The sandbox path preserves project ownership and restricts recipients, while ordinary throttling retries reacquire sending limits. However, delayed quota refunds can outlive their duplicate protection, potentially weakening those limits during recovery. Production permission scope and long-delay recovery behavior remain incompletely verified.

Retained concerns

  • Medium · reliability · inferred: Refund duplicate protection expires after one hour without a demonstrated matching transport-replay bound. A successfully applied refund whose response is lost can be applied again after that marker expires; following intervening sends, clamping available tokens to capacity does not prevent replenishing allowance twice. Returns also identify buckets only by key, so an old return cannot distinguish a recreated bucket. This can weaken sandbox abuse limits during delayed recovery, although production reachability of those long-delay states remains unverified.
Security review details

Security Blast Radius

  • inferred — A workflow author who can invoke sandbox sending reaches a centrally owned sender and shared SES tenant, but inspected application controls restrict integration selection to the invocation project and recipients to active, verified members of its organization. Quota recovery errors affect project/recipient allowance and can indirectly burden the shared sender's reputation; cross-project delivery or credential escalation was not established.

Security Findings and Attack Paths

  • inferred — The supported concern is a delayed-recovery path: a refund executes, its response is lost, duplicate protection expires, and transport replay restores tokens again after intervening consumption. This is not a demonstrated malicious-request path; grants and claim identifiers are generated internally and are not carried into ordinary queue retries.

Trust Boundaries and Controls

  • observed — Workflow-controlled To, CC, and BCC recipients undergo organization eligibility checks, including immediately before SES submission. Missing membership data or lookup failures block recipients. Sandbox tenant, configuration set, and From identity are centrally selected rather than supplied by workflow parameters.
  • observed — Managed sandbox integrations reject user-managed mutation operations, and native email integration creation rejects the configured sandbox domain and its subdomains. These controls preserve ownership of the shared sender identity.

Resilience and Maintainability Implications

  • observed — Atomic quota claims prevent partial admission across project and recipient buckets. Immediate duplicate refunds are suppressed, and absent expired buckets are skipped. These controls contain normal concurrency and short reconnects, but do not establish safety after marker expiry or bucket recreation.

Hardening Proposals

  • proposed — Align refund deduplication retention with an enforced maximum recovery lifetime, and bind refunds to the bucket generation they debited. Validate response-loss replay after marker expiry and stale returns after bucket recreation before treating quota restoration as an at-most-once contract.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description explains the problem, user-visible changes, test rationale and evidence, release status, documentation impact, and agent context. The release-status section uses checkboxes and include…
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/workflows-sandbox-send-guards
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
nodejs/src/cdp/services/messaging/email.service.ts-812-814 (1)

812-814: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore email queue parameters for every SES throttle retry.

When SES throttles a non-sandbox send, the retry is scheduled without queueParameters. On dequeue, the executor can resume the Hog VM instead of retrying the email, dropping the send. Restore params for every throttled send:

Suggested fix
         if (throttled) {
-            if (isSandbox) {
-                result.invocation.queueParameters = params
-            }
+            result.invocation.queueParameters = params

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: PostHog/posthog/.coderabbit.yaml
  • Review profile: QUIET
  • Plan: Enterprise
  • Run ID: b627d3f0-6b80-4af7-a200-69c0c91c4f95
📥 Commits

Reviewing files that changed from the base of the PR and between 9295706 and 949b105.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • products/integrations/frontend/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (43)
  • .github/scripts/check-idor-model-coverage.py
  • docs/internal/workflows-email-sending-tiers.md
  • nodejs/package.json
  • nodejs/src/cdp/cdp-api.ts
  • nodejs/src/cdp/cdp-services.ts
  • nodejs/src/cdp/config.ts
  • nodejs/src/cdp/services/managers/organization-members.service.ts
  • nodejs/src/cdp/services/managers/sandbox-sender-state.service.ts
  • nodejs/src/cdp/services/messaging/email-tracking.service.test.ts
  • nodejs/src/cdp/services/messaging/email-tracking.service.ts
  • nodejs/src/cdp/services/messaging/email.service.test.ts
  • nodejs/src/cdp/services/messaging/email.service.ts
  • nodejs/src/cdp/services/messaging/helpers/ses.ts
  • nodejs/src/cdp/services/messaging/sandbox-email-sender.ts
  • nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts
  • nodejs/src/cdp/types.ts
  • nodejs/src/cdp/utils.ts
  • nodejs/src/server.ts
  • posthog/api/integration.py
  • posthog/models/integration/__init__.py
  • posthog/models/integration/email.py
  • posthog/plugins/plugin_server_api.py
  • posthog/settings/ses.py
  • products/integrations/mcp/tools.yaml
  • products/workflows/backend/facade/api.py
  • products/workflows/backend/facade/contracts.py
  • products/workflows/backend/migrations/0030_sandboxsendertenantstate.py
  • products/workflows/backend/migrations/max_migration.txt
  • products/workflows/backend/models/__init__.py
  • products/workflows/backend/models/sandbox_sender_tenant_state.py
  • products/workflows/backend/presentation/views/hog_flow.py
  • products/workflows/backend/providers/ses.py
  • products/workflows/backend/services/email_sending_tier.py
  • products/workflows/backend/services/sandbox_sender.py
  • products/workflows/backend/services/ses_tenant_events.py
  • products/workflows/backend/services/ses_tenant_state.py
  • products/workflows/backend/tasks/ses_tenant_state.py
  • products/workflows/backend/test/test_email_sending_tier.py
  • products/workflows/backend/test/test_sandbox_sender_lifecycle.py
  • products/workflows/backend/test/test_ses_tenant_events.py
  • products/workflows/backend/test/test_ses_tenant_state.py
  • products/workflows/backend/tests/api/test_email_reputation.py
  • products/workflows/backend/tests/api/test_sandbox_sender.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 6 remain after this review.

ioredis resends a command whose reply it lost when it reconnects. A
replayed claim return would credit the daily cap buckets twice and let
sends past the cap. Each grant now carries a claim ID, and the return
script sets a short-lived marker for it first, so a second return of the
same claim changes nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: PostHog/posthog/.coderabbit.yaml
  • Review profile: QUIET
  • Plan: Enterprise
  • Run ID: cd387d67-c3a5-4e32-a1c7-9eebcabeb0c6
📥 Commits

Reviewing files that changed from the base of the PR and between 949b105 and b706c59.

📒 Files selected for processing (3)
  • nodejs/src/cdp/services/messaging/sandbox-email-sender.ts
  • nodejs/src/cdp/services/rate-limiter/rate-limiter.service.test.ts
  • nodejs/src/cdp/services/rate-limiter/rate-limiter.service.ts

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 0 remain after this review.

ioredis keeps an unanswered command and resends it after it reconnects,
which can be long after the return marker expired. Each return now
carries a deadline a few minutes out, and the return script ignores it
after that, so a late replay cannot credit the buckets again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Silthus

Silthus commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

@coderabbitai on restoring queueParameters for every SES throttle (email.service.ts 812-814): no change in this PR. The own-domain throttle path is the same on master, and this PR only covers sandbox sends, so it goes to a follow-up.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline nodejs/src/cdp/services/messaging/email.service.ts --match 'executeSendEmail|sendEmail' --view expanded
rg -n -C 8 'queueParameters|Throttling|returnDailyCaps' nodejs/src/cdp/services/messaging/email.service.ts

Length of output: 8321


@Silthus The follow-up should restore queueParameters for own-domain SES throttles and add a regression test that confirms the retry re-enters the email send path. Would you like me to open a GitHub issue?


✏️ Learnings added
Learnt from: Silthus
URL: https://github.com/PostHog/posthog/pull/112071

Timestamp: 2026-10-05T19:05:45.505Z
Learning: In PostHog/posthog PR #112071, restoring queueParameters for own-domain SES throttles in nodejs/src/cdp/services/messaging/email.service.ts is outside the sandbox-send scope. The author states that the own-domain throttle behavior already exists on master and defers the fix to a follow-up.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Silthus added a commit to Silthus/posthog that referenced this pull request Oct 12, 2026
Keep master UTM tagging and throttle retries. Append the organization footer to delivered and stored content, disable tracking for sandbox sends, and fold in the empty-route guard from PostHog#112071.

Co-Authored-By: Codex GPT 6.1 Sol <noreply@openai.com>
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 12, 2026
Keep master UTM tagging and throttle retries. Append the organization footer to delivered and stored content, disable tracking for sandbox sends, and fold in the empty-route guard from PostHog#112071.

Co-Authored-By: Codex GPT 6.1 Sol <noreply@openai.com>
@scheduled-actions-posthog

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, please remove the stale label – otherwise this will be closed in another week. If you want to permanently keep it open, use the waiting label.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant