Repository navigation
feat(cdp): deliver a code send's content on its own transactional email route - #127122
Conversation
…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]>
…flow Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
😎 Stack merged successfully - details. |
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. ✅ Duplication (Python) — cleanNew Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Duplication (TypeScript) — cleanNew TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Hogbox preview — ready, open the preview▶ Open the preview
commit |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 920e7c9: 1 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
HostHog preview —
|
…ode-send Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ode-send Co-Authored-By: Claude Opus 5.5 <[email protected]>
A send with captureEngagement false still put the recipient's distinct_id in its SES header and pixel/redirect tracking codes. Minting still emits the v2 payload, which has no capture choice, so the SES delivery, bounce, open and click callbacks for that send captured engagement events. Every reader skips engagement capture for a code without a distinct_id, so leaving it out honors the choice on every deployed reader now. The v3 payload and its minting switch stay as they are. The send-time capture and the message assets row keep their distinct_id. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The capture-off fix also changed the pixel and redirect codes that dev and test mint, but only the SES header path had a test. The tracking URL test now runs per capture choice and fails if those codes keep the distinct_id of a capture-off send. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The recipient pattern let a dot lead, trail or repeat in the local part, so [email protected] reached the provider instead of a 422. Dots now only separate the characters, as Django's pattern does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # nodejs/src/cdp/services/hogflows/hogflow-executor.service.test.ts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The code-key and capture tests sent real email through the shared maildev, which races email.service.test.ts clearing the same inbox when both files run in one CI shard. Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
Stamphog can't approve this pull request automatically because two gates refused it. The deny-list gate flagged it for matching the auth category, which fits the changes to internal-api-auth.ts and the new kind: code_send token claim on the transactional email route in cdp-api.ts. The tier gate classified it as T2-never (1620 lines, 24 files, a feature touching the email delivery path), and that tier is never auto-approved.
The size gate passed, so splitting the PR wouldn't clear these refusals. Ask a human reviewer, ideally someone familiar with CDP email delivery and token auth, to review it.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: auth |
| size | ✓ | 598L, 14F substantive, 1620L/24F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (1620L, 24F, single-area, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head eb26545 |
The template route now requires a token without a kind claim, and the code send route keeps requiring kind code_send, so neither token works on the other route. Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't auto-review this pull request because two gates refused it. The deny-list gate flagged it for matching auth, which fits the changes to nodejs/src/common/api/middleware/internal-api-auth.ts and the new token-claim checks in cdp-api.ts. The tier gate classified it as T2-never (1630 lines, 24 files, feat), so it always needs a human reviewer. The size gate passed, so splitting the PR wouldn't change this outcome.
Please ask a human reviewer, preferably someone who owns the CDP email path or the internal API auth, to review it.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: auth |
| size | ✓ | 602L, 14F substantive, 1630L/24F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (1630L, 24F, single-area, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 6f4136c |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
/trunk merge |
|
/trunk merge |
#127122 landed on master, so this layer now targets master. Conflicts in cdp-api.ts keep this layer's retry fingerprint (the sender the caller chose) and take master's concurrent team, workflow and refusal lookups. The test file keeps this layer's retry cases and takes master's folded over-quota case. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Moved from Silthus#565. Part of the code-first rebuild of the email send stack; replaces parts of #121272 (part 2, the cdp-api half).
Problem
sendfield. A cdp-api without the change dropped the field and answered202. It then sent nothing, or the stored template under the same key.workflows-email-from-codeflag.Changes
Warning
🔴 Merge risk: high · two-way door once the email queue drains: reverting removes the route, and Django then answers 503 for code sends
state.sendover the email step: content, sender, reply-to and capture choice. A wrong overlay sends the wrong content for a code send.workflows-email-from-codeon only after cdp-api and both CDP workers (hogflow, email) run this code. A worker without it ignoresstate.send. At this head, an old email worker refuses a new key's code-send job and sends nothing, after Django answered202. An earlier mixed-rollout case sent the stored template under a stored template's key.send, then roll back.state.send, it needs a Django token withkind: code_send, and Django mints one only behindworkflows-email-from-code. A rerun skips any stored run that carriessendunless the request setsreplay_code_sends, which feat(messaging): send an email with inline html from code #127123 sets from the flag. A code send through a template's workflow is covered too.503and sends nothing.state.send. A template send takes the unchanged path, and the template route ignores asendfield.POST /api/projects/:team_id/hog_flows/:id/transactional_email/send, which requiressend.503, nothing sent.kind: code_send, which feat(messaging): send an email with inline html from code #127123 mints only for a code send. The template route refuses any token with akind. A token on the other route answers401.202.hog_flow_lookup_failedand keeps the workflows already found.getHogFlowsCheckingPrimaryOnMiss, stays in this PR rather than its own. The hogflow worker and email feedback need it for a first send's workflow, and a split would ship this route without it.sendpayload and the route. A retry with changed content, or the same key on the other route, answers422 idempotency_key_reused. The template route keeps its fingerprint, so its existing keys still replay..../transactional_emailkeeps its schema and drops anysendfield, so a stored template send never picks up code content.sendfield this cdp-api does not know is refused, not dropped. The send route answers400 invalid_requestfor a key outside its schema, at the top ofsend(such asccorattachments) or insidecontent,fromortracking, and queues nothing. feat(messaging): send cc, bcc and attachments on a code send #127988, the Django layer that adds copies and attachments, maps that 400 toinvalid_requestnaming the field. A newer Django ahead of this cdp-api then fails loudly instead of sending a bare email.sendEmailhand-off laysstate.sendover the email step's rendered inputs: content, sender, reply-to. Recipients stay as the step renders them. The overlay is typed as the email queue parameters, so a renamed field fails to compile.state.send.tracking. Nothing writes it into the hog function metadata any more.state.sendover a saved hog function state too, so a paused, throttled or retried step still honorscapture_engagement: false.capture_engagement: falseturns off the send's$workflows_email_sent,_failed, open, click and bounce events. The tracking code then carries nodistinct_id, so feedback has no person to capture against.truenever turns capture on over a team that turned it off.state.sendonly when the request setsreplay_code_sends; otherwise it skips the run. A sensitive run's lifecycle snapshot drops it, as it drops the variables, so no rendered reset link is stored.skipped=1. feat(messaging): send an email with inline html from code #127123 makes every code send under a key without a template sensitive.=,~,#or another symbol email allows before the@now gets the email. Zod's default check refused[email protected], while the send path and the provider accept it.towithRECIPIENT_ADDRESS_PATTERN: a dot-atom local part and an ASCII domain with at least one dot. feat(messaging): send an email with inline html from code #127123 applies the same pattern, so Django refuses anything cdp-api would refuse.to, such as.jane@, an IP literal or a non-ASCII domain, still answers422 recipient_unreachable, as fix(cdp): treat malformed transactional email recipients as unreachable #125908 set it.resolveEmailTrackingDistinctIdandreplay_code_sendscarry the reasons; the code has no comments.Rollback runbook
For the
rolloutskill. It applies to any rollback of cdp-api or the CDP hogflow or email workers whileworkflows-email-from-codeis on for any organization, not only to a revert of this PR.Turn
workflows-email-from-codeoff. Django then answers404to a new code send, and a rerun skips every stored run that carries a code send, whichever workflow it went through.Wait until this query returns
0on the job queue database of every region:The second condition counts reruns that started while the flag was on. They still replay code sends until they finish.
Roll back. A code-send job that an older worker picks up fails without sending, and keeps its
sendfor a rerun after the roll forward.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 inemail.serviceandemail-tracking.service.Test rationale:
stamps %s on the queued runon the new route, andqueues 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.refuses a code send with a field this version does not know, and itscontent, sender and tracking variants: a key a newer Django adds, such ascc, dropped after a202. Each fails without its.strict().authenticates every send on %s: the new route skipping the scoped JWT check.refuses a template send token on the code send routeandrefuses a code send token on the template send route: a token minted for one route queueing a send on the other.trueoverriding a team that turned capture off.rejects the key for a request with changed contentand... with the other route: a reused key replaying a different email.runs a flow the replica does not have yet, and fails one missing from both: a first send under a new key refused by a lagging replica.reads the primary once per batch of workflows the database no longer has, and again once the miss expires: a deleted workflow's backlog sending one primary query per job.writes a hog_flow log entry for a bounce that resolves to a flow only on the primary, with and without a failing hog function lookup: feedback for a workflow in replica lag dropped.counts a failed primary lookup as a lookup failure for a bounce on a flow only the primary has: a failed primary read going uncounted.send: a resumed step capturing engagement after the caller turned it off.skips a run with %s: a rerun replaying a code send while the flag is off, or skipping a sensitive run with no log line on it.email.service.test.tsclearing the same inbox in one CI shard, and maildev restarted.answers a send to %sgains 13 address rows, the same table feat(messaging): send an email with inline html from code #127123 tests: a valid address refused by the recipient check, or a dot that leads, trails or repeats let through.replays a keyed send whose workflow stopped being active: the test storesstatus = 'draft'and runs the reload, so a manager that stops reading the stored status fails it.At the head
eb26545, with Django at #127123's213fad6: a code send delivers once, and its status readssent. Master's reader parses a capture-off tracking code and captures no event for it. The recipient check accepts and refuses the same 23 sample addresses as Django's.Addresses, live at the final head
3639e5dwith Django at #127123's7571429: code sends to[email protected](Reply-Too'[email protected]) and to an address with every symbol email allows, and a template send to[email protected], each deliver once. An IP-literaltoand a leading-dotreply_toanswer400in Django, and cdp-api gets no request.The other proofs ran at
2eac306, with Django at #127123's31dc832to5ec28fe. Since2eac306only the recipient pattern and its test rows changed:fromfallbacks, and two environments under one key each deliver as sent.distinct_id, and redirect, pixel and SES callbacks to both cdp-apis capture 0 events.sendcompletes, delivers the caller's content, and captures 0 events.replay_code_sendsfalse skips it and queues nothing. With it true, the rerun sends it once, with the caller's content.503and queues nothing. Template sends deliver in both orders, and both versions read their tracking codes.f4ee0e5: 50 tests move from fail to pass, each a behavior above. No test that passes on base fails here.ccdd9ff: a code send under a sensitive workflow delivers once and stores no content. A rerun skips it with the warning on that run's log and queues nothing. A control send that is not sensitive replays once.20fb623changes only the warning's wording.Proofs at earlier heads
34347b2: the strict schema, live. Every send form master's Django and feat(messaging): send an email with inline html from code #127123'sc4b7fb2make answers202and delivers once. Asendcarryingccorattachmentsanswers400naming the field.abbf035: a first code send under a new key during replica lag delivers one mail, and the primary-read shadow diff and prod shape.b5889c9and the pre-rebase4182790: the first live runs, cross-version runs and the mixed-rollout run that set the deploy order in the merge-risk callout.Release status
Docs update
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5)Skills:
/tdd,/writing-tests,/writing-code-comments,/writing-pr-descriptions,/pr-description,/stacking-prs,/recreating-fork-prs,qa-swarmcapture_engagementdo against the team switch?workflows-email-from-code, per organization; master's surfaces keepworkflows-transactional-email-apidistinctIdout instead of tracking code v3?sendfield the schema does not declare?.strict(); a cross-version run foundcc,bccandattachmentsdropped after a202; nested objects too, per final read 9sendjobs, then roll back; the flag must gate every entry pointstate.send, explain the batched lookupreplay_code_sendsfrom the flag, so a code send through a template's workflow is covered toosendon a resumed step?state.sendinto a saved state, so the root state stays the sourcetopattern after the rebase?toasrecipient_unreachable, and that holdstofor valid addresses Zod refused?Involvement: one brief from Michael with decisions; QA swarm rounds 1 and 2, seventeen final reads, a full two-family panel and a narrow round (GPT-6.1 Sol reviews, Opus 5.5 fresh eyes); shadow diff, live, cross-version, prod-shape and mixed-rollout proof captured.
More context for AI agents
920e7c9was pushed here unchanged, signed by its author.43333f8before the move, then merged master2468be9,d8cbc31,b8095bf,47f64f0and99e1a37; the node diff of the reviewed fork stack moved over as is, and the own route is new.eb26545, via feat(messaging): send an email with inline html from code #127123's review: a code send token still passed the template route;6f4136cmakes the template route refuse anykind, with tests both ways.distinctIdsimplification, before flag-on.nodejs/src/cdp/services/hogflows/hogflow-manager.service.ts, the overlay inasync-functions/send-email.ts, and thecode_sendclaim on the send route.🤖 Generated with Claude Code