Repository navigation
posthog-node: emails.send from a key or an HTML file, synced by content hash, returning { data, error } #474
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, sections "posthog-node: emails.send", "Content hash" and "Hash on send, and the rolling-deploy rule" (retargeted from the superseded #467)
Cast: Opus 5.5, high
Repository: PostHog/posthog-js (packages/node, packages/core)
Question
Can Node code send an HTML file from its own repository with posthog.emails.send({ template, to, data, distinctId }), against a local stack with no upload or deploy step, and handle every failure without a try/catch?
Delivers end to end. A lazy emails getter on the Node client, server only, like messaging in PostHog/posthog-js#5173, through the core requestJson helper from PostHog#5173 (this PR carries the same helper; whichever merges second drops its copy).
emails.send({ template, to, data?, distinctId? })posts to the send route (#488) withAuthorization: Bearer <secretKey>.databecomesmessage_data(read as{{ variables.* }});distinctIdbecomesidentifiers.distinct_id, for attribution only.templateis a key string (sends{template: {key}}), or anEmailTemplateFilefromemails.templateFile(url, { key?, name? }): a fileURLor absolute path to an.htmlfile, key from the file name unless given, subject from<title>, preheader from<meta name="preheader">, no text part.- With a file reference, every send reads the file, hashes it per the spec's "Content hash" section, and sends
{template: {key, hash}}. On409 template_outdatedor404 template_not_foundit uploads through the upload route (#487) withsource.path, then retries the send once. Atemplate_supersededwarning comes back indata.warningsand is logged once per key and hash. Steady state: one request. emails.uploadTemplate(ref, { source? })uploads explicitly (the rollback path) and returns{ data: { id, key, result, contentHash }, error }.- The file reference is in the Node entry only; edge entries accept key strings.
- The send route and the upload route are one path constant each.
- Returns
{ data: { id, warnings }, error: null }(idfrommessage_id) or{ data: null, error: { code, message, statusCode, docsUrl, field } }. Never throws on API or network errors. SDK-only codes:missing_secret_key,client_disabled,network_error,invalid_template_key,template_file_unreadable,template_missing_subject. - No idempotency. No idempotency option. The only retry is the one after
template_outdated(nothing was queued); never after a timeout or 5xx. The reference docs say so. - Exported types
SendEmailOptions,SendEmailResult,EmailTemplateFile,EmailWarning; generated references and a changeset.
A superseded hash sends the current content with a template_superseded warning and the SDK does not upload; only a hash PostHog never published triggers an upload (Card 2 → i, decided).
Test seams.
- The hash function against the spec's test vectors (the only unit test; it must match the server byte for byte).
emails.sendandemails.uploadTemplateagainst a fake PostHog API that implements the hash rule: key send; file send with a current hash (one request); first file send (template_not_found→ upload → send); edited file (template_outdated→ upload → send); superseded hash (warning, no upload); a secondtemplate_outdatedreturned as the error; upload failure returned; each error shape mapped from the HTTP body; network failure with no retry;missing_secret_keybefore any request. Prior art: the fake messaging preferences API in Filter for people with session recordings on persons modal PostHog/posthog#5173.
Write scope. packages/node/src/client.ts, a new packages/node/src/emails.ts (and a small hash module), packages/node/src/types.ts, packages/node/src/exports.ts, packages/core/src/posthog-core-stateless.ts (requestJson), packages/core/src/types.ts, the tests, generated references, changesets. Use pnpm. Verify that @current resolves for a project secret API key on these routes; if not, take the project id from the client config.
Proof.
- Red: a test calls
posthog.emails.sendand fails (no method). - Green: every case above passes.
- Local: a scratch Node project links the local
posthog-node, pointshostat the local dev stack, and sendsemails/welcome.htmlthrough a file reference with no prior upload; the email lands in the dev mail catcher. Edit the<title>, send again: the new subject arrives. Run an older copy of the file: the send prints thetemplate_supersededwarning and the email keeps the new subject. Request log of the three runs in the PR.
Acceptance criteria
- A send from a file reference works against a fresh project with no upload step.
- An older file never puts old content back.
- The call never throws on API or network errors; no idempotency option, no retries after a possible queue.
- The hash matches the spec's test vectors.
- Types are exported and the reference docs regenerate.
How to build (map #461)
/tddred-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.- Repo skills apply where the diff touches their area:
/writing-tests,/improving-drf-endpoints,/django-migrations,/adding-api-scopes,/implementing-mcp-tools,/writing-ui-components,/writing-kea-logics,/writing-user-facing-copy,/writing-code-comments. - Read code from
upstream/master(PostHog/posthog). The fork'smasteris stale. - Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use
example.com. The private note the map points at is background only; never quote it. - Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge.
Activity
[-]posthog-node: emails.send returning { data, error }[/-][+]posthog-node: emails.send a code-managed template by key or file, returning { data, error }[/+][-]posthog-node: emails.send a code-managed template by key or file, returning { data, error }[/-][+]posthog-node: emails.send from a key or an HTML file, synced by content hash, returning { data, error }[/+]Silthus commentedon Oct 16, 2026
Runtime: T3 child task
Task:
node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-474-2Cast: Opus 5.5, high. Cap 240 minutes.
Silthus commentedon Oct 17, 2026
Resolution
Shipped: draft PostHog/posthog-js#5312, branch
Silthus:feat/node-emails-sendagainstmain. Reviewed head363b0e332a9.fleet proof gatepasses on it (live-run and cross-version evidence at HEAD, HEAD reviewed).What shipped
posthog.emails.send({ template, to, data?, distinctId? }), a lazy getter on the Node client, through core'srequestJson(copied from Filter for people with session recordings on persons modal PostHog/posthog#5173, plus aretryOptionsargument).templateis a key string (Node and edge entries) or anEmailTemplateFilefromemails.templateFile(url | absolutePath, { key?, name? })(Node entry only,NodeEmailstype).{ key, hash }. Only409 template_outdatedor404 template_not_foundupload (withsource.path) and send once more.template_supersededcomes back indata.warningsand is logged once per key and hash.emails.uploadTemplate(file, { source? })for the rollback path.{ data, error }, never throws. Email requests run once (retryCount: 0).minorchangeset for posthog-node,patchfor@posthog/core.Proof
Red:
emails.spec.tsbefore the client hademails:Green on
363b0e332a9:email-content-hash.spec.ts: 3 passed (both spec vectors, plus line endings).emails.spec.ts: 29 passed againstFakeEmailsApi, which implements the hash rule, the upload results and the error envelope.template_outdatedreturned, a failed upload returned, API error shapes, a 5xx carryingtemplate_outdated(no upload, no resend), a network failure with no retry, and each refusal before any request.source, no subject whitespace collapse, no preheader decoding. The linear-time case times out (5000 ms) on the earlier parser.Local gate on
363b0e332a9, viafleet heavy:pnpm format,pnpm lint:oxlint, nodelintandcheck-types: pass.test:unit1198 passed,test:edge-compat1198 passed.generate-references: pass.Local live run on
363b0e332a9. Django and the CDP workflows worker run fromspec/code-managed-templates(e5234aa0bf9) on a fresh project, with aphs_key (hog_flow:write), a verified maildev sender, and the flag forced on by the #487/#488 harness. A scratch Node project links the localposthog-nodeand sendsemails/welcome.htmlwith no prior upload.Request log of the three runs, and the mail catcher
content_hash: run 2's resend answered 202, and run 3's old hash was recognized as superseded.@currentresolves thephs_key's project on both routes, so the SDK needs no project id.Review
GPT-6.1 Sol at high effort through T3, recorded with
fleet proof review-startandreview-finish: one full round and four final reads.<meta>quotes and comments, and the fake'sunchangedrule. Rejected 2 with a reason (below).<!--, quadratic scanning, and a second<title>body. All three are fixed with red tests first.Calls made
retryCount: 0). Core's transport retries timeouts and 5xx by default, which would break "no retry after a possible queue".api_error(an answer without a code) andunexpected_error(any failure outside HTTP), so the call never rejects.source.pathis the file's path relative to the working directory. An explicitsourcewins.EmailWarningis{ code, message }, mapping the API'sdetailtomessagelike the error. The API'sfixis not carried.<title>(bundle size). Numeric references and& < > " ' are decoded, and the TSDoc says so.requestJsonin core matches Filter for people with session recordings on persons modal PostHog/posthog#5173's helper apart from theretryOptionsargument. Whichever PR merges second drops its copy and keeps the argument.Silthuslacks the permission on PostHog/posthog-js).Follow-ups
template_outdatedcan republish content that a newer deploy superseded within the same round trip. That happens if another instance publishes this hash and a newer one supersedes it in that window. Closing it needs the upload route to accept a flag that refuses a hash insuperseded_content_hashesunless the upload is explicit, and the SDK to send it on auto-uploads.sync_hog_function_templatesrun against Node's template API before start, and a private SES Valkey with@posthog/ses/email-mx/example.com = 1to deliver toexample.com.