Skip to content

feat(node): send email content from code with emails.send - #5509

Draft
Silthus wants to merge 5 commits into
mainfrom
emails-v2/01-send-inline-html
Draft

Silthus wants to merge 5 commits into
mainfrom
emails-v2/01-send-inline-html

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown

Problem

A server that sends transactional email with PostHog wants one call on the client it already has. Many teams send HTML their own code rendered, such as React Email output. #5418 added posthog.emails.send() for templates made in PostHog; this PR adds the second form, content from code, on the same call and the same send/email/ URL. The SDK contract is PostHog/sdk-specs openspec/specs/emails/spec.md, plus the draft change add-emails-code-form.

const { data, error } = await posthog.emails.send({
  to: 'jane@example.com',
  distinctId: user.id,
  templateKey: 'password-reset',
  subject: 'Reset your password',
  html: await render(<PasswordReset url={url} />),
  idempotencyKey: `password-reset:${resetId}`,
})

Moved from Silthus#5.

Caution

Merge risk: high, one-way door. The code form of emails.send(), its exported option types and the migratedFrom client option become public API once released. The code form needs the server's code arm of send/email/ (PostHog/posthog#127122 and PostHog/posthog#127123). Until both deploy, a code send gets a 400. Release this after them.

Changes

  • Code form. templateKey, subject, and html, text or both, with optional from and replyTo. send() posts a flat body to POST /api/projects/@current/messaging/send/email/. A string replyTo becomes a one-item list.
  • Flat body in both forms. Every send puts distinct_id at the top level, never identifiers. The template form sends template_id flat, as main has it since feat(node): send a flat body from emails.send #5487.
  • One option type per form. SendEmailOptions is a union of SendTemplateEmailOptions and SendCodeEmailOptions, so TypeScript refuses a mix of the two forms.
  • One field set per form at runtime. A field the chosen form doesn't take returns invalid_form with its field, before any request. When the other form takes it, why and fix name that form.
  • Resend's second argument. send(payload, { idempotencyKey }) returns invalid_form instead of sending with a random key. An undefined second argument still sends.
  • Engagement capture is code form only. captureEngagement is sent as capture_engagement, true by default. The template form refuses it.
  • migratedFrom client option. Adds X-PostHog-Migrated-From to every emails.send and emails.get request.

Test seams: posthog.emails.send() on a real PostHog client with an injected fetch (emails.spec.ts), and the accepted and refused option shapes as type tests (__tests__/types/emails-forms.ts). At 933d907f3: the posthog-node build, type checks, unit and edge tests, lint, format, references and public API pass locally. On a57fb728b, before the main merge: a live run against the server code send, delivered into a mail catcher. Production shape: posthog-node reads no stored data, and the server's sender inputs are measured on PostHog/posthog#127123. Cross-version and shadow diff at 933d907f3 are 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 4 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>
@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: 933d907
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.

Take main's flat send body from #5487: the template form sends template_id and distinct_id at the top level, like the code form.

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

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