Skip to content

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

Closed
Silthus wants to merge 20 commits into
PostHog:mainfrom
Silthus:feat/node-emails-code-form
Closed

Silthus wants to merge 20 commits into
PostHog:mainfrom
Silthus:feat/node-emails-code-form

Conversation

@Silthus

@Silthus Silthus commented Oct 22, 2026 •

Copy link
Copy Markdown

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 #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#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 #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 #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 12 commits October 23, 2026 13:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and comments

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>
The upload after `template_outdated` now sets `if_not_superseded`. When a newer
deploy replaced the file's content between the send and the upload, PostHog
changes nothing and the one resend returns the `template_superseded` warning.
`uploadTemplate` still publishes replaced content, so it stays the rollback path.

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

An upload of a template file now sends source.repository, source.path and
source.ref without configuration. Each part comes from the explicit source
option, then from GitHub Actions, GitLab CI, Vercel or Railway variables, then
from the nearest git checkout above the file. The checkout is read as files,
without running git, and credentials in a remote URL never leave the process.
Detection never fails a send.

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

Source detection read any file named like a git file, so a pipe in a .git
directory could hang a send. It now reads only regular files up to 4 MiB.
A Vercel provider named like an object property, such as "constructor", no
longer throws, and a quoted origin URL followed by a comment is read the way
git reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog.emails.send now takes the flat template form of the shared SDK
contract: templateId with templateData, posted to the template send
route. Every send carries an Idempotency-Key, a UUID v4 unless the
caller gives one, and retries network errors, an in-flight key,
throttling and 503 with the same key. A monthly quota is never retried.

Errors carry Resend's names, why and fix. New client options appHost,
maxRetries and migratedFrom. The secret key only goes to the app host:
derived for PostHog Cloud, required for any other host.

The unreleased template field, templateFile() and uploadTemplate() go
away. The content hash stays for local templates.

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

A send to a workflow that isn't active answers 400 transactional_email_paused,
a validation_error. The idempotency key docs now say a replay survives a paused
workflow or a changed default sender, and still refuses a deleted or changed
template or a project with no default sender (PostHog/posthog#120921).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A send with options that are not an object returns invalid_form instead
  of throwing.
- A non-string idempotencyKey returns invalid_idempotency_key.
- A maxRetries that is not a whole number from 0 falls back to the client
  option, then to 2, so NaN or Infinity can't retry forever.
- A queued 202 whose body breaks off while it is read is a network error,
  so the send retries with the same key and returns the first id.
- A network error keeps its cause in the message.
- The secretKey docs say to turn off local evaluation for a key that can't
  read feature flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fused connection's cause

- A send with an option posthog-node doesn't take, such as Resend's
  scheduledAt, returns invalid_form on that field instead of sending
  without it.
- A refused connection to a host with two addresses keeps both causes in
  the network error message.
- Core parses a JSON answer with trySafe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the feat/node-emails-code-form branch from 1434877 to b9b52ca Compare October 23, 2026 14:53
@Silthus

Silthus commented Oct 24, 2026

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions from the QA swarm rounds on the restacked stack.

Silthus and others added 7 commits October 24, 2026 12:38
A send after shutdown(), or while it drains, returns client_closed,
named application_error, before any request, as the shared contract's
2026-10-24 entry asks.

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