Skip to content

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

Draft
Silthus wants to merge 2 commits into
emails-v2/02-template-form-routefrom
emails-v2/03-local-template
Draft

Silthus wants to merge 2 commits into
emails-v2/02-template-form-routefrom
emails-v2/03-local-template

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Part of the code-first rebuild of posthog-node's email API; replaces parts of PostHog#5371.

Problem

A team that keeps its email templates in code wants PostHog to render them with per-send data, without uploading 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 has an older version.

Stacks on #6. Needs the server's template upload by key and template: { key, hash } arm.

Impact

Same as the stack: Grow Workflows, billed workflows_emails from API sends.

Merge Danger

Caution

🔴 Merge risk: high · one-way door (merge-risk rubric)

  • Writes templates in a customer's project (PUT messaging_templates/by_key/{key}/) from code, guarded by if_code_managed and if_not_superseded. The content hash v1 becomes a format the server and posthog-python must keep computing the same way.

Changes

Kind Files Lines (this PR, against #6)
Production 3 +199 −47
Tests 4 +206 −59
References and changesets 2 +13 −3

Evidence

  • Test seams: posthog.emails.send() on a real client with FakeEmailsApi, which keeps templates by key and hash; the content hash through its public function.
  • What the tests pin: the first send's body with the hash and capture_engagement: true; the upload then the resend with one idempotency key; template_edited_in_posthog not retried; a second template_outdated returned; options changed during hashing change nothing.
  • Local gate on 4a62a77a: node vitest (1329 passed), edge (1329 passed), tsc, oxlint, oxfmt, build, references committed; core vitest.
  • Owed proofs:
    • Live run, done on 2026-10-26 at da59e276, against Django at feat(messaging): send each email once per idempotency key posthog#578 c4c5ff05d2f (slice 09, with 05 e20426aa1e5, 06 6730e409b4b and 08d 0eeb308f962 below it) and cdp-api at feat(cdp): replay a keyed send after its workflow or default sender changes posthog#580 1a43bdda327, on a local stack with a dev mail catcher. Every send names from: Support <support@example.com> and replyTo: help@example.com:

      Case Requests Delivered
      a new key send 409 template_outdated, upload 201, send 202, one idempotency key on all three one email from support@example.com, Reply-To: help@example.com, variables filled, a text part derived from the html
      a key uploaded before with the same content send 202 the same
      a key uploaded before with older content send 409, upload 200, send 202, one key the same, with the new content
    • Cross-version (hash v1 against the server's hash): covered by shared test vectors in the content-hash tests, and live: the server's upload returned the same content_hash the SDK sent, so a matching key sent in one request.

    • Production shape: gap until read-only production access is available.

Decisions

No load-bearing decisions.

Involvement: carries the PostHog#5371 review rounds; a fresh HEAD read and a final read on the restack (both clean), live run captured.

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

@github-actions

Copy link
Copy Markdown

Coherence

4 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 7 bullets (5 invariants, 2 requirements), 4 problems

Spec problems (spec --check)

  • packages/ai/src/otel/Otel.spec.md:7 an invariant bullet starts with <name>: <sentence>
  • packages/ai/src/otel/Otel.spec.md:8 an invariant bullet starts with <name>: <sentence>
  • Posthog-js.practice.md:20 practice add an invariant cites 187a5c5, which is no commit in this repository
  • Posthog-js.practice.md:20 practice add an invariant cites 8922ab2, which is no commit in this repository

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Node SDK compliance v2

Commit: da59e27
Workflow run and artifacts

Capture v0 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 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:

Harness exit: 1; report validation exit: 1.

Capture v1 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 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:

Harness exit: 1; report validation exit: 1.

Capture v0 — migration

✅ 55 passed / ❌ 1 non-passing / ⚠️ 101 not selected

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

❌ 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 — migration

✅ 120 passed / ❌ 1 non-passing / ⚠️ 36 not selected

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

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

@Silthus
Silthus force-pushed the emails-v2/02-template-form-route branch from 86b2ad7 to db5ac2d Compare October 25, 2026 10:44
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch from 4a62a77 to 95be057 Compare October 25, 2026 10:44
Silthus and others added 2 commits October 25, 2026 19:09
A code send with templateData sends template: { key, hash }. When PostHog
answers template_outdated, the SDK uploads the content by key and sends again
once with the same idempotency key. Moves the local template arm of
PostHog#5371, with its content hash module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch from 95be057 to da59e27 Compare October 25, 2026 19:11
@Silthus
Silthus force-pushed the emails-v2/02-template-form-route branch from db5ac2d to 8384b8c Compare October 25, 2026 19:11
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