Repository navigation
Conversation
Moves the reviewed send surface of PostHog#5312 (feat/node-emails-send at 3b3e05f) onto the synced main, without the content hash module that only the local template slice uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by default A send with templateKey, subject and html or text posts the content to /messaging/send/email/, where PostHog sends it as is. The template form keeps its route until the server moves it. Every send carries capture_engagement: true unless it passes captureEngagement: false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gement setting for now 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>
posthog-js Compliance ReportDate: 2026-10-25 19:24:48 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
…cond send argument
A send checks its options against the field set of its form, so a field
the other form takes names that form. A second argument, such as Resend's
{ idempotencyKey }, is refused instead of dropped. requestJson sends one
attempt, and the email headers take User-Agent from the client's headers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d second argument A field that only the other form takes now carries `why` and `fix` on the error, instead of a bare pointer to the other form. Each form's field set is derived from its option types, so a typed field missing from it fails to compile. A wrapper that passes `undefined` as a second argument sends as before. 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: 815f192 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. |
|
Size Change: +39.6 kB (+0.16%) Total Size: 25.2 MB 📦 View Changed
ℹ️ View Unchanged
|
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made in the agent session and the review rounds:
|
…esponse Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…s nothing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of the code-first rebuild of posthog-node's email API; replaces parts of PostHog#5312 and PostHog#5371.
Problem
A server that sends transactional email with PostHog wants one call on the client it already has, and the first thing most teams send is HTML their own code rendered. The shared SDK contract is PostHog/posthog#120921.
This PR adds
posthog.emails.send()with the code form first: inline HTML, named by atemplateKey, posted tosend/email/. A template made in PostHog still sends by its id on the route that exists today.Bottom of the rebuilt stack for map Silthus/posthog#461. Above it: the template form on
send/email/(#6), local templates by hash (#7), cc, bcc, headers, tags and attachments (#8),emails.get(#9).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: send attribution plus its insight). In scope: Messaging, email. Expected move: medium, up, together with the rest of the stack. Run cost: n/a.Merge Danger
Caution
🔴 Merge risk: high · one-way door (merge-risk rubric)
posthog.emails, the optionsappHost,maxRetriesandmigratedFrom) and a new corerequestJson. 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.send/email/, which is not on PostHog master yet. That server must acceptcapture_engagementandreply_to, because this SDK sends them. The server chain through slice 09 (feat(messaging): send each email once per idempotency key posthog#578) passed the live run below.Idempotency-Key, so a503that this SDK retries with the same key can send the email twice. The live run below shows two emails for one key before slice 09 and one email after it.Changes
main)templateKey(letters, digits,_and-, up to 400),subject, andhtmlortextor both, with optionalfromandreplyTo. Posted toPOST /api/projects/@current/messaging/send/email/as{ template_key, subject, html?, text?, from?, reply_to?, to, identifiers?, capture_engagement }. A malformed key returnsinvalid_template_key; a missing subject, missing content, non-string content ortemplateDataon a code send returninvalid_formwith itsfield.templateId(a UUID) with optionaltemplateData, posted toPOST /api/projects/@current/messaging_templates/{id}/send/. A code-only field on a template send returnsinvalid_form.captureEngagementdefaults totrueon every form and is always sent ascapture_engagement. The docs say a project whose admin turned capture off captures nothing, whatever the send asks, and that the template route still follows the project setting until feat(node): send a template by id on the send/email route #6 moves it.{ data: { id, warnings }, error: null }or{ data: null, error: { name, code, message, ... } }, with Resend's error names. It never throws.invalid_formon that field before any request, and the message names the other form when that one takes it. This covers code fields on a template send,templateDataon a code send, and Resend options such asscheduledAt. A second argument, such as Resend'ssend(payload, { idempotencyKey }), also returnsinvalid_forminstead of sending with a random key.Idempotency-Key, a UUID v4 when none is given. Network errors,409 idempotency_key_in_flight,429 throttledand503retry with the same key: 1 s, then 2 s, ±20 % jitter, or a longerRetry-Aftercapped at 30 s.maxRetriesis a client option (default 2) and a per-send override. The same key with other options, such as anothercaptureEngagementorreplyTo, returnsinvalid_idempotent_request(422), and theidempotencyKeydocs say so.hostwithoutappHostfails withmissing_app_hostand sends nothing.requestJsonsends one JSON request through the shared transport and returnsRetry-AfterasretryAfterMs;posthog.emailsowns the retries.Unchanged from the reviewed PostHog#5312: the client wiring, error table, retries, app host,
client_closed, warnings and corerequestJson. Left out until #7: the content-hash module.Evidence
posthog.emails.send()on a realPostHogclient withfetchmocked byFakeEmailsApi, an in-memory app host with both routes and the idempotency rule (emails.spec.ts); the accepted and refused option shapes as type tests (__tests__/types/emails-forms.ts).capture_engagementtruewhen absent andfalsewhen passed on both forms;invalid_template_keyand eachinvalid_formrefusal before any request, including a second argument.5c10c83c: nodevitest(1322 passed), edge build tests (1322 passed),tscfor the build and type tests,oxlint,oxfmt, build,generate-referencescommitted; corevitest.af5fe5e0adds the react-native references for the new core typePostHogApiResponse, andpnpm check:public-apipasses on it.Live run, done on 2026-10-26 at
815f1924, against Django at feat(messaging): send each email once per idempotency key posthog#578c4c5ff05d2f(slice 09, with 05e20426aa1e5, 066730e409b4band 08d0eeb308f962below it) and cdp-api at feat(cdp): replay a keyed send after its workflow or default sender changes posthog#5801a43bdda327, on a local stack with a dev mail catcher:idempotencyKeywithreplyToReply-To: help@example.comreplyToinvalid_idempotent_request,idempotency_key_reused, 422captureEngagement: falseinvalid_idempotent_request,idempotency_key_reused, 422Earlier live runs, on the code-send slices (feat(cdp): deliver a code send's content on its own transactional email route posthog#565, fix: remove warning of duplicate nextjs import PostHog/posthog-js#566, chore: Revert canvas recording option PostHog/posthog-js#567 at
8726a3ba6ba) and slice 09 ata02de53f5f3captureEngagement: falsecapture_engagement: falsereplyToReply-To: support@example.comidempotencyKeytwiceidempotencyKeytwice, against slice 09 (a02de53f5f3)from{ data: null, error }withsender_not_verified,why,fix, 400; no throwCross-version (the code form against a server without the inline-HTML arm): gap, same reason. A server without it answers
404, which maps tonot_found.Shadow diff: gap.
mainhas no email API, so there is no earlier output to compare.Production shape: gap until read-only production access is available.
Decisions
captureEngagementdefaults totrueon every form, always sentmigratedFromwhyandfix; replaced a union allowlist plus a template-form blocklistsendargumentinvalid_form,undefinedpasses; not accepted as a second way to passidempotencyKeyOpen: release #5 together with #6 and after the server's inline-HTML arm is deployed, so
captureEngagement: falsenever goes to the legacy template route (recommended), or refusefalseon the template form until #6.Involvement: one design pass (2 findings: 1 fixed, 1 rejected), review round 1 (GPT-6.1 Sol: Correctness, Risk, Design, Anti-Overengineering, two Team perspectives; Opus 5.5: Fresh eyes; 4 fixed, 4 rejected, 2 for Michael), round 2 (GPT-6.1 Sol: Design, Anti-Overengineering; Opus 5.5: Fresh eyes; 3 fixed), one final read (clean), live run captured.
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)
claude-opus-5-5) in Claude Code, as build lane js2 of map Map: send code-managed email templates with one PostHog call posthog#461, directed by Michael. The code comes from the reviewed stack feat(node): send transactional email with the flat SDK contract PostHog/posthog-js#5312, feat(node): send email content from code, uploading local templates by hash PostHog/posthog-js#5371, feat(node): send cc, bcc, headers, tags and attachments with a code send PostHog/posthog-js#5373 and feat(node): read an email's status with emails.get PostHog/posthog-js#5372 (closed; fork copies feat(node): send transactional email with the flat SDK contract #1–feat(node): read an email's status with emails.get #4), restacked code-first. The equality with each reviewed head is checked by diff and listed in the lane ledger.🤖 Generated with Claude Code