Skip to content

feat(cdp): send transactional email from code on the sandbox sender - #132813

Merged
trunk-io[bot] merged 159 commits into
masterfrom
workflows-sandbox/n5-code-send-sandbox
Nov 13, 2026
Merged

trunk-io[bot] merged 159 commits into
masterfrom
workflows-sandbox/n5-code-send-sandbox

Conversation

@Silthus

@Silthus Silthus commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Changes

  • A code send from the team's email_sandbox sender goes out through the sandbox path: member checks, caps, retries, no billing.
  • The 202 and keyed replays carry sender_kind: "email_sandbox".
  • Refusals: non-member 422; cc, bcc, reply-to, custom from or file attachments 400; blocked, paused or foreign sandbox sender 403.
  • A keyed retry without a from address replays after Django resolved another sender.
  • A keyed retry replays across the deploy: keys store both fingerprint shapes.
  • A keyed send moved by a reschedule keeps both shapes and its sender, so a retry still replays it on either version.
  • A new sandbox sender missing from the replica is read from the primary.
  • Sandbox code sends carry no open or click tracking.
  • A project suspension or workflow email pause set after the 202 stops the send at delivery, as for an ordinary code send.
  • The message status moves to sent, or to failed with the reason.
  • A rerun of a failed or canceled sandbox code send goes out on the sandbox sender again. Mail SES already accepted is not sent twice. Reruns of workflow sandbox email steps behave the same.
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;
Loading

Sandbox sender isolation, C1 highlighted

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.

  • Keyed code sends without a from address, on any sender, in both regions: keys store both fingerprint shapes. The shadow diff and the cross-version proof show replies and replays match master's. No switch-off covers this path.
  • A keyed retry after the sender changed now gets the earlier send's replay; master answered a conflict. Neither sends again, so no mail is newly lost.
  • Sandbox code sends and their reruns, and reruns of workflow sandbox email steps, in both regions: a wrong sender, a lost outcome or a wrong reset sends twice or not at all. Tests cover reruns; no live run does.
  • Code sends with a from address, on any sender, in both regions: the endpoint reads the sender before the replay check. A cache miss reads the primary database, and a failed read answers an error instead of the replay.
  • Keyed sends moved by a reschedule, in both regions: the rewritten key keeps both shapes and the sandbox sender kind. While a pod on the previous release handles a reschedule, it keeps only the older shape: a retry still replays, but without sender_kind, or answers a conflict when Django resolved another sender. Neither sends again. Tests cover this; no live run does.
  • Proved at 394a392: own-sender code sends match master's replies, jobs and deliveries (shadow diff), and keyed retries replay across versions (cross-version).
  • Tier: critical: hog flow delivery and the mail sending flow in CDP.
  • Switch-off: WORKFLOWS_SANDBOX_SENDER_ENABLED for the sandbox path; the fingerprint change is live on deploy, fix forward.

Rollout

  • Stages: Node live in both regions before the Django change that sends sandbox sender ids.
  • Watch: reserve conflict in cdp_hogflow_idempotency_operations_total; no alert pages on it.

How did you test this code?

Test rationale: sandbox-code-send.test.ts drives 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. One cdp-api.serial.test.ts row moved from conflict to replay.

  • Proofs: live run at 2153c3a, prod shape at 77d3607. Since then only the rerun rebuild, master merges and tests changed, which neither run reaches. Cross-version and shadow diff ran again at 394a392 against master; 378a61c changes only sandbox-code-send.test.ts, so both are reused there.
  • Master's attachments and scheduled sends came in through merges. A sandbox code send with files is refused, because the sandbox sender cannot attach them. Both fingerprint shapes carry the scheduled time.
  • A scheduled sandbox send answers scheduled, sends nothing before its time, then delivers to the member.
  • A keyed retry gets its replay before sandbox-only fields are refused, so a retry that now resolves to the sandbox sender does not hide a send already accepted on an own sender.
Proof evidence
  • Live run: a member send through the sandbox path reached a local SES stand-in; a non-member got 422; a keyed retry replayed with one message sent; a send paused after its 202 sent nothing and its status reads failed, sending_paused; delivered sends read sent.
  • Prod shape: every stored email sender in both regions is an own sender, and no queued job carries a sandbox sender. The split of stored idempotency keys by from shape is not measurable, a recorded gap; the cross-version run covers both shapes.
  • Cross-version at 394a392, against master's merge base: keyed retries replay in both directions, and own-sender sends deliver. A sandbox send run by old workers fails without sending, so the deploy order holds.
  • Shadow diff at 394a392, against master's merge base: own-sender code sends give the same replies, queued jobs and SES deliveries, with the sandbox switch off and on.
  • Not checked: a live send against real SES, and a live rerun. Reruns of code sends and workflow steps run only in sandbox-code-send.test.ts, from lifecycle rows the workers wrote, stored in ClickHouse.

Release status

  • No feature flag controls this change

Docs update

  • None.

🤖 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-risk

More context for AI agents
  • Session: T3 Code implementation lane; Claude Code with Jest, tsc and the Fleet heavy runner.
  • Decisions: the endpoint checks the sender's team and kind; the worker routes a function_email step with state.senderKind to the sandbox handler.
  • Decisions: a failed membership or tenant read lets the send queue; the sandbox worker rechecks and retries.
  • Decisions: the workflow email quota check still applies to sandbox sends.
  • Follow-ups: retire the previous fingerprint shape. One key lifetime (24 hours) after this is live in both regions, write the current shape into fingerprint; drop the previous shape in a later deploy.
Question Answer Who Where
Owed proofs Live run, cross-version, prod shape and shadow diff 👤 comment
Schema-check CI failure Base failure, accepted CI gap 👤 comment
Suspension and email pause Checked at delivery, as for ordinary code sends 👤 comment
Final read 6 findings Rejected: master's tests, outside this diff 👤 comment
Prod-shape gap Accepted: the key split by from shape is unmeasurable 👍 comment
Rerun of a sandbox code send Keep the sender and an accepted outcome; retry a failed one 🤖 comment
Keyed retries during the deploy Store both fingerprint shapes 🤖 comment
Sender missing from the replica Read the primary on a miss 🤖 comment
Engagement tracking None on sandbox sends, documented 🤖 comment
Message status Sent, or failed with the reason 🤖 comment

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.

  • Read closely: handler dispatch in hogflow-executor.service.ts, the queue predicate in the sandbox consumer, and the rerun rebuild in rerun-paginator.service.ts.

🤖 Generated with Claude Code

Silthus and others added 30 commits November 9, 2026 11:23
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>
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>
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>
stamphog[bot]

This comment was marked as outdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 13:02

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

stamphog[bot]

This comment was marked as outdated.

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>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 16:09

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>
stamphog[bot]

This comment was marked as outdated.

@stamphog
stamphog Bot dismissed their stale review November 12, 2026 16:15

A new stamphog review started for this PR — the fresh verdict replaces this approval.

stamphog[bot]

This comment was marked as outdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 12, 2026 18:05

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

stamphog[bot]

This comment was marked as outdated.

Silthus and others added 2 commits November 13, 2026 06:34
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>
@stamphog
stamphog Bot dismissed their stale review November 13, 2026 05:41

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

@posthog posthog 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.

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.

Comment thread nodejs/src/cdp/types.ts Outdated
export type QueuedIdempotencyKey = {
hash: string
fingerprint: string
fingerprint: string | { current: string; previousShape: string }

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.

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.

Silthus and others added 2 commits November 13, 2026 06:51
…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>

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

@Silthus

Silthus commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io
trunk-io Bot merged commit 140d498 into master Nov 13, 2026
240 checks passed
@trunk-io
trunk-io Bot deleted the workflows-sandbox/n5-code-send-sandbox branch November 13, 2026 06:45
Silthus added a commit that referenced this pull request Nov 13, 2026
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>
@deployment-status-posthog

deployment-status-posthog Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-13 07:07 UTC Run
prod-us ✅ Deployed 2026-11-13 07:28 UTC Run
prod-eu ✅ Deployed 2026-11-13 07:25 UTC Run

This table shows the posthog release only. Hogstation shows every service that this PR reaches, Kargo services included.

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

Labels

run-ci-backend Force ci-backend's full test matrices to run even on a draft PR run-ci-frontend Force ci-storybook's full visual-regression matrix to run even on a draft PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant