Repository navigation
feat(emails): send transactional email and read its status - #406
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 <noreply@anthropic.com>
posthog-go Compliance ReportDate: 2026-11-05 11:04:18 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
@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 <noreply@anthropic.com>
|
@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 <noreply@anthropic.com>
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. |
… reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.
There was a problem hiding this comment.
Not approved — escalated to a human reviewer.
Re-add the stamphog label to request another review once you have addressed this.
The disclosed changes enter public-contract, credential-handling and write-path territory, but all supplied reviews are on older commits and no owning-team or strong-familiarity assurance is reported. Request a current-head review covering these areas from Codex, Claude or a human teammate before re-requesting.
- Risky territory without independent assurance: Public API contracts — posthog.go adds Emails to the exported Client interface, requiring downstream custom implementations to add a method; emails.go, emails_error.go and config.go also introduce exported types and configuration.; Auth or security-sensitive surface / secrets — emails.go sends a secret API key in bearer-authenticated requests and adds app-host selection and redirect handling to control where that credential goes.; Data ingestion or write paths — emails.go introduces transactional email POST requests with recipient content, idempotency keys and automatic retries.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 913L, 5F substantive, 2065L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (2065L, 9F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 6a05673 · reviewed head 6a05673 |
…cut-off reply Email requests carry the secret key, so Validate now refuses an http AppHost unless the new AllowInsecureAppHost option is set, as in posthog-dotnet. A reply that breaks off mid-body keeps its HTTP status on the network_error. Such an error stays retryable whatever its status: the cut-off body can't say what PostHog decided, and the retry under the same idempotency key gets the full answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Cross-family review of head 40ee07b by GPT-6.1 Sol (high effort), since Claude wrote the code. Result: no findings. Scope, the three areas from the last StampHog run:
The first pass on 6a05673 found two real defects, both fixed in 40ee07b:
Three other first-pass findings don't apply to this PR. The flag poller sending |
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.
Independent review is reported for the current head, but @veria-ai's substantive credential-isolation concern remains supported by the code. Address that thread with credential isolation or trusted-host routing and a regression test, then request a teammate or agent review before re-requesting.
- PostHog Review (Standard) reviewed the current head: no findings.
- @veria-ai on emails.go:297, reiterated by @veria-ai[bot]: the credential-isolation concern remains unfixed. The email sender uses effectiveSecretKey(), and posthog.go:325-339 also starts the flag poller with that key and Endpoint; featureflags.go:829-830, 860-867 and 1207 send an authenticated definitions request to that endpoint independently of AppHost. Although the poller predates this change, the new email feature still requires credentials that can reach an ingestion proxy, even when email requests refuse to run. Isolate email credentials from flag polling or route secret-bearing polling to the trusted app host, add a regression test using distinct ingestion and app hosts, and obtain independent acceptance of this thread before re-requesting.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 932L, 5F substantive, 2094L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (2094L, 9F, two-areas, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 40ee07b · reviewed head 40ee07b |
When Config.AppHost is set, the local evaluation poll of /flags/definitions and GetRemoteConfigPayload now go to it instead of Endpoint, so a reverse proxy at Endpoint never receives the secret key. Without AppHost, both keep using Endpoint as before. 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>
#406 landed on main as a squash, with its last fixes (transactional_email scopes, https app host, secret-bearing flag requests to the app host). This branch now differs from main by its own change only: Cc, Bcc and Attachments on a CodeEmail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
💡 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