Repository navigation
Conversation
A send now takes one of two forms on the same send endpoint: a template made in PostHog by templateId, or content your code rendered with templateKey, subject, html or text, and an optional from and replyTo. The client refuses a field outside the chosen form and a second send argument before any request. Every send carries capture_engagement, true unless the send turns it off, and the migratedFrom client option adds the X-PostHog-Migrated-From header to every email request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The send endpoint takes capture_engagement only on a send with template_key and refuses it on a template send. A template made in PostHog follows the project's engagement setting, so the client now refuses captureEngagement on the template form before any request and leaves it out of the template body. 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.
PostHog Review · Standard · reviewed d5b5945: 1 Must fix, 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.
| const body = safeJsonStringify({ name: key, ...content, if_code_managed: true, if_not_superseded: true }) | ||
| await this.withRetries(maxRetries, async () => parseUploadReply(await this.request(url, 'PUT', headers, body))) |
There was a problem hiding this comment.
P1 · Gate automatic uploads on server support for the ownership guard
When a local send receives template_outdated for a key edited in PostHog, upload() automatically publishes its content. The older server upload handler ignores if_code_managed and adopts the template, so this overwrites published edits instead of returning template_edited_in_posthog. Gate automatic uploads on server support for the ownership guard.
| if (!prepared.localTemplate || !isOutdatedTemplate(error)) { | ||
| throw error | ||
| } | ||
| await this.upload(prepared.localTemplate, appHost, secretKey, prepared.maxRetries) |
There was a problem hiding this comment.
P2 · Coalesce concurrent uploads of the same template content
When concurrent sends encounter the same missing template, every deliver() uploads independently: 100 sends start 100 identical PUTs before any completes. This multiplies full-content transfers and template writes; redundant uploads that exhaust throttling retries fail their sends despite another upload succeeding. Share an in-flight upload by template key and hash.
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: d5b5945 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. |
Problem
A team that keeps its email templates in code wants PostHog to fill them with per-send data, without uploading them by hand on every deploy. This PR adds the local template: a code send with
templateDatasends a content hash, and the SDK uploads the template by key only when PostHog doesn't hold that content.Stacks on #5509. Moved from Silthus#7. Part of map Silthus/posthog#461.
Caution
Merge risk: high, one-way door.
PUT messaging_templates/by_key/{key}/) from code. It sendsif_code_managedandif_not_superseded. The server branch it targets ignoresif_code_managedand adopts the template instead.v1becomes a format the server and posthog-python must keep computing the same way.Changes
templateData): the SDK hashessubject,htmlandtextwith content hashv1and sendstemplate: { key, hash }.409 template_outdatedthe SDK uploads by key and sends once more with the same idempotency key. It uploads at most once per send.validation_error.409 template_edited_in_posthogmeans people edit the template in PostHog.htmland a subject, checked before any request.template_supersededwarning.templateDatanever uploads.Server prerequisites: contract PostHog/posthog#120921 refuses an upload over a template people edit in PostHog. The server must settle that before this ships. A new key also needs a verified default sender in the project.
Test seams:
posthog.emails.send()on a real client with an injectedfetch, andemailContentHashagainst the server'sv1test vectors. Proofs: a shadow diff against #5509 where only the new local-template cases differ. A live run on Silthus/posthog#585 (now PostHog/posthog#127188) at an identical tree, where a new key uploads and sends, and a template edited in PostHog refuses the upload. Production shape: production templates have no key yet, so a first send uploads once. Old-server runs are still owed.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), as lane sdk3 of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, directed by Michael. The behavior comes from the reviewed feat(node): send email content from code, uploading local templates by hash #5371.d5b5945, whose tree is identical to the reviewed head1c241bb6c.identifiers.distinct_id, like the template form; a text-only local template is refused, since PostHog can't store it.🤖 Generated with Claude Code