Skip to content

feat(cdp): deliver a code send's content on its own transactional email route - #565

Closed
Silthus wants to merge 2 commits into
masterfrom
email-v2/01-cdp-code-send
Closed

Silthus wants to merge 2 commits into
masterfrom
email-v2/01-cdp-code-send

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Part of the code-first rebuild of the email send stack; replaces parts of PostHog#121272 (part 2, the cdp-api half).

Problem

  • An app that renders its own emails, such as a password reset built with React Email, can't send them through PostHog. The send API only sends templates stored in PostHog.
  • The Django half, feat(messaging): send an email with inline html from code #566, accepts the caller's content. cdp-api has to deliver exactly that content, or deliver nothing.
  • In the earlier stack, the content rode on the template route as an extra send field. A cdp-api without the change dropped the field and answered 202. It then sent nothing, or the stored template under the same key.
  • Map: Silthus/posthog#461. Contract: #120921.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org, once the SDKs send code-rendered email. In scope: Messaging, email API sends. This PR only adds the delivery path; the route that opens it is behind the workflows-email-from-code flag, which gates everything the code-first send adds.

Merge Danger

Warning

🔴 Merge risk: high · two-way door once the email queue drains: reverting removes the route, and Django then answers 503 for code sends

  • Behavior changes on the CDP email delivery path, where a wrong send is silent harm, so the grade is high even behind the flag.
  • The worker now lays a queued run's state.send over the email step: content, sender, reply-to and capture choice. A wrong overlay sends the wrong content for a code send.
  • Rollout order: turn workflows-email-from-code on only after cdp-api and both CDP workers (hogflow, email) run this code. A worker without it ignores state.send. The mixed-rollout live case shows a 202 that sends the stored template under the same key, or nothing for a new key.
  • Revert: turn the flag off and let the email queue drain before rolling the workers back, for the same reason.
  • Merge order: feat(messaging): send an email with inline html from code #566 (Django) sits on master in its own chain and may land before or after this PR. Until cdp-api runs this PR, a code send answers 503 and sends nothing.
  • Only the new route sets state.send. A template send takes the unchanged path, and the template route ignores a send field.
  • The capture rule changes from "the send's choice, else the team switch" to "both must allow it". A send without a choice reads the team switch as before.

Changes

Kind Files Lines
Production 14 +370 −171
Tests 8 +538 −198

Mechanical: the comments this slice added are gone (house rule); their reasons became names such as signedCaptureChoice and EVERY_TRACKING_DEPLOYMENT_PARSES_CAPTURE_CHOICE.

How did you test this code?

Test seams: the cdp-api routes through supertest (cdp-api.serial.test.ts), the auth middleware (internal-api-auth.test.ts), the executor's email hand-off (hogflow-executor.service.test.ts), and the capture decision in email.service and email-tracking.service.

Test rationale:

  • stamps %s on the queued run on the new route, and queues a template send without an accepted send, even when the request carries one: content leaking onto the template route, or not reaching the run.
  • refuses a send on the accepted send route without the send: the new route silently sending a stored template.
  • authenticates every send on %s: the new route skipping the scoped JWT check.
  • The middleware rows for both transactional email routes: the new route answering 401 in any deploy with an internal secret. Live run found this one, see below.
  • The capture tables: a send's true overriding a team that turned capture off.
  • rejects the key for a request with changed content and ... with the other route: a reused key replaying a different email.
  • sends on transactional_email under a workflow created after the cached read missed it, both routes: a first send under a new key refused by a lagging replica.
  • reads the primary once for concurrent jobs of a workflow the database no longer has: a deleted workflow's backlog sending one primary query per job.
  • replays a keyed send whose workflow stopped being active: the test stores status = 'draft' and runs the reload, so a manager that stops reading the stored status fails it.

Proofs at 920e7c9:

  • Lane gate: typescript:check, eslint, oxlint and prettier pass. The nine changed test files pass: 289 parallel and 163 serial tests.
  • Shadow diff: those nine files at HEAD, run against base 43333f8, move 39 outcomes, each one named by the goal (the code-send route, the overlay, the capture choice, the primary read, rerun and snapshot of send). No test that passes on base fails on HEAD. Evidence is saved with fleet proof.
  • Live run and cross-version: owed at this head; lane dj3 of map Map: send code-managed email templates with one PostHog call #461 runs them. Not run here.
  • Prod shape: a gap until read-only production access exists on 2026-11-02.
Proofs at the pre-rebase head 4182790
  • Live run at that head, with Django from feat(messaging): send an email with inline html from code #566 at c4095dd, a curl into maildev, saved with fleet proof. The goal curl delivers <p>Hello</p> from [email protected]. Cases S1 to S5 and a template control pass.
  • Cross-version, Django at d38fb25 and cdp-api at master: three code sends answer 503 and maildev gets nothing. A template send still delivers its stored template.
  • Cross-version, Django at master and cdp-api at this head: a template send delivers the stored template, unchanged.
  • Mixed rollout, cdp-api at d91ec2d and both workers at master: a code send under a stored template's key answers 202 and delivers the stored template; a new key delivers nothing. This is the rollout order in the merge-risk callout.

Not run locally: the whole Node suite. CI runs the rest. The fork has no Depot runners, so the Depot jobs never start there.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

None. The docs land with the Django route.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

  • Built as lane srv2 of map Map: send code-managed email templates with one PostHog call #461, restacked onto master by lane node3 on 2026-10-31 (modernize-pr, stacking-prs). The node diff of the reviewed stack (fork feat(cdp): deliver a send django accepted as given #554) moved over as is. Fix A, the own route, is new.
  • Skills: tdd, pr-description, writing-pr-descriptions, writing-tests, writing-code-comments, qa-swarm.
  • The live run found the missing middleware exemption. Jest runs without an internal secret, so no test saw it.
  • QA swarm round 1 (GPT-6.1 Sol review, design and team lanes; Opus 5.5 fresh eyes) found the fingerprint gap, the replica-lag refusal, a test that mocked a private collaborator, and three overstated description lines. All fixed here.
  • QA swarm on 2026-11-01: a full two-family panel (GPT-6.1 Sol on correctness, risk, design, anti-overengineering, systems, sweep and the Michael lens; Opus 5.5 fresh eyes) found 7 items. Fixed: one shared primary read for a deleted workflow's jobs, and a replay test that stubbed the manager. Rejected with reasons: 4. Owed: shadow diff, now run. A narrow round on the fixes found only stale description lines, fixed here.
  • An earlier QA swarm round 2 (review, design and fresh eyes) found that the workers still dropped a flow the replica missed, a route mode the schemas already carried, and a test that reached into a private collaborator. All fixed here. A final read (GPT-6.1 Sol) of those fixes found only missing proof at the head, which the runs above close.
Question Answer Who Where
How does a code send fail on an old cdp-api? Own route, so an old cdp-api answers 404 and Django answers 503 with nothing sent 👤 Human map #461, lane brief of 2026-10-25
What does capture_engagement do against the team switch? A send can turn capture off, never on; default true 👤 Human map #461, lane brief of 2026-10-25
Which flag gates the code-first send? A new workflows-email-from-code, per organization; master's surfaces keep workflows-transactional-email-api 👤 Human map #461, 2026-10-25
May the code carry comments? No; names carry the reasons 👤 Human map #461, 2026-10-25
When does a capture-off send also stop its opens and clicks? When the flag-on gate flips EVERY_TRACKING_DEPLOYMENT_PARSES_CAPTURE_CHOICE, after this PR rolls out everywhere; minting v3 codes is a condition for turning workflows-email-from-code on 🤖 Agent map #461 conductor, 2026-10-25
Carry the capture choice by leaving distinctId out instead of tracking code v3? Not in this PR; v3 with minting off stays, the simpler shape goes to the conductor as a follow-up before flag-on 🤖 Agent qa-swarm round 2, 2026-11-01
Should SES feedback read the primary on a cache miss too? No, follow-up; tracking reads the replica on master too, and this PR adds the fallback only where a miss drops the send 🤖 Agent qa-swarm round 2, 2026-11-01
Keep the looser to pattern after the rebase? No; master's PostHog#125908 refuses a malformed to as recipient_unreachable, and that holds 🤖 Agent map #461 lane node3, restack of 2026-10-31

Involvement: one brief from Michael with decisions; QA swarm round 1 (4 lanes), round 2 (3 lanes), a final read, a full panel (8 lanes) and a narrow round (2 lanes); live, cross-version and mixed-rollout proof captured.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🤖 CI report

✅ Comment density — 1% of added code lines are comments (12 of 883)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/cdp/cdp-api.ts 11 173
nodejs/src/cdp/cdp-api.serial.test.ts 1 219

This check does not block merging. It updates on every push and clears when the share drops.

…il route

A code send posts its accepted content to transactional_email/send. The route
runs the same reserve, replay, refusal and enqueue steps as a template send.
The accepted send binds the idempotency fingerprint, and a miss on a new
workflow reads it from the primary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Moved to PostHog#127122.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant