Repository navigation
feat(emails): send transactional email and read its status - #324
Conversation
Adds client.emails.send for a template made in PostHog (template_id) or content your code rendered (template_key, subject, html, text), and client.emails.get for the status of a sent email. Ports posthog-node and posthog-python's emails to Ruby: the same wire body, app host rules, idempotency key, retries and error names. Co-Authored-By: Claude Opus 5.5 <[email protected]>
posthog-ruby-sync Compliance ReportDate: 2026-11-05T11:01:55.811389+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 9ms |
| Format Validation.Event Has Uuid | ✅ | 7ms |
| Format Validation.Event Has Lib Properties | ✅ | 6ms |
| Format Validation.Distinct Id Is String | ✅ | 10ms |
| Format Validation.Token Is Present | ✅ | 7ms |
| Format Validation.Custom Properties Preserved | ✅ | 8ms |
| Format Validation.Event Has Timestamp | ✅ | 8ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 7ms |
| Retry Behavior.Retries On 503 | ✅ | 5239ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 8ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 6ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8017ms |
| Retry Behavior.Implements Backoff | ❌ | 309ms |
| Retry Behavior.Retries On 500 | ✅ | 5160ms |
| Retry Behavior.Retries On 502 | ✅ | 5120ms |
| Retry Behavior.Retries On 504 | ✅ | 5119ms |
| Retry Behavior.Max Retries Respected | ❌ | 530ms |
| Deduplication.Generates Unique Uuids | ✅ | 18ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5114ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10251ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5162ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 19ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 9ms |
| Compression.Sends Gzip When Enabled | ✅ | 7ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 6ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 3ms |
| Batch Format.Multiple Events Batched Together | ❌ | 17ms |
| Error Handling.Does Not Retry On 403 | ❌ | 5ms |
| Error Handling.Does Not Retry On 413 | ❌ | 5ms |
| Error Handling.Retries On 408 | ✅ | 5110ms |
Failures
retry_behavior.does_not_retry_on_400
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.does_not_retry_on_401
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.implements_backoff
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.max_retries_respected
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
batch_format.multiple_events_batched_together
Expected 1 requests, got 5
error_handling.does_not_retry_on_403
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
error_handling.does_not_retry_on_413
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 9ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 8ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 8ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 8ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 7ms |
| Request Payload.Groups Round Trip | ✅ | 6ms |
| Request Payload.Groups Default To Empty Object | ✅ | 7ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 6ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 7ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 6ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 2ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 7ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 9ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 8ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 109ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 150ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 10ms |
Failures
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', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
posthog-ruby-async Compliance ReportDate: 2026-11-05T11:02:41.522075+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 108ms |
| Format Validation.Event Has Uuid | ✅ | 105ms |
| Format Validation.Event Has Lib Properties | ✅ | 120ms |
| Format Validation.Distinct Id Is String | ✅ | 106ms |
| Format Validation.Token Is Present | ✅ | 106ms |
| Format Validation.Custom Properties Preserved | ✅ | 106ms |
| Format Validation.Event Has Timestamp | ✅ | 105ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 7ms |
| Retry Behavior.Retries On 503 | ✅ | 5410ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2107ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2108ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8115ms |
| Retry Behavior.Implements Backoff | ✅ | 15720ms |
| Retry Behavior.Retries On 500 | ✅ | 5211ms |
| Retry Behavior.Retries On 502 | ✅ | 5210ms |
| Retry Behavior.Retries On 504 | ✅ | 5211ms |
| Retry Behavior.Max Retries Respected | ✅ | 15622ms |
| Deduplication.Generates Unique Uuids | ✅ | 108ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5210ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10416ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5212ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 109ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 105ms |
| Compression.Sends Gzip When Enabled | ✅ | 104ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 104ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 3ms |
| Batch Format.Multiple Events Batched Together | ✅ | 108ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2108ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2108ms |
| Error Handling.Retries On 408 | ✅ | 5211ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 106ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 105ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 105ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 105ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 106ms |
| Request Payload.Groups Round Trip | ✅ | 105ms |
| Request Payload.Groups Default To Empty Object | ✅ | 105ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 105ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 105ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 105ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 2ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 104ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 111ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 107ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 210ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 247ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 107ms |
Failures
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', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 156067d: 2 Should fix.
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.
A child forked while another thread sent email inherited a positive in-flight count that no thread could settle, so shutdown waited for its whole timeout, or forever without one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building this PR:
|
Net::HTTP treats a connection closed partway through a body as the end of the body, so a truncated 202 raised a non-retryable api_error. Turn EOF suppression off so the cut-off read raises a network error and the send retries with the same idempotency key. 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-adding the stamphog label gives the same result unless the changed files change.
stamphog can't approve this pull request: two policy gates refused it. The deny-list gate matched public_api, because the change adds new public surface, including Client#emails, the PostHog::Defaults::Emails constants and an updated public_api_snapshot.txt. The tier gate classified it as T2-never (1198 lines across 9 files, cross-cutting, a new feature), a tier that is never auto-approved.
Please ask a human reviewer to look at this pull request. Splitting it up won't clear the public API match, since the new emails interface is the change itself.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 612L, 6F substantive, 1198L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (1198L, 9F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head e33feda |
- Explicit nil email options fall back to the defaults, and the client checks them before the worker, instance count and flags poller start. - app_host must be an http or https URL with a host. - A refusal whose body broke off keeps its status and Retry-After; a corrupt compressed body is a network error and retries. - skip_ssl_verification also applies to email requests. - Specs: real-socket server reads whole requests and runs without WebMock, the backoff spec waits on a queue instead of a fixed sleep, and the warning spec reads the logger output. Co-Authored-By: Claude Opus 5.5 <[email protected]>
| # Built before the worker, the instance count and the flags poller start, so a bad email option raises | ||
| # before anything runs or sends the secret key. | ||
| @emails = Emails.new( | ||
| secret_key: secret_key, |
There was a problem hiding this comment.
Medium: Email credentials still reach the ingestion proxy
With host: 'https://proxy.example.com' and app_host: 'https://us.posthog.com', this email credential is also passed to FeatureFlagsPoller at client.rb:236–240, whose constructor fetches /flags/definitions with Authorization: Bearer <secret_key> by default (feature_flags.rb:115–121, 1854–1858), exposing the key to the proxy before any email call. Keep email-only credentials out of the ingestion-host poller, or route its secret-bearing requests through the trusted app host.
PR overviewThe PR adds support for sending transactional emails and reading their status through the PostHog client. One issue remains open: the email credential is also used by the feature-flag poller, which sends it to the configured ingestion proxy rather than the trusted app host. This can expose the credential to the proxy before any email request is made, though the exposure depends on the client's host configuration. No issues have been addressed yet. Open issues (1)
Fixed/addressed: 0 · PR risk: 5/10 |
A reply whose body broke off is a network error again, so a cut-off 409 idempotency_key_in_flight retries as before. It now keeps the head's Retry-After: the retry waits for it, and one over 30 seconds raises at once. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The spec released the retry on a 50 ms timer, so it passed even when shutdown returned at once. It now holds the retry until it has seen shutdown still running, then releases it and expects true. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The shutdown backoff specs released and joined their threads only after their assertions passed, so a failing assertion left the sender blocked. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… reads Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
SDK performance: posthog-rubyBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't approve this pull request because two gates refused it. The deny-list gate matched public_api: the change adds Client#emails and new PostHog::Defaults::Emails constants, which shows up in public_api_snapshot.txt. The tier gate classified it as T2-never (1346 lines across 9 files, cross-cutting, a new feature), so it needs a human reviewer regardless of the size check passing.
Please ask a maintainer to review it. If you'd like to reduce what a reviewer has to take in, you could split it, for example by moving the shared parse_retry_after refactor in transport.rb and utils.rb into its own pull request.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 643L, 6F substantive, 1346L/9F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (1346L, 9F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 5cd43aa |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
💡 Motivation and Context
Ruby apps can't send transactional email through PostHog today. posthog-node and posthog-python already can, with
posthog.emails.send. This PR adds the same API to posthog-ruby:client.emails.sendqueues one email, andclient.emails.getreads what happened to it.It follows the cross-SDK contract in PostHog/posthog#120921, and matches posthog-node's
packages/node/src/emails/with PostHog/posthog-js#5509 and posthog-python'sposthog/emails.pywith PostHog/posthog-python#1171.Important
Don't release the code form (
template_key) before the server's code-form send route is deployed: until then a code send answers400. The template form (template_id) andgetwork against PostHog today.What it does
POST /api/projects/@current/messaging/send/email/on the app host:template_id(lowercased) andtemplate_data(sent asmessage_data);template_key,subject,htmland/ortext, plusfrom,reply_to(a string becomes a list) andcapture_engagement(always sent,trueby default).to,distinct_id,idempotency_keyandmax_retries. A send without an idempotency key gets a UUID v4, and every attempt reuses it.GET /api/projects/@current/messaging/emails/{message_id}/returns anEmails::StatuswithTimevalues forcreated_atandstatus_at.secret_key, sent asAuthorization: Bearer. A PostHog Cloudhostmaps to its region's app host. Any other host needs the newapp_hostoption, because a reverse proxy for ingestion must not receive the secret key (missing_app_host).409 idempotency_key_in_flight,429other thanquota_limited, and503. The waits are about 1 s and then 2 s with jitter, orRetry-Afterwhen that's longer; a reply whose body broke off still counts itsRetry-After. ARetry-Afterover 30 s raises at once.PostHog::EmailError. It carriescode,name(the contract's Resend-style category),status_code,why,fix,docs_url,fieldandretry_afterin seconds. Bad arguments raiseArgumentErrorbefore any request, as Ruby does for an unknown keyword.api_key) raisesclient_disabled. Aftershutdown, sends raiseclient_closed.shutdown(timeout:)waits for sends in flight, retries included, within its timeout. A forked child doesn't wait for its parent's sends.app_host,email_request_timeout_seconds(15),email_request_max_retries(2) andmigrated_from(sent asX-PostHog-Migrated-From).nilmeans the default.app_hostmust be an http or https URL. A bad value raisesArgumentErrorfromClient.new, before the client starts its worker or polls flags.skip_ssl_verification: the existing option also turns off certificate checks for email requests.template_dataon the code form,cc,bcc,headers,tagsand attachments, and any React or rendering dependency. The app renders the email and passeshtml.Transport#parse_retry_aftermoved toUtilsso both share it. It also ignores a non-finite value now.Divergence from sdk-specs
The
emailsspec in PostHog/sdk-specs covers the template form andget, and says the code form isn't covered yet. This PR adds the code form as posthog-node and posthog-python ship it. Everything the spec covers follows it, including the Shutdown requirement.Impact
Pays into the Workflows team's growth goal for Messaging (email is in scope) through transactional email sends from server SDKs. Ruby is the next server SDK after Node and Python. Expected move: small to medium, up, once released. Run cost: n/a, it uses the same send route as the other SDKs. Measure: sends by SDK, not measured yet. The server would need to attribute
posthog-ruby/User-Agents the way it plans to for the other SDKs.Merge Danger
Low, two-way door until release. The change is additive and opt-in: nothing runs unless the app calls
emails. The only existing path it touches isClient#shutdown, which now also waits for email requests in flight; with none in flight, that wait returns at once. Releasing the gem makes the API public, so its shape should settle in review. Releasing the code form before the server deploys it would give callers a400.💚 How did you test it?
21c81ad:bundle exec rspec spec/posthog/emails_spec.rb63 examples, 0 failures, three runs in random order.bundle exec rubocop: 146 files, no offenses.bundle exec rake public_api:checkpasses. The full suite last ran locally at90c1afb(2080 examples, 0 failures); after that the box was below its disk floor for heavy runs, so CI runs it.spec/posthog/emails_spec.rbdrives the public API,Client.new(...).emails.send/get, against WebMock at the HTTP boundary. 63 examples cover both forms' wire bodies and headers, app host mapping and its URL check, the custom host refusal, the missing secret key, the disabled and closed client, argument and option checks (includingniloptions and a bad option raising before any flags request), the retry table,Retry-After, the error names, warnings (read from the logger's output), status parsing, shutdown waiting within its timeout during a retry backoff, and a forked child that doesn't wait for the parent's send.202whose body breaks off (retried), a202with a corrupt gzip body (retried), a409whose body breaks off (retried), a429withRetry-After: 60whose body breaks off (raises withretry_after60, no retry), a503withRetry-After: 3whose body breaks off (waits 3 s, then succeeds), and a TLS server with a self-signed certificate (succeeds withskip_ssl_verification).21c81ad: 25 of 25 checks green (23 passed, 2 neutral compliance checks), including RSpec on Ruby 3.2, 3.3, 3.4 and latest Rails, and the gem build.Net::HTTPto a local server that answers like the send route. Results:503withRetry-After: 1was retried once with the same idempotency key, after about 1.2 s;429 quota_limitedraisedmonthly_quota_exceededwithout a retry;getsent the ID lowercased and parsed the record;shutdown, a send raisedclient_closed;app_hostraisedmissing_app_hostand sent nothing.us.posthog.com, invalid key): both forms andgetraisedauthentication_failed/missing_api_keywith status 401 and the server's message.fleet proof gateon21c81ad: passes, judge included):e94874cwith the same results, scripts and raw output saved.c843d53and21c81adchange only the spec file.Client#shutdownand the batch transport'sRetry-Afterparsing ran with the base's code and with HEAD's over the same inputs (12Retry-Aftervalues, a shutdown after three captures, a second shutdown, a shutdown after a429withRetry-After: 1, atest_modeshutdown and a disabled client). The one difference is intended: aRetry-Afterof1e400was Infinity on the base and is ignored on HEAD.shutdownwaited out its timeout. Fixed in16a2c62with a fork test that failed before the fix. The final read found nothing.michaelandsdk-spec-conformanceperspectives on GPT-6.1 Sol): seven findings fixed in90c1afbwith tests that failed first. Anilemail option failedClient.newafter the flags poller had started; anapp_hostwithout a scheme sent to localhost; a corrupt gzip body escaped asZlib::DataError;skip_ssl_verificationdidn't reach email; a refusal whose body broke off lost itsRetry-After; and two specs leaned on timing and a logger mock. Three were rejected: no shadow diff is owed for a new capability, the flags poller's use ofhostis unchanged from main, and the comments record security reasons.409whose body broke off no longer retried. Fixed ine94874c: a reply whose body broke off is a retryable network error that keeps itsRetry-After. Final reads one94874cand on the spec fixes after it found nothing. The proof judge then found that a shutdown spec released its retry on a timer and could pass without shutdown waiting;c843d53makes it fail when shutdown doesn't wait, and21c81adreleases held threads even when an assertion fails.202whose body was cut off by a closed connection, whichNet::HTTPreturned as a whole body, so the send raised a non-retryableapi_error. Fixed ine33fedawithignore_eof: falseand a real-socket spec that failed before the fix. GPT-6.1 Sol's round 2 on the fixes found nothing.📝 Checklist
Client#emails,Emails#sendandEmails#getdescribe the API. The posthog.com Ruby page is a follow-up.If releasing new changes
pnpm changesetto generate a changeset file.posthog-rubyminor.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code (Claude Opus 5.5) as one slice of a map that ports
emails.sendto every server SDK. The design ports posthog-node and posthog-python; it doesn't invent new behavior. The review was run by GPT-6.1 Sol throughfleet proof review.html; the SDK adds no dependencyemails.send, overridingObject#sendonPostHog::Emailsonly, as Resend's Ruby SDK and the other PostHog SDKs name it; reflection on that object usespublic_sendor__send__ArgumentError, before any request, like posthog-python's native exceptions; request and SDK failures raisePostHog::EmailErrortest_modestop email sends?test_modequeues events only, and a disabled client (noapi_key) raisesclient_disabled, as in the other SDKsPostHog.init(app_host: ...)passes them through, and the init block already lists only some core optionsSendWorkerdoes after a forkInvolvement: no grilling rounds, no prototypes; four review rounds (two single-reviewer rounds by GPT-6.1 Sol, then two QA swarm panels with the team's perspectives) and final reads, plus PostHog Review; proof captured.
🤖 Generated with Claude Code