Skip to content

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

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

Silthus wants to merge 7 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-09T13:34:21.938449+00:00
Duration: 56317ms

⚠️ 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 ✅ 8ms
Format Validation.Event Has Uuid ✅ 5ms
Format Validation.Event Has Lib Properties ✅ 5ms
Format Validation.Distinct Id Is String ✅ 7ms
Format Validation.Token Is Present ✅ 6ms
Format Validation.Custom Properties Preserved ✅ 6ms
Format Validation.Event Has Timestamp ✅ 7ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 5ms
Retry Behavior.Retries On 503 ✅ 5323ms
Retry Behavior.Does Not Retry On 400 ❌ 6ms
Retry Behavior.Does Not Retry On 401 ❌ 5ms
Retry Behavior.Respects Retry After Header ✅ 8012ms
Retry Behavior.Implements Backoff ❌ 346ms
Retry Behavior.Retries On 500 ✅ 5163ms
Retry Behavior.Retries On 502 ✅ 5151ms
Retry Behavior.Retries On 504 ✅ 5149ms
Retry Behavior.Max Retries Respected ❌ 631ms
Deduplication.Generates Unique Uuids ✅ 15ms
Deduplication.Preserves Uuid On Retry ✅ 5161ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10376ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5147ms
Deduplication.No Duplicate Events In Batch ✅ 15ms
Deduplication.Different Events Have Different Uuids ✅ 8ms
Compression.Sends Gzip When Enabled ✅ 5ms
Batch Format.Uses Proper Batch Structure ✅ 5ms
Batch Format.Flush With No Events Sends Nothing ✅ 3ms
Batch Format.Multiple Events Batched Together ❌ 13ms
Error Handling.Does Not Retry On 403 ❌ 4ms
Error Handling.Does Not Retry On 413 ❌ 4ms
Error Handling.Retries On 408 ✅ 5113ms

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 ✅ 8ms
Request Payload.Flags Request Uses V2 Query Param ✅ 7ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 7ms
Request Payload.Flags Request Omits Authorization Header ✅ 6ms
Request Payload.Token In Flags Body Matches Init ✅ 6ms
Request Payload.Groups Round Trip ✅ 6ms
Request Payload.Groups Default To Empty Object ✅ 5ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 5ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 5ms
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 ✅ 4ms
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 ✅ 155ms
Retry Behavior.Retries Flags On 504 ✅ 153ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 7ms

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-09T13:34:52.119630+00:00
Duration: 98501ms

⚠️ 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 ✅ 108ms
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 ✅ 105ms
Format Validation.Custom Properties Preserved ✅ 106ms
Format Validation.Event Has Timestamp ✅ 106ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 106ms
Retry Behavior.Retries On 503 ✅ 5409ms
Retry Behavior.Does Not Retry On 400 ✅ 2109ms
Retry Behavior.Does Not Retry On 401 ✅ 2108ms
Retry Behavior.Respects Retry After Header ✅ 8114ms
Retry Behavior.Implements Backoff ✅ 15617ms
Retry Behavior.Retries On 500 ✅ 5212ms
Retry Behavior.Retries On 502 ✅ 5211ms
Retry Behavior.Retries On 504 ✅ 5211ms
Retry Behavior.Max Retries Respected ✅ 15614ms
Deduplication.Generates Unique Uuids ✅ 109ms
Deduplication.Preserves Uuid On Retry ✅ 5207ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10416ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5210ms
Deduplication.No Duplicate Events In Batch ✅ 109ms
Deduplication.Different Events Have Different Uuids ✅ 105ms
Compression.Sends Gzip When Enabled ✅ 105ms
Batch Format.Uses Proper Batch Structure ✅ 105ms
Batch Format.Flush With No Events Sends Nothing ✅ 4ms
Batch Format.Multiple Events Batched Together ✅ 109ms
Error Handling.Does Not Retry On 403 ✅ 2110ms
Error Handling.Does Not Retry On 413 ✅ 2107ms
Error Handling.Retries On 408 ✅ 5211ms

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 106ms
Request Payload.Flags Request Uses V2 Query Param ✅ 106ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 106ms
Request Payload.Flags Request Omits Authorization Header ✅ 106ms
Request Payload.Token In Flags Body Matches Init ✅ 106ms
Request Payload.Groups Round Trip ✅ 107ms
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 ❌ 106ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 106ms
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 ✅ 110ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 107ms
Retry Behavior.Retries Flags On 502 ✅ 236ms
Retry Behavior.Retries Flags On 504 ✅ 248ms
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']

@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.

Silthus and others added 2 commits November 5, 2026 13:10
Refs PostHog/michaelr-context#43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 97588f8: 2 Should fix.

This is review round 5 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.


# Mail's own attachment? looks for a filename, but a Content-Disposition of attachment needs none.
def attached?(part)
part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment')

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.

P2 · Classify inline resources before mapping them as bodies

For a related HTML message with a filename-less inline text/plain resource, Mail's attachment? and this predicate return false. content sends the resource as the plain-text body and drops its Content-ID, breaking the HTML's cid: reference instead of refusing the mail. Recognize inline resources during classification so the filename check runs.

refuse('unsupported_part', "PostHog can't send the #{part.charset} charset of #{part.filename}")
end
file = { filename: part.filename, content_type: part.mime_type, content: part.body.decoded.b }
return file unless part.inline?

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.

P2 · Preserve Content-ID on related resources without inline disposition

A multipart/related image with a filename and Content-ID but no Content-Disposition is a valid CID resource. Mail's inline? returns false, so attachment sends its bytes without content_id, leaving the HTML's reference unresolved. Preserve the identifier for related resources regardless of disposition, or refuse content the API cannot preserve.

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