Repository navigation
Conversation
Add Client.Emails(), a port of posthog.emails from posthog-node and
posthog-python. Send takes a TemplateEmail (a template made in PostHog,
by ID) or a CodeEmail (content the app rendered, named by a key), posts
the flat body to /api/projects/@current/messaging/send/email/ on the app
host with the project secret key, and retries with one idempotency key.
Get reads /api/projects/@current/messaging/emails/{id}/. Close waits for
email requests in flight.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
posthog-go Compliance ReportDate: 2026-11-04 07:18:53 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
One request method sends, retries and classifies a reply that isn't a 2xx, so Send and Get parse a successful reply once. The Close test checks the retry reached the server before Close returned, instead of a race on the result channel. Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 53dd2f6: 2 Must fix, 3 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions for this PR:
|
…ng bytes - Send refuses a nil *TemplateEmail or *CodeEmail with invalid_form instead of panicking. - When the context ends during a retry wait, the error wraps ctx.Err(), so errors.Is finds context.DeadlineExceeded. - A reply that isn't JSON as a whole carries no fields, so a 2xx with trailing bytes is an api_error. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
goccy/go-json's MarshalNoEscape hides its argument from escape analysis, so a body built on the stack can move while it encodes. The Go 1.21 race tests caught it reading the template body as garbage and panicking. The email bodies now use the standard library encoder with HTML escaping off, and a test pins the HTML as written on the wire. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-add the stamphog label to request another review once you have addressed this.
Two substantive concerns from @PostHog remain unresolved, so its current-head commented review does not provide concern-free assurance for the risky changes. Address the interface compatibility concern in posthog.go and failed-response reads in emails.go, then request a fresh review from @PostHog or a human teammate before re-requesting.
- PostHog Review (Standard) reviewed the current head: 1 finding, posted on the PR.
- posthog[bot] reviewed the current head.
- @PostHog on posthog.go:156: Client still requires the additional Emails method, while .changeset/emails-send.md selects a minor release; external implementations lose interface compatibility. RELEASING.md:35-39 distinguishes backward-compatible features from breaking changes, and no independent acceptance of this concern is present.
- @PostHog on emails.go:504-511: response-body read failures are preserved only for successful HTTP statuses. A truncated 409 response loses its retryable error code and becomes a non-retryable API error, so the transport-recovery concern remains unfixed.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 868L, 5F substantive, 1937L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (1937L, 9F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 5417c2d · reviewed head 5417c2d |
A dropped connection after a 409's headers lost idempotency_key_in_flight and became a non-retryable api_error. A failed body read now is a network error for every status, so the client retries it with the same idempotency key. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 8cef8a9: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
- Cap EmailRequestMaxRetries and a send's MaxRetries at 9, as MaxRetries and FeatureFlagRequestMaxRetries are, so one send can't retry for hours and hold Close. - Match a PostHog Cloud endpoint written with upper case or the default :443 port to its app host. - Keep Retry-After on a 2xx answer that lacks its message ID or status. - Check UUIDs with google/uuid instead of a second pattern. - Merge the caller's context and the shutdown context once per request, which removes the separate shutdown branch of the retry wait. - Move the reasons in function bodies into named helpers, and correct the AppHost doc: SecretKey also feeds local flag evaluation on Endpoint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Approved.
The public API, email write path and secret-bearing requests have independent assurance from the current-head agent review. No concrete unresolved review concerns or other policy refusal grounds are present.
- PostHog Review (Standard) reviewed the current head: no findings.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 912L, 5F substantive, 2064L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (2064L, 9F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 80ee4a5 · reviewed head 80ee4a5 |
💡 Motivation and Context
A Go backend can't send transactional email through PostHog today. posthog-go has no emails API, while posthog-node and posthog-python send by template and, in PostHog/posthog-js#5509 and PostHog/posthog-python#1171, by rendered content too. This PR adds
Client.Emails().Sendtakes a template made in PostHog, by its ID, or content your code rendered, named by a key.Getreads the email's status by its message ID. Part of map Silthus/posthog#461.Impact
Pays into Grow Workflows through
workflows_emailsbilled actions, which count API sends (measured on the team's Workflows usage dashboard). In scope: Messaging, transactional email. Expected move: small, up, as it opens API sends to Go backends. Run cost: n/a.Caution
Merge risk: medium, one-way door once released.
EmailsAPI, theEmailErrorcodes and fourConfigfields a public contract.Clientgains theEmailsmethod, so a type that implementsClientitself stops compiling until it adds one. posthog-go addedFlushthe same way, in a minor release.template.idbody, so until then everySendgets a 400requiredontemplate.Getalready works. The cross-version run below shows both.What changes
Client.Emails()returnsEmails, withSend(ctx, Email)andGet(ctx, messageID). A no-op client'sEmailsrefuses every call withclient_disabled.Send.Emailis sealed and has two types, in the style ofEnqueue(Message):TemplateEmail{To, TemplateID, TemplateData, DistinctID, IdempotencyKey, MaxRetries}poststemplate_idandmessage_data.CodeEmail{To, TemplateKey, Subject, HTML, Text, From, ReplyTo, CaptureEngagement, DistinctID, IdempotencyKey, MaxRetries}posts a flat body.CaptureEngagementis a*bool, so it is on unless set to false.POST /api/projects/@current/messaging/send/email/andGET /api/projects/@current/messaging/emails/{id}/on the app host, withConfig.SecretKeyas the bearer token: the project secret API key that the flag poller already uses. The app host isConfig.AppHost, or the app host of a PostHog CloudEndpoint(us.igoes tous.posthog.com,eu.itoeu.posthog.com), matched without regard to case, a trailing slash or port 443. Any other endpoint, such as a proxy, needsAppHost: email requests never go toEndpoint.ReplyTo, the idempotency key andMaxRetries. The client state comes next: disabled, closed, no app host, no secret key.idempotency_key_in_flight, 429 exceptquota_limited, and 503 retry up toConfig.EmailRequestMaxRetries(default 2) or a send'sMaxRetries, each 0 to 9 like the client's other retry options. The wait is 2^n s with jitter, at most 30 s, orRetry-Afterwhen longer. ARetry-Afterover 30 s fails at once.Getretries the same way, as both reference SDKs do.*EmailErrorvalues with PostHog'sCode,Message,Why,Fix,DocsURL,Field,StatusCodeandRetryAfter, plus a coarseNamefrom the spec's table.errors.IsfindsErrSDKDisabled,ErrClosedandcontext.DeadlineExceededthroughUnwrap. An error never holds the secret key or the request body.SendEmailResult.Warnings.Closewaits for email requests in flight within its context, then cancels them.Configfields:AppHost,EmailRequestTimeout(default 15 s),EmailRequestMaxRetriesandMigratedFrom, which setsX-PostHog-Migrated-From.Validatechecks each.TemplateDataon a code send, the content hash, the upload by key), headers, tags, and any rendering dependency. Cc, bcc and attachments come in #408, stacked on this PR. The app renders the email and passesHTML.Spec divergence. PostHog/sdk-specs
openspec/specs/emails/spec.mdcovers the template form and the status read. The code form andCaptureEngagementgo beyond it, matching posthog-node in PostHog/posthog-js#5509 and posthog-python in PostHog/posthog-python#1171.💚 How did you test it?
Test seams:
Client.Emails().Sendand.Geton a real client built withNewWithConfig, against anhttptestserver or a fake transport. The tests pin each body and header, each refusal before a request, each error code andName, retries,Retry-After, the request timeout, a cancelled context, a closed or disabled client, andCloseduring a retry wait. ThreeExamplefunctions document the API.Gate at
80ee4a5passes:gofmt -l,go mod tidywith no diff,go vet ./...,go test -race ./...,bin/api-diffagainst the regeneratedapi/public-api.txt, and theotelandposthogmcpsdkmodules. A fullgo test -race ./...under Go 1.21.13, the module's floor, passes too.Live run at
8e181dfagainst a local PostHog stack: Django at PostHog/posthog#127123 (51409d9), cdp-api and the email and hogflow workers at PostHog/posthog#127122 (b5889c9), and maildev. A Go program drove the branch through a recording proxy. All six cases passed:TemplateEmailby ID withTemplateDataCodeEmailwithHTML,Text,From,ReplyTo,CaptureEngagement: falsecapture_engagement: falseIdempotencyKeyReplayedfalse, then true; one mailFromon an unverified domain*EmailErrorsender_not_verified,validation_error, HTTP 400; no mailGeton the code send's IDsent, withCreatedAtandStatusAt*EmailErrorauthentication_failed,missing_api_key, HTTP 401Cross-version run at
8e181dfagainst master's server (762afdf), without the two server PRs. Both send forms get a 400requiredontemplate, because master still takes the nested body.Getreads a status once it exists: it gets anot_foundfor the first seconds, thensent. An unknown ID getsnot_found.The raw request and response pairs, the maildev readbacks and the driver are saved with
fleet proof. After it,5417c2dcloses a client in one unit test, and8cef8a9andc7d0a9cmake an answer cut off mid-body a retryable network error for every status that keeps itsRetry-After(found by PostHog Review and the fleet review). The live cases never hit those paths. Then a QA swarm panel (round 7: fresh eyes on Claude, eight lenses on GPT-6.1 Sol) led to80ee4a5: the 0 to 9 retry range, Cloud host matching without regard to case or port 443,RetryAfterkept on an accepted reply without a message ID,uuid.Validatein place of a regex, one cancellation path inrequest, and theAppHostdoc. Each fix has a test that fails without it. Those paths don't change what the live cases sent or received. The narrow round 8 on the fixes found nothing in the code.Shadow diff at
80ee4a5against main (fee6566): the same 26 inputs (capture with and without feature flags, identify, alias, group identify, exception, everyEnqueuetype, remote and local flag evaluation withPersonalApiKeyandSecretKey,Flush,Closetwice,EnqueueafterClose) give the same requests, return values, errors and log lines on both. Each side matches four repeat runs, and a planted change shows up in the diff. So nothing outsideEmails()changes,Closeincluded.fleet proof gatepasses at80ee4a5: live run, cross-version and shadow diff, CI green, review round 8, judge pass.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
HTML; no rendering dependencySendSend(ctx, Email), sealedEmail, two typesConfig.SecretKey, as in Node and PythonGetretryEmailstoClientFlushprecedentmissing_app_hostuntilAppHostis setInvolvement: no grilling rounds; reviews: one GPT-6.1 Sol design read, six fleet proof review rounds (GPT-6.1 Sol), then a QA swarm panel of nine lenses and a narrow round on its fixes, which found nothing in the code; three PostHog Review passes, every finding fixed or answered on its thread; live run and cross-version run against a local PostHog stack.
claude-opus-5-5), as the posthog-go slice of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461, directed by Michael.78c6e6c. A fleet proof review round (GPT-6.1 Sol) found three edge cases: a nil*TemplateEmailpanicked, a context ending during a retry wait lost its cause, and a 2xx with trailing bytes after valid JSON passed. All three are fixed in0ffc1f6, each with a test that fails without the fix. The Go 1.21 race job then caughtgoccy/go-json'sMarshalNoEscapereading a stack-built body as garbage. The email bodies now encode withencoding/json, HTML unescaped, in8e181df, and a fullgo test -race ./...under Go 1.21.13 passes. A third review round on the fixes found nothing.80ee4a5. Rejected: the flag poller sendingSecretKeytoEndpoint(unchanged on main: local evaluation already does it wheneverSecretKeyis set; theAppHostdoc now says so), a shadow diff forClose(nothing is replaced; the drain is a no-op without email requests), and an encoder error branch Go requires. Follow-up for sdk-specs: spec coverage for the code form,CaptureEngagementandMigratedFrom.🤖 Generated with Claude Code