Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9449b6e
feat(rails): send Action Mailer mail through PostHog
Silthus Nov 5, 2026
cbeae2a
fix(rails): refuse mail structures a rebuilt send would change
Silthus Nov 5, 2026
c0507e2
fix(rails): check part headers and attachment dispositions before a send
Silthus Nov 5, 2026
e14111d
fix(rails): refuse an attachment charset PostHog would drop
Silthus Nov 5, 2026
662acf0
fix(rails): refuse a Content-Type parameter PostHog would drop
Silthus Nov 5, 2026
ec26492
test(rails): cover the attachment Content-Type parameter refusal
Silthus Nov 5, 2026
97588f8
docs(rails): say what X-PostHog-Capture-Engagement does
Silthus Nov 9, 2026
41dc288
Merge feat/emails-integration-option into feat/rails-action-mailer-de…
Silthus Nov 13, 2026
7e3b6bf
fix(rails): keep related resources' Content-IDs, retry what the SDK r…
Silthus Nov 13, 2026
7501162
fix(rails): cap template keys at the 256 characters PostHog accepts
Silthus Nov 13, 2026
d982659
test(rails): install the delivery method on Action Mailer without a g…
Silthus Nov 13, 2026
3b60c9d
Merge remote-tracking branch 'origin/feat/emails-integration-option' …
Silthus Nov 13, 2026
79f0797
fix(rails): keep inline bodies as bodies, refuse body sections, and n…
Silthus Nov 13, 2026
ca9bd6e
test(rails): boot each spawned Rails app outside the checkout
Silthus Nov 13, 2026
82108c5
fix(rails): refuse body sections an alternative holds, and test retri…
Silthus Nov 13, 2026
421577e
Merge remote-tracking branch 'origin/feat/emails-integration-option' …
Silthus Nov 13, 2026
63707ad
feat(rails): set tracking and sensitivity from reserved headers
Silthus Nov 13, 2026
95a00a9
fix(rails): refuse click tracking without open tracking before any re…
Silthus Nov 13, 2026
ed1a910
test(rails): apps on another delivery method send as before
Silthus Nov 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rails-action-mailer-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-rails": minor
---

Send Action Mailer mail through PostHog: set `config.action_mailer.delivery_method = :posthog`, and `deliver_now` and `deliver_later` send each mail as one PostHog email through the client `PostHog.init` made, which needs `secret_key`. Each mail is named `<mailer>-<action>`, such as `user_mailer-welcome`, unless it sets `X-PostHog-Template-Key`; `config.action_mailer.posthog_settings = { default_template_key: '...' }` names mail sent without a mailer. The headers `X-PostHog-Distinct-Id` and `X-PostHog-Idempotency-Key` set those send options, `X-PostHog-Track-Opens`, `X-PostHog-Track-Clicks` and `X-PostHog-Capture-Engagement` set what PostHog tracks, and `X-PostHog-Sensitive` marks mail with login links or codes; none of them is sent. Without `X-PostHog-Sensitive`, a mail takes the setting of the PostHog template with its key, and is sensitive when no template has it, which is usual for mail named after its mailer; set it to `false` to track clicks on mail without secrets. A queued mail should set `X-PostHog-Idempotency-Key` from a stable ID, so a job retry can't send it twice. A mail PostHog can't carry, such as one with two To addresses or a custom header, raises `PostHog::Rails::UnsupportedMailError` before any request; a send PostHog refuses raises `PostHog::Rails::DeliveryError`, whose `retryable?` says whether to retry it. Both honour `raise_delivery_errors`; with it off, the error goes to `PostHog::Logging.logger` instead. After a send, the mail's `message_id` is PostHog's message ID.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ rails_version = ENV.fetch('RAILS_VERSION', '~> 7.1')

group :development, :test do
gem 'actioncable', rails_version
gem 'actionmailer', rails_version
gem 'activejob', rails_version
gem 'activesupport', rails_version
gem 'commander', '~> 5.0'
Expand Down
33 changes: 33 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ GEM
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailer (7.2.3.1)
actionpack (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.3.1)
actionview (= 7.2.3.1)
activesupport (= 7.2.3.1)
Expand Down Expand Up @@ -98,9 +105,25 @@ GEM
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
mcp (1.7.0)
json_schemer (>= 2.4)
mini_mime (1.1.5)
minitest (5.27.0)
net-imap (0.6.8)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
timeout
net-smtp (0.5.2)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
Expand Down Expand Up @@ -232,6 +255,7 @@ GEM
thor (1.5.0)
tilt (2.7.0)
timecop (0.9.11)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -261,6 +285,7 @@ PLATFORMS

DEPENDENCIES
actioncable (~> 7.1)
actionmailer (~> 7.1)
activejob (~> 7.1)
activesupport (~> 7.1)
commander (~> 5.0)
Expand All @@ -281,6 +306,7 @@ DEPENDENCIES

CHECKSUMS
actioncable (7.2.3.1) sha256=d3bf40a3f4fc79a09709878f0e5c43a5e2d8e6607089f6b38f9472b8715eb33c
actionmailer (7.2.3.1) sha256=f578b6d5c5f81a20b6f6a796187698890c8348c041daa5e2e7cf7814ac520467
actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca
actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c
activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8
Expand Down Expand Up @@ -317,8 +343,14 @@ CHECKSUMS
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8
mcp (1.7.0) sha256=bdf3261177b948b0777a6f247c57aad5f64c84830c02d126d4bfea188b0cebb7
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
net-imap (0.6.8) sha256=eb30bb62227b64a3af6b518b04b2375113d69f68f15de59b0193b07d98acf6d9
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
net-smtp (0.5.2) sha256=35103cc04d6b2d94632f9928ede07483c24b70b9c0db1f231fea43909b678f3c
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
Expand Down Expand Up @@ -375,6 +407,7 @@ CHECKSUMS
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
Expand Down
33 changes: 33 additions & 0 deletions gemfiles/otel.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ GEM
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailer (7.2.3.1)
actionpack (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.3.1)
actionview (= 7.2.3.1)
activesupport (= 7.2.3.1)
Expand Down Expand Up @@ -121,9 +128,25 @@ GEM
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
mcp (1.5.1)
json_schemer (>= 2.4)
mini_mime (1.1.5)
minitest (5.27.0)
net-imap (0.6.8)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
timeout
net-smtp (0.5.2)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
Expand Down Expand Up @@ -294,6 +317,7 @@ GEM
thor (1.5.0)
tilt (2.7.0)
timecop (0.9.11)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -323,6 +347,7 @@ PLATFORMS

DEPENDENCIES
actioncable (~> 7.1)
actionmailer (~> 7.1)
activejob (~> 7.1)
activesupport (~> 7.1)
commander (~> 5.0)
Expand All @@ -347,6 +372,7 @@ DEPENDENCIES

CHECKSUMS
actioncable (7.2.3.1) sha256=d3bf40a3f4fc79a09709878f0e5c43a5e2d8e6607089f6b38f9472b8715eb33c
actionmailer (7.2.3.1) sha256=f578b6d5c5f81a20b6f6a796187698890c8348c041daa5e2e7cf7814ac520467
actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca
actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c
activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8
Expand Down Expand Up @@ -391,8 +417,14 @@ CHECKSUMS
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8
mcp (1.5.1) sha256=883ddd0f59e5bf43805e300158c7ecc4b6a559d126d584d41bd241355545bedf
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
net-imap (0.6.8) sha256=eb30bb62227b64a3af6b518b04b2375113d69f68f15de59b0193b07d98acf6d9
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
net-smtp (0.5.2) sha256=35103cc04d6b2d94632f9928ede07483c24b70b9c0db1f231fea43909b678f3c
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
Expand Down Expand Up @@ -460,6 +492,7 @@ CHECKSUMS
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
Expand Down
63 changes: 63 additions & 0 deletions posthog-rails/lib/posthog/rails/delivery_errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

module PostHog
module Rails
# PostHog can't carry part of this mail, so nothing was sent. Raised before any request, and only when the mailer's
# `raise_delivery_errors` is on; with it off, the delivery method logs the error to `PostHog::Logging.logger`.
class UnsupportedMailError < StandardError
# @return [String] Which part PostHog can't carry, such as `multiple_to`, `unsupported_header` or
# `template_unsupported`, or `invalid_mail` for a field PostHog would turn down, such as a missing subject.
attr_reader :refusal

# @api private
def initialize(refusal:, detail:)
super("PostHog cannot send this email: #{refusal}. #{detail}.")
@refusal = refusal
end
end

# PostHog did not queue the mail, or the delivery method could not tell whether it did. The PostHog::EmailError
# behind it is its {#cause}. With the mailer's `raise_delivery_errors` off, the delivery method logs it instead.
class DeliveryError < StandardError
# @return [Integer, nil] The HTTP status, when PostHog answered.
attr_reader :status
# @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`.
attr_reader :code
# @return [String, nil] The request field the error is about, when PostHog named one.
attr_reader :attr
# @return [Float, nil] How long PostHog asked to wait before sending again, in seconds.
attr_reader :retry_after

# @api private
def initialize(error)
super(error.message)
@status = error.status_code
@code = error.code
@attr = error.field
@retry_after = error.retry_after
end

# @return [String] Why PostHog did not queue the mail.
def detail
message
end

# Whether a later delivery of the same mail can succeed. Retry with the same X-PostHog-Idempotency-Key: PostHog
# may have queued the first attempt, and a retry with a new key could send the mail twice.
#
# @return [Boolean]
# A file storage failure is `attachment_upload_failed` with storage's own status: its 500, 502, 503 and 504
# pass. A used-up quota, for sends or for uploads, lasts until the quota resets. This is the rule
# PostHog::Emails retries by.
def retryable?
case status
when nil then code == 'network_error'
when 409 then code == 'idempotency_key_in_flight'
when 429 then !%w[quota_limited upload_quota_exceeded].include?(code)
when 500, 502, 504 then code == 'attachment_upload_failed'
else status == 503
end
end
end
end
end
90 changes: 90 additions & 0 deletions posthog-rails/lib/posthog/rails/delivery_method.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# frozen_string_literal: true

require 'posthog/rails/version'
require 'posthog/rails/delivery_errors'
require 'posthog/rails/mail_mapper'

module PostHog
module Rails
# The `:posthog` Action Mailer delivery method. It sends each mail as one PostHog email, through the client that
# `PostHog.init` made, and sets the mail's `message_id` to PostHog's message ID.
#
# A mail's reserved headers set the send's options, and are never sent. Each of these takes `true` or `false`, and
# PostHog decides when the mail leaves it out:
# - `X-PostHog-Track-Opens`: whether PostHog adds its open pixel to count opens;
# - `X-PostHog-Track-Clicks`: whether PostHog rewrites the links to count clicks. Off by default for a sensitive
# mail, and it needs open tracking;
# - `X-PostHog-Capture-Engagement`: whether PostHog captures the email's engagement events (sent, delivered,
# opened, clicked, bounced) as events on the recipient's person;
# - `X-PostHog-Sensitive`: `true` for mail with login links, reset tokens or codes. PostHog leaves its links
# unchanged unless `X-PostHog-Track-Clicks` is `true`, its subject off engagement events, and its content out of
# logs. Left out, PostHog takes the setting of the template whose key matches, and treats the mail as sensitive
# when none matches.
#
# @example config/environments/production.rb
# config.action_mailer.delivery_method = :posthog
# config.action_mailer.posthog_settings = { default_template_key: 'transactional' }
class DeliveryMethod
INTEGRATION = "#{MailMapper::INTEGRATION_NAME}/#{VERSION}".freeze
private_constant :INTEGRATION

# @return [Hash] `config.action_mailer.posthog_settings`.
attr_accessor :settings

# @param settings [Hash] `default_template_key:` names mail that has no mailer name and sets no
# X-PostHog-Template-Key.
def initialize(settings = {})
@settings = settings
end

# @param mail [Mail::Message]
# @return [PostHog::Emails::SendResult]
# @raise [UnsupportedMailError] PostHog can't send part of the mail, so nothing was sent.
# @raise [DeliveryError] PostHog did not queue the mail.
def deliver!(mail)
result = send_email(mail)
mail.message_id = result.id
result
rescue UnsupportedMailError, DeliveryError => e
# Mail's deliver drops the error when the mailer turns raise_delivery_errors off; this line keeps a trace of it.
PostHog::Logging.logger.error("PostHog did not send an email: #{e.message}") unless mail.raise_delivery_errors
raise
end

private

def send_email(mail)
arguments = MailMapper.new(default_template_key: settings[:default_template_key])
.map(mail, derived_template_key: MailerExtensions.template_key(mail))
begin
PostHog.emails.send(**arguments, integration: INTEGRATION)
rescue ArgumentError => e
# The SDK checks its arguments before any request, such as the subject a send needs.
raise UnsupportedMailError.new(refusal: 'invalid_mail', detail: e.message.chomp('.'))
end
rescue PostHog::EmailError => e
raise DeliveryError, e
end
end

# Names each mail an Action Mailer mailer builds `<mailer>-<action>`, such as `user_mailer-welcome`, so PostHog
# groups its sends.
#
# @api private
module MailerExtensions
TEMPLATE_KEY = :@posthog_template_key
private_constant :TEMPLATE_KEY

# @param mail [Mail::Message]
# @return [String, nil]
def self.template_key(mail)
mail.instance_variable_get(TEMPLATE_KEY) if mail.instance_variable_defined?(TEMPLATE_KEY)
end

# @api private
def mail(...)
super.tap { |message| message.instance_variable_set(TEMPLATE_KEY, "#{mailer_name}-#{action_name}") }
end
end
end
end
Loading
Loading