From 9449b6e2e3b8883f9a14d4c4107846c1c714b3da Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:30:38 +0000 Subject: [PATCH 01/16] feat(rails): send Action Mailer mail through PostHog `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 - 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/. Ticket: https://github.com/PostHog/michaelr-context/issues/43 Co-Authored-By: Claude Opus 5.5 --- .changeset/rails-action-mailer-delivery.md | 5 + Gemfile | 1 + Gemfile.lock | 33 ++ gemfiles/otel.gemfile.lock | 33 ++ .../lib/posthog/rails/delivery_errors.rb | 59 +++ .../lib/posthog/rails/delivery_method.rb | 63 +++ .../lib/posthog/rails/mail_mapper.rb | 155 ++++++++ posthog-rails/lib/posthog/rails/railtie.rb | 11 + public_api_snapshot.txt | 13 + scripts/public_api_snapshot.rb | 2 + spec/posthog/rails/action_mailer_spec.rb | 359 ++++++++++++++++++ 11 files changed, 734 insertions(+) create mode 100644 .changeset/rails-action-mailer-delivery.md create mode 100644 posthog-rails/lib/posthog/rails/delivery_errors.rb create mode 100644 posthog-rails/lib/posthog/rails/delivery_method.rb create mode 100644 posthog-rails/lib/posthog/rails/mail_mapper.rb create mode 100644 spec/posthog/rails/action_mailer_spec.rb diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md new file mode 100644 index 00000000..4d812963 --- /dev/null +++ b/.changeset/rails-action-mailer-delivery.md @@ -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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. 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`. After a send, the mail's `message_id` is PostHog's message ID. diff --git a/Gemfile b/Gemfile index 1a2d57f6..dc84de43 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'irb' rails_version = ENV.fetch('RAILS_VERSION', '~> 7.1') group :development, :test do + gem 'actionmailer', rails_version gem 'activejob', rails_version gem 'activesupport', rails_version gem 'commander', '~> 5.0' diff --git a/Gemfile.lock b/Gemfile.lock index 593204c9..be05369e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,13 @@ PATH GEM remote: https://rubygems.org/ specs: + 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) @@ -92,9 +99,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 nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.4-aarch64-linux-musl) @@ -217,6 +240,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) @@ -241,6 +265,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + actionmailer (~> 7.1) activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) @@ -259,6 +284,7 @@ DEPENDENCIES webmock CHECKSUMS + 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 @@ -295,8 +321,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 nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca @@ -350,6 +382,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 diff --git a/gemfiles/otel.gemfile.lock b/gemfiles/otel.gemfile.lock index 2ae6d9ab..85a327d7 100644 --- a/gemfiles/otel.gemfile.lock +++ b/gemfiles/otel.gemfile.lock @@ -7,6 +7,13 @@ PATH GEM remote: https://rubygems.org/ specs: + 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) @@ -115,9 +122,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 nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.4-aarch64-linux-musl) @@ -279,6 +302,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) @@ -303,6 +327,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + actionmailer (~> 7.1) activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) @@ -325,6 +350,7 @@ DEPENDENCIES webmock CHECKSUMS + 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 @@ -369,8 +395,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 nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca @@ -435,6 +467,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 diff --git a/posthog-rails/lib/posthog/rails/delivery_errors.rb b/posthog-rails/lib/posthog/rails/delivery_errors.rb new file mode 100644 index 00000000..5aba18d8 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/delivery_errors.rb @@ -0,0 +1,59 @@ +# 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. + class UnsupportedMailError < StandardError + # @return [String] Which part PostHog can't carry, such as `multiple_to`, `unsupported_header` or + # `template_unsupported`. + 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}. + 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] + def retryable? + case status + when nil then code == 'network_error' + when 409 then code == 'idempotency_key_in_flight' + when 429 then code != 'quota_limited' + else status == 503 + end + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/delivery_method.rb b/posthog-rails/lib/posthog/rails/delivery_method.rb new file mode 100644 index 00000000..8d35d354 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -0,0 +1,63 @@ +# 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. + # + # @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) + arguments = MailMapper.new(default_template_key: settings[:default_template_key]) + .map(mail, derived_template_key: MailerExtensions.template_key(mail)) + result = PostHog.emails.send(**arguments, integration: INTEGRATION) + mail.message_id = result.id + result + rescue PostHog::EmailError => e + raise DeliveryError, e + end + end + + # Names each mail an Action Mailer mailer builds `-`, 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 diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb new file mode 100644 index 00000000..c3819dc6 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'posthog/rails/delivery_errors' + +module PostHog + module Rails + # Turns one Mail::Message into the arguments of one {PostHog::Emails#send}, or refuses it with + # {UnsupportedMailError} when PostHog can't carry part of it. It reads the message and nothing else. + # + # @api private + class MailMapper + INTEGRATION_NAME = 'action-mailer' + TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/ + MAPPED_HEADERS = %w[from to cc bcc reply-to subject].freeze + IGNORED_HEADERS = %w[message-id date mime-version x-mailer return-path sender].freeze + OPTION_HEADERS = { + 'x-posthog-distinct-id' => :distinct_id, + 'x-posthog-template-key' => :template_key, + 'x-posthog-idempotency-key' => :idempotency_key, + 'x-posthog-capture-engagement' => :capture_engagement + }.freeze + TEMPLATE_HEADER = 'x-posthog-template-id' + NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze + private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, + :NORMAL_PRIORITY + + # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. + def initialize(default_template_key: nil) + @default_template_key = default_template_key + end + + # @param mail [Mail::Message] + # @param derived_template_key [String, nil] The mailer's own name for the mail, such as `user_mailer-welcome`. + # @return [Hash] Keyword arguments for {PostHog::Emails#send}. + # @raise [UnsupportedMailError] PostHog can't send part of the message. + def map(mail, derived_template_key: nil) + options = posthog_options(mail) + recipient = single_recipient(mail) + check_envelope(mail, recipient) + { + to: recipient, + from: sender(mail), + reply_to: mail.reply_to, + cc: mail.cc, + bcc: mail.bcc, + subject: mail.subject, + template_key: template_key(options[:template_key], derived_template_key), + **content(mail), + **options.slice(:distinct_id, :idempotency_key, :capture_engagement) + }.compact + end + + private + + def posthog_options(mail) + mail.header.fields.each_with_object({}) do |field, options| + name = field.name.downcase + value = field.value.to_s + next if MAPPED_HEADERS.include?(name) || IGNORED_HEADERS.include?(name) || name.start_with?('content-') + next if NORMAL_PRIORITY.key?(name) && NORMAL_PRIORITY[name].match?(value.strip) + + options[OPTION_HEADERS.fetch(name) { refuse_header(name, field.name) }] = option_value(name, value) + end + end + + def refuse_header(name, header) + refuse('template_unsupported', 'Action Mailer sends rendered mail, not a template made in PostHog') if + name == TEMPLATE_HEADER + refuse('unsupported_priority', "PostHog sends every email at normal priority, so it can't send #{header}") if + NORMAL_PRIORITY.key?(name) + refuse('unsupported_header', "PostHog can't send the #{header} header") + end + + def option_value(name, value) + return value unless name == 'x-posthog-capture-engagement' + return value == 'true' if %w[true false].include?(value) + + refuse('unsupported_header', 'X-PostHog-Capture-Engagement must be true or false') + end + + def single_recipient(mail) + recipients = Array(mail.to) + return recipients.first if recipients.one? + + refuse('multiple_to', "PostHog sends each email to exactly one To address, and this one has #{recipients.size}") + end + + def sender(mail) + addresses = mail[:from]&.addrs || [] + refuse('unsupported_header', 'PostHog sends from one From address') if addresses.size > 1 + address = addresses.first + return if address.nil? + + address.display_name ? "#{address.display_name} <#{address.address}>" : address.address + end + + def check_envelope(mail, recipient) + sender_matches = mail.smtp_envelope_from == Array(mail.from).first + recipients_match = Array(mail.smtp_envelope_to).sort == [recipient, *mail.cc, *mail.bcc].sort + return if sender_matches && recipients_match + + refuse('envelope_mismatch', 'PostHog sends to the To, Cc and Bcc addresses from the From address only') + end + + def template_key(explicit, derived) + unless explicit.nil? + return explicit if TEMPLATE_KEY_PATTERN.match?(explicit) + + refuse('invalid_template_key', 'X-PostHog-Template-Key can only have letters, digits, hyphens and ' \ + 'underscores, at most 400') + end + + sanitized(derived) || @default_template_key || INTEGRATION_NAME + end + + def sanitized(key) + key = key.to_s.gsub(/[^a-zA-Z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')[0, 400] + key unless key.empty? + end + + def content(mail) + leaves = mail.multipart? ? mail.all_parts.reject(&:multipart?) : [mail] + files, bodies = leaves.partition(&:attachment?) + content = bodies.each_with_object({}) do |part, by_kind| + kind = body_kind(part) + refuse('unsupported_part', "PostHog sends one HTML and one text body, so it can't send #{part.mime_type}") if + kind.nil? || by_kind.key?(kind) + by_kind[kind] = part.decoded + end + files.empty? ? content : content.merge(attachments: files.map { |file| attachment(file) }) + end + + def body_kind(part) + case part.mime_type + when 'text/html' then :html + when 'text/plain', nil then :text + end + end + + def attachment(part) + file = { filename: part.filename, content_type: part.mime_type, content: part.body.decoded.b } + return file unless part.inline? + + refuse('inline_without_cid', "The inline attachment #{part.filename} has no Content-ID") unless + part.has_content_id? + file.merge(content_id: part.cid) + end + + def refuse(code, detail) + raise UnsupportedMailError.new(refusal: code, detail: detail) + end + end + private_constant :MailMapper + end +end diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index 4dcadc7c..40a28e4c 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -11,6 +11,17 @@ class Railtie < ::Rails::Railtie PostHog::Rails.install_posthog_facade! end + # Register the :posthog delivery method before Action Mailer applies config.action_mailer, so apps can set + # posthog_settings there. Apps without Action Mailer load none of it. + initializer 'posthog.action_mailer', before: 'action_mailer.set_configs' do + ActiveSupport.on_load(:action_mailer) do + require 'posthog/rails/delivery_method' + prepend PostHog::Rails::MailerExtensions + + add_delivery_method :posthog, PostHog::Rails::DeliveryMethod + end + end + # Insert middleware for request context and exception capturing initializer 'posthog.insert_middlewares' do |app| # Wrap the Rails exception middleware so request context is active for diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 50d9d7e3..9e363992 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -397,6 +397,17 @@ instance_method PostHog::Rails::Configuration#use_tracing_headers=(arg0) instance_method PostHog::Rails::Configuration#user_id_method() instance_method PostHog::Rails::Configuration#user_id_method=(arg0) constant PostHog::Rails::Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE: Integer +class PostHog::Rails::DeliveryError < StandardError +instance_method PostHog::Rails::DeliveryError#attr() +instance_method PostHog::Rails::DeliveryError#code() +instance_method PostHog::Rails::DeliveryError#detail() +instance_method PostHog::Rails::DeliveryError#retry_after() +instance_method PostHog::Rails::DeliveryError#retryable?() +instance_method PostHog::Rails::DeliveryError#status() +class PostHog::Rails::DeliveryMethod +instance_method PostHog::Rails::DeliveryMethod#deliver!(mail) +instance_method PostHog::Rails::DeliveryMethod#settings() +instance_method PostHog::Rails::DeliveryMethod#settings=(arg0) class PostHog::Rails::InitConfig instance_method PostHog::Rails::InitConfig#api_key=(value) instance_method PostHog::Rails::InitConfig#before_send=(value) @@ -414,4 +425,6 @@ instance_method PostHog::Rails::InitConfig#test_mode=(value) instance_method PostHog::Rails::InitConfig#to_client_options() module PostHog::Rails::Logs class PostHog::Rails::Railtie < Rails::Railtie +class PostHog::Rails::UnsupportedMailError < StandardError +instance_method PostHog::Rails::UnsupportedMailError#refusal() constant PostHog::Rails::VERSION: String diff --git a/scripts/public_api_snapshot.rb b/scripts/public_api_snapshot.rb index 5a531795..051dcc28 100644 --- a/scripts/public_api_snapshot.rb +++ b/scripts/public_api_snapshot.rb @@ -77,6 +77,8 @@ def load_rails_sdk add_load_path(RAILS_LIB_DIR) require 'rails' require 'posthog-rails' + # Loaded by Action Mailer's load hook in an app; loaded here so its public surface is tracked too. + require 'posthog/rails/delivery_method' end def add_load_path(path) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb new file mode 100644 index 00000000..f35043d0 --- /dev/null +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'logger' +require 'rails' +require 'action_mailer' +require 'active_job' +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) +require 'posthog/rails' + +initializer = PostHog::Rails::Railtie.initializers.find { |i| i.name == 'posthog.action_mailer' } +PostHog::Rails::Railtie.instance.instance_exec(nil, &initializer.block) + +class ShippingMailer < ActionMailer::Base + self.delivery_method = :posthog + self.posthog_settings = { default_template_key: 'transactional' } + + def shipped(headers = {}) + attachments['invoice.pdf'] = "%PDF-1.4\n" + addresses = { from: 'Shop ', to: 'Ana ' } + mail(**addresses, subject: 'Your order shipped', **headers) do |format| + format.text { render plain: 'On its way.' } + format.html { render html: '

On its way.

'.html_safe } + end + end +end + +module Admin + class UserMailer < ActionMailer::Base + self.delivery_method = :posthog + + def welcome_email + mail(from: 'shop@example.com', to: 'ana@example.com', subject: 'Welcome') do |format| + format.text { render plain: 'Hi' } + end + end + end +end + +RSpec.describe PostHog::Rails::DeliveryMethod do + let(:send_url) { 'https://us.posthog.com/api/projects/@current/messaging/send/email/' } + let(:message_id) { '01a18356-c56c-0001-e494-7666bc915188' } + let(:settings) { {} } + let(:shipped_body) do + { 'to' => 'ana@example.com', 'from' => 'Shop ', 'subject' => 'Your order shipped', + 'html' => '

On its way.

', 'text' => 'On its way.', 'template_key' => 'action-mailer', + 'capture_engagement' => true } + end + + before do + PostHog::Logging.logger = Logger.new(File::NULL) + stub_request(:get, %r{/flags/definitions}).to_return(status: 200, body: '{"flags":[]}') + stub_request(:post, send_url).to_return(status: 202, body: JSON.generate(message_id: message_id)) + PostHog.init(api_key: API_KEY, secret_key: 'phs_test', test_mode: true, email_request_max_retries: 0) + end + + after do + PostHog.client&.shutdown + PostHog.client = nil + end + + # The message of conformance case 01: one recipient, a From display name, and HTML and text bodies. + def shipped_mail + Mail.new do + from 'Shop ' + to 'Ana ' + subject 'Your order shipped' + text_part { body 'On its way.' } + html_part { body '

On its way.

' } + end + end + + def deliver(mail) + mail.delivery_method(described_class, settings) + mail.deliver + end + + def sent_request + requests = [] + expect(a_request(:post, send_url).with { |request| requests << request }).to have_been_made.once + requests.first + end + + def sent_body + JSON.parse(sent_request.body) + end + + def refusal_of(mail) + refusal = nil + expect { deliver(mail) }.to raise_error(PostHog::Rails::UnsupportedMailError) { |error| refusal = error.refusal } + expect(a_request(:post, send_url)).not_to have_been_made + refusal + end + + describe 'conformance cases' do + it '01 sends a rendered message with the integration marker, and takes its message ID' do + mail = deliver(shipped_mail) + + expect(sent_body).to eq(shipped_body) + expect(sent_request.headers['X-Posthog-Integration']).to eq("action-mailer/#{PostHog::Rails::VERSION}") + expect(mail.message_id).to eq(message_id) + end + + it '02 and 03 drop recipient display names and send cc, bcc and reply-to as lists' do + mail = shipped_mail + mail.cc = 'Bo ' + mail.bcc = 'audit@example.com' + mail.reply_to = ['Help ', 'b@example.com'] + + deliver(mail) + + expect(sent_body).to eq(shipped_body.merge('cc' => ['bo@example.com'], 'bcc' => ['audit@example.com'], + 'reply_to' => ['a@example.com', 'b@example.com'])) + end + + it '04 sends attachments, and an inline one with its content ID' do + mail = shipped_mail + mail.html_part.body = '' + mail.attachments['invoice.pdf'] = "%PDF-1.4\n" + mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b + mail.attachments['logo.png'].content_id = '' + + deliver(mail) + + expect(sent_body).to eq(shipped_body.merge( + 'html' => '', + 'attachments' => [ + { 'filename' => 'invoice.pdf', 'content_type' => 'application/pdf', + 'content' => 'JVBERi0xLjQK' }, + { 'filename' => 'logo.png', 'content_type' => 'image/png', + 'content' => 'iVBORw0KGgo=', 'content_id' => 'logo' } + ] + )) + end + + it '05 reads the PostHog options from the reserved headers and sends none of them' do + mail = shipped_mail + mail['X-PostHog-Distinct-Id'] = 'user_42' + mail['X-PostHog-Template-Key'] = 'order-shipped' + mail['X-PostHog-Idempotency-Key'] = 'order-981-shipped' + mail['X-PostHog-Capture-Engagement'] = 'false' + + deliver(mail) + + expect(sent_body).to eq(shipped_body.merge('template_key' => 'order-shipped', 'distinct_id' => 'user_42', + 'capture_engagement' => false)) + expect(sent_request.headers['Idempotency-Key']).to eq('order-981-shipped') + expect(sent_request.headers.keys.grep(/distinct|template|engagement/i)).to be_empty + end + + it '06 names mail from its mailer and action, made safe for a template key' do + Admin::UserMailer.welcome_email.deliver_now + + expect(sent_body['template_key']).to eq('admin-user_mailer-welcome_email') + end + + it '07 falls back to the default template key from posthog_settings' do + settings[:default_template_key] = 'transactional' + + deliver(shipped_mail) + + expect(sent_body['template_key']).to eq('transactional') + end + + it '08 refuses an explicit template key PostHog does not accept' do + mail = shipped_mail + mail['X-PostHog-Template-Key'] = 'order shipped!' + + expect(refusal_of(mail)).to eq('invalid_template_key') + end + + it '09 refuses more than one To address' do + mail = shipped_mail + mail.to = ['ana@example.com', 'bo@example.com'] + + expect(refusal_of(mail)).to eq('multiple_to') + end + + it '10 refuses an inline attachment without a content ID' do + mail = shipped_mail + mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b + + expect(refusal_of(mail)).to eq('inline_without_cid') + end + + it '11 ignores the headers Mail writes itself and refuses any other' do + mail = shipped_mail + mail.message_id = '' + mail['X-Campaign'] = 'spring' + + expect(refusal_of(mail)).to eq('unsupported_header') + end + + it '13 refuses an envelope sender other than From' do + mail = shipped_mail + mail.smtp_envelope_from = 'bounce@example.com' + + expect(refusal_of(mail)).to eq('envelope_mismatch') + end + + it '16 refuses a template made in PostHog, since Action Mailer sends rendered mail' do + mail = shipped_mail + mail['X-PostHog-Template-Id'] = '0190f0c2-7d1e-7a3b-9c2e-5f1a2b3c4d5e' + + expect(refusal_of(mail)).to eq('template_unsupported') + end + end + + describe 'other refusals' do + it 'refuses a body part PostHog cannot send, such as a calendar invite' do + mail = shipped_mail + mail.add_part(Mail::Part.new(content_type: 'text/calendar; method=REQUEST', body: 'BEGIN:VCALENDAR')) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'sends normal priority and refuses any other' do + normal = shipped_mail + normal['X-Priority'] = '3 (Normal)' + urgent = shipped_mail + urgent['Importance'] = 'high' + + expect(refusal_of(urgent)).to eq('unsupported_priority') + deliver(normal) + + expect(a_request(:post, send_url)).to have_been_made.once + end + + it 'sends nothing and raises nothing when the mail turns delivery errors off' do + mail = shipped_mail + mail.to = ['ana@example.com', 'bo@example.com'] + mail.raise_delivery_errors = false + + expect { deliver(mail) }.not_to raise_error + expect(a_request(:post, send_url)).not_to have_been_made + end + end + + describe 'errors' do + [ + [400, 'invalid_input', {}, false, nil], + [401, 'authentication_failed', {}, false, nil], + [404, 'not_found', {}, false, nil], + [409, 'idempotency_key_in_flight', {}, true, nil], + [422, 'recipient_suppressed', {}, false, nil], + [429, 'quota_limited', {}, false, nil], + [429, 'throttled', { 'Retry-After' => '5' }, true, 5.0], + [503, 'delivery_unavailable', {}, true, nil], + [500, 'server_error', {}, false, nil] + ].each do |status, code, headers, retryable, retry_after| + it "raises a #{retryable ? 'retryable' : 'permanent'} DeliveryError for #{status} #{code}" do + stub_request(:post, send_url).to_return( + status: status, headers: headers, body: JSON.generate(code: code, detail: 'No.', attr: 'to') + ) + + expect { deliver(shipped_mail) }.to raise_error(PostHog::Rails::DeliveryError) { |error| + expect([error.status, error.code, error.detail, error.attr]).to eq([status, code, 'No.', 'to']) + expect([error.retryable?, error.retry_after]).to eq([retryable, retry_after]) + expect(error.cause).to be_a(PostHog::EmailError) + } + end + end + + it 'raises a retryable DeliveryError when PostHog could not be reached' do + stub_request(:post, send_url).to_raise(Errno::ECONNREFUSED) + + expect { deliver(shipped_mail) }.to raise_error(PostHog::Rails::DeliveryError) { |error| + expect([error.status, error.code, error.retryable?]).to eq([nil, 'network_error', true]) + } + end + end + + describe 'Action Mailer' do + it 'sends a mailer action with deliver_now, its attachment and the headers Rails adds' do + ShippingMailer.shipped.deliver_now + + expect(sent_body).to eq(shipped_body.merge( + 'template_key' => 'shipping_mailer-shipped', + 'attachments' => [{ 'filename' => 'invoice.pdf', 'content_type' => 'application/pdf', + 'content' => 'JVBERi0xLjQK' }] + )) + end + + it 'sends a deliver_later job with the idempotency key the mail sets' do + adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.logger = Logger.new(File::NULL) + ActiveJob::Base.queue_adapter = :inline + + ShippingMailer.shipped('X-PostHog-Idempotency-Key' => 'order-981-shipped').deliver_later + + expect(sent_request.headers['Idempotency-Key']).to eq('order-981-shipped') + ensure + ActiveJob::Base.queue_adapter = adapter + end + end + + describe 'a Rails app' do + def run_app(script) + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-Iposthog-rails/lib', '-e', script) + expect(status).to be_success, "#{stdout}\n#{stderr}" + stdout + end + + it 'sends UserMailer#welcome through delivery_method :posthog with deliver_now' do + output = run_app(<<~RUBY) + require 'rails' + require 'action_mailer/railtie' + require 'posthog' + require 'posthog/rails' + require 'webmock' + include WebMock::API + WebMock.enable! + + class App < Rails::Application + config.eager_load = false + config.logger = Logger.new(File::NULL) + config.action_mailer.delivery_method = :posthog + config.action_mailer.posthog_settings = { default_template_key: 'transactional' } + end + App.initialize! + + class UserMailer < ActionMailer::Base + def welcome + mail(from: 'shop@example.com', to: 'ana@example.com', subject: 'Welcome') { |f| f.text { render plain: 'Hi' } } + end + end + + stub_request(:get, %r{/flags/definitions}).to_return(status: 200, body: '{"flags":[]}') + stub_request(:post, '#{send_url}').to_return(status: 202, body: '{"message_id":"#{message_id}"}') + PostHog.init(api_key: 'phc_test', secret_key: 'phs_test', test_mode: true) + UserMailer.welcome.deliver_now + WebMock::RequestRegistry.instance.requested_signatures.hash.each_key do |request| + puts request.body if request.method == :post + end + RUBY + + expect(JSON.parse(output)).to include('to' => 'ana@example.com', 'template_key' => 'user_mailer-welcome', + 'text' => 'Hi') + end + + it 'boots without Action Mailer and loads none of the delivery method' do + output = run_app(<<~RUBY) + require 'rails' + require 'posthog' + require 'posthog/rails' + + class App < Rails::Application + config.eager_load = false + config.logger = Logger.new(File::NULL) + end + App.initialize! + p [defined?(ActionMailer), defined?(PostHog::Rails::DeliveryMethod)] + RUBY + + expect(output.strip).to eq('[nil, nil]') + end + end +end From cbeae2a9a8661738eadcc3504a297218cfaf0686 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:36:02 +0000 Subject: [PATCH 02/16] fix(rails): refuse mail structures a rebuilt send would change Keep the quotes a From display name needs, such as "Doe, Jane" , 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: https://github.com/PostHog/michaelr-context/issues/43 Co-Authored-By: Claude Opus 5.5 --- .../lib/posthog/rails/mail_mapper.rb | 21 ++++++++---- spec/posthog/rails/action_mailer_spec.rb | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index c3819dc6..59916c05 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -12,7 +12,9 @@ class MailMapper INTEGRATION_NAME = 'action-mailer' TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/ MAPPED_HEADERS = %w[from to cc bcc reply-to subject].freeze - IGNORED_HEADERS = %w[message-id date mime-version x-mailer return-path sender].freeze + IGNORED_HEADERS = %w[message-id date mime-version x-mailer return-path sender content-type + content-transfer-encoding].freeze + CONTAINERS = %w[multipart/mixed multipart/alternative multipart/related].freeze OPTION_HEADERS = { 'x-posthog-distinct-id' => :distinct_id, 'x-posthog-template-key' => :template_key, @@ -22,7 +24,7 @@ class MailMapper TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, - :NORMAL_PRIORITY + :NORMAL_PRIORITY, :CONTAINERS # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. def initialize(default_template_key: nil) @@ -56,7 +58,7 @@ def posthog_options(mail) mail.header.fields.each_with_object({}) do |field, options| name = field.name.downcase value = field.value.to_s - next if MAPPED_HEADERS.include?(name) || IGNORED_HEADERS.include?(name) || name.start_with?('content-') + next if MAPPED_HEADERS.include?(name) || IGNORED_HEADERS.include?(name) next if NORMAL_PRIORITY.key?(name) && NORMAL_PRIORITY[name].match?(value.strip) options[OPTION_HEADERS.fetch(name) { refuse_header(name, field.name) }] = option_value(name, value) @@ -91,7 +93,7 @@ def sender(mail) address = addresses.first return if address.nil? - address.display_name ? "#{address.display_name} <#{address.address}>" : address.address + address.format end def check_envelope(mail, recipient) @@ -119,8 +121,7 @@ def sanitized(key) end def content(mail) - leaves = mail.multipart? ? mail.all_parts.reject(&:multipart?) : [mail] - files, bodies = leaves.partition(&:attachment?) + files, bodies = leaves(mail).partition(&:attachment?) content = bodies.each_with_object({}) do |part, by_kind| kind = body_kind(part) refuse('unsupported_part', "PostHog sends one HTML and one text body, so it can't send #{part.mime_type}") if @@ -130,6 +131,14 @@ def content(mail) files.empty? ? content : content.merge(attachments: files.map { |file| attachment(file) }) end + def leaves(part) + return [part] unless part.multipart? + return part.parts.flat_map { |child| leaves(child) } if CONTAINERS.include?(part.mime_type) && + !part.attachment? + + refuse('unsupported_part', "PostHog can't send a #{part.mime_type} part as it was built") + end + def body_kind(part) case part.mime_type when 'text/html' then :html diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index f35043d0..86ae1647 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -102,6 +102,15 @@ def refusal_of(mail) expect(mail.message_id).to eq(message_id) end + it '01 keeps the quotes a From display name needs' do + mail = shipped_mail + mail.from = '"Doe, Jane" ' + + deliver(mail) + + expect(sent_body['from']).to eq('"Doe, Jane" ') + end + it '02 and 03 drop recipient display names and send cc, bcc and reply-to as lists' do mail = shipped_mail mail.cc = 'Bo ' @@ -215,6 +224,31 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_part') end + it 'refuses a signed message, whose signature a rebuilt message would break' do + mail = shipped_mail + mail.content_type = 'multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256' + mail.add_file(filename: 'smime.p7s', content: 'signature') + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'refuses an attachment that is itself a multipart message' do + mail = shipped_mail + forwarded = Mail::Part.new(content_type: 'multipart/alternative', + content_disposition: 'attachment; filename="fwd"') + forwarded.add_part(Mail::Part.new(content_type: 'text/html', body: '

Forwarded

')) + mail.add_part(forwarded) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'refuses a content header PostHog does not carry, such as Content-Language' do + mail = shipped_mail + mail['Content-Language'] = 'de' + + expect(refusal_of(mail)).to eq('unsupported_header') + end + it 'sends normal priority and refuses any other' do normal = shipped_mail normal['X-Priority'] = '3 (Normal)' From c0507e2a4bd1dde2fb3a7506314701a75dc7d984 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:41:37 +0000 Subject: [PATCH 03/16] fix(rails): check part headers and attachment dispositions before a send 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 --- .../lib/posthog/rails/mail_mapper.rb | 32 +++++++++++--- spec/posthog/rails/action_mailer_spec.rb | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index 59916c05..e33352a5 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -14,6 +14,7 @@ class MailMapper MAPPED_HEADERS = %w[from to cc bcc reply-to subject].freeze IGNORED_HEADERS = %w[message-id date mime-version x-mailer return-path sender content-type content-transfer-encoding].freeze + PART_HEADERS = %w[content-type content-transfer-encoding content-disposition content-id].freeze CONTAINERS = %w[multipart/mixed multipart/alternative multipart/related].freeze OPTION_HEADERS = { 'x-posthog-distinct-id' => :distinct_id, @@ -24,7 +25,7 @@ class MailMapper TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, - :NORMAL_PRIORITY, :CONTAINERS + :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. def initialize(default_template_key: nil) @@ -121,7 +122,7 @@ def sanitized(key) end def content(mail) - files, bodies = leaves(mail).partition(&:attachment?) + files, bodies = leaves(mail).partition { |part| attached?(part) } content = bodies.each_with_object({}) do |part, by_kind| kind = body_kind(part) refuse('unsupported_part', "PostHog sends one HTML and one text body, so it can't send #{part.mime_type}") if @@ -131,14 +132,28 @@ def content(mail) files.empty? ? content : content.merge(attachments: files.map { |file| attachment(file) }) end - def leaves(part) + def leaves(part, root: true) + check_part_headers(part) unless root return [part] unless part.multipart? - return part.parts.flat_map { |child| leaves(child) } if CONTAINERS.include?(part.mime_type) && - !part.attachment? + return part.parts.flat_map { |child| leaves(child, root: false) } if CONTAINERS.include?(part.mime_type) && + !attached?(part) refuse('unsupported_part', "PostHog can't send a #{part.mime_type} part as it was built") end + def check_part_headers(part) + part.header.fields.each do |field| + next if PART_HEADERS.include?(field.name.downcase) + + refuse('unsupported_header', "PostHog can't send the #{field.name} header of a #{part.mime_type} part") + end + end + + # 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') + end + def body_kind(part) case part.mime_type when 'text/html' then :html @@ -147,12 +162,17 @@ def body_kind(part) end def attachment(part) + if part.filename.nil? + refuse('unsupported_part', "PostHog sends each attachment with a filename, and a #{part.mime_type} " \ + 'attachment has none') + end file = { filename: part.filename, content_type: part.mime_type, content: part.body.decoded.b } return file unless part.inline? refuse('inline_without_cid', "The inline attachment #{part.filename} has no Content-ID") unless part.has_content_id? - file.merge(content_id: part.cid) + # Mail's cid escapes the ID as a URL, so the HTML's cid: reference would no longer match it. + file.merge(content_id: part.content_id.to_s.delete_prefix('<').delete_suffix('>')) end def refuse(code, detail) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 86ae1647..35f5d79c 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -71,6 +71,16 @@ def shipped_mail end end + def text_only_mail + Mail.new do + from 'Shop ' + to 'ana@example.com' + subject 'Your order shipped' + content_type 'multipart/mixed' + text_part { body 'On its way.' } + end + end + def deliver(mail) mail.delivery_method(described_class, settings) mail.deliver @@ -242,6 +252,23 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_part') end + it 'refuses a multipart attachment that has no filename' do + mail = text_only_mail + forwarded = Mail::Part.new(content_type: 'multipart/alternative', content_disposition: 'attachment') + forwarded.add_part(Mail::Part.new(content_type: 'text/html', body: '

Forwarded

')) + mail.add_part(forwarded) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'refuses an attachment that has no filename, and does not send it as a body' do + mail = text_only_mail + mail.add_part(Mail::Part.new(content_type: 'text/html', content_disposition: 'attachment', + body: '

Notes

')) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + it 'refuses a content header PostHog does not carry, such as Content-Language' do mail = shipped_mail mail['Content-Language'] = 'de' @@ -249,6 +276,23 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_header') end + it 'refuses a header PostHog does not carry on a body part too' do + mail = shipped_mail + mail.html_part['Content-Language'] = 'de' + + expect(refusal_of(mail)).to eq('unsupported_header') + end + + it 'sends the content ID of an inline attachment as written' do + mail = shipped_mail + mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b + mail.attachments['logo.png'].content_id = '' + + deliver(mail) + + expect(sent_body['attachments'].first['content_id']).to eq('logo%1@example.com') + end + it 'sends normal priority and refuses any other' do normal = shipped_mail normal['X-Priority'] = '3 (Normal)' From e14111d167bb58b637ccd7eb86fce922e95f7ec6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:48:47 +0000 Subject: [PATCH 04/16] fix(rails): refuse an attachment charset PostHog would drop 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 --- posthog-rails/lib/posthog/rails/mail_mapper.rb | 7 ++++++- spec/posthog/rails/action_mailer_spec.rb | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index e33352a5..df7140e9 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -16,6 +16,7 @@ class MailMapper content-transfer-encoding].freeze PART_HEADERS = %w[content-type content-transfer-encoding content-disposition content-id].freeze CONTAINERS = %w[multipart/mixed multipart/alternative multipart/related].freeze + ATTACHMENT_CHARSETS = %w[utf-8 us-ascii].freeze OPTION_HEADERS = { 'x-posthog-distinct-id' => :distinct_id, 'x-posthog-template-key' => :template_key, @@ -25,7 +26,7 @@ class MailMapper TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, - :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS + :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. def initialize(default_template_key: nil) @@ -166,6 +167,10 @@ def attachment(part) refuse('unsupported_part', "PostHog sends each attachment with a filename, and a #{part.mime_type} " \ 'attachment has none') end + # PostHog takes an attachment's MIME type alone, so it would lose any charset but the UTF-8 that Mail writes. + unless part.charset.nil? || ATTACHMENT_CHARSETS.include?(part.charset.downcase) + 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? diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 35f5d79c..689f2bf4 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -283,6 +283,19 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_header') end + it 'sends a UTF-8 text attachment, and refuses one in a charset its MIME type would drop' do + utf8 = shipped_mail + utf8.attachments['notes.txt'] = 'café' + latin1 = shipped_mail + latin1.attachments['latin.txt'] = { mime_type: 'text/plain; charset=ISO-8859-1', content: "caf\xE9".b } + + expect(refusal_of(latin1)).to eq('unsupported_part') + deliver(utf8) + + expect(sent_body['attachments']).to eq([{ 'filename' => 'notes.txt', 'content_type' => 'text/plain', + 'content' => 'Y2Fmw6k=' }]) + end + it 'sends the content ID of an inline attachment as written' do mail = shipped_mail mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b From 662acf00fd89252403275f80d92fcdfaa686c67f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:04:59 +0000 Subject: [PATCH 05/16] fix(rails): refuse a Content-Type parameter PostHog would drop 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 --- posthog-rails/lib/posthog/rails/mail_mapper.rb | 15 ++++++++++++++- spec/posthog/rails/action_mailer_spec.rb | 7 +++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index df7140e9..ae544169 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -17,6 +17,8 @@ class MailMapper PART_HEADERS = %w[content-type content-transfer-encoding content-disposition content-id].freeze CONTAINERS = %w[multipart/mixed multipart/alternative multipart/related].freeze ATTACHMENT_CHARSETS = %w[utf-8 us-ascii].freeze + BODY_PARAMETERS = %w[charset].freeze + ATTACHMENT_PARAMETERS = %w[charset name filename].freeze OPTION_HEADERS = { 'x-posthog-distinct-id' => :distinct_id, 'x-posthog-template-key' => :template_key, @@ -26,7 +28,8 @@ class MailMapper TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, - :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS + :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS, + :BODY_PARAMETERS, :ATTACHMENT_PARAMETERS # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. def initialize(default_template_key: nil) @@ -125,6 +128,7 @@ def sanitized(key) def content(mail) files, bodies = leaves(mail).partition { |part| attached?(part) } content = bodies.each_with_object({}) do |part, by_kind| + check_parameters(part, BODY_PARAMETERS) kind = body_kind(part) refuse('unsupported_part', "PostHog sends one HTML and one text body, so it can't send #{part.mime_type}") if kind.nil? || by_kind.key?(kind) @@ -155,6 +159,14 @@ def attached?(part) part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment') end + # PostHog sends a body or an attachment by its MIME type, so a parameter such as format=flowed would be lost. + def check_parameters(part, allowed) + dropped = part.content_type_parameters.to_h.keys.map { |name| name.to_s.downcase } - allowed + return if dropped.empty? + + refuse('unsupported_part', "PostHog can't send the #{dropped.join(', ')} parameter of a #{part.mime_type} part") + end + def body_kind(part) case part.mime_type when 'text/html' then :html @@ -167,6 +179,7 @@ def attachment(part) refuse('unsupported_part', "PostHog sends each attachment with a filename, and a #{part.mime_type} " \ 'attachment has none') end + check_parameters(part, ATTACHMENT_PARAMETERS) # PostHog takes an attachment's MIME type alone, so it would lose any charset but the UTF-8 that Mail writes. unless part.charset.nil? || ATTACHMENT_CHARSETS.include?(part.charset.downcase) refuse('unsupported_part', "PostHog can't send the #{part.charset} charset of #{part.filename}") diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 689f2bf4..e43033a3 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -296,6 +296,13 @@ def refusal_of(mail) 'content' => 'Y2Fmw6k=' }]) end + it 'refuses a Content-Type parameter PostHog would drop, such as format=flowed' do + mail = shipped_mail + mail.text_part.content_type = 'text/plain; charset=UTF-8; format=flowed; delsp=yes' + + expect(refusal_of(mail)).to eq('unsupported_part') + end + it 'sends the content ID of an inline attachment as written' do mail = shipped_mail mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b From ec26492084acac4975d2115f0981f94958e6e95d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:10:32 +0000 Subject: [PATCH 06/16] test(rails): cover the attachment Content-Type parameter refusal Refs PostHog/michaelr-context#43 Co-Authored-By: Claude Opus 5.5 --- spec/posthog/rails/action_mailer_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index e43033a3..cafcf01f 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -303,6 +303,13 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_part') end + it 'refuses an attachment with a Content-Type parameter PostHog would drop' do + mail = shipped_mail + mail.attachments['notes.txt'] = { mime_type: 'text/plain; charset=UTF-8; format=flowed', content: 'Notes' } + + expect(refusal_of(mail)).to eq('unsupported_part') + end + it 'sends the content ID of an inline attachment as written' do mail = shipped_mail mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b From 97588f8877f990ce6a869721fad740dbf62f754f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:32:36 +0000 Subject: [PATCH 07/16] docs(rails): say what X-PostHog-Capture-Engagement does Co-Authored-By: Claude Opus 5.5 --- .changeset/rails-action-mailer-delivery.md | 2 +- posthog-rails/lib/posthog/rails/delivery_method.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md index 4d812963..72ad3fa8 100644 --- a/.changeset/rails-action-mailer-delivery.md +++ b/.changeset/rails-action-mailer-delivery.md @@ -2,4 +2,4 @@ "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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. 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`. After a send, the mail's `message_id` is PostHog's message ID. +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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person: PostHog still adds its open pixel, and never rewrites the email's links, so it counts no clicks. 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`. After a send, the mail's `message_id` is PostHog's message ID. diff --git a/posthog-rails/lib/posthog/rails/delivery_method.rb b/posthog-rails/lib/posthog/rails/delivery_method.rb index 8d35d354..06bdcc07 100644 --- a/posthog-rails/lib/posthog/rails/delivery_method.rb +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -9,6 +9,11 @@ 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 `X-PostHog-Capture-Engagement` header, `true` or `false`, decides whether PostHog captures the email's + # engagement events (sent, delivered, opened, clicked, bounced) as events on the recipient's person. It defaults to + # `true`. Turning it off doesn't change the email: PostHog still adds its open pixel to count opens. It never + # rewrites the email's links, so it counts no clicks. + # # @example config/environments/production.rb # config.action_mailer.delivery_method = :posthog # config.action_mailer.posthog_settings = { default_template_key: 'transactional' } From 7e3b6bfe151beac03bb6fd0e7d9c4dd5a833d1d4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:53:48 +0000 Subject: [PATCH 08/16] fix(rails): keep related resources' Content-IDs, retry what the SDK retries A multipart/related part with a Content-ID is a resource the HTML refers to, never a body: one without a filename is refused, and one without an inline Content-Disposition keeps its Content-ID. DeliveryError#retryable? now follows the rule PostHog::Emails retries by: a used-up upload quota is permanent, and file storage's 500, 502 and 504 pass like its 503. Co-Authored-By: Claude Opus 5.5 --- .../lib/posthog/rails/delivery_errors.rb | 6 +- .../lib/posthog/rails/mail_mapper.rb | 14 ++-- spec/posthog/rails/action_mailer_spec.rb | 65 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/delivery_errors.rb b/posthog-rails/lib/posthog/rails/delivery_errors.rb index 5aba18d8..a52eba31 100644 --- a/posthog-rails/lib/posthog/rails/delivery_errors.rb +++ b/posthog-rails/lib/posthog/rails/delivery_errors.rb @@ -46,11 +46,15 @@ def detail # 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 code != 'quota_limited' + 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 diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index ae544169..55f79fa3 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -154,9 +154,11 @@ def check_part_headers(part) end end - # Mail's own attachment? looks for a filename, but a Content-Disposition of attachment needs none. + # Mail's own attachment? looks for a filename, but a Content-Disposition of attachment needs none, and a part + # with a Content-ID is a resource the HTML refers to, never a body. def attached?(part) - part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment') + part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment') || + !part.header[:content_id].nil? end # PostHog sends a body or an attachment by its MIME type, so a parameter such as format=flowed would be lost. @@ -185,10 +187,12 @@ def attachment(part) 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? + # A multipart/related resource may carry its Content-ID without an inline Content-Disposition. + unless part.has_content_id? + refuse('inline_without_cid', "The inline attachment #{part.filename} has no Content-ID") if part.inline? + return file + end - refuse('inline_without_cid', "The inline attachment #{part.filename} has no Content-ID") unless - part.has_content_id? # Mail's cid escapes the ID as a URL, so the HTML's cid: reference would no longer match it. file.merge(content_id: part.content_id.to_s.delete_prefix('<').delete_suffix('>')) end diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index b903ad47..ded42e65 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -332,6 +332,45 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('unsupported_part') end + def related_mail + mail = Mail.new do + from 'Shop ' + to 'ana@example.com' + subject 'Your order shipped' + end + mail.content_type = 'multipart/related' + mail.add_part(Mail::Part.new(content_type: 'text/html; charset=UTF-8', body: '')) + mail + end + + it 'refuses an inline resource without a filename instead of sending it as a body' do + mail = related_mail + mail.add_part(Mail::Part.new(content_type: 'text/plain; charset=UTF-8', content_disposition: 'inline', + content_id: '', body: 'Notes')) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'sends the content ID of a related resource that has no Content-Disposition' do + mail = related_mail + mail.add_part(Mail::Part.new(content_type: 'image/png; name=logo.png', content_id: '', + body: "\x89PNG\r\n\x1A\n".b)) + + deliver(mail) + + expect(sent_files).to eq([{ 'filename' => 'logo.png', 'content_type' => 'image/png', + 'content' => "\x89PNG\r\n\x1A\n".b, 'content_id' => 'logo' }]) + end + + it 'sends a body part marked inline as a body' do + mail = shipped_mail + mail.text_part.content_disposition = 'inline' + + deliver(mail) + + expect(sent_body).to eq(shipped_body) + end + it 'sends the content ID of an inline attachment as written' do mail = shipped_mail mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b @@ -389,6 +428,32 @@ def refusal_of(mail) end end + def invoice_mail + shipped_mail.tap { |mail| mail.attachments['invoice.pdf'] = "%PDF-1.4\n" } + end + + it 'raises a permanent DeliveryError when the upload quota is used up' do + stub_request(:post, %r{/attachments/uploads/\z}).to_return( + status: 429, body: JSON.generate(code: 'upload_quota_exceeded', detail: 'No.', attr: 'attachments') + ) + + expect { deliver(invoice_mail) }.to raise_error(PostHog::Rails::DeliveryError) { |error| + expect([error.status, error.code, error.retryable?]).to eq([429, 'upload_quota_exceeded', false]) + } + expect(a_request(:post, send_url)).not_to have_been_made + end + + [[500, true], [502, true], [503, true], [504, true], [403, false]].each do |status, retryable| + it "raises a #{retryable ? 'retryable' : 'permanent'} DeliveryError when file storage answers #{status}" do + stub_request(:put, %r{\Ahttps://files\.example\.com/}).to_return(status: status, body: '') + + expect { deliver(invoice_mail) }.to raise_error(PostHog::Rails::DeliveryError) { |error| + expect([error.status, error.code, error.retryable?]).to eq([status, 'attachment_upload_failed', retryable]) + } + expect(a_request(:post, send_url)).not_to have_been_made + end + end + it 'raises a retryable DeliveryError when PostHog could not be reached' do stub_request(:post, send_url).to_raise(Errno::ECONNREFUSED) From 750116247899adcf96bfc1f3dff3cf73464e2cf6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:58:05 +0000 Subject: [PATCH 09/16] fix(rails): cap template keys at the 256 characters PostHog accepts PostHog's send API refuses a template_key over 256 characters since PostHog/posthog#134247. An explicit X-PostHog-Template-Key over 256 is refused before any request, and a derived - key is cut to 256. Co-Authored-By: Claude Opus 5.5 --- .../lib/posthog/rails/mail_mapper.rb | 11 +++++----- spec/posthog/rails/action_mailer_spec.rb | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index 55f79fa3..e6fc9132 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -10,7 +10,8 @@ module Rails # @api private class MailMapper INTEGRATION_NAME = 'action-mailer' - TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/ + MAX_TEMPLATE_KEY = 256 + TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,#{MAX_TEMPLATE_KEY}}\z/ MAPPED_HEADERS = %w[from to cc bcc reply-to subject].freeze IGNORED_HEADERS = %w[message-id date mime-version x-mailer return-path sender content-type content-transfer-encoding].freeze @@ -27,8 +28,8 @@ class MailMapper }.freeze TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze - private_constant :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, :TEMPLATE_HEADER, - :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS, + private_constant :MAX_TEMPLATE_KEY, :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, + :TEMPLATE_HEADER, :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS, :BODY_PARAMETERS, :ATTACHMENT_PARAMETERS # @param default_template_key [String, nil] The key for mail that sets none and has no mailer name. @@ -114,14 +115,14 @@ def template_key(explicit, derived) return explicit if TEMPLATE_KEY_PATTERN.match?(explicit) refuse('invalid_template_key', 'X-PostHog-Template-Key can only have letters, digits, hyphens and ' \ - 'underscores, at most 400') + "underscores, at most #{MAX_TEMPLATE_KEY}") end sanitized(derived) || @default_template_key || INTEGRATION_NAME end def sanitized(key) - key = key.to_s.gsub(/[^a-zA-Z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')[0, 400] + key = key.to_s.gsub(/[^a-zA-Z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')[0, MAX_TEMPLATE_KEY] key unless key.empty? end diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index ded42e65..73324299 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -211,6 +211,26 @@ def refusal_of(mail) expect(refusal_of(mail)).to eq('invalid_template_key') end + it 'sends an explicit template key of 256 characters and refuses a longer one' do + longest = shipped_mail + longest['X-PostHog-Template-Key'] = 'k' * 256 + too_long = shipped_mail + too_long['X-PostHog-Template-Key'] = 'k' * 257 + + expect(refusal_of(too_long)).to eq('invalid_template_key') + deliver(longest) + + expect(sent_body['template_key']).to eq('k' * 256) + end + + it 'cuts a derived template key to the 256 characters PostHog accepts' do + mailer = Class.new(Admin::UserMailer) { self.mailer_name = 'm' * 300 } + + mailer.welcome_email.deliver_now + + expect(sent_body['template_key']).to eq('m' * 256) + end + it '09 refuses more than one To address' do mail = shipped_mail mail.to = ['ana@example.com', 'bo@example.com'] From d9826590f6ff9b70c5185e6d6aa37ddf32470fde Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:05:04 +0000 Subject: [PATCH 10/16] test(rails): install the delivery method on Action Mailer without a global load hook The spec ran the Railtie's initializer, which left its :action_mailer load hook registered for the whole suite. A spec that runs that load hook on a stand-in class then called add_delivery_method on it. The spec now does what the hook does on ActionMailer::Base; 'a Rails app' still boots the Railtie's own hook. Co-Authored-By: Claude Opus 5.5 --- spec/posthog/rails/action_mailer_spec.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 73324299..c7b6ce1a 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -9,8 +9,12 @@ $LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) require 'posthog/rails' -initializer = PostHog::Rails::Railtie.initializers.find { |i| i.name == 'posthog.action_mailer' } -PostHog::Rails::Railtie.instance.instance_exec(nil, &initializer.block) +require 'posthog/rails/delivery_method' + +# What the Railtie's Action Mailer load hook does, done once on Action Mailer itself. Registering the hook here would +# run it on the stand-in classes other specs pass to run_load_hooks. 'a Rails app' below boots the Railtie's own hook. +ActionMailer::Base.prepend PostHog::Rails::MailerExtensions +ActionMailer::Base.add_delivery_method :posthog, PostHog::Rails::DeliveryMethod class ShippingMailer < ActionMailer::Base self.delivery_method = :posthog From 79f0797161b41e6e30b0ab845dfae0a0f9dfe7e9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:33:23 +0000 Subject: [PATCH 11/16] fix(rails): keep inline bodies as bodies, refuse body sections, and name every refusal Action Mailer encodes a mail before it delivers it, and encoding gives every inline part a Content-ID, so a Content-ID no longer makes a part a file. Two bodies must share their nearest multipart/alternative, since PostHog sends them as alternatives. The SDK's argument checks raise UnsupportedMailError invalid_mail, and with raise_delivery_errors off the error goes to the log, since Mail drops it. A spec holds DeliveryError#retryable? to the SDK's own retry rule. Co-Authored-By: Claude Opus 5.5 --- .changeset/rails-action-mailer-delivery.md | 2 +- .../lib/posthog/rails/delivery_errors.rb | 6 +- .../lib/posthog/rails/delivery_method.rb | 21 +++++- .../lib/posthog/rails/mail_mapper.rb | 41 ++++++++--- spec/posthog/rails/action_mailer_spec.rb | 71 +++++++++++++++++++ 5 files changed, 123 insertions(+), 18 deletions(-) diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md index 72ad3fa8..ed929709 100644 --- a/.changeset/rails-action-mailer-delivery.md +++ b/.changeset/rails-action-mailer-delivery.md @@ -2,4 +2,4 @@ "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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person: PostHog still adds its open pixel, and never rewrites the email's links, so it counts no clicks. 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`. After a send, the mail's `message_id` is PostHog's message ID. +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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person: PostHog still adds its open pixel, and never rewrites the email's links, so it counts no clicks. 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. diff --git a/posthog-rails/lib/posthog/rails/delivery_errors.rb b/posthog-rails/lib/posthog/rails/delivery_errors.rb index a52eba31..22685bda 100644 --- a/posthog-rails/lib/posthog/rails/delivery_errors.rb +++ b/posthog-rails/lib/posthog/rails/delivery_errors.rb @@ -3,10 +3,10 @@ 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. + # `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`. + # `template_unsupported`, or `invalid_mail` for a field PostHog would turn down, such as a missing subject. attr_reader :refusal # @api private @@ -17,7 +17,7 @@ def initialize(refusal:, detail:) 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}. + # 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 diff --git a/posthog-rails/lib/posthog/rails/delivery_method.rb b/posthog-rails/lib/posthog/rails/delivery_method.rb index 06bdcc07..b120169f 100644 --- a/posthog-rails/lib/posthog/rails/delivery_method.rb +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -35,11 +35,26 @@ def initialize(settings = {}) # @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) - arguments = MailMapper.new(default_template_key: settings[:default_template_key]) - .map(mail, derived_template_key: MailerExtensions.template_key(mail)) - result = PostHog.emails.send(**arguments, integration: INTEGRATION) + result = send_email(mail) mail.message_id = result.id result + rescue UnsupportedMailError, DeliveryError => e + # Mail drops the error when the mailer turns raise_delivery_errors off, so this line is all that is left 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 diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index e6fc9132..8917f4ec 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -127,7 +127,7 @@ def sanitized(key) end def content(mail) - files, bodies = leaves(mail).partition { |part| attached?(part) } + bodies, files = sorted_parts(mail) content = bodies.each_with_object({}) do |part, by_kind| check_parameters(part, BODY_PARAMETERS) kind = body_kind(part) @@ -138,13 +138,33 @@ def content(mail) files.empty? ? content : content.merge(attachments: files.map { |file| attachment(file) }) end - def leaves(part, root: true) - check_part_headers(part) unless root - return [part] unless part.multipart? - return part.parts.flat_map { |child| leaves(child, root: false) } if CONTAINERS.include?(part.mime_type) && - !attached?(part) + # Splits the message into its bodies and its files. PostHog sends an HTML and a text body as alternatives of + # each other, so two bodies must share their nearest multipart/alternative. + def sorted_parts(mail) + bodies = [] + files = [] + sort_part(mail, bodies, files, alternative: nil) + groups = bodies.map(&:last) + if groups.size > 1 && (groups.uniq.size > 1 || groups.include?(nil)) + refuse('unsupported_part', "PostHog sends an HTML and a text body as alternatives, so it can't send " \ + 'bodies that are sections of one message') + end + [bodies.map(&:first), files] + end + + def sort_part(part, bodies, files, alternative:) + attached = attached?(part) + return attached ? files << part : bodies << [part, alternative] unless part.multipart? - refuse('unsupported_part', "PostHog can't send a #{part.mime_type} part as it was built") + if attached || !CONTAINERS.include?(part.mime_type) + refuse('unsupported_part', "PostHog can't send a #{part.mime_type} part as it was built") + end + + alternative = part if part.mime_type == 'multipart/alternative' + part.parts.each do |child| + check_part_headers(child) + sort_part(child, bodies, files, alternative: alternative) + end end def check_part_headers(part) @@ -155,11 +175,10 @@ def check_part_headers(part) end end - # Mail's own attachment? looks for a filename, but a Content-Disposition of attachment needs none, and a part - # with a Content-ID is a resource the HTML refers to, never a body. + # Mail's own attachment? looks for a filename, but a Content-Disposition of attachment needs none. A Content-ID + # alone makes no file: Mail gives one to every inline part, bodies too, when it encodes the mail. def attached?(part) - part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment') || - !part.header[:content_id].nil? + part.attachment? || part.content_disposition.to_s.downcase.start_with?('attachment') end # PostHog sends a body or an attachment by its MIME type, so a parameter such as format=flowed would be lost. diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index c7b6ce1a..12db9dd2 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -395,6 +395,52 @@ def related_mail expect(sent_body).to eq(shipped_body) end + # Action Mailer encodes the mail before it delivers it, and encoding gives every inline part a Content-ID. + it 'sends body parts marked inline as bodies after Action Mailer encodes them' do + delivery = ShippingMailer.shipped + delivery.message.text_part.content_disposition = 'inline' + delivery.message.html_part.content_disposition = 'inline' + + delivery.deliver_now + + expect(sent_body.except('attachments')).to eq(shipped_body.merge('template_key' => 'shipping_mailer-shipped')) + expect(sent_files.map { |file| file['filename'] }).to eq(['invoice.pdf']) + end + + it 'refuses text and HTML bodies that are sections of a mixed message, since PostHog sends them as alternatives' do + mail = Mail.new do + from 'Shop ' + to 'ana@example.com' + subject 'Your order shipped' + end + mail.content_type = 'multipart/mixed' + mail.add_part(Mail::Part.new(content_type: 'text/plain; charset=UTF-8', body: 'Read the notes below.')) + mail.add_part(Mail::Part.new(content_type: 'text/html; charset=UTF-8', body: '

Notes

')) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'sends a text alternative to HTML that refers to its own inline image' do + mail = Mail.new do + from 'Shop ' + to 'ana@example.com' + subject 'Your order shipped' + end + mail.content_type = 'multipart/alternative' + mail.add_part(Mail::Part.new(content_type: 'text/plain; charset=UTF-8', body: 'On its way.')) + html = related_mail.parts.first + related = Mail::Part.new(content_type: 'multipart/related') + related.add_part(html) + related.add_part(Mail::Part.new(content_type: 'image/png; name=logo.png', content_id: '', + body: "\x89PNG\r\n\x1A\n".b)) + mail.add_part(related) + + deliver(mail) + + expect(sent_body.slice('text', 'html')).to eq('text' => 'On its way.', 'html' => '') + expect(sent_files.map { |file| file['content_id'] }).to eq(['logo']) + end + it 'sends the content ID of an inline attachment as written' do mail = shipped_mail mail.attachments.inline['logo.png'] = "\x89PNG\r\n\x1A\n".b @@ -422,8 +468,19 @@ def related_mail mail.to = ['ana@example.com', 'bo@example.com'] mail.raise_delivery_errors = false + log = StringIO.new + PostHog::Logging.logger = Logger.new(log) + expect { deliver(mail) }.not_to raise_error expect(a_request(:post, send_url)).not_to have_been_made + expect(log.string).to include('PostHog did not send an email', 'multiple_to') + end + + it 'refuses a mail PostHog would turn down as invalid, such as one with no subject' do + no_subject = shipped_mail.tap { |mail| mail.subject = nil } + spaced_key = shipped_mail.tap { |mail| mail['X-PostHog-Idempotency-Key'] = 'order 1' } + + expect([refusal_of(no_subject), refusal_of(spaced_key)]).to eq(%w[invalid_mail invalid_mail]) end end @@ -478,6 +535,20 @@ def invoice_mail end end + # DeliveryError#retryable? repeats the rule PostHog::Emails retries by, which the SDK keeps private. + it 'judges every failure retryable exactly as the SDK does' do + statuses = [nil, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504] + codes = %w[network_error idempotency_key_in_flight quota_limited upload_quota_exceeded throttled + attachment_upload_failed delivery_unavailable server_error invalid_input] + emails = PostHog.emails + statuses.product(codes).each do |status, code| + error = PostHog::EmailError.new(code: code, message: 'No.', status_code: status) + + expect(PostHog::Rails::DeliveryError.new(error).retryable?).to eq(emails.__send__(:retryable?, error)), + "#{status.inspect} #{code}" + end + end + it 'raises a retryable DeliveryError when PostHog could not be reached' do stub_request(:post, send_url).to_raise(Errno::ECONNREFUSED) From ca9bd6e466ce1876c0df80893d63d6f8660baa8c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:34:41 +0000 Subject: [PATCH 12/16] test(rails): boot each spawned Rails app outside the checkout Booting an app in development writes tmp/local_secret.txt into the working directory, which nothing ignores, so a run of the suite left a secret in the checkout for the next commit to pick up. Co-Authored-By: Claude Opus 5.5 --- spec/posthog/rails/action_mailer_spec.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 12db9dd2..5a59de9f 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'open3' +require 'tmpdir' require 'logger' require 'rails' require 'action_mailer' @@ -581,8 +582,13 @@ def invoice_mail end describe 'a Rails app' do + # Runs each app from a directory of its own, so the files Rails writes when it boots, such as + # tmp/local_secret.txt, never land in the checkout. def run_app(script) - stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-Iposthog-rails/lib', '-e', script) + paths = %w[lib posthog-rails/lib].map { |path| "-I#{File.expand_path("../../../#{path}", __dir__)}" } + stdout, stderr, status = Dir.mktmpdir do |root| + Open3.capture3(RbConfig.ruby, *paths, '-e', script, chdir: root) + end expect(status).to be_success, "#{stdout}\n#{stderr}" stdout end From 82108c5dce961e44468d2ce2e6fbcc0f658609bf Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:00:29 +0000 Subject: [PATCH 13/16] fix(rails): refuse body sections an alternative holds, and test retries outside in Two bodies go out as alternatives only when the nearest container they share is multipart/alternative, so alternative[mixed[text, html]] is refused too. Body kinds are checked first, so an image without a filename is refused by its MIME type. The retry drift spec now sends each failure through a client that retries once, instead of calling the SDK's private rule. Co-Authored-By: Claude Opus 5.5 --- .../lib/posthog/rails/delivery_method.rb | 2 +- .../lib/posthog/rails/mail_mapper.rb | 41 +++++------ spec/posthog/rails/action_mailer_spec.rb | 69 ++++++++++++++++--- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/delivery_method.rb b/posthog-rails/lib/posthog/rails/delivery_method.rb index b120169f..478f7606 100644 --- a/posthog-rails/lib/posthog/rails/delivery_method.rb +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -39,7 +39,7 @@ def deliver!(mail) mail.message_id = result.id result rescue UnsupportedMailError, DeliveryError => e - # Mail drops the error when the mailer turns raise_delivery_errors off, so this line is all that is left of it. + # 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 diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index 8917f4ec..c267969a 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -127,46 +127,47 @@ def sanitized(key) end def content(mail) - bodies, files = sorted_parts(mail) - content = bodies.each_with_object({}) do |part, by_kind| + bodies = [] + files = [] + sort_part(mail, bodies, files, []) + content = bodies.each_with_object({}) do |(part, _), by_kind| check_parameters(part, BODY_PARAMETERS) kind = body_kind(part) refuse('unsupported_part', "PostHog sends one HTML and one text body, so it can't send #{part.mime_type}") if kind.nil? || by_kind.key?(kind) by_kind[kind] = part.decoded end + check_alternatives(bodies.map(&:last)) files.empty? ? content : content.merge(attachments: files.map { |file| attachment(file) }) end - # Splits the message into its bodies and its files. PostHog sends an HTML and a text body as alternatives of - # each other, so two bodies must share their nearest multipart/alternative. - def sorted_parts(mail) - bodies = [] - files = [] - sort_part(mail, bodies, files, alternative: nil) - groups = bodies.map(&:last) - if groups.size > 1 && (groups.uniq.size > 1 || groups.include?(nil)) - refuse('unsupported_part', "PostHog sends an HTML and a text body as alternatives, so it can't send " \ - 'bodies that are sections of one message') - end - [bodies.map(&:first), files] - end - - def sort_part(part, bodies, files, alternative:) + # Collects each body with the containers above it, and each file. + def sort_part(part, bodies, files, containers) attached = attached?(part) - return attached ? files << part : bodies << [part, alternative] unless part.multipart? + return attached ? files << part : bodies << [part, containers] unless part.multipart? if attached || !CONTAINERS.include?(part.mime_type) refuse('unsupported_part', "PostHog can't send a #{part.mime_type} part as it was built") end - alternative = part if part.mime_type == 'multipart/alternative' part.parts.each do |child| check_part_headers(child) - sort_part(child, bodies, files, alternative: alternative) + sort_part(child, bodies, files, [*containers, part]) end end + # PostHog sends an HTML and a text body as alternatives of each other, so the nearest container they share + # must be a multipart/alternative. + def check_alternatives(paths) + return if paths.size < 2 + + shared = paths.reduce { |left, right| left.take_while.with_index { |container, i| container.equal?(right[i]) } } + return if shared.last&.mime_type == 'multipart/alternative' + + refuse('unsupported_part', "PostHog sends an HTML and a text body as alternatives, so it can't send " \ + 'bodies that are sections of one message') + end + def check_part_headers(part) part.header.fields.each do |field| next if PART_HEADERS.include?(field.name.downcase) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 5a59de9f..07e0d8ab 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -421,6 +421,28 @@ def related_mail expect(refusal_of(mail)).to eq('unsupported_part') end + it 'refuses text and HTML sections of a mixed message even when an alternative holds them' do + mail = Mail.new do + from 'Shop ' + to 'ana@example.com' + subject 'Your order shipped' + end + mail.content_type = 'multipart/alternative' + sections = Mail::Part.new(content_type: 'multipart/mixed') + sections.add_part(Mail::Part.new(content_type: 'text/plain; charset=UTF-8', body: 'Read the notes below.')) + sections.add_part(Mail::Part.new(content_type: 'text/html; charset=UTF-8', body: '

Notes

')) + mail.add_part(sections) + + expect(refusal_of(mail)).to eq('unsupported_part') + end + + it 'names the MIME type of a related image that has no filename' do + mail = related_mail + mail.add_part(Mail::Part.new(content_type: 'image/png', content_id: '', body: "\x89PNG\r\n\x1A\n".b)) + + expect { deliver(mail) }.to raise_error(PostHog::Rails::UnsupportedMailError, %r{can't send image/png}) + end + it 'sends a text alternative to HTML that refers to its own inline image' do mail = Mail.new do from 'Shop ' @@ -536,18 +558,45 @@ def invoice_mail end end - # DeliveryError#retryable? repeats the rule PostHog::Emails retries by, which the SDK keeps private. - it 'judges every failure retryable exactly as the SDK does' do - statuses = [nil, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504] - codes = %w[network_error idempotency_key_in_flight quota_limited upload_quota_exceeded throttled - attachment_upload_failed delivery_unavailable server_error invalid_input] - emails = PostHog.emails - statuses.product(codes).each do |status, code| - error = PostHog::EmailError.new(code: code, message: 'No.', status_code: status) + # DeliveryError#retryable? repeats the rule PostHog::Emails retries by. Each failure goes to a client that + # doesn't retry, to read the error, and to one that retries once, to see whether the SDK sends again. + it 'calls a failure retryable exactly when the SDK would retry it' do + once = PostHog.client + retrying = PostHog::Client.new(api_key: API_KEY, secret_key: 'phs_test', test_mode: true, + email_request_max_retries: 1) + allow(retrying.emails).to receive(:sleep) + codes = %w[idempotency_key_in_flight quota_limited upload_quota_exceeded throttled attachment_upload_failed + delivery_unavailable server_error invalid_input] + failures = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504].product(codes).map do |status, code| + { status: status, body: JSON.generate(code: code, detail: 'No.') } + end + accepted = { status: 202, body: JSON.generate(message_id: message_id) } - expect(PostHog::Rails::DeliveryError.new(error).retryable?).to eq(emails.__send__(:retryable?, error)), - "#{status.inspect} #{code}" + (failures + [Errno::ECONNREFUSED]).each do |failure| + answer = lambda do + stub = stub_request(:post, send_url) + failure.is_a?(Hash) ? stub.to_return(failure, accepted) : stub.to_raise(failure).then.to_return(accepted) + end + PostHog.client = once + answer.call + error = nil + expect { deliver(shipped_mail) }.to raise_error(PostHog::Rails::DeliveryError) { |raised| error = raised } + + PostHog.client = retrying + WebMock::RequestRegistry.instance.reset! + answer.call + begin + deliver(shipped_mail) + rescue PostHog::Rails::DeliveryError + nil + end + attempts = WebMock::RequestRegistry.instance.times_executed(WebMock::RequestPattern.new(:post, send_url)) + expect(error.retryable?).to eq(attempts == 2), "#{failure.inspect}: sent #{attempts} times" + WebMock::RequestRegistry.instance.reset! end + ensure + PostHog.client = once + retrying&.shutdown end it 'raises a retryable DeliveryError when PostHog could not be reached' do From 63707adc64c2b32e0a797c909a9916b20634646c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:14:24 +0000 Subject: [PATCH 14/16] feat(rails): set tracking and sensitivity from reserved headers X-PostHog-Track-Opens, X-PostHog-Track-Clicks and X-PostHog-Capture-Engagement set tracking.opens, tracking.clicks and tracking.engagement, and X-PostHog-Sensitive sets sensitive. Each takes true or false, and none is sent. The adapter never sends capture_engagement, so a send can't carry it next to tracking. Co-Authored-By: Claude Opus 5.5 --- .changeset/rails-action-mailer-delivery.md | 2 +- .../lib/posthog/rails/delivery_method.rb | 15 ++++++++--- .../lib/posthog/rails/mail_mapper.rb | 19 ++++++++----- spec/posthog/rails/action_mailer_spec.rb | 27 ++++++++++++++++++- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md index ed929709..6cb9f95b 100644 --- a/.changeset/rails-action-mailer-delivery.md +++ b/.changeset/rails-action-mailer-delivery.md @@ -2,4 +2,4 @@ "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 `-`, 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`, `X-PostHog-Idempotency-Key` and `X-PostHog-Capture-Engagement` set those send options and are never sent. `X-PostHog-Capture-Engagement: false` only keeps the email's engagement events off the recipient's person: PostHog still adds its open pixel, and never rewrites the email's links, so it counts no clicks. 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. +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 `-`, 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. 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. diff --git a/posthog-rails/lib/posthog/rails/delivery_method.rb b/posthog-rails/lib/posthog/rails/delivery_method.rb index 478f7606..aff0a265 100644 --- a/posthog-rails/lib/posthog/rails/delivery_method.rb +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -9,10 +9,17 @@ 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 `X-PostHog-Capture-Engagement` header, `true` or `false`, decides whether PostHog captures the email's - # engagement events (sent, delivered, opened, clicked, bounced) as events on the recipient's person. It defaults to - # `true`. Turning it off doesn't change the email: PostHog still adds its open pixel to count opens. It never - # rewrites the email's links, so it counts no clicks. + # 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 diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index c267969a..06d41101 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -24,11 +24,17 @@ class MailMapper 'x-posthog-distinct-id' => :distinct_id, 'x-posthog-template-key' => :template_key, 'x-posthog-idempotency-key' => :idempotency_key, - 'x-posthog-capture-engagement' => :capture_engagement + 'x-posthog-sensitive' => :sensitive, + 'x-posthog-track-opens' => :opens, + 'x-posthog-track-clicks' => :clicks, + 'x-posthog-capture-engagement' => :engagement }.freeze + SWITCHES = %i[sensitive opens clicks engagement].freeze + TRACKING = %i[opens clicks engagement].freeze TEMPLATE_HEADER = 'x-posthog-template-id' NORMAL_PRIORITY = { 'x-priority' => /\A3\b/, 'priority' => /\Anormal\z/i, 'importance' => /\Anormal\z/i }.freeze private_constant :MAX_TEMPLATE_KEY, :TEMPLATE_KEY_PATTERN, :MAPPED_HEADERS, :IGNORED_HEADERS, :OPTION_HEADERS, + :SWITCHES, :TRACKING, :TEMPLATE_HEADER, :NORMAL_PRIORITY, :CONTAINERS, :PART_HEADERS, :ATTACHMENT_CHARSETS, :BODY_PARAMETERS, :ATTACHMENT_PARAMETERS @@ -54,7 +60,8 @@ def map(mail, derived_template_key: nil) subject: mail.subject, template_key: template_key(options[:template_key], derived_template_key), **content(mail), - **options.slice(:distinct_id, :idempotency_key, :capture_engagement) + **options.slice(:distinct_id, :idempotency_key, :sensitive), + tracking: (options.slice(*TRACKING) unless options.slice(*TRACKING).empty?) }.compact end @@ -67,7 +74,8 @@ def posthog_options(mail) next if MAPPED_HEADERS.include?(name) || IGNORED_HEADERS.include?(name) next if NORMAL_PRIORITY.key?(name) && NORMAL_PRIORITY[name].match?(value.strip) - options[OPTION_HEADERS.fetch(name) { refuse_header(name, field.name) }] = option_value(name, value) + option = OPTION_HEADERS.fetch(name) { refuse_header(name, field.name) } + options[option] = SWITCHES.include?(option) ? switch(field.name, value) : value end end @@ -79,11 +87,10 @@ def refuse_header(name, header) refuse('unsupported_header', "PostHog can't send the #{header} header") end - def option_value(name, value) - return value unless name == 'x-posthog-capture-engagement' + def switch(header, value) return value == 'true' if %w[true false].include?(value) - refuse('unsupported_header', 'X-PostHog-Capture-Engagement must be true or false') + refuse('unsupported_header', "#{header} must be true or false") end def single_recipient(mail) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 82f7a4f6..7fae75e7 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -189,11 +189,36 @@ def refusal_of(mail) deliver(mail) expect(sent_body).to eq(shipped_body.merge('template_key' => 'order-shipped', 'distinct_id' => 'user_42', - 'capture_engagement' => false)) + 'tracking' => { 'engagement' => false })) expect(sent_request.headers['Idempotency-Key']).to eq('order-981-shipped') expect(sent_request.headers.keys.grep(/distinct|template|engagement/i)).to be_empty end + it 'sends the tracking and sensitivity the reserved headers choose, and never capture_engagement' do + mail = shipped_mail + mail['X-PostHog-Track-Opens'] = 'true' + mail['X-PostHog-Track-Clicks'] = 'false' + mail['X-PostHog-Capture-Engagement'] = 'true' + mail['X-PostHog-Sensitive'] = 'true' + + deliver(mail) + + expect(sent_body).to eq(shipped_body.merge( + 'tracking' => { 'opens' => true, 'clicks' => false, 'engagement' => true }, + 'sensitive' => true + )) + expect(sent_request.headers.keys.grep(/track|sensitive|engagement/i)).to be_empty + end + + %w[X-PostHog-Track-Opens X-PostHog-Track-Clicks X-PostHog-Capture-Engagement X-PostHog-Sensitive].each do |header| + it "refuses #{header} other than true or false" do + mail = shipped_mail + mail[header] = 'yes' + + expect(refusal_of(mail)).to eq('unsupported_header') + end + end + it '06 names mail from its mailer and action, made safe for a template key' do Admin::UserMailer.welcome_email.deliver_now From 95a00a93af270d0cf0da6ebd386038a984a1a0bf Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:22:56 +0000 Subject: [PATCH 15/16] fix(rails): refuse click tracking without open tracking before any request Co-Authored-By: Claude Opus 5.5 --- .changeset/rails-action-mailer-delivery.md | 2 +- .../lib/posthog/rails/mail_mapper.rb | 13 ++++++++++++- spec/posthog/rails/action_mailer_spec.rb | 19 +++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md index 6cb9f95b..7dcfe4e4 100644 --- a/.changeset/rails-action-mailer-delivery.md +++ b/.changeset/rails-action-mailer-delivery.md @@ -2,4 +2,4 @@ "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 `-`, 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. 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. +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 `-`, 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. diff --git a/posthog-rails/lib/posthog/rails/mail_mapper.rb b/posthog-rails/lib/posthog/rails/mail_mapper.rb index 06d41101..8ccc1301 100644 --- a/posthog-rails/lib/posthog/rails/mail_mapper.rb +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -61,12 +61,23 @@ def map(mail, derived_template_key: nil) template_key: template_key(options[:template_key], derived_template_key), **content(mail), **options.slice(:distinct_id, :idempotency_key, :sensitive), - tracking: (options.slice(*TRACKING) unless options.slice(*TRACKING).empty?) + tracking: tracking(options.slice(*TRACKING)) }.compact end private + def tracking(switches) + return if switches.empty? + + if switches[:clicks] && switches[:opens] == false + refuse('unsupported_header', 'X-PostHog-Track-Clicks: true needs open tracking, so it can\'t go with ' \ + 'X-PostHog-Track-Opens: false') + end + + switches + end + def posthog_options(mail) mail.header.fields.each_with_object({}) do |field, options| name = field.name.downcase diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 7fae75e7..134a2221 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -92,6 +92,10 @@ def text_only_mail end end + def invoice_mail + shipped_mail.tap { |mail| mail.attachments['invoice.pdf'] = "%PDF-1.4\n" } + end + def deliver(mail) mail.delivery_method(described_class, settings) mail.deliver @@ -219,6 +223,17 @@ def refusal_of(mail) end end + it 'refuses click tracking without open tracking, which PostHog would refuse after the uploads' do + mail = invoice_mail + mail['X-PostHog-Track-Opens'] = 'false' + mail['X-PostHog-Track-Clicks'] = 'true' + + expect { deliver(mail) }.to raise_error(PostHog::Rails::UnsupportedMailError, /needs open tracking/) { |error| + expect(error.refusal).to eq('unsupported_header') + } + expect(a_request(:any, %r{/messaging/|files\.example\.com})).not_to have_been_made + end + it '06 names mail from its mailer and action, made safe for a template key' do Admin::UserMailer.welcome_email.deliver_now @@ -556,10 +571,6 @@ def related_mail end end - def invoice_mail - shipped_mail.tap { |mail| mail.attachments['invoice.pdf'] = "%PDF-1.4\n" } - end - it 'raises a permanent DeliveryError when the upload quota is used up' do stub_request(:post, %r{/attachments/uploads/\z}).to_return( status: 429, body: JSON.generate(code: 'upload_quota_exceeded', detail: 'No.', attr: 'attachments') From ed1a910cc0d9331a6251bf20f01e8977326a7cc3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:32:31 +0000 Subject: [PATCH 16/16] test(rails): apps on another delivery method send as before Co-Authored-By: Claude Opus 5.5 --- spec/posthog/rails/action_mailer_spec.rb | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/spec/posthog/rails/action_mailer_spec.rb b/spec/posthog/rails/action_mailer_spec.rb index 134a2221..96539560 100644 --- a/spec/posthog/rails/action_mailer_spec.rb +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -714,6 +714,42 @@ def welcome 'text' => 'Hi') end + it 'leaves an app on another delivery method sending as before' do + output = run_app(<<~RUBY) + require 'rails' + require 'action_mailer/railtie' + require 'posthog' + require 'posthog/rails' + require 'webmock' + include WebMock::API + WebMock.enable! + + class App < Rails::Application + config.eager_load = false + config.logger = Logger.new(File::NULL) + config.action_mailer.delivery_method = :test + end + App.initialize! + + class UserMailer < ActionMailer::Base + def welcome + attachments['invoice.pdf'] = 'pdf' + mail(from: 'shop@example.com', to: %w[ana@example.com bo@example.com], subject: 'Welcome', + 'X-Campaign' => 'spring') { |f| f.text { render plain: 'Hi' } } + end + end + + stub_request(:any, /posthog/) + PostHog.init(api_key: 'phc_test', secret_key: 'phs_test', test_mode: true) + UserMailer.welcome.deliver_now + sent = ActionMailer::Base.deliveries.first + p [ActionMailer::Base.deliveries.size, sent.to, sent['X-Campaign'].to_s, sent.attachments.size, + WebMock::RequestRegistry.instance.requested_signatures.hash.keys.count { |r| r.uri.path.include?("/messaging/") }] + RUBY + + expect(output.strip).to eq('[1, ["ana@example.com", "bo@example.com"], "spring", 1, 0]') + end + it 'boots without Action Mailer and loads none of the delivery method' do output = run_app(<<~RUBY) require 'rails'