Skip to content

feat(node): send email content from code, uploading local templates by hash - #2

Closed
Silthus wants to merge 6 commits into
feat/node-emails-sendfrom
feat/node-emails-code-form
Closed

Silthus wants to merge 6 commits into
feat/node-emails-sendfrom
feat/node-emails-code-form

Conversation

@Silthus

@Silthus Silthus commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Moved here from PostHog#5371, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.

Problem

An app that renders its own emails, or keeps a template with variables in its code, cannot send through posthog-node: emails.send() takes only a template made in PostHog. A Resend html: or React Email call site needs a hand conversion first.

This PR adds the code form of the shared SDK contract PostHog/posthog#120921. The app names the email with a templateKey, and the SDK keeps a local template in sync by its content hash. Nobody calls an upload.

// Rendered content, such as React Email rendered to HTML
await posthog.emails.send({ to, distinctId, templateKey: 'password-reset', subject: 'Reset your password', html })

// A local template: the code holds it, PostHog fills in the variables
await posthog.emails.send({
  to,
  distinctId,
  templateKey: 'welcome',
  subject: 'Welcome, {{ variables.name }}',
  html: welcomeHtml,
  templateData: { name: 'Jane' },
})

Slice 10 of strand Silthus/posthog#536, map Silthus/posthog#461. Its extra fields stack on it (#5373), then emails.get (#5372).

Stacked on #5312 (slice 09). This fork PR targets main, so its diff includes PostHog#5312. This layer alone: feat/node-emails-send...feat/node-emails-code-form.

Server dependency, not merged: the code form and template: { id } on POST messaging/send/email/, 409 template_outdated for an unknown key, and if_code_managed on the upload come from PostHog/posthog#121272 on #118924. This PR is built against the contract and PostHog/posthog#121272's routes at the HTTP seam. The send route honors the Idempotency-Key only after slice 06 of the send-api strand.

Impact

Pays into Grow Workflows through billed workflows_emails from API sends, read as orgs that send from code and orgs that switched (not measured yet: the send attribution of PostHog/posthog#120628 plus its insight). In scope: Messaging, email. Expected move: medium, up: this is the form a Resend call site moves to. Run cost: n/a.

Merge Danger

Caution

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

  • Extends the public posthog.emails.send() contract with the code form, moves the template form to messaging/send/email/, and uploads local templates to PostHog by key on 409 template_outdated. Once released, the field names and the upload rule are a contract, and a send delivers a real email.
  • The upload carries if_code_managed and if_not_superseded, so it never overwrites a template edited in PostHog or newer content. A JS caller that mixes the forms gets invalid_form before any request. Tests at the HTTP boundary pin every body; not yet run against a live server (gap below).

Changes

Kind Files Lines (this layer)
Production 3 +286 −69
Tests 4 +293 −41
References and changeset 2 +137 −38
  • Code form. templateKey, subject, html and/or text, optional from, replyTo (a string becomes a one-item list) and templateData. captureEngagement applies to both forms.
  • Exactly one form. The types refuse a mixed send, a code form without subject or without html and text, and code fields on the template form. At runtime, before any request, a JS caller gets invalid_form for a mixed send. It gets invalid_form with the field for from, replyTo, subject, html or text on the template form (it would drop them), for a code form without a string subject or without html or text, and for an option neither form takes, such as Resend's scheduledAt. A bad templateKey gets invalid_template_key.
  • Rendered content (no templateData): POST messaging/send/email/ with template_key, subject, html, text, from, reply_to. No text is derived in the SDK: PostHog derives it, and sends HTML with no text in it, such as a single image, without a text part. The docs say so.
  • Local template (with templateData): the SDK copies the request body, hashes subject, html and text with the content hash (v1), and sends template: { key, hash } with message_data. A caller that changes templateData or the options while the hash runs changes nothing. On 409 template_outdated it uploads by key (PUT messaging_templates/by_key/{key}/ with name: <key>, the content, if_code_managed: true, if_not_superseded: true, and no source) and sends once more with the same idempotency key.
  • A 409 template_edited_in_posthog from the upload returns as the error and is never retried. A second template_outdated returns as the error.
  • The template form moves to messaging/send/email/ as template: { id }. No caller change.

Parity with the contract and posthog-python

Contract row posthog-node (this PR) posthog-python (#1101)
Rendered content body to, template_key, subject, html, text, from, reply_to[], capture_engagement, identifiers.distinct_id Same
Local template body to, template: { key, hash }, message_data, from, reply_to[], identifiers Same
Template form template: { id }, message_data, capture_engagement Same
Content hash v1: SHA-256, length-prefixed subject, preheader, html, text, line endings normalized; the server's three test vectors Same vectors
Upload name: <templateKey>, content, if_code_managed, if_not_superseded, no source Same
When to upload Only after 409 template_outdated, then one resend with the same key and body Same
Edited in PostHog 409 returned as validation_error, no retry Same
JSON encoding JSON.stringify, compact, UTF-8 Compact separators, UTF-8

Evidence

  • Test seams:
    • The emails namespace on a real client, fetch mocked by FakeEmailsApi, which now speaks the send route (template by id, rendered content, local template with its hash rule) and the upload route with if_code_managed (emails.spec.ts).
    • The hash against the server's content_hash v1 test vectors, the same three that test_content_hash.py on feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924 asserts (email-content-hash.spec.ts). The round-trip test expects a fixed hash, computed with posthog-python's rule, not with the SDK's helper.
    • The form types, checked by tsc with @ts-expect-error cases (__tests__/types/emails-forms.ts).
  • What the tests pin: the body for three rendered sends with no upload; the first local send as send, upload, send with one idempotency key and the exact upload body; a held template in one request; changed content uploading again; the edited-key 409 with no send; a second template_outdated returned; two local sends that reuse one options object and change its to and templateData, each reaching the recipient with the data it was called with; invalid_form with its field for each code field on the template form, for a code form without a subject or content, and for scheduledAt on either form.
  • Red first: 21 cases failed before the code form, then green. Each review fix's cases failed before the fix (9 cases in round 1, 2 in round 2), then green.
  • Local gate on 65fcbb97: node vitest (1325 passed), the edge build tests (1325 passed), tsc for the build and the type tests, oxlint, oxfmt, build and generate-references; core vitest.
  • Owed proofs:
    • Live run (a rendered send, an HTML-only send without a text part, a first local send that uploads, a second that does not, a changed one that uploads again, each in the dev mail catcher): gap. The server PRs are drafts being restacked; the end-to-end lane runs it.
    • A text-only local template upload depends on the server accepting it without html: covered by the same live run.

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

  • 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)

Michael (@Silthus) decided the two code cases and the upload rule in the spec. Built by Claude Code (Claude Opus 5.5) test-first, as one build lane of the map.

Question Answer Who Where
How rendered content and a local template differ By templateData: absent sends as is, present sends a hash and uploads on template_outdated 👤 Human spec, Code form
Upload flags if_code_managed per the contract, plus if_not_superseded, so an older deploy never republishes replaced content 🤖 Agent this PR
Upload name The templateKey, as posthog-python does 🤖 Agent this PR
Upload retries The upload uses the send's retry rule and key; it is idempotent by key and content 🤖 Agent this PR
An option neither form takes, such as Resend's scheduledAt Refused with invalid_form on its field before any request, as PostHog#5312 does, instead of being dropped; a list of Resend-only names would miss typos and future fields 🤖 Agent decisions comment
Code fields on the template form at runtime Refused with invalid_form on the field, not dropped 🤖 Agent decisions comment

Open: none beyond PostHog#5312's. The text-only upload is a server fix in the send-api strand.

Involvement: spec decisions by Michael; one strand review by GPT-6.1 Sol with a final read; a QA swarm over the restacked stack in two rounds (Review lane GPT-6.1 Sol with seven lenses, Fresh-eyes lane Claude Opus 5.5) with its fixes in this PR; no live run yet.

🤖 Generated with Claude Code

Silthus and others added 6 commits October 24, 2026 12:38
…y hash

posthog.emails.send takes the code form of the shared SDK contract:
templateKey, subject, html and/or text, with optional from, replyTo and
captureEngagement. The types refuse a send that mixes the two forms.

Without templateData the content is rendered and goes to the send route
as is, never uploaded. With templateData it is a local template: the
SDK sends its content hash, and on 409 template_outdated uploads it by
key with if_code_managed and sends once more with the same idempotency
key. An upload refused because the key was edited in PostHog returns
its error and is never retried.

Template sends by id move to the send route as template.id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local template send read its options after hashing the content, so a
caller that changed the same options object before the send finished
sent to the new recipient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d's data

- A template send with from, replyTo, subject, html or text returns
  invalid_form on that field, instead of sending without it.
- A code send without a subject, or without html or text, returns
  invalid_form before any request.
- A local template send copies its request body before it hashes the
  content, so a caller that changes templateData during the send changes
  nothing.
- The round-trip tests expect a fixed content hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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

Copy link
Copy Markdown

Node SDK compliance v2

Commit: 65fcbb9
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 closed this Oct 28, 2026
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