Repository navigation
Conversation
…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>
Coherence4 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)
Chokepoints: none declared. Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline
Close one with |
Node SDK compliance v2Commit: 65fcbb9 Capture v0 — acceptance✅ 28 passed / ❌ 3 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: Harness exit: 1; report validation exit: 1. Capture v1 — acceptance✅ 28 passed / ❌ 3 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: Harness exit: 1; report validation exit: 1. Capture v0 — migration✅ 55 passed / ❌ 1 non-passing / Drill-down: run artifacts → ❌ 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 — migration✅ 120 passed / ❌ 1 non-passing / Drill-down: run artifacts → ❌ 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. |
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 Resendhtml: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.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 }onPOST messaging/send/email/,409 template_outdatedfor an unknown key, andif_code_managedon 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 theIdempotency-Keyonly after slice 06 of the send-api strand.Impact
Pays into Grow Workflows through billed
workflows_emailsfrom 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)
posthog.emails.send()contract with the code form, moves the template form tomessaging/send/email/, and uploads local templates to PostHog by key on409 template_outdated. Once released, the field names and the upload rule are a contract, and a send delivers a real email.if_code_managedandif_not_superseded, so it never overwrites a template edited in PostHog or newer content. A JS caller that mixes the forms getsinvalid_formbefore any request. Tests at the HTTP boundary pin every body; not yet run against a live server (gap below).Changes
templateKey,subject,htmland/ortext, optionalfrom,replyTo(a string becomes a one-item list) andtemplateData.captureEngagementapplies to both forms.subjector withouthtmlandtext, and code fields on the template form. At runtime, before any request, a JS caller getsinvalid_formfor a mixed send. It getsinvalid_formwith thefieldforfrom,replyTo,subject,htmlortexton the template form (it would drop them), for a code form without a stringsubjector withouthtmlortext, and for an option neither form takes, such as Resend'sscheduledAt. A badtemplateKeygetsinvalid_template_key.templateData):POST messaging/send/email/withtemplate_key,subject,html,text,from,reply_to. Notextis 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.templateData): the SDK copies the request body, hashessubject,htmlandtextwith the content hash (v1), and sendstemplate: { key, hash }withmessage_data. A caller that changestemplateDataor the options while the hash runs changes nothing. On409 template_outdatedit uploads by key (PUT messaging_templates/by_key/{key}/withname: <key>, the content,if_code_managed: true,if_not_superseded: true, and no source) and sends once more with the same idempotency key.409 template_edited_in_posthogfrom the upload returns as the error and is never retried. A secondtemplate_outdatedreturns as the error.messaging/send/email/astemplate: { id }. No caller change.Parity with the contract and posthog-python
to,template_key,subject,html,text,from,reply_to[],capture_engagement,identifiers.distinct_idto,template: { key, hash },message_data,from,reply_to[],identifierstemplate: { id },message_data,capture_engagementv1:SHA-256, length-prefixed subject, preheader, html, text, line endings normalized; the server's three test vectorsname: <templateKey>, content,if_code_managed,if_not_superseded, nosource409 template_outdated, then one resend with the same key and body409returned asvalidation_error, no retryJSON.stringify, compact, UTF-8Evidence
emailsnamespace on a real client,fetchmocked byFakeEmailsApi, which now speaks the send route (template by id, rendered content, local template with its hash rule) and the upload route withif_code_managed(emails.spec.ts).content_hashv1 test vectors, the same three thattest_content_hash.pyon 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.tscwith@ts-expect-errorcases (__tests__/types/emails-forms.ts).409with no send; a secondtemplate_outdatedreturned; two local sends that reuse one options object and change itstoandtemplateData, each reaching the recipient with the data it was called with;invalid_formwith itsfieldfor each code field on the template form, for a code form without a subject or content, and forscheduledAton either form.65fcbb97: nodevitest(1325 passed), the edge build tests (1325 passed),tscfor the build and the type tests,oxlint,oxfmt, build andgenerate-references; corevitest.html: covered by the same live run.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)
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.
templateData: absent sends as is, present sends a hash and uploads ontemplate_outdatedif_code_managedper the contract, plusif_not_superseded, so an older deploy never republishes replaced contentnametemplateKey, as posthog-python doesscheduledAtinvalid_formon its field before any request, as PostHog#5312 does, instead of being dropped; a list of Resend-only names would miss typos and future fieldsinvalid_formon the field, not droppedOpen: 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