Repository navigation
Conversation
This was referenced Oct 17, 2026
Closed
Closed
Closed
Closed
This was referenced Oct 22, 2026
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>
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>
4 of 5 tasks
- 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>
4 of 5 tasks
Silthus
force-pushed
the
feat/node-emails-send
branch
from
October 23, 2026 14:32
ad0a833 to
195dbf5
Compare
…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>
Author
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions from the QA swarm rounds on the restacked stack.
|
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>
This was referenced Oct 24, 2026
Draft
This was referenced Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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_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, together with the code form. Run cost: n/a.Merge Danger
Caution
🔴 Merge risk: high · one-way door (merge-risk rubric)
posthog.emails, the optionsappHost,maxRetriesandmigratedFrom) and a new corerequestJsonthat 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.emails.send. Not yet run against a live server (gap below).Changes
main)templateId(a UUID) with optionaltemplateData, posted toPOST /api/projects/@current/messaging_templates/{id}/send/as{ to, message_data?, identifiers? }.{ 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.senddoesn't take, such as Resend'sscheduledAt, returnsinvalid_formwith itsfieldbefore any request, instead of being dropped. Each layer above adds its own fields to the known set.400 transactional_email_pausedfor a paused workflow. SDK codes:missing_secret_key,missing_app_host,client_disabled,client_closedfor a send aftershutdown()or while it drains,network_error(its message keeps the cause, such asgetaddrinfo ENOTFOUND, or each address'sECONNREFUSEDfor a host with two addresses),api_error,unexpected_error, plusinvalid_form,invalid_template_idandinvalid_idempotency_keyfor a send refused before any request.Idempotency-Key, a UUID v4 when none is given. A replayed key returns the firstid, also after the workflow was paused or the default sender changed; theidempotencyKeydocs say what still refuses. Network errors,409 idempotency_key_in_flight,429 throttledand503retry with the same key: 1 s, then 2 s, with ±20 % jitter, or a longerRetry-After, capped at 30 s. A202whose body breaks off while it is read counts as a network error, so the retry returns the queued email'sid.429 quota_limitednever retries.maxRetriesis a client option (default 2) and a per-send override; a value that is not a whole number from 0, such asNaN, falls back to the default.us.i.posthog.com,us.posthog.com,app.posthog.com, and theeuones). Any otherhostwithoutappHostfails withmissing_app_hostand sends nothing.User-Agent: posthog-node/<version>,X-PostHog-Migrated-FromwhenmigratedFromis set. No client events, no request context.secretKeyalso starts local flag evaluation. The docs say to setenableLocalEvaluation: falsefor a key that can't read feature flags.email-content-hash.tsand its tests land here; the code form (#5371) uses it.requestJsonsends a JSON request through the shared transport, returns the answer'sRetry-AfterasretryAfterMs, and lets a body that breaks off fail as a network error.Parity with the contract and posthog-python
to,distinct_id,template_datato,identifiers.distinct_id(absent: left out),message_data(absent: left out)message_data,{}when absenttemplate_id/messaging_templates/{id}/send/; not a UUID:invalid_template_idValueErroridempotency_keyinvalid_idempotency_keyValueErrormax_retries0off; not a whole number from 0: the defaultValueError409 idempotency_key_in_flight,429exceptquota_limited,503Retry-Afterwhen longer, capped at 30 sRetry-Afterexceeds 30 sapp_hostmissing_app_hostapp_host_requiredmigrated_fromX-PostHog-Migrated-Fromclient_disabledid: Noneshutdown()client_closed, from the momentshutdown()is called{ id, warnings }id,warnings, andreplayed400validation_error401,missing_secret_keymissing_api_key403invalid_permission404not_found409in flightconcurrent_idempotent_requests409 template_outdated, other409validation_errortemplate_outdated: same; other409:concurrent_idempotent_requests422 idempotency_key_reusedinvalid_idempotent_request429 throttled/quota_limitedrate_limit_exceeded/monthly_quota_exceeded503service_unavailable5xx,unexpected_errorapplication_errorapi_error(an answer without a code)503retriesnetwork_errorservice_unavailablemissing_app_host,client_disabled,client_closedapplication_errorapp_host_required:application_errordebug)WARNINGposthog-node/<version>posthog-python/<version>Evidence
emailsnamespace on a realPostHogclient,fetchmocked at the HTTP boundary byFakeEmailsApi, an in-memory app host with the template route and its idempotency rule (emails.spec.ts). Retries run on fake timers. Core'srequestJsonis driven through the same seam.Authorization,Idempotency-Keyas a UUID v4,User-Agent,X-PostHog-Migrated-From.idand delivers one email.transactional_email_paused), each SDK code (client_closedaftershutdown()and while it drains),invalid_formfor missing,nulland string options and for an optionsenddoesn'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.Retry-After5 s honored and 120 s capped at 30 s,maxRetries: 0on the client and on the send, a send raising it,NaN,Infinity,-1and1.5falling back, a202whose body breaks off retried to one delivered email, no retry forquota_limited, an edited-key409,400and500.missing_app_hostwith no request for a proxy host, the request onappHostand never onhost.client_closedcases failed before it, then green. The contract alignment rows (transactional_email_paused) passed at once: the status table already named them.3b3e05fa: nodevitest(1305 passed), the edge build tests (1305 passed),tscfor the build and the type tests,oxlint,oxfmt, build andgenerate-references; corevitest.Idempotency-Keyand routes against the server's released and next versions): gap, for the same reason.mainhas 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,5a896e92and87e9e95cshowed 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
Checklist
If releasing new changes
pnpm changesetto 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.
send{ data, error }, Resend's error namestemplateFile()anduploadTemplate()sendby hashnetwork_errorisservice_unavailable,missing_secret_keyismissing_api_key, the othersapplication_error; local checks arevalidation_errorclient_closedstartsshutdown()is called, so a send during the drain is refused too; the drain would not wait for itRetry-Afterover 30 stemplateIdand a malformed key withvalidation_error, so a bad value never reaches the URL or a headermaxRetriesofNaN,Infinityor a negative numbersenddoesn't take, such as Resend'sscheduledAtinvalid_formon its field before any request, instead of being dropped; a list of Resend-only names would miss typos and future fieldsclient.disable()disable()is the analytics opt-out, and transactional email such as a password reset must not stop with it; thedisabledoption stops sendsdebugdebug, as the contract names; also returned indata.warningsOpen:
app_host_requiredvsmissing_app_host, giving up on a longRetry-After,replayedin the result, a disabled client returning a result, and noclient_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