diff --git a/.changeset/rails-action-mailer-delivery.md b/.changeset/rails-action-mailer-delivery.md new file mode 100644 index 0000000..7dcfe4e --- /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` 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/Gemfile b/Gemfile index 27d8f16..876567a 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ rails_version = ENV.fetch('RAILS_VERSION', '~> 7.1') group :development, :test do gem 'actioncable', rails_version + gem 'actionmailer', rails_version gem 'activejob', rails_version gem 'activesupport', rails_version gem 'commander', '~> 5.0' diff --git a/Gemfile.lock b/Gemfile.lock index 7b26583..c5d1ad7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,6 +13,13 @@ GEM nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) actionpack (7.2.3.1) actionview (= 7.2.3.1) activesupport (= 7.2.3.1) @@ -98,9 +105,25 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp mcp (1.7.0) json_schemer (>= 2.4) + mini_mime (1.1.5) minitest (5.27.0) + net-imap (0.6.8) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.4.0) + timeout + net-smtp (0.5.2) + net-protocol nio4r (2.7.5) nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) @@ -232,6 +255,7 @@ GEM thor (1.5.0) tilt (2.7.0) timecop (0.9.11) + timeout (0.6.1) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -261,6 +285,7 @@ PLATFORMS DEPENDENCIES actioncable (~> 7.1) + actionmailer (~> 7.1) activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) @@ -281,6 +306,7 @@ DEPENDENCIES CHECKSUMS actioncable (7.2.3.1) sha256=d3bf40a3f4fc79a09709878f0e5c43a5e2d8e6607089f6b38f9472b8715eb33c + actionmailer (7.2.3.1) sha256=f578b6d5c5f81a20b6f6a796187698890c8348c041daa5e2e7cf7814ac520467 actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8 @@ -317,8 +343,14 @@ CHECKSUMS lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 mcp (1.7.0) sha256=bdf3261177b948b0777a6f247c57aad5f64c84830c02d126d4bfea188b0cebb7 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + net-imap (0.6.8) sha256=eb30bb62227b64a3af6b518b04b2375113d69f68f15de59b0193b07d98acf6d9 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 + net-smtp (0.5.2) sha256=35103cc04d6b2d94632f9928ede07483c24b70b9c0db1f231fea43909b678f3c nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af @@ -375,6 +407,7 @@ CHECKSUMS thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 diff --git a/gemfiles/otel.gemfile.lock b/gemfiles/otel.gemfile.lock index cb42281..cdfb613 100644 --- a/gemfiles/otel.gemfile.lock +++ b/gemfiles/otel.gemfile.lock @@ -13,6 +13,13 @@ GEM nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) actionpack (7.2.3.1) actionview (= 7.2.3.1) activesupport (= 7.2.3.1) @@ -121,9 +128,25 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp mcp (1.5.1) json_schemer (>= 2.4) + mini_mime (1.1.5) minitest (5.27.0) + net-imap (0.6.8) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.4.0) + timeout + net-smtp (0.5.2) + net-protocol nio4r (2.7.5) nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) @@ -294,6 +317,7 @@ GEM thor (1.5.0) tilt (2.7.0) timecop (0.9.11) + timeout (0.6.1) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -323,6 +347,7 @@ PLATFORMS DEPENDENCIES actioncable (~> 7.1) + actionmailer (~> 7.1) activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) @@ -347,6 +372,7 @@ DEPENDENCIES CHECKSUMS actioncable (7.2.3.1) sha256=d3bf40a3f4fc79a09709878f0e5c43a5e2d8e6607089f6b38f9472b8715eb33c + actionmailer (7.2.3.1) sha256=f578b6d5c5f81a20b6f6a796187698890c8348c041daa5e2e7cf7814ac520467 actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8 @@ -391,8 +417,14 @@ CHECKSUMS lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 mcp (1.5.1) sha256=883ddd0f59e5bf43805e300158c7ecc4b6a559d126d584d41bd241355545bedf + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + net-imap (0.6.8) sha256=eb30bb62227b64a3af6b518b04b2375113d69f68f15de59b0193b07d98acf6d9 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 + net-smtp (0.5.2) sha256=35103cc04d6b2d94632f9928ede07483c24b70b9c0db1f231fea43909b678f3c nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af @@ -460,6 +492,7 @@ CHECKSUMS thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 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 0000000..22685bd --- /dev/null +++ b/posthog-rails/lib/posthog/rails/delivery_errors.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module PostHog + module Rails + # PostHog can't carry part of this mail, so nothing was sent. Raised before any request, and only when the mailer's + # `raise_delivery_errors` is on; with it off, the delivery method logs the error to `PostHog::Logging.logger`. + class UnsupportedMailError < StandardError + # @return [String] Which part PostHog can't carry, such as `multiple_to`, `unsupported_header` or + # `template_unsupported`, or `invalid_mail` for a field PostHog would turn down, such as a missing subject. + attr_reader :refusal + + # @api private + def initialize(refusal:, detail:) + super("PostHog cannot send this email: #{refusal}. #{detail}.") + @refusal = refusal + end + end + + # PostHog did not queue the mail, or the delivery method could not tell whether it did. The PostHog::EmailError + # behind it is its {#cause}. With the mailer's `raise_delivery_errors` off, the delivery method logs it instead. + class DeliveryError < StandardError + # @return [Integer, nil] The HTTP status, when PostHog answered. + attr_reader :status + # @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`. + attr_reader :code + # @return [String, nil] The request field the error is about, when PostHog named one. + attr_reader :attr + # @return [Float, nil] How long PostHog asked to wait before sending again, in seconds. + attr_reader :retry_after + + # @api private + def initialize(error) + super(error.message) + @status = error.status_code + @code = error.code + @attr = error.field + @retry_after = error.retry_after + end + + # @return [String] Why PostHog did not queue the mail. + def detail + message + end + + # Whether a later delivery of the same mail can succeed. Retry with the same X-PostHog-Idempotency-Key: PostHog + # may have queued the first attempt, and a retry with a new key could send the mail twice. + # + # @return [Boolean] + # A file storage failure is `attachment_upload_failed` with storage's own status: its 500, 502, 503 and 504 + # pass. A used-up quota, for sends or for uploads, lasts until the quota resets. This is the rule + # PostHog::Emails retries by. + def retryable? + case status + when nil then code == 'network_error' + when 409 then code == 'idempotency_key_in_flight' + when 429 then !%w[quota_limited upload_quota_exceeded].include?(code) + when 500, 502, 504 then code == 'attachment_upload_failed' + else status == 503 + end + end + end + end +end 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 0000000..aff0a26 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/delivery_method.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'posthog/rails/version' +require 'posthog/rails/delivery_errors' +require 'posthog/rails/mail_mapper' + +module PostHog + module Rails + # The `:posthog` Action Mailer delivery method. It sends each mail as one PostHog email, through the client that + # `PostHog.init` made, and sets the mail's `message_id` to PostHog's message ID. + # + # A mail's reserved headers set the send's options, and are never sent. Each of these takes `true` or `false`, and + # PostHog decides when the mail leaves it out: + # - `X-PostHog-Track-Opens`: whether PostHog adds its open pixel to count opens; + # - `X-PostHog-Track-Clicks`: whether PostHog rewrites the links to count clicks. Off by default for a sensitive + # mail, and it needs open tracking; + # - `X-PostHog-Capture-Engagement`: whether PostHog captures the email's engagement events (sent, delivered, + # opened, clicked, bounced) as events on the recipient's person; + # - `X-PostHog-Sensitive`: `true` for mail with login links, reset tokens or codes. PostHog leaves its links + # unchanged unless `X-PostHog-Track-Clicks` is `true`, its subject off engagement events, and its content out of + # logs. Left out, PostHog takes the setting of the template whose key matches, and treats the mail as sensitive + # when none matches. + # + # @example config/environments/production.rb + # config.action_mailer.delivery_method = :posthog + # config.action_mailer.posthog_settings = { default_template_key: 'transactional' } + class DeliveryMethod + INTEGRATION = "#{MailMapper::INTEGRATION_NAME}/#{VERSION}".freeze + private_constant :INTEGRATION + + # @return [Hash] `config.action_mailer.posthog_settings`. + attr_accessor :settings + + # @param settings [Hash] `default_template_key:` names mail that has no mailer name and sets no + # X-PostHog-Template-Key. + def initialize(settings = {}) + @settings = settings + end + + # @param mail [Mail::Message] + # @return [PostHog::Emails::SendResult] + # @raise [UnsupportedMailError] PostHog can't send part of the mail, so nothing was sent. + # @raise [DeliveryError] PostHog did not queue the mail. + def deliver!(mail) + result = send_email(mail) + mail.message_id = result.id + result + rescue UnsupportedMailError, DeliveryError => e + # Mail's deliver drops the error when the mailer turns raise_delivery_errors off; this line keeps a trace of it. + PostHog::Logging.logger.error("PostHog did not send an email: #{e.message}") unless mail.raise_delivery_errors + raise + end + + private + + def send_email(mail) + arguments = MailMapper.new(default_template_key: settings[:default_template_key]) + .map(mail, derived_template_key: MailerExtensions.template_key(mail)) + begin + PostHog.emails.send(**arguments, integration: INTEGRATION) + rescue ArgumentError => e + # The SDK checks its arguments before any request, such as the subject a send needs. + raise UnsupportedMailError.new(refusal: 'invalid_mail', detail: e.message.chomp('.')) + end + rescue PostHog::EmailError => e + raise DeliveryError, e + end + end + + # Names each mail an Action Mailer mailer builds `-`, 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 0000000..8ccc130 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/mail_mapper.rb @@ -0,0 +1,245 @@ +# 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' + 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 + 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, + 'x-posthog-idempotency-key' => :idempotency_key, + '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 + + # @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, :sensitive), + 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 + value = field.value.to_s + next if MAPPED_HEADERS.include?(name) || IGNORED_HEADERS.include?(name) + next if NORMAL_PRIORITY.key?(name) && NORMAL_PRIORITY[name].match?(value.strip) + + option = OPTION_HEADERS.fetch(name) { refuse_header(name, field.name) } + options[option] = SWITCHES.include?(option) ? switch(field.name, value) : 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 switch(header, value) + return value == 'true' if %w[true false].include?(value) + + refuse('unsupported_header', "#{header} 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.format + 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 #{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, MAX_TEMPLATE_KEY] + key unless key.empty? + end + + def content(mail) + 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 + + # 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, 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 + + part.parts.each do |child| + check_part_headers(child) + 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) + + 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. 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') + 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 + when 'text/plain', nil then :text + end + 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 + 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}") + end + file = { filename: part.filename, content_type: part.mime_type, content: part.body.decoded.b } + # 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 + + # 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) + 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 1c7f123..3fc83c0 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -12,6 +12,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 + initializer 'posthog.controller_helpers' do ActiveSupport.on_load(:action_controller) do include PostHog::Rails::ControllerHelpers unless method_defined?(:capture_exception) || diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 0640f6f..d856d64 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -402,6 +402,17 @@ 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 module PostHog::Rails::ControllerHelpers +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) @@ -421,4 +432,6 @@ module PostHog::Rails::Logs class PostHog::Rails::Railtie < Rails::Railtie module PostHog::Rails::Sidekiq class_method PostHog::Rails::Sidekiq.install(config, error_reporting:) +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 78c4bf8..3a045ba 100644 --- a/scripts/public_api_snapshot.rb +++ b/scripts/public_api_snapshot.rb @@ -78,6 +78,8 @@ def load_rails_sdk require 'rails' require 'posthog-rails' require 'posthog/rails/sidekiq' + # 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 0000000..9653956 --- /dev/null +++ b/spec/posthog/rails/action_mailer_spec.rb @@ -0,0 +1,770 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'tmpdir' +require 'logger' +require 'rails' +require 'action_mailer' +require 'active_job' +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) +require 'posthog/rails' + +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 + 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(:stored) { {} } + 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' } + 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)) + stub_request(:post, 'https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/') + .to_return { |request| upload_slots(JSON.parse(request.body)['files']) } + stub_request(:put, %r{\Ahttps://files\.example\.com/team/\h{64}\?}).to_return do |request| + stored[request.uri.path[/\h{64}\z/]] = request.body.b + { status: 200 } + end + 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 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 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 + 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 + + # PostHog's upload route: file storage takes every file of the manifest. + def upload_slots(files) + slots = files.map do |file| + { sha256: file['sha256'], status: 'upload', method: 'PUT', headers: {}, + url: "https://files.example.com/team/#{file['sha256']}?X-Amz-Signature=fake", + url_expires_at: '2026-11-13T12:15:00Z' } + end + { status: 200, body: JSON.generate(files: slots) } + end + + # The files PostHog attaches to the sent email: each one the send names, with the bytes file storage holds for it. + def sent_files + sent_body.fetch('attachments').map do |file| + file.except('sha256').merge('content' => stored.fetch(file['sha256'])) + end + 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 '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 ' + 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.except('attachments')).to eq(shipped_body.merge('html' => '')) + expect(sent_files).to eq([ + { 'filename' => 'invoice.pdf', 'content_type' => 'application/pdf', + 'content' => "%PDF-1.4\n" }, + { 'filename' => 'logo.png', 'content_type' => 'image/png', + 'content' => "\x89PNG\r\n\x1A\n".b, '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', + '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 '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 + + 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 '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'] + + 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 '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 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' + + 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 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_files).to eq([{ 'filename' => 'notes.txt', 'content_type' => 'text/plain', + 'content' => 'café'.b }]) + 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 '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 + + 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 + + # 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 '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 ' + 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 + 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)' + 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 + + 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 + + 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 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 + + # 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) } + + (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 + 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.except('attachments')).to eq(shipped_body.merge('template_key' => 'shipping_mailer-shipped')) + expect(sent_files).to eq([{ 'filename' => 'invoice.pdf', 'content_type' => 'application/pdf', + 'content' => "%PDF-1.4\n" }]) + 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 + # 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) + 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 + + 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 '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' + 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