Repository navigation
feat(cdp): send transactional email from code on the sandbox sender - #132813
Conversation
Pure modules the sandbox email worker will call before it sends: a fresh primary read of membership, organization block and tenant mirror; tenant admission with freshness bounds; the two refill daily caps; recipient suppression and opt-out checks; and the sandbox message builder. Nothing calls them yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-outs A sandbox authorization read that passes its deadline now runs no SQL on a connection handed over late and destroys a connection still busy, so a stalled primary cannot hold pool slots. An opt-out lookup that fails blocks the send for a retry instead of letting it through. The caps test shares one Redis pool per file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add EmailSuppressionService.lookupSuppression, which throws when the team list or the global hard-bounce list cannot be read. isSuppressed keeps its signature, its caching and its fail-open result. The sandbox recipient check calls the strict lookup and retries on a read failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the email_sandbox workflow action, the emailsandbox queue and its worker mode. The hogflow queue routes a sandbox step to the sandbox worker, which stores its outcome on the step and hands the job back; the hogflow queue then finishes the step and runs the next action. Sending stays off: WORKFLOWS_SANDBOX_SENDER_ENABLED defaults to false and only the literal 'true' turns it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A step whose filter stopped matching, or that errored with on_error continue, moved on inside the sandbox worker and ran the next action there. The executor now runs one action per dequeue on the emailsandbox queue, and the sandbox worker hands any job that left the sandbox step back to the hogflow queue. Also adds email_sandbox to the integration kind type the wrong-kind test inserts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…licy' into workflows-sandbox/n3-sandbox-sender
The sandbox worker renders a sandbox email step, runs the delivery checks in order, and sends through the shared sandbox SES identity, tenant and configuration set with run tags. It retries with backoff (8 tries, 30 s to 15 min, jitter), records sent, failed and blocked as sandbox-only metrics and events, and never bills. The worker claims the account-wide SES bucket the email worker uses. Editor test sends render in cdp-api and run inline on the worker through a scoped, short-lived token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exempt the sandbox test route from the shared internal secret, since it verifies its own scoped token. Accept the unsaved workflow id in test sends, and report a stalled response body as pending. Bound the caps Valkey pool and each SES request with deadlines, and log SES errors before classifying them. Check stored preferences under the address the unsubscribe link carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check sandbox opt-outs under both the trimmed address and the rendered spelling its unsubscribe link carries, and claim the frequency cap once. - A retry after SES throttles keeps the daily cap tokens it already holds. - A render failure writes its reason to the run's logs, queued and in tests. - The provisioning read runs under the authorization read's deadline. - SES sends carry ph_sbx_parent_run for batch-triggered runs, with the tag names the sandbox feedback webhook reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A retry reuses the daily cap tokens only after SES throttled the send, which means SES refused it. A transient failure may follow an acceptance, so the next attempt claims again. - The held claim names a digest of the organization and stored recipient, so a re-render that changes the recipient claims that recipient's caps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A retryable refusal before SES, such as a failed membership read, now carries the cap claim a throttled attempt holds, so the next attempt does not spend the daily caps again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l alive - The editor test waits 30 seconds for the sandbox worker, longer than the worker's own reads, cap claims, and 10-second SES send, so a send SES accepted is not reported as pending. - The caps Valkey pool runs in non-fatal mode, so a stalled store retries its connections instead of leaking clients or exiting the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES accepts only letters, digits, underscores and dashes in a tag value, and the sandbox feedback webhook attributes the same set. A step whose action id falls outside it now fails with unsupported_action_id before any check or cap claim, instead of SES rejecting it after the caps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A denied or failed project claim no longer leaves the recipient bucket charged, and the reverse. RateLimiterService gains claimAllOrNothingAcross, one Lua script that reads every bucket before it writes any. Both sandbox keys carry the organization as a hash tag so they share a cluster slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claimAllOrNothingAcross checked each bucket's balance before writing, but a TTL that EXPIRE rejects only failed after the first bucket was charged, leaving it charged without an expiry. The script now validates every TTL in its read phase, so the claim charges nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Take master's fail-closed EmailSuppressionService.isSuppressed for the sandbox recipient check and drop the duplicate strict lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sandbox-sender Brings in master through N2. internal-api-auth keeps both scoped routes: master's manual_invocations and the sandbox test_send route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/n3-sandbox-sender
A TTL so large that Redis's expiry overflows passed the guard, so an earlier bucket was charged before EXPIRE failed. The read phase now caps the TTL as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The authorization deadline cannot cancel a pending pool acquisition, so during pool exhaustion every timed-out read left one behind. A read now refuses at once while 8 reads already wait on the same router, and a connection that arrives after the deadline goes straight back to the pool. The stall tests now run against real Postgres and check outcomes: a refused read, a pool that serves the next read, and no open transaction left behind. The caps test closes its own Valkey client, the recipient test drops a callback call count, and the suppression test checks that a retry on the same service reads the global list again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/n3-sandbox-sender
A retry that resolves to the sandbox sender after the first send went out on an own sender with cc, bcc, a reply-to or files was refused with sandbox_fields_unsupported instead of answering the accepted send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
The workers now write their results to a shared fake queue that keeps each handoff and releases a job only once it is due. A lost result write now fails the delivery and rerun tests, and the scheduled case checks that nothing is sent before its time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
A rerun revives a finished job only with overwriteExisting, a live job refuses the overwrite, and a plain insert of an existing id fails. The rerun adapter forwards the option, so dropping it fails the rerun tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new stamphog review started for this PR — the fresh verdict replaces this approval.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reschedule extends its key The reschedule path from master rewrote the stored key with the fingerprint as one string and no sender kind. A retry of a keyed sandbox send after a reschedule then conflicted instead of replaying, and a replay lost the sandbox sender kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 6adb9f2: 1 Should fix.
This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
| export type QueuedIdempotencyKey = { | ||
| hash: string | ||
| fingerprint: string | ||
| fingerprint: string | { current: string; previousShape: string } |
There was a problem hiding this comment.
P2 · Preserve the legacy fingerprint format in queued jobs
During a rolling deploy, an old pod rescheduling a keyed code send queued by a new pod passes this object to the old replayUntilDayAfter, which writes it verbatim as Redis fingerprint. Both versions then reject identical retries with 409 because they compare that object to strings. Keep the queued fingerprint a legacy string and store the new hash separately.
…can read A pod still on the previous release reads and rewrites the queued key's fingerprint as a string during a rolling deploy. The queued state now holds the same string fields as the stored key, so those pods keep matching the previous request shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ifferently Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approved.
Risky delivery and replay changes have owning-team authorship, strong familiarity, and targeted regression coverage. The older review concern is fixed: queued jobs retain a legacy string fingerprint and store the newer hash separately; no substantive concerns remain.
- Author wrote 88% of the modified lines and has 34 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 404L, 17F substantive, 1336L/22F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (1336L, 22F, single-area, feat) |
| stamphog 2.8.0 | .stamphog/policy.yml @ af5172f · reviewed head af5172f |
|
/trunk merge |
Brings in the Node sandbox email worker from #132813, which this branch's code sends hand to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploy status
This table shows the |
Problem
Changes
email_sandboxsender goes out through the sandbox path: member checks, caps, retries, no billing.sender_kind: "email_sandbox".flowchart LR subgraph Before A1[code send] --> B1[email queue] --> C1[email service: refused] end subgraph After A2[code send] --> B2[email queue] --> D2[sandbox queue] --> E2[sandbox SES] end classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; class A1,A2 phYellow; class B1,B2,D2 phBlue; class C1,E2 phRed;This PR builds the part outlined in blue: code sends that run through the sandbox worker.
Caution
🔴 Merge risk: high · one-way door at merge: keyed code sends store both fingerprint shapes, and later versions must keep replaying them. Delivered sandbox mail stays sent.
sender_kind, or answers a conflict when Django resolved another sender. Neither sends again. Tests cover this; no live run does.WORKFLOWS_SANDBOX_SENDER_ENABLEDfor the sandbox path; the fingerprint change is live on deploy, fix forward.Rollout
conflictincdp_hogflow_idempotency_operations_total; no alert pages on it.How did you test this code?
Test rationale:
sandbox-code-send.test.tsdrives the endpoint and the real workers through a fake Cyclotron queue into a fake SES; each case failed first. The queue keeps each handoff, releases a job only once it is due, and follows Cyclotron's overwrite rules for reruns. Onecdp-api.serial.test.tsrow moved from conflict to replay.sandbox-code-send.test.ts, so both are reused there.scheduled, sends nothing before its time, then delivers to the member.Proof evidence
sending_paused; delivered sends read sent.sandbox-code-send.test.ts, from lifecycle rows the workers wrote, stored in ClickHouse.Release status
Docs update
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5
Skills:
/writing-tests,/writing-pr-descriptions,/qa-swarm,/pr-description,/merge-riskMore context for AI agents
function_emailstep withstate.senderKindto the sandbox handler.fingerprint; drop the previous shape in a later deploy.Involvement: review rounds: an eight-lens panel, a fixes round, a third round and fifteen final reads; live-run, prod-shape, cross-version and shadow-diff proof captured.
hogflow-executor.service.ts, the queue predicate in the sandbox consumer, and the rerun rebuild inrerun-paginator.service.ts.🤖 Generated with Claude Code