Skip to content

feat(rails): send Action Mailer mail through PostHog - #329

Draft
Silthus wants to merge 6 commits into
feat/emails-integration-optionfrom
feat/rails-action-mailer-delivery
Draft

Silthus wants to merge 6 commits into
feat/emails-integration-optionfrom
feat/rails-action-mailer-delivery

Conversation

@Silthus

@Silthus Silthus commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

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.send and lose deliver_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 the emails.send SDKs release.

Summary

# config/environments/production.rb
config.action_mailer.delivery_method = :posthog
config.action_mailer.posthog_settings = { default_template_key: 'transactional' } # optional

deliver_now and deliver_later then send each mail as one PostHog email through the client PostHog.init made (it needs secret_key).

  • Mapping. Exactly one To address. Recipient display names are dropped, the From display name is kept, with the quotes it needs ("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.
  • Options. X-PostHog-Distinct-Id, X-PostHog-Template-Key, X-PostHog-Idempotency-Key and X-PostHog-Capture-Engagement set those send options. They are read, not sent.
  • Template key. An explicit X-PostHog-Template-Key, else <mailer>-<action> such as user_mailer-welcome (made safe: Admin::UserMailer#welcome_email becomes admin-user_mailer-welcome_email), else default_template_key, else action-mailer.
  • Refusals, before any request. These raise PostHog::Rails::UnsupportedMailError with refusal set to the code. Nothing is dropped silently.
    • Two To addresses, or two From addresses.
    • An inline file without a content ID, or an attachment without a filename (Mail's attachment? needs one, but Content-Disposition: attachment doesn't).
    • Any header outside the ones Mail writes itself, on the message or on any part. Of the content headers, only Content-Type and Content-Transfer-Encoding are ignored (and Content-Disposition and Content-ID on parts).
    • A Content-Type parameter PostHog would drop: a body keeps only charset, and an attachment only charset, name, and filename. A text attachment in a charset other than UTF-8 or US-ASCII.
    • An envelope that differs from the headers, or a non-normal priority.
    • A part other than one HTML body, one text body, and attachments: a calendar invite, a signed or encrypted message, or a multipart attachment. Only multipart/mixed, alternative, and related containers are opened.
    • A template header, or an invalid explicit key.
  • Errors. A send PostHog refuses raises PostHog::Rails::DeliveryError with status, code, detail, attr, retry_after and retryable?. The SDK's own retries still run first, with one idempotency key.
  • Both errors honour raise_delivery_errors, through Mail's own delivery path.
  • After a send, mail.message_id is PostHog's message ID, ready for PostHog.emails.get.
  • Every send carries X-PostHog-Integration: action-mailer/<posthog-rails version>.

Changeset: posthog-rails minor. actionmailer joins the development dependencies only; the gem still depends on railties alone.

Evidence

  • Red on feat(emails): name the integration that sends an email #328's head: the new spec fails to load, because there is no posthog.action_mailer initializer.
  • Green on this head: bundle exec rspec spec/posthog/rails/action_mailer_spec.rb, 40 examples, 0 failures. Each review fix started as a failing test.
  • Lane gate on Ruby 3.2.2: bundle exec rspec 2133 examples, 0 failures (40 pending, the OpenTelemetry-bundle specs, which CI runs on Ruby 3.3+); bundle exec rubocop no offenses; bundle exec rake public_api:check passes. The snapshot now loads the delivery method, so its classes are tracked.
  • Test seams: the HTTP request the real SDK makes, stubbed with WebMock, or the error Mail raises with no request.
    • The adapter contract's conformance cases that apply to Action Mailer: 01 to 11, 13 and 16, built as real Mail::Messages and delivered with mail.deliver.
    • One test per row of the contract's error table.
    • A booted Rails app (Rails::Application#initialize! in a subprocess) whose UserMailer#welcome sends key user_mailer-welcome with deliver_now, with posthog_settings set in config.action_mailer.
    • A mailer's deliver_later carrying the mail's idempotency key.
    • A Rails app without Action Mailer that boots and loads none of the delivery method.
  • Not applicable to Action Mailer: cases 12 (tags) and 14 (open and click tracking), since Action Mailer has no slot for either (a header for them is refused as unsupported_header); 15 and 17 are template-form cases for adapters that carry templates.
  • Live run: one real send of case 01 through this delivery method at 9449b6e, to a PostHog inbox: 202, and mail.message_id set 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-Integration value 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

Question Answer Who Where
Where do per-mail PostHog options go? Reserved X-PostHog-* headers, read and never sent 👍 Accepted default ticket
What names a mail? Explicit key, then <mailer>-<action>, then default_template_key, then action-mailer 👍 Accepted default ticket
What happens to mail PostHog can't carry? Refused with a named code before any request, never dropped 👍 Accepted default ticket
How does the delivery method learn the mailer action? The mailer's mail call records <mailer>-<action> on the message; Mail itself only knows the mailer class 🤖 Agent ticket
What error does a failed send raise? PostHog::Rails::DeliveryError with retryable?, wrapping the SDK's EmailError as its cause 🤖 Agent ticket
What is the mail's message ID after a send? PostHog's message ID 🤖 Agent ticket
Which content headers are ignored? Only Content-Type and Content-Transfer-Encoding, stricter than the contract's Content-* row, so that Content-Language is refused, not dropped 🤖 Agent ticket
What happens to an attachment's charset? UTF-8 and US-ASCII go out as the MIME type (Mail writes UTF-8 on every text attachment); any other charset is refused until the API is known to take a charset 🤖 Agent ticket

Involvement: 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

`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>
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-05T13:12:01.752773+00:00
Duration: 56256ms

⚠️ Some Tests Failed

39/47 tests passed, 8 failed


Capture Tests

⚠️ 23/30 tests passed, 7 failed

View Details
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

⚠️ 16/17 tests passed, 1 failed

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']

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-05T13:12:44.768437+00:00
Duration: 98240ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

✅ 30/30 tests passed

View Details
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

⚠️ 16/17 tests passed, 1 failed

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']

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed cbeae2a: 2 Should fix.

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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 662acf0: no findings.

Refs PostHog/michaelr-context#43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant