Skip to content

feat(node): send transactional email with the flat SDK contract - #5312

Closed
Silthus wants to merge 14 commits into
PostHog:mainfrom
Silthus:feat/node-emails-send
Closed

Silthus wants to merge 14 commits into
PostHog:mainfrom
Silthus:feat/node-emails-send

Conversation

@Silthus

@Silthus Silthus commented Oct 17, 2026 •

Copy link
Copy Markdown

Problem

A server that sends transactional email with PostHog wants one call on the client it already has, with the same answers in Node and Python. The shared SDK contract is PostHog/posthog#120921. posthog-python builds the template form now (PostHog/posthog-python#1093).

This PR makes posthog.emails.send() in posthog-node match that contract for templates made in PostHog, on the route that exists today:

const posthog = new PostHog('phc_...', { secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, migratedFrom: 'resend' })

const { data, error } = await posthog.emails.send({
  to: 'jane@example.com',
  distinctId: user.id,
  templateId: '0190f6d2-7c1e-4b3a-9f5e-1c2d3e4f5a6b',
  templateData: { firstName: 'Jane' },
  idempotencyKey: `welcome:${user.id}`,
})

Slice 09 of strand Silthus/posthog#536, map Silthus/posthog#461. The code form stacks on this PR (#5371), then its extra fields (#5373), then emails.get (#5372).

Impact

Pays into Grow Workflows through billed workflows_emails from 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, together with the code form. Run cost: n/a.

Merge Danger

Caution

🔴 Merge risk: high · one-way door (merge-risk rubric)

  • A new public API in posthog-node (posthog.emails, the options appHost, maxRetries and migratedFrom) and a new core requestJson that other SDKs can build on. Once released, the names, error codes and retry rules are a contract callers depend on, and a send that reaches PostHog delivers a real email.
  • The secret key goes only to the app host, and every send carries one idempotency key across its retries. Focused tests at the HTTP boundary pin each request, error row and retry; nothing calls the API until a caller adds emails.send. Not yet run against a live server (gap below).

Changes

Kind Files Lines (this PR, against main)
Production 7 +605 −0
Tests 3 +634 −0
References and changesets 3 +217 −1
  • Template form. templateId (a UUID) with optional templateData, posted to POST /api/projects/@current/messaging_templates/{id}/send/ as { to, message_data?, identifiers? }.
  • Result. { data: { id, warnings }, error: null } or { data: null, error: { name, code, message, why?, fix?, statusCode?, docsUrl?, field? } }. It never throws, also not for options that are not an object.
  • Unknown options. An option send doesn't take, such as Resend's scheduledAt, returns invalid_form with its field before any request, instead of being dropped. Each layer above adds its own fields to the known set.
  • Error names. One table maps every answer to Resend's names (below), including 400 transactional_email_paused for a paused workflow. SDK codes: missing_secret_key, missing_app_host, client_disabled, client_closed for a send after shutdown() or while it drains, network_error (its message keeps the cause, such as getaddrinfo ENOTFOUND, or each address's ECONNREFUSED for a host with two addresses), api_error, unexpected_error, plus invalid_form, invalid_template_id and invalid_idempotency_key for a send refused before any request.
  • Idempotency and retries. Every call carries an Idempotency-Key, a UUID v4 when none is given. A replayed key returns the first id, also after the workflow was paused or the default sender changed; the idempotencyKey docs say what still refuses. Network errors, 409 idempotency_key_in_flight, 429 throttled and 503 retry with the same key: 1 s, then 2 s, with ±20 % jitter, or a longer Retry-After, capped at 30 s. A 202 whose body breaks off while it is read counts as a network error, so the retry returns the queued email's id. 429 quota_limited never retries. maxRetries is a client option (default 2) and a per-send override; a value that is not a whole number from 0, such as NaN, falls back to the default.
  • App host. Email requests, and with them the secret key, go only to the app host. It is derived for PostHog Cloud (us.i.posthog.com, us.posthog.com, app.posthog.com, and the eu ones). Any other host without appHost fails with missing_app_host and sends nothing.
  • Headers. User-Agent: posthog-node/<version>, X-PostHog-Migrated-From when migratedFrom is set. No client events, no request context.
  • Warnings are returned and logged once each through the client's logger.
  • Secret key docs. secretKey also starts local flag evaluation. The docs say to set enableLocalEvaluation: false for a key that can't read feature flags.
  • Content hash. email-content-hash.ts and its tests land here; the code form (#5371) uses it.
  • Core. requestJson sends a JSON request through the shared transport, returns the answer's Retry-After as retryAfterMs, and lets a body that breaks off fail as a network error.

Parity with the contract and posthog-python

Contract row posthog-node (this PR) posthog-python (#1093)
to, distinct_id, template_data to, identifiers.distinct_id (absent: left out), message_data (absent: left out) Same, but always sends message_data, {} when absent
template_id Path /messaging_templates/{id}/send/; not a UUID: invalid_template_id Same path; not a UUID: ValueError
idempotency_key Header; generated UUID v4; malformed: invalid_idempotency_key Same; malformed: ValueError
max_retries Client default 2, per-send override, 0 off; not a whole number from 0: the default Client option, no per-send override; invalid: ValueError
Retry set Network, 409 idempotency_key_in_flight, 429 except quota_limited, 503 Same
Backoff 1 s, 2 s ±20 %, or Retry-After when longer, capped at 30 s Same, but gives up when Retry-After exceeds 30 s
app_host Derived for Cloud, else missing_app_host Derived for Cloud, else the host as given; error code app_host_required
migrated_from X-PostHog-Migrated-From Not yet
Disabled client Error client_disabled Returns a result with id: None
Send after shutdown() Error client_closed, from the moment shutdown() is called Not yet
Result { id, warnings } id, warnings, and replayed
400 validation_error Same
401, missing_secret_key missing_api_key Same
403 invalid_permission Same
404 not_found Same
409 in flight concurrent_idempotent_requests Same
409 template_outdated, other 409 validation_error template_outdated: same; other 409: concurrent_idempotent_requests
422 idempotency_key_reused invalid_idempotent_request Same
429 throttled / quota_limited rate_limit_exceeded / monthly_quota_exceeded Same
503 service_unavailable Same
Other 5xx, unexpected_error application_error Same
api_error (an answer without a code) The name and retry of its status, so a proxy's 503 retries Same, as the contract says since 2026-10-24
network_error service_unavailable Same
missing_app_host, client_disabled, client_closed application_error app_host_required: application_error
Warnings Logged once each through the client logger (prints with debug) Logged at WARNING
User-Agent posthog-node/<version> posthog-python/<version>

Evidence

  • Test seams: the emails namespace on a real PostHog client, fetch mocked at the HTTP boundary by FakeEmailsApi, an in-memory app host with the template route and its idempotency rule (emails.spec.ts). Retries run on fake timers. Core's requestJson is driven through the same seam.
  • What the tests pin:
    • The request: path, origin, body per mapping row, Authorization, Idempotency-Key as a UUID v4, User-Agent, X-PostHog-Migrated-From.
    • A replayed key returns the first id and delivers one email.
    • One case per error-table row (14, with transactional_email_paused), each SDK code (client_closed after shutdown() and while it drains), invalid_form for missing, null and string options and for an option send doesn't take (scheduledAt), a numeric idempotency key, the network cause in the message, also each address of a refused connection, warnings returned and logged once.
    • Retries: the same key on all three attempts for each retried answer, the 1 s and 2 s waits, Retry-After 5 s honored and 120 s capped at 30 s, maxRetries: 0 on the client and on the send, a send raising it, NaN, Infinity, -1 and 1.5 falling back, a 202 whose body breaks off retried to one delivered email, no retry for quota_limited, an edited-key 409, 400 and 500.
    • App host: derived for six Cloud hosts, missing_app_host with no request for a proxy host, the request on appHost and never on host.
  • Red first: each review fix's cases failed before the fix (9 cases in round 1, 2 in round 2), then green. The 2 client_closed cases failed before it, then green. The contract alignment rows (transactional_email_paused) passed at once: the status table already named them.
  • Local gate on 3b3e05fa: node vitest (1305 passed), the edge build tests (1305 passed), tsc for the build and the type tests, oxlint, oxfmt, build and generate-references; core vitest.
  • Owed proofs:
    • Live run (a template send into the dev mail catcher, a replayed key, a paused workflow): gap. The server PRs are drafts being restacked; the end-to-end lane runs it with the install notes for the stack tip.
    • Cross-version (the Idempotency-Key and routes against the server's released and next versions): gap, for the same reason.
    • Shadow diff: gap. main has no email API, so there is no earlier output to compare.
Earlier evidence for a template file API this PR no longer ships

Live runs at 363b0e33, 5a896e92 and 87e9e95c showed template file uploads, the superseded-content race and source detection against a local PostHog. That API was dropped before release; its content hash carries over to the code form.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Michael (@Silthus) decided the contract and the slices in the spec. Built by Claude Code (Claude Opus 5.5) test-first, as one build lane of the map.

Question Answer Who Where
Shape of send Flat fields in two forms, { data, error }, Resend's error names 👤 Human #120921
Fate of templateFile() and uploadTemplate() Removed; local templates upload inside send by hash 👤 Human spec, decision 3
Error names of the SDK's own codes network_error is service_unavailable, missing_secret_key is missing_api_key, the others application_error; local checks are validation_error 👤 Human #120921, Behavior both SDKs share
When client_closed starts When shutdown() is called, so a send during the drain is refused too; the drain would not wait for it 🤖 Agent this PR
A Retry-After over 30 s Wait 30 s and retry, per the contract's "capped" 🤖 Agent this PR
Local checks before any request Refuse a non-UUID templateId and a malformed key with validation_error, so a bad value never reaches the URL or a header 🤖 Agent this PR
A maxRetries of NaN, Infinity or a negative number Fall back to the client option, then 2; refusing it would add an SDK code the contract lacks 🤖 Agent decisions comment
An option send doesn't take, such as Resend's scheduledAt Refused with invalid_form on its field before any request, instead of being dropped; a list of Resend-only names would miss typos and future fields 🤖 Agent decisions comment
Email after client.disable() Still sends: disable() is the analytics opt-out, and transactional email such as a password reset must not stop with it; the disabled option stops sends 🤖 Agent decisions comment
Warnings without debug Logged through the client logger, which prints only with debug, as the contract names; also returned in data.warnings 🤖 Agent decisions comment

Open:

  • posthog-python differs from the contract on app_host_required vs missing_app_host, giving up on a long Retry-After, replayed in the result, a disabled client returning a result, and no client_closed. Recommendation: align posthog-python to the contract.

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

This was referenced Oct 17, 2026
@Silthus Silthus changed the title feat(node): send transactional email from a key or an HTML template file feat(node): send transactional email with the flat SDK contract Oct 22, 2026
Silthus and others added 3 commits October 23, 2026 13:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 8 commits October 23, 2026 13:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upload after `template_outdated` now sets `if_not_superseded`. When a newer
deploy replaced the file's content between the send and the upload, PostHog
changes nothing and the one resend returns the `template_superseded` warning.
`uploadTemplate` still publishes replaced content, so it stays the rollback path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plate file

An upload of a template file now sends source.repository, source.path and
source.ref without configuration. Each part comes from the explicit source
option, then from GitHub Actions, GitLab CI, Vercel or Railway variables, then
from the nearest git checkout above the file. The checkout is read as files,
without running git, and credentials in a remote URL never leave the process.
Detection never fails a send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ments

Source detection read any file named like a git file, so a pipe in a .git
directory could hang a send. It now reads only regular files up to 4 MiB.
A Vercel provider named like an object property, such as "constructor", no
longer throws, and a quoted origin URL followed by a comment is read the way
git reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog.emails.send now takes the flat template form of the shared SDK
contract: templateId with templateData, posted to the template send
route. Every send carries an Idempotency-Key, a UUID v4 unless the
caller gives one, and retries network errors, an in-flight key,
throttling and 503 with the same key. A monthly quota is never retried.

Errors carry Resend's names, why and fix. New client options appHost,
maxRetries and migratedFrom. The secret key only goes to the app host:
derived for PostHog Cloud, required for any other host.

The unreleased template field, templateFile() and uploadTemplate() go
away. The content hash stays for local templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…refuses

A send to a workflow that isn't active answers 400 transactional_email_paused,
a validation_error. The idempotency key docs now say a replay survives a paused
workflow or a changed default sender, and still refuses a deleted or changed
template or a project with no default sender (PostHog/posthog#120921).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A send with options that are not an object returns invalid_form instead
  of throwing.
- A non-string idempotencyKey returns invalid_idempotency_key.
- A maxRetries that is not a whole number from 0 falls back to the client
  option, then to 2, so NaN or Infinity can't retry forever.
- A queued 202 whose body breaks off while it is read is a network error,
  so the send retries with the same key and returns the first id.
- A network error keeps its cause in the message.
- The secretKey docs say to turn off local evaluation for a key that can't
  read feature flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fused connection's cause

- A send with an option posthog-node doesn't take, such as Resend's
  scheduledAt, returns invalid_form on that field instead of sending
  without it.
- A refused connection to a host with two addresses keeps both causes in
  the network error message.
- Core parses a JSON answer with trySafe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Oct 24, 2026

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions from the QA swarm rounds on the restacked stack.

  • 🤖 A maxRetries of NaN, Infinity or a negative number Falls back to the client option, then 2. Refusing it would add an SDK error code the contract lacks.
  • 🤖 Email after client.disable() Still sends. disable() is the analytics opt-out, and transactional email such as a password reset must not stop with it. The disabled client option stops sends with client_disabled.
  • 🤖 Warnings without debug Logged through the client logger, which prints only with debug, as the contract names. Every warning is also returned in data.warnings.
  • 🤖 An option send doesn't take, such as Resend's scheduledAt Refused with invalid_form on its field before any request, instead of being dropped. A list of Resend-only names would miss typos and future fields. Each later layer adds its own fields to the known set.

A send after shutdown(), or while it drains, returns client_closed,
named application_error, before any request, as the shared contract's
2026-10-24 entry asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant