Repository navigation
feat(emails): let a code send set its tracking and sensitivity - #5630
Conversation
A send with templateKey now takes trackOpensAndClicks. With false, PostHog sends the email without its open pixel and without links rewritten to count clicks. The client sends track_opens_and_clicks only when the caller set it, so a default send keeps today's request body and still works against an API that predates the field. The template form refuses it before any request, like captureEngagement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coherence📄 0 spec file errors · ⛔ 0 broken rules · 🚪 2 unchecked inputs · 🐛 0 bugs that came back What is Coherence?Coherence compares the code with its spec files ( This check read 44 parts of the code and 7 rules. Note These findings do not make this check fail. None of these findings are in files that this pull request changes. You do not need to fix them in this pull request. 🚪 Unchecked inputs (2)
What this means and how to fix itInput from outside this code comes in at these places. The spec does not say what checks that input. 10 more places were already like this when Coherence was added. This list does not show them. To close one of these, find the place by its name in the
To get the line to add, run |
Node SDK acceptance v2Commit: 1d7c4e4 Capture v0✅ 83 passed / ❌ 4 non-passing / Drill-down: run artifacts → ❌ server_snapshot_payload_false — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_zero — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_null — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertionFlags field differs Code: Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134 the first flags request field "geoip_disable" should equal JSON false Operation: Field: Expected: false Actual value: true Harness exit: 1; report validation exit: 1. Capture v1✅ 148 passed / ❌ 4 non-passing / Drill-down: run artifacts → ❌ server_snapshot_payload_false — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_zero — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_null — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertionFlags field differs Code: Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134 the first flags request field "geoip_disable" should equal JSON false Operation: Field: Expected: false Actual value: true Harness exit: 1; report validation exit: 1. |
SDK performance: posthog-nodeBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
…code send PostHog never rewrites links on an email sent from code, so it counts no clicks with or without the field. With false, the email goes out without PostHog's open pixel, so PostHog can't count its opens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace trackOpensAndClicks with tracking: true, false, or
{ opens, clicks, engagement }. Leave tracking and captureEngagement
out of the body unless the caller sets them, so the API can refuse a
send that sets both. Deprecate captureEngagement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Send each tracking form through the public send API to a local HTTP server and assert the body it receives, instead of a stubbed transport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 956c45a: 1 Must fix.
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.
A code send now takes an optional `sensitive`, sent only when set. Without it, PostHog keeps today's behavior: a send whose templateKey matches a saved template follows that template, and any other send is sensitive. Click tracking follows it unless `tracking` sets clicks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The template-form refusals now go through a real local server and check that no request arrived. The latest API reference documents `sensitive`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-add the stamphog label to request another review once you have addressed this.
The risky changes have tests and STRONG author familiarity, but the current-head review still reports a must-fix finding whose details are unavailable. Ask @PostHog[bot] or a teammate to clarify and clear that finding before re-requesting.
- Author wrote 100% of the modified lines and has 1 merged PRs in these paths (familiarity STRONG).
- PostHog Review (Standard) reviewed the current head: 1 finding, posted on the PR.
- posthog[bot] reviewed the current head.
- @PostHog[bot]'s current-head COMMENTED review reports one must-fix finding, but the supplied context contains no finding details or acceptance of it. Its resolution cannot be verified against the current code; obtain the finding details and address it or have the reviewer explicitly clear it before re-requesting.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 104L, 4F substantive, 203L/7F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1c-medium (203L, 7F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 4993044 · reviewed head 4993044 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approved.
The risky changes have assurance through the author's STRONG familiarity and accompanying HTTP and type tests. All listed reviews are historical; the older bot summaries provide no concrete concern to verify, and no substantive blocker is evident in the current code.
- Author wrote 75% of the modified lines and has 2 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 53L, 3F substantive, 184L/8F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1c-medium (184L, 8F, two-areas, feat) |
| stamphog 2.8.0 | .stamphog/policy.yml @ 1d7c4e4 · reviewed head 1d7c4e4 |
Main added tracking and sensitive to a code send (#5630). This branch sends a code send's form fields through codeFormFields, so they now go out from there, and capture_engagement is sent only when the caller sets it, as on main. The fake PostHog takes tracking and sensitive as the send API does, and a test sends a calendar invite with its file and tracking: { opens: false }. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Note
👀 Reviewed together with #131780. The team question for this change is there. Once you're happy with #131780, please approve this one too.
Problem
A code send (
posthog.emails.send()withtemplateKey) can't choose what PostHog tracks, or say whether its email is sensitive. Some emails must go out with no open pixel. A newsletter wants click tracking. PostHog can't tell a newsletter from a password reset email on its own, so the caller needs a way to say which one it is.Changes
A code send takes a new optional
sensitive:trueorfalse. A sensitive email keeps its links as they are, leaves its subject off engagement events, and keeps its content out of PostHog's logs. Left out, PostHog keeps today's behavior: a send whosetemplateKeymatches a saved template follows that template, and any other send is sensitive.A code send takes a new optional
tracking:true,false, or{ opens?, clicks?, engagement? }, exported asEmailTracking.trackingtruefalsesensitiveA send that sets neither option behaves exactly as it does today.
captureEngagementis deprecated in favor oftracking: { engagement: false }. It keeps working.The client sends
sensitive,trackingandcapture_engagementonly when the caller sets them. Before,capture_engagement: truewent on every code send. The API reads a missingcapture_engagementastrue, so a default send is unchanged. Leaving it out lets the API refuse a send that sets both it andtracking.The SDK checks no combination. The API refuses
opens: falsewithclicks: true, an unknown key, andtrackingtogether withcaptureEngagement.The template form (
templateId) refusessensitiveandtrackingbefore any request, the same ascaptureEngagement. The types refuse them too.A changeset (
posthog-node, minor) says both options need PostHog's API support, which is rolling out.Release once PostHog's API takes
trackingandsensitivein both regions: #131780, #131756.Tests
emails.spec.ts,emails over HTTP: each form goes throughposthog.emails.send()and the client's real fetch to a local HTTP server, which checks the body it received. Left out sends none ofsensitive,trackingorcapture_engagement.sensitive: true,sensitive: false,tracking: false,true, an object and the deprecatedcaptureEngagementgo on the wire as given.sensitiveandtrackingon the template form. Each returns aninvalid_formerror on that field, and the server receives no request.types/emails-forms.ts:sensitive: false,tracking: falseandtracking: { clicks: true }type-check.sensitiveortrackingon the template form, and a misspelled key such as{ open: false }, are type errors.sensitive, always sending it, or leaving it out of the code form's fields each fails the suite. So does letting the template form acceptsensitiveandtracking.oxlint,oxfmt,check-types, the full posthog-node unit suite,generate-referencesandpnpm check:public-api.trackingandsensitiveuntil the two PRs above deploy. The real send through this branch runs in the SDK release gate once they are live.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5) in Claude Code, as a worker on a task the PR's assignee directed.trackOpensAndClicksoption, which never shipped, with thetrackingandsensitivefields the API takes.capture_engagementis now left out unless set, because the API refuses a send that sets both it andtracking.latestAPI reference file is regenerated becausecheck:public-apirequires it in the PR.🤖 Generated with Claude Code