Skip to content

feat(node): upload local email templates by hash on send - #5513

Draft
Silthus wants to merge 5 commits into
mainfrom
emails-v2/03-local-template
Draft

Silthus wants to merge 5 commits into
mainfrom
emails-v2/03-local-template

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 templateData sends 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.

  • The SDK writes templates in a project (PUT messaging_templates/by_key/{key}/) from code. It sends if_code_managed and if_not_superseded. The server branch it targets ignores if_code_managed and adopts the template instead.
  • The content hash v1 becomes a format the server and posthog-python must keep computing the same way.

Changes

  • Local template (code form with templateData): the SDK hashes subject, html and text with content hash v1 and sends template: { key, hash }.
  • Upload on demand. On 409 template_outdated the SDK uploads by key and sends once more with the same idempotency key. It uploads at most once per send.
  • A refused upload is a validation_error. 409 template_edited_in_posthog means people edit the template in PostHog.
  • A local template needs html and a subject, checked before any request.
  • Superseded content isn't uploaded again. The send uses PostHog's newer content and returns a template_superseded warning.
  • A send copies its options and checks the client before it hashes. Rendered content without templateData never 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 injected fetch, and emailContentHash against the server's v1 test 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

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • All commits are signed (see Commit signing)
  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

🤖 Generated with Claude Code

Silthus and others added 5 commits October 31, 2026 08:09
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>
Squash of Silthus#7 at 1c241bb, re-signed for required signatures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Comment on lines +219 to +220
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)))

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.

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)

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 · 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.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

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 (*.spec.md). A spec file lists the rules for one part of the code.

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)

  • public api in packages/web/Web.spec.md. The input comes from: host-app.
  • webpack plugin in packages/webpack-plugin/Webpack-plugin.spec.md. The input comes from: build.
What this means and how to fix it

Input 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 ## entrances section of its spec file. Add one line under it:

  • If all of the input goes through one function that a rule in the spec protects, add guard: <function>.
  • If a rule in the spec already checks input from the same source, add the place's name to the entrances: line of that rule.
  • If the input needs no check, add control: none - <reason>.

To get the line to add, run npx --no -- coherence scaffold control "<name>".

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Node SDK acceptance v2

Commit: d5b5945
Workflow run and artifacts

Capture v0

✅ 83 passed / ❌ 4 non-passing / ⚠️ 954 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

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_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1

✅ 148 passed / ❌ 4 non-passing / ⚠️ 889 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

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_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

This branch has not been deployed

No deployments
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