Repository navigation
Conversation
`config.action_mailer.delivery_method = :posthog` sends each mail as one PostHog email through the client PostHog.init made, for deliver_now and deliver_later. The delivery method registers in Action Mailer's load hook, so apps without Action Mailer load none of it. Each mail maps to one send or a named refusal before any request: one To address, bare recipient addresses, the From display name, HTML and text bodies, attachments with inline content IDs, and the reserved X-PostHog-* headers for the send options, which never go out. Mail is named <mailer>-<action> unless it sets a template key. Refusals raise PostHog::Rails::UnsupportedMailError and failed sends PostHog::Rails::DeliveryError with retryable?, both honouring raise_delivery_errors. Every send carries X-PostHog-Integration: action-mailer/<posthog-rails version>. Ticket: PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-ruby-sync Compliance ReportDate: 2026-11-05T13:12:01.752773+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 10ms |
| Format Validation.Event Has Uuid | ✅ | 6ms |
| Format Validation.Event Has Lib Properties | ✅ | 6ms |
| Format Validation.Distinct Id Is String | ✅ | 9ms |
| Format Validation.Token Is Present | ✅ | 7ms |
| Format Validation.Custom Properties Preserved | ✅ | 6ms |
| Format Validation.Event Has Timestamp | ✅ | 8ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 7ms |
| Retry Behavior.Retries On 503 | ✅ | 5306ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 6ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 7ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8014ms |
| Retry Behavior.Implements Backoff | ❌ | 326ms |
| Retry Behavior.Retries On 500 | ✅ | 5159ms |
| Retry Behavior.Retries On 502 | ✅ | 5116ms |
| Retry Behavior.Retries On 504 | ✅ | 5117ms |
| Retry Behavior.Max Retries Respected | ❌ | 631ms |
| Deduplication.Generates Unique Uuids | ✅ | 18ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5112ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10306ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5122ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 19ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 9ms |
| Compression.Sends Gzip When Enabled | ✅ | 6ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 7ms |
| 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 | ✅ | 5159ms |
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 | ✅ | 7ms |
| 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 | ✅ | 3ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 6ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 9ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 7ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 144ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 160ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 8ms |
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-05T13:12:44.768437+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 10ms |
| Format Validation.Event Has Uuid | ✅ | 105ms |
| Format Validation.Event Has Lib Properties | ✅ | 107ms |
| Format Validation.Distinct Id Is String | ✅ | 106ms |
| Format Validation.Token Is Present | ✅ | 107ms |
| Format Validation.Custom Properties Preserved | ✅ | 107ms |
| Format Validation.Event Has Timestamp | ✅ | 107ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 7ms |
| Retry Behavior.Retries On 503 | ✅ | 5311ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2108ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2109ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8115ms |
| Retry Behavior.Implements Backoff | ✅ | 15623ms |
| Retry Behavior.Retries On 500 | ✅ | 5212ms |
| Retry Behavior.Retries On 502 | ✅ | 5213ms |
| Retry Behavior.Retries On 504 | ✅ | 5212ms |
| Retry Behavior.Max Retries Respected | ✅ | 15622ms |
| Deduplication.Generates Unique Uuids | ✅ | 112ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5211ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10311ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5215ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 112ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 107ms |
| Compression.Sends Gzip When Enabled | ✅ | 106ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 106ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 4ms |
| Batch Format.Multiple Events Batched Together | ✅ | 112ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2109ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2109ms |
| Error Handling.Retries On 408 | ✅ | 5212ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 108ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 107ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 106ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 107ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 106ms |
| Request Payload.Groups Round Trip | ✅ | 106ms |
| Request Payload.Groups Default To Empty Object | ✅ | 106ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 107ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 107ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 107ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 3ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 105ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 110ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 106ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 207ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 209ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 109ms |
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 9449b6e: 2 Should fix.
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.
Keep the quotes a From display name needs, such as "Doe, Jane" <jane@example.com>, by formatting the address with Mail. Walk only multipart/mixed, alternative and related containers, and refuse any other multipart part, such as a signed message or a multipart attachment, with unsupported_part. Ignore only Content-Type and Content-Transfer-Encoding at the top level, so a header such as Content-Language is refused instead of dropped. Ticket: PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuse a body part header PostHog can't carry, and an attachment that has no filename. Send an inline attachment's Content-ID as written. Refs PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed c0507e2: 1 Should fix.
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.
PostHog takes an attachment's MIME type alone. Send the UTF-8 that Mail writes, and refuse any other charset. Refs PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed e14111d: 1 Should fix.
This is review round 4 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.
PostHog sends a body or an attachment by its MIME type. A body keeps only charset, and an attachment only charset, name, and filename. Refs PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A Rails app that sends mail through Action Mailer can't pick PostHog as its provider. To send through PostHog today it has to replace each mailer call with
PostHog.emails.sendand losedeliver_later, previews and mailer tests.Part of PostHog/michaelr-context#43. Merge first: #328 (the
integration:option this PR passes), which stacks on #325.Merge Danger
Medium, two-way door until released. The new code runs only in apps that set
delivery_method = :posthog, and it sends real email. Apps that don't set it load none of it: the delivery method registers inside Action Mailer's load hook, and an app without Action Mailer never fires that hook. No existing behaviour changes. Held as a draft until theemails.sendSDKs release.Summary
deliver_nowanddeliver_laterthen send each mail as one PostHog email through the clientPostHog.initmade (it needssecret_key)."Doe, Jane" <jane@example.com>). HTML and text bodies, cc, bcc, reply-to and attachments go through; an inline attachment sends its Content-ID as written.X-PostHog-Distinct-Id,X-PostHog-Template-Key,X-PostHog-Idempotency-KeyandX-PostHog-Capture-Engagementset those send options. They are read, not sent.X-PostHog-Template-Key, else<mailer>-<action>such asuser_mailer-welcome(made safe:Admin::UserMailer#welcome_emailbecomesadmin-user_mailer-welcome_email), elsedefault_template_key, elseaction-mailer.PostHog::Rails::UnsupportedMailErrorwithrefusalset to the code. Nothing is dropped silently.attachment?needs one, butContent-Disposition: attachmentdoesn't).Content-TypeandContent-Transfer-Encodingare ignored (andContent-DispositionandContent-IDon parts).charset, and an attachment onlycharset,name, andfilename. A text attachment in a charset other than UTF-8 or US-ASCII.multipart/mixed,alternative, andrelatedcontainers are opened.PostHog::Rails::DeliveryErrorwithstatus,code,detail,attr,retry_afterandretryable?. The SDK's own retries still run first, with one idempotency key.raise_delivery_errors, through Mail's own delivery path.mail.message_idis PostHog's message ID, ready forPostHog.emails.get.X-PostHog-Integration: action-mailer/<posthog-rails version>.Changeset:
posthog-railsminor.actionmailerjoins the development dependencies only; the gem still depends onrailtiesalone.Evidence
posthog.action_mailerinitializer.bundle exec rspec spec/posthog/rails/action_mailer_spec.rb, 40 examples, 0 failures. Each review fix started as a failing test.bundle exec rspec2133 examples, 0 failures (40 pending, the OpenTelemetry-bundle specs, which CI runs on Ruby 3.3+);bundle exec rubocopno offenses;bundle exec rake public_api:checkpasses. The snapshot now loads the delivery method, so its classes are tracked.Mail::Messages and delivered withmail.deliver.Rails::Application#initialize!in a subprocess) whoseUserMailer#welcomesends keyuser_mailer-welcomewithdeliver_now, withposthog_settingsset inconfig.action_mailer.deliver_latercarrying the mail's idempotency key.unsupported_header); 15 and 17 are template-form cases for adapters that carry templates.202, andmail.message_idset to PostHog's message ID. The later commits only add refusals that raise before a request and the From quoting; spec case 01 asserts the same request body at this head.Seams: the
X-PostHog-Integrationvalue is read by the send API's usage event (the instrumentation stack of the same plan). The reserved header names and refusal codes are shared with the Symfony and Laravel adapters of the same plan.Decisions
X-PostHog-*headers, read and never sent<mailer>-<action>, thendefault_template_key, thenaction-mailermailcall records<mailer>-<action>on the message; Mail itself only knows the mailer classPostHog::Rails::DeliveryErrorwithretryable?, wrapping the SDK'sEmailErroras its causeContent-TypeandContent-Transfer-Encoding, stricter than the contract'sContent-*row, so thatContent-Languageis refused, not droppedInvolvement: no grilling or prototype rounds in this PR; cross-model review rounds and ReviewHog until both came back clean; proof captured.
🤖 Generated with Claude Code