Repository navigation
Conversation
Add PostHog.Emails.send/2 for a template made in PostHog (template_id, template_data) or content the app rendered (template_key, subject, html and/or text), and PostHog.Emails.get/2 for an email's status. Both use the project secret key against the app host, with one idempotency key across retries. New config options: app_host, email_request_timeout_ms, email_request_max_retries and migrated_from. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decision record for this port.
|
There was a problem hiding this comment.
PostHog Review · Standard · reviewed c5c4933: 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.
posthog-elixir Compliance ReportDate: 2026-11-02T17:25:21.068115+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 608ms |
| Format Validation.Event Has Uuid | ✅ | 608ms |
| Format Validation.Event Has Lib Properties | ✅ | 609ms |
| Format Validation.Distinct Id Is String | ✅ | 607ms |
| Format Validation.Token Is Present | ✅ | 607ms |
| Format Validation.Custom Properties Preserved | ✅ | 607ms |
| Format Validation.Event Has Timestamp | ✅ | 608ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ❌ | 608ms |
| Retry Behavior.Retries On 503 | ✅ | 5612ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2610ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2611ms |
| Retry Behavior.Respects Retry After Header | ✅ | 5614ms |
| Retry Behavior.Implements Backoff | ✅ | 15621ms |
| Retry Behavior.Retries On 500 | ✅ | 5613ms |
| Retry Behavior.Retries On 502 | ✅ | 5614ms |
| Retry Behavior.Retries On 504 | ✅ | 5614ms |
| Retry Behavior.Max Retries Respected | ✅ | 15624ms |
| Deduplication.Generates Unique Uuids | ✅ | 616ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5613ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10618ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5614ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 612ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 610ms |
| Compression.Sends Gzip When Enabled | ✅ | 608ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 608ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 606ms |
| Batch Format.Multiple Events Batched Together | ✅ | 611ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2609ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2610ms |
| Error Handling.Retries On 408 | ✅ | 5614ms |
Failures
format_validation.non_utc_event_timestamp_is_converted_to_utc
Event 0 field 'timestamp' instant '2026-11-02T17:23:37.090620Z' != expected '2025-01-02T03:04:05Z'
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 7ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 5ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 5ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 4ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 5ms |
| Request Payload.Groups Round Trip | ✅ | 5ms |
| Request Payload.Groups Default To Empty Object | ❌ | 4ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 5ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 4ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 5ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 2ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 606ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 8ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ❌ | 5ms |
| Retry Behavior.Retries Flags On 502 | ❌ | 307ms |
| Retry Behavior.Retries Flags On 504 | ❌ | 307ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ❌ | 607ms |
Failures
request_payload.groups_default_to_empty_object
Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']
request_lifecycle.mock_response_value_is_returned_to_caller
Last action result missing field 'value'. Keys: ['error', 'success']
retry_behavior.retries_flags_on_502
Last action result missing field 'value'. Keys: ['error', 'success']
retry_behavior.retries_flags_on_504
Last action result missing field 'value'. Keys: ['error', 'success']
side_effect_events.get_feature_flag_captures_feature_flag_called_event
Expected 1 events with name '$feature_flag_called', got 0
… absolute app_host - Turn off Req's body decoding, so a 2xx with malformed JSON is an api_error with its status instead of a retried network_error. - Run each request in a task stopped at email_request_timeout_ms, so the timeout bounds the whole request, not only each socket read. - Validate app_host as an absolute http(s) URL, so a relative value can't resolve against the ingestion host and send it the secret key. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A linked Task left {:EXIT, pid, :normal} in the mailbox of a caller that
traps exits, which crashes a GenServer without a matching handle_info/2.
The worker is now spawned with a monitor only, and the deadline path waits
for its DOWN message and drops a late reply, so the caller's mailbox stays
clean after a reply and after a timeout.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The unlinked worker outlived a caller killed mid-request, such as by a job timeout, so the request could still go out after cancellation. The worker is now spawned linked and monitored in one step. It unlinks before it replies, and the caller unlinks before it kills the worker at the deadline, so a caller that traps exits still gets no exit message. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A linked worker killed its caller when it crashed. The worker is now only monitored, so its death returns network_error, and a watchdog kills it when the caller dies. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Other async modules start the default PostHog instance, so asserting it was not running flaked on CI. The test now starts the instance in the PostHog group and checks that send/1 and get/1 reach it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A secret key starts the flag definition loader, whose requests could take the mock's expectations. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…down Finch raises when no pooled connection frees up in time, which skipped the retry loop as an unexpected error; it is now a retryable network error. A call waiting to retry now checks the PostHog instance first and returns client_closed, without a request, when the instance has stopped. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Email requests never send the secret key to an ingestion proxy, but local flag evaluation fetches definitions from api_host with the same key. Tell users behind such a proxy to turn local evaluation off. Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
Not approved yet — waiting on the conditions below.
Re-add the stamphog label to request another review once you have addressed this.
stamphog won't auto-review this pull request, and a human reviewer needs to take it. Three gates refused it. The deny-list gate matched public_api, because the change adds the PostHog.Emails modules and new entries in public_api.snapshot. The size gate failed because the substantive changes come to 1223 lines against a 1200-line ceiling (2217 lines across 13 files including tests and docs). The tier gate classified it as T2-never, a cross-cutting feature.
Next, ask a human reviewer to look at it. Splitting it won't clear the deny-list or tier refusals, since the public API addition stays in the change. Splitting could still make it easier to review, for example by separating the config and registry changes from the PostHog.Emails modules.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✗ | too large for auto-review (1223L substantive in global — ceiling is 1200L; 1223L, 9F total, 2217L/13F incl. docs/generated/snapshots) |
| tier | ✗ | classified as T2-never: T2-never (2217L, 13F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head da5e56c |
💡 Motivation and Context
An Elixir server that sends transactional email with PostHog, such as a password reset or a receipt, has no SDK call for it. This PR adds
PostHog.Emails, the Elixir port ofposthog.emailsin posthog-node and posthog-python.send/2queues one email, from a template made in PostHog or from content your code rendered.get/2reads what happened to it.Contract: PostHog/posthog#120921 and the emails spec in PostHog/sdk-specs. The spec covers the template form. The code form follows posthog-node (PostHog/posthog-js#5509) and posthog-python (PostHog/posthog-python#1171), which add it to the other SDKs.
Impact
Pays into Grow Workflows, Messaging scope, through emails sent from code. Elixir apps can send without a hand-written HTTP client. Expected move: small, up. Run cost: n/a.
Merge Danger
Warning
🟡 Merge risk: medium · one-way door (merge-risk rubric)
PostHog.Emails, its result and error structs, and the config optionsapp_host,email_request_timeout_ms,email_request_max_retriesandmigrated_from. Once released, these can't change without a breaking release.send/email/(feat(cdp): deliver a code send's content on its own transactional email route posthog#127122). Until that deploys, a code send gets a400. Hold the release until then. The template form already works against production.PostHog.Emails.git revert <merge commit>before a release. After a release, a revert removes public API, so deprecate instead.What changes
PostHog.Emails.send/2takes a keyword list or map in one of two forms. It posts a flat JSON body toPOST {app host}/api/projects/@current/messaging/send/email/:template_id(sent in lowercase) andtemplate_data(sent asmessage_data,%{}when left out).template_key,subject,htmland/ortext, optionalfromandreply_to(a string becomes a one-item list), andcapture_engagement(sent always,trueby default).to,distinct_id(never taken fromPostHog.set_context/2),idempotency_key(a UUID v4 per call when left out, the same on every retry) andmax_retries.invalid_formwith itsfield, before any request. When the other form takes it,whyandfixname that form.PostHog.Emails.get/2readsGET /api/projects/@current/messaging/emails/{id}/into a%PostHog.Emails.Status{}withDateTimetimes. Unknown statuses pass through.{:error, %PostHog.Emails.Error{}}withname,code,message,status_code,why,fix,docs_url,fieldandretry_after_ms.namemaps400,401,403,404,409,422,429and503as the spec's table says.409 idempotency_key_in_flight,429other thanquota_limited, and503, with about 1 s and then 2 s of jittered backoff, or a longerRetry-After(seconds or an HTTP date). ARetry-Afterover 30 s fails at once. A call waiting to retry returnsclient_closed, sending nothing, when the PostHog instance has stopped.email_request_timeout_ms(15 s by default) bounds each whole attempt, connection and response included. The attempt runs in a monitored worker that is killed at the deadline and stopped when its caller dies.Authorization: Bearer <secret_key>. A PostHog Cloudapi_hostmaps tous.posthog.comoreu.posthog.com. Any other host needsapp_host, an absolutehttporhttpsURL checked at config time, or the call returnsmissing_app_host, so email requests never send the secret key to an ingestion proxy.secret_keyalso turns on local flag evaluation againstapi_host, as it does onmain; the docs tell users behind such a proxy to setenable_local_evaluation: false.PostHog.Emailsmoduledoc covers setup, both forms, retries, errors and testing. The config options are documented inPostHog.Config, an Emails group is added to HexDocs, and the README links to it. Aminorchangeset is included.💚 How did you test it?
Head
da5e56c: CI green, 30 checks across the Elixir 1.17 to 1.20 and OTP 25 to 29 matrix (Wiz Secret Scanner neutral, as on every head). Live-run evidence at this head includes a real template send, delivery and status read against local PostHogmaster. Review round 9 by GPT-6.1 Sol was clean. The proof judge's remaining finding is the code form, which real PostHog can't serve until its server PRs deploy; Michael accepted that gap. The PostHog Review bot found nothing atcb165cc. Locally on Elixir 1.20 / OTP 29 at6392363:mix format --check-formatted,mix compile --warnings-as-errors,mix posthog.public_api --check,mix credo --strict(no issues) andmix test(920 passed); aftercb165ccthe email tests (61) and credo were rerun locally, and CI ran the full matrix on every head. The SDK compliance report's 7 capture and feature-flag failures appear on other open PRs (#231 to #235) too; this PR doesn't touch those areas.test/posthog/emails_test.exsdrivesPostHog.Emails.send/2andget/2on a realPostHog.Supervisor, with the repo's MoxPostHog.API.Mockas the HTTP client and an injected sleep to observe backoff. It covers both forms' bodies, every argument check, headers and hosts, retries and their delays,Retry-After, error fields and names, warnings, and status records. Athrough the default Req clientblock runs the realPostHog.API.ClientagainstReq.Testfor the cases a mock can't show: malformed JSON, noapi_keyin the body, no gzip, and the request deadline. Worker tests prove that a caller trapping exits keeps a clean mailbox, that the request stops when its caller is killed, and that a worker that dies returnsnetwork_errorwithout hurting the caller. Further tests cover a pool checkout timeout being retried, and a retry being dropped withclient_closedafter the instance stops.test/posthog/emails_default_instance_test.exsruns in thePostHogtest group and checks thatsend/1andget/1reach the default instance.da5e56c(raw output in proof storage). A real BEAM process used the realPostHog.API.Client(Req and Finch) over TCP:reply_toas a list andcapture_engagement: true. Also a503then202retried after about 1 s with the same generated key, a429withRetry-After: 60failing at once withretry_after_ms: 60000, a422 recipient_suppressedkeeping every field, an HTML502asapi_error, the status read, and a code send withtemplate_datarefused before any request.202with malformed JSON is oneapi_errorwith status202. A server that hangs 3 s againstemail_request_timeout_ms: 500returnsnetwork_errorafter 501 ms. A relativeapp_hostis refused at config time.us.posthog.comfromapi_host: "https://us.i.posthog.com"with a made-upphs_key:sendin both forms andgetreturn the real401 authentication_failedasname: :missing_api_key, the same answercurlgets.da5e56cagainst a local PostHog on currentmaster(PostHog/posthog@c985c0d), with a valid project secret key, a transactional template and Maildev as the mail sink. No reply was scripted:template_data,distinct_idand anidempotency_keyreturns{:ok, %SendResult{id: "01a1a10f-…", replayed: false}}. The same send again returns the same id withreplayed: true, and only one email arrives.template_datafilled into the subject and body.get/2returnsnot_foundfor the first few seconds, until the status row is written, then{:ok, %Status{status: "sent"}}.400asvalidation_errorontemplate_id, sincemasterdoesn't serve the code form yet. An unknown template id and a made-up message id returnnot_found.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), for Michael, as the Elixir slice of the transactional-email SDK ports.shutdown()fail withclient_closed. Stopping the PostHog instance does that, and drops a pending retry withclient_closed. It doesn't wait for calls in flight: each send runs synchronously in the calling process and the SDK keeps no email queue, so the caller's own supervision decides when it stops.master; the code form's live run accepted as a gap until PostHog/posthog#127122 and PostHog/posthog#127123 deploy (recommended default){:ok, _}/{:error, %PostHog.Emails.Error{}}, argument errors included, no raisesapi_client_modulewith an absolute URL andbody:; Req retries, redirects and gzip offPostHog.config/1client_disabledwhen the instance isn't running as a call starts; see Shutdown for one that stops during a callniloptionsretry_after_msin millisecondsapi_errorwith the statusapp_hostcheckhttporhttpsURL, checked at config timeclient_closed, as the contract asks; calls in flight finish in their caller, with no drainnetwork_error; other raises stayunexpected_errorsecret_keymain; documented, withenable_local_evaluation: falsefor proxiesInvolvement: no grilling rounds, no prototypes; nine GPT-6.1 Sol review rounds, the PostHog Review bot and a proof judge; live-run proof captured, including a real send and status read.
🤖 Generated with Claude Code