Skip to content

posthog-node: emails.send from a key or an HTML file, synced by content hash, returning { data, error } #474

Closed

Description

@Silthus

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) with Authorization: Bearer <secretKey>. data becomes message_data (read as {{ variables.* }}); distinctId becomes identifiers.distinct_id, for attribution only.
  • template is a key string (sends {template: {key}}), or an EmailTemplateFile from emails.templateFile(url, { key?, name? }): a file URL or absolute path to an .html file, 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}}. On 409 template_outdated or 404 template_not_found it uploads through the upload route (#487) with source.path, then retries the send once. A template_superseded warning comes back in data.warnings and 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 } (id from message_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.send and emails.uploadTemplate against 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 second template_outdated returned as the error; upload failure returned; each error shape mapped from the HTTP body; network failure with no retry; missing_secret_key before 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.send and fails (no method).
  • Green: every case above passes.
  • Local: a scratch Node project links the local posthog-node, points host at the local dev stack, and sends emails/welcome.html through 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 the template_superseded warning 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)

  • /tdd red-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's master is 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

  1. changed the title [-]posthog-node: emails.send returning { data, error }[/-] [+]posthog-node: emails.send a code-managed template by key or file, returning { data, error }[/+] on Oct 16, 2026
  2. changed the title [-]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 }[/+] on Oct 16, 2026
  3. self-assigned this
    on Oct 16, 2026
  4. Silthus commented on Oct 16, 2026

    @Silthus
    OwnerAuthor

    Runtime: T3 child task
    Task: node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-474-2
    Cast: Opus 5.5, high. Cap 240 minutes.

  5. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Shipped: draft PostHog/posthog-js#5312, branch Silthus:feat/node-emails-send against main. Reviewed head 363b0e332a9. fleet proof gate passes 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's requestJson (copied from Filter for people with session recordings on persons modal PostHog/posthog#5173, plus a retryOptions argument).
    • template is a key string (Node and edge entries) or an EmailTemplateFile from emails.templateFile(url | absolutePath, { key?, name? }) (Node entry only, NodeEmails type).
    • File sends read the file and send { key, hash }. Only 409 template_outdated or 404 template_not_found upload (with source.path) and send once more. template_superseded comes back in data.warnings and 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).
    • Content hash module, tested against the spec vectors.
    • Exported types, regenerated references, minor changeset for posthog-node, patch for @posthog/core.

    Proof

    Red: emails.spec.ts before the client had emails:

    TypeError: Cannot read properties of undefined (reading 'send')
     ❯ src/__tests__/emails.spec.ts:37:43
    Tests  1 failed (1)
    

    Green on 363b0e332a9:

    • email-content-hash.spec.ts: 3 passed (both spec vectors, plus line endings).
    • emails.spec.ts: 29 passed against FakeEmailsApi, which implements the hash rule, the upload results and the error envelope.
      • Covers: key send, a current hash (one request), first file send (404, upload, send), edited file (409, upload, send), superseded hash (warning, no upload), a second template_outdated returned, a failed upload returned, API error shapes, a 5xx carrying template_outdated (no upload, no resend), a network failure with no retry, and each refusal before any request.
    • Mutations, each caught: retrying email requests, never uploading, uploading on superseded, warning every time, retrying until sent, no key check, dropping an explicit source, no subject whitespace collapse, no preheader decoding. The linear-time case times out (5000 ms) on the earlier parser.

    Local gate on 363b0e332a9, via fleet heavy:

    • pnpm format, pnpm lint:oxlint, node lint and check-types: pass.
    • node test:unit 1198 passed, test:edge-compat 1198 passed.
    • core unit 2094 passed, 1 skipped (in an existing spec).
    • Build and generate-references: pass.

    Local live run on 363b0e332a9. Django and the CDP workflows worker run from spec/code-managed-templates (e5234aa0bf9) on a fresh project, with a phs_ key (hog_flow:write), a verified maildev sender, and the flag forced on by the #487/#488 harness. A scratch Node project links the local posthog-node and sends emails/welcome.html with no prior upload.

    Request log of the three runs, and the mail catcher
    ### Run 1: send emails/welcome.html, never uploaded
        <title>Welcome, {{ variables.first_name }}</title>
    POST /api/projects/@current/messaging/send/email/ {"key":"welcome","hash":"v1:3a47735c08dc242c5efea3a3237e6d68ebe055b87fea0c3dfcd96481ef04dde2"} -> 404
    PUT /api/projects/@current/messaging_templates/by_key/welcome/ {"subject":"Welcome, {{ variables.first_name }}"} -> 201
    POST /api/projects/@current/messaging/send/email/ {"key":"welcome","hash":"v1:3a47735c08dc242c5efea3a3237e6d68ebe055b87fea0c3dfcd96481ef04dde2"} -> 202
    result {"data":{"id":"…","warnings":[]},"error":null}
    
    ### Run 2: edit the <title>, send again
        <title>Welcome aboard, {{ variables.first_name }}</title>
    POST /api/projects/@current/messaging/send/email/ {"key":"welcome","hash":"v1:4ab72f2969940e32de8d7c744c431f2a45ba44ee5948c49ef638d316351e96dd"} -> 409
    PUT /api/projects/@current/messaging_templates/by_key/welcome/ {"subject":"Welcome aboard, {{ variables.first_name }}"} -> 200
    POST /api/projects/@current/messaging/send/email/ {"key":"welcome","hash":"v1:4ab72f2969940e32de8d7c744c431f2a45ba44ee5948c49ef638d316351e96dd"} -> 202
    result {"data":{"id":"…","warnings":[]},"error":null}
    
    ### Run 3: an older copy of the file (the first <title>) sends
        <title>Welcome, {{ variables.first_name }}</title>
    POST /api/projects/@current/messaging/send/email/ {"key":"welcome","hash":"v1:3a47735c08dc242c5efea3a3237e6d68ebe055b87fea0c3dfcd96481ef04dde2"} -> 202
    [PostHog] The email template "welcome" was sent with newer content than this file (v1:3a47735c…dde2). Deploy the newer file, or call posthog.emails.uploadTemplate() to publish this one again.
    result {"data":{"id":"…","warnings":[{"code":"template_superseded","message":"The email was sent with the template's current content, because newer content replaced the content you hold."}]},"error":null}
    
    ### The dev mail catcher afterwards
    {"to": "jane@example.com", "subject": "Welcome, Jane"}
    {"to": "jane@example.com", "subject": "Welcome aboard, Jane"}
    {"to": "jane@example.com", "subject": "Welcome aboard, Jane"}
    
    • The Node hash equals the server's stored content_hash: run 2's resend answered 202, and run 3's old hash was recognized as superseded.
    • @current resolves the phs_ 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-start and review-finish: one full round and four final reads.

    • Round 1: 6 findings. Fixed 4: the 5xx retry, never-throws gaps, <meta> quotes and comments, and the fake's unchanged rule. Rejected 2 with a reason (below).
    • Final reads 1 to 3 each found one parser issue: an attribute holding <!--, quadratic scanning, and a second <title> body. All three are fixed with red tests first.
    • Final read 4: no findings, PASS.

    Calls made

    • Email requests never retry (retryCount: 0). Core's transport retries timeouts and 5xx by default, which would break "no retry after a possible queue".
    • Two SDK codes beyond the spec's list: api_error (an answer without a code) and unexpected_error (any failure outside HTTP), so the call never rejects.
    • source.path is the file's path relative to the working directory. An explicit source wins.
    • EmailWarning is { code, message }, mapping the API's detail to message like the error. The API's fix is not carried.
    • Rejected: decoding every HTML5 named reference in <title> (bundle size). Numeric references and &amp; &lt; &gt; &quot; &apos; &nbsp; are decoded, and the TSDoc says so.
    • The template file scan is linear, and stops at a tag that never closes, as a browser does.
    • requestJson in core matches Filter for people with session recordings on persons modal PostHog/posthog#5173's helper apart from the retryOptions argument. Whichever PR merges second drops its copy and keeps the argument.
    • The assignee could not be set on the upstream PR (Silthus lacks the permission on PostHog/posthog-js).
    • Upstream Actions do not run automatically on this fork PR. Only the Wiz scanners ran (pass), so the local gate is the full check until a maintainer approves the workflows.

    Follow-ups

    • Upload race (server, then SDK): an auto-upload after template_outdated can 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 in superseded_content_hashes unless the upload is explicit, and the SDK to send it on auto-uploads.
    • A real SES send from a deployed project once the server routes deploy.
    • Local proof setup worth a dev-stack note: the CDP worker needs sync_hog_function_templates run against Node's template API before start, and a private SES Valkey with @posthog/ses/email-mx/example.com = 1 to deliver to example.com.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions