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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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: 8da3b71 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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
Potential customer breaksThe new code-content send form is additive, but the existing email API has a few compatibility changes:
@Silthus, is it worth preserving compatibility for these cases, or has the existing email API not shipped / not gained users yet? If nobody is using it yet, these changes should not pose a customer compatibility problem. |
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.Do not merge before the server deploys. The code form depends on the code arm of
send/email/in PostHog/posthog #127122 and #127123. Until both deploy, a code send gets a400. Merge and release this after them.Note
Two refusals change for malformed template sends. A send with neither
templateIdnortemplateKeynow returnsinvalid_form, notinvalid_template_id. Untyped JavaScript that passeshtmlorsubjectwithtemplateIdis now refused withinvalid_form; TypeScript already refuses it.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, and an empty list sends no reply-to.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).Proof at
8da3b71c2, throughfleet proof(the gate passes):933d907f3, delivered into a mail catcher. At8da3b71c2, a code send withreplyTo: []is accepted and delivered, and the old body withreply_to: []gets a400.400.mainand at this head, each difference classified as intended.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.933d907f3and a GPT-6.1 Sol full round at8da3b71c2.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).8da3b71c2leavesreplyTo: []out of the body, because the server refuses an empty list. It also namesidempotency_key_reusedas the error code in theidempotencyKeydoc.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