Repository navigation
Conversation
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>
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 ( 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)
What this means and how to fix itInput 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
To get the line to add, run |
Node SDK acceptance v2Commit: 933d907 Capture v0✅ 83 passed / ❌ 4 non-passing / Drill-down: run artifacts → ❌ server_snapshot_payload_false — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_zero — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_null — failed_assertionExpected native data result Code: 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_assertionFlags field differs Code: Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134 the first flags request field "geoip_disable" should equal JSON false Operation: Field: Expected: false Actual value: true Harness exit: 1; report validation exit: 1. Capture v1✅ 148 passed / ❌ 4 non-passing / Drill-down: run artifacts → ❌ server_snapshot_payload_false — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_zero — failed_assertionExpected native data result Code: Failed step: acceptance/public/evaluate-flags.feature:868 the public snapshot outcomes should have these semantics: ❌ server_snapshot_payload_null — failed_assertionExpected native data result Code: 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_assertionFlags field differs Code: Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134 the first flags request field "geoip_disable" should equal JSON false Operation: Field: 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>
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 samesend/email/URL. The SDK contract is PostHog/sdk-specsopenspec/specs/emails/spec.md, plus the draft changeadd-emails-code-form.Moved from Silthus#5.
Caution
Merge risk: high, one-way door. The code form of
emails.send(), its exported option types and themigratedFromclient option become public API once released. The code form needs the server's code arm ofsend/email/(PostHog/posthog#127122 and PostHog/posthog#127123). Until both deploy, a code send gets a400. Release this after them.Changes
templateKey,subject, andhtml,textor both, with optionalfromandreplyTo.send()posts a flat body toPOST /api/projects/@current/messaging/send/email/. A stringreplyTobecomes a one-item list.distinct_idat the top level, neveridentifiers. The template form sendstemplate_idflat, asmainhas it since feat(node): send a flat body from emails.send #5487.SendEmailOptionsis a union ofSendTemplateEmailOptionsandSendCodeEmailOptions, so TypeScript refuses a mix of the two forms.invalid_formwith itsfield, before any request. When the other form takes it,whyandfixname that form.send(payload, { idempotencyKey })returnsinvalid_forminstead of sending with a random key. Anundefinedsecond argument still sends.captureEngagementis sent ascapture_engagement,trueby default. The template form refuses it.migratedFromclient option. AddsX-PostHog-Migrated-Fromto everyemails.sendandemails.getrequest.Test seams:
posthog.emails.send()on a realPostHogclient with an injectedfetch(emails.spec.ts), and the accepted and refused option shapes as type tests (__tests__/types/emails-forms.ts). At933d907f3: the posthog-node build, type checks, unit and edge tests, lint, format, references and public API pass locally. Ona57fb728b, before themainmerge: 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 at933d907f3are owed.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), as part of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, directed by Michael. The exact reviewed SHA moved here unchanged.a57fb728b.933d907f3mergesmainwith the flat send body from feat(node): send a flat body from emails.send #5487 (feat(messaging): take template_id and distinct_id at the top of the send body posthog#126287 on the server).distinct_idflat at the top level, like feat(messaging): take template_id and distinct_id at the top of the send body posthog#126287. Both forms or neither returninvalid_form; a template ID that isn't a UUID keepsinvalid_template_id.🤖 Generated with Claude Code