From 156067d37bfb02962a82692e075be2b618f5bc97 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:51 +0000 Subject: [PATCH 01/10] feat(emails): send transactional email and read its status Adds client.emails.send for a template made in PostHog (template_id) or content your code rendered (template_key, subject, html, text), and client.emails.get for the status of a sent email. Ports posthog-node and posthog-python's emails to Ruby: the same wire body, app host rules, idempotency key, retries and error names. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-send.md | 5 + lib/posthog/client.rb | 28 ++- lib/posthog/defaults.rb | 5 + lib/posthog/email_error.rb | 57 +++++ lib/posthog/emails.rb | 478 +++++++++++++++++++++++++++++++++++ lib/posthog/transport.rb | 13 - lib/posthog/utils.rb | 18 ++ public_api_snapshot.txt | 57 +++++ spec/posthog/emails_spec.rb | 482 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1129 insertions(+), 14 deletions(-) create mode 100644 .changeset/emails-send.md create mode 100644 lib/posthog/email_error.rb create mode 100644 lib/posthog/emails.rb create mode 100644 spec/posthog/emails_spec.rb diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md new file mode 100644 index 00000000..0119c487 --- /dev/null +++ b/.changeset/emails-send.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +Send transactional email with `client.emails.send`, from an email template made in PostHog (`template_id:` with `template_data:`) or from content your code rendered (`template_key:`, `subject:` and `html:` or `text:`). Read what happened to a sent email with `client.emails.get(message_id)`. Sends need `secret_key`, a project secret API key with the `hog_flow:write` scope, and go to the PostHog Cloud app host of the client's `host`; set `app_host` for any other host. A send retries network errors, rate limits and an unavailable service with the same idempotency key, and raises `PostHog::EmailError` when PostHog doesn't queue the email. To send a React email, render it to HTML in your app first. New client options: `app_host`, `email_request_timeout_seconds`, `email_request_max_retries` and `migrated_from`. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 1b4912f9..5292f44d 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -15,6 +15,7 @@ require 'posthog/feature_flag_evaluations' require 'posthog/send_feature_flags_options' require 'posthog/exception_capture' +require 'posthog/emails' require 'posthog/internal/context' module PostHog @@ -127,6 +128,14 @@ def _decrement_instance_count(api_key) # @option opts [Boolean] :compress_request Set to +false+ to disable gzip compression for batch uploads. # @option opts [Object] :flag_definition_cache_provider An object implementing the {FlagDefinitionCacheProvider} # interface for distributed flag definition caching. + # @option opts [String, nil] :app_host Your PostHog app host, such as `https://posthog.example.com`, for {#emails}. + # Needed only when +:host+ is not PostHog Cloud, such as a reverse proxy, which must not receive the secret key. + # @option opts [Numeric] :email_request_timeout_seconds How long each {#emails} request may take, in seconds. + # Defaults to 15. + # @option opts [Integer] :email_request_max_retries How many times {#emails} retries a request after a network + # error, a rate limit or an unavailable service. Defaults to 2. Set to 0 when a job runner already retries. + # @option opts [String, nil] :migrated_from The email provider you moved from, such as `resend`. {#emails} sends + # it with every request. # @option opts [Boolean] :is_server +true+ to stamp captured events with `$is_server => true` so PostHog # attributes them as server-side. Defaults to +true+. Set to +false+ when running posthog-ruby as a # client/CLI so the device OS is attributed normally and `$is_server` is omitted. @@ -227,12 +236,28 @@ def initialize(opts = {}) ) end + @emails = Emails.new( + secret_key: secret_key, + host: opts[:host], + app_host: opts[:app_host], + timeout: opts.fetch(:email_request_timeout_seconds, Defaults::Emails::REQUEST_TIMEOUT_SECONDS), + max_retries: opts.fetch(:email_request_max_retries, Defaults::Emails::REQUEST_MAX_RETRIES), + migrated_from: opts[:migrated_from], + user_agent: @headers['User-Agent'], + disabled: @disabled + ) @before_send = opts[:before_send] @is_server = opts.fetch(:is_server, true) != false @release_id = normalize_string_option(ENV.fetch(RELEASE_ID_ENV_VAR, nil), blank_as_nil: true) @deprecation_emitted_for = Concurrent::Set.new end + # Sends transactional email from email templates made in PostHog or from content your code rendered, and reads + # the status of each email sent. Needs +:secret_key+, a project secret API key with the `hog_flow:write` scope. + # + # @return [PostHog::Emails] + attr_reader :emails + # When sync_mode is enabled, blocks until in-flight requests are complete # and returns true. Timeout parameter has no effect. # @@ -977,7 +1002,8 @@ def shutdown(timeout: nil) @distinct_id_has_sent_flag_calls_mutex.synchronize do @distinct_id_has_sent_flag_calls.clear end - flushed + emails_settled = @emails.close(timeout: deadline && [deadline - monotonic_time, 0].max) + flushed &&= emails_settled ensure @shutdown_mutex.synchronize do @shutdown_result = flushed diff --git a/lib/posthog/defaults.rb b/lib/posthog/defaults.rb index d152ec46..af57f356 100644 --- a/lib/posthog/defaults.rb +++ b/lib/posthog/defaults.rb @@ -26,6 +26,11 @@ module FeatureFlags FLAG_REQUEST_MAX_RETRIES = 1 end + module Emails + REQUEST_TIMEOUT_SECONDS = 15 + REQUEST_MAX_RETRIES = 2 + end + module Queue MAX_SIZE = 10_000 end diff --git a/lib/posthog/email_error.rb b/lib/posthog/email_error.rb new file mode 100644 index 00000000..0133b7b1 --- /dev/null +++ b/lib/posthog/email_error.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module PostHog + # PostHog did not queue the email, or the client could not tell whether it did. + # + # Branch on {#code}, PostHog's stable error code, or on {#name}, a coarse category of the failure. + # The message never contains the secret key or the request body. + class EmailError < StandardError + # @return [String] A coarse category of the failure, such as `validation_error`, `rate_limit_exceeded` + # or `service_unavailable`. + attr_reader :name + # @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`. + attr_reader :code + # @return [Integer, nil] The HTTP status, when PostHog answered. + attr_reader :status_code + # @return [String, nil] Why PostHog refused the email. + attr_reader :why + # @return [String, nil] How to fix the request. + attr_reader :fix + # @return [String, nil] A link to the docs for this error. + attr_reader :docs_url + # @return [String, nil] The request field the error is about, when PostHog named one. + attr_reader :field + # @return [Float, nil] How long PostHog asked the client to wait before it sends again, in seconds. + attr_reader :retry_after + + # @api private + def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url: nil, field: nil, retry_after: nil) + super(message) + @name = self.class.name_for(status_code, code) + @code = code + @status_code = status_code + @why = why + @fix = fix + @docs_url = docs_url + @field = field + @retry_after = retry_after + end + + # @api private + def self.name_for(status_code, code) + return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401 + return code == 'network_error' ? 'service_unavailable' : 'application_error' if status_code.nil? + + case status_code + when 400 then 'validation_error' + when 403 then 'invalid_permission' + when 404 then 'not_found' + when 409 then code == 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests' + when 422 then code == 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error' + when 429 then code == 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded' + when 503 then 'service_unavailable' + else 'application_error' + end + end + end +end diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb new file mode 100644 index 00000000..f9faf438 --- /dev/null +++ b/lib/posthog/emails.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'openssl' +require 'securerandom' +require 'time' + +require 'posthog/email_error' +require 'posthog/logging' +require 'posthog/utils' + +module PostHog + # Sends transactional email, from an email template made in PostHog or from content your code rendered, + # and reads the status of each email sent. Reach it through {Client#emails}. + # + # A send needs the client's `secret_key`, a project secret API key (`phs_...`) with the `hog_flow:write` scope. + # Sends go to the PostHog app host: a client on PostHog Cloud uses its region's app host, and any other `host` + # needs `app_host`, because a reverse proxy for ingestion must not receive the secret key. + # + # Each send retries network errors, rate limits and an unavailable service, waiting about 1 and then 2 seconds, + # or the server's `Retry-After` when that is longer. Every attempt sends the same idempotency key, so a retry + # never sends twice. + # + # @example A template made in PostHog + # posthog.emails.send( + # to: 'user@example.com', + # template_id: '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b', + # template_data: { name: 'Ada' }, + # distinct_id: 'user_123', + # idempotency_key: 'welcome:user_123' + # ) + # + # @example Content your code rendered, such as React Email output + # posthog.emails.send( + # to: 'user@example.com', + # template_key: 'password-reset', + # subject: 'Reset your password', + # html: rendered_html, + # distinct_id: 'user_123' + # ) + class Emails + include PostHog::Logging + include PostHog::Utils + + # The queued email. A result means PostHog queued the email, not that it was delivered. + # + # @!attribute [r] id + # @return [String] The queued email's message ID. + # @!attribute [r] replayed + # @return [Boolean] Whether PostHog had already accepted this idempotency key and returned the first result. + # @!attribute [r] warnings + # @return [Array] + SendResult = Struct.new(:id, :replayed, :warnings, keyword_init: true) + + # A warning PostHog returned with a queued email. + Warning = Struct.new(:code, :message, :fix, keyword_init: true) + + # What happened to an email that {Emails#send} queued. + # + # @!attribute [r] status + # @return [String] `queued`, `sent`, `failed`, `delivered`, `bounced` or `complained`. Later SDK versions + # can see values this list does not have yet. + # @!attribute [r] reason + # @return [String, nil] Why a failed email was not sent, such as `recipient_suppressed`, or the bounce type + # of a bounced email: `hard`, `soft` or `unknown`. + Status = Struct.new(:id, :status, :created_at, :status_at, :reason, keyword_init: true) + + SEND_PATH = '/api/projects/@current/messaging/send/email/' + STATUS_PATH = '/api/projects/@current/messaging/emails/%s/' + US_APP_HOST = 'https://us.posthog.com' + EU_APP_HOST = 'https://eu.posthog.com' + CLOUD_APP_HOSTS = { + 'https://us.i.posthog.com' => US_APP_HOST, + 'https://us.posthog.com' => US_APP_HOST, + 'https://app.posthog.com' => US_APP_HOST, + 'https://eu.i.posthog.com' => EU_APP_HOST, + 'https://eu.posthog.com' => EU_APP_HOST + }.freeze + MAX_RETRY_DELAY_SECONDS = 30.0 + UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ + IDEMPOTENCY_KEY_PATTERN = /\A[\x21-\x7e]{1,255}\z/ + TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/ + HEADER_TOKEN_PATTERN = /\A[\x21-\x7e]+\z/ + SEND_KEY_NEEDED = 'Sending email needs secret_key, a project secret API key with the hog_flow:write scope.' + READ_KEY_NEEDED = 'Reading an email status needs secret_key, a project secret API key with the hog_flow:read ' \ + 'or hog_flow:write scope.' + TEMPLATE_FORM_WHY = 'A template made in PostHog sets its own sender, subject and content, and follows the ' \ + "project's engagement setting." + CODE_FORM_WHY = 'A send with template_key takes its content from your code.' + TEMPLATE_FIELDS = %i[template_data].freeze + CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze + NETWORK_ERRORS = [ + Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, + Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError + ].freeze + private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS, + :MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN, + :HEADER_TOKEN_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY, + :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS + + # @api private + def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:) + check_options(timeout, max_retries, migrated_from) + @secret_key = secret_key.is_a?(String) ? secret_key.strip : nil + @app_host = resolve_app_host(host, app_host) + @timeout = timeout + @max_retries = max_retries + @migrated_from = migrated_from + @user_agent = user_agent + @disabled = disabled + @closed = false + @in_flight = 0 + @mutex = Mutex.new + @idle = ConditionVariable.new + end + + # Queues one email, from a template made in PostHog or from content your code rendered. + # + # Pass exactly one form. The template form names a template made in PostHog with `template_id`. The code form + # names the email with `template_key` and passes `subject` and `html`, `text` or both. To send a React email, + # render it to HTML in your app first. + # + # @param to [String] One recipient email address. + # @param template_id [String] Template form: the UUID of a template made transactional in PostHog. + # @param template_data [Hash] Template form: values for the template's `{{ variables.* }}` placeholders. + # @param template_key [String] Code form: names the email, such as `password-reset`, so its metrics, logs and + # status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400. + # @param subject [String] Code form: the subject line. + # @param html [String] Code form: the HTML body. + # @param text [String] Code form: the plain-text body. Without it, PostHog derives it from `html`. + # @param from [String] Code form: a verified sender, such as `Acme `. Without it, the + # project's default sender. + # @param reply_to [String, Array] Code form: one address or a list of addresses. + # @param capture_engagement [Boolean] Code form: whether PostHog captures the email's opens and clicks as events + # of the recipient. Defaults to +true+. A project whose admin turned engagement capture off captures nothing. + # @param distinct_id [String] The recipient's distinct ID in your product. The email's events go to this + # person. Without it, they go to a person whose distinct ID is the email address. + # @param idempotency_key [String] 1 to 255 visible ASCII characters. A send with a key PostHog has already + # accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers + # its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, when a job runner + # retries the send. + # @param max_retries [Integer] Overrides the client's `email_request_max_retries` for this send. +0+ turns + # retries off. + # @return [SendResult] The queued email. + # @raise [EmailError] PostHog did not queue the email, or the client could not tell whether it did. + # @raise [ArgumentError] An argument has the wrong type or format, or the call mixes the two forms. + def send( + to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil, + reply_to: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil + ) + fields = { + template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to, + capture_engagement: capture_engagement + }.compact + check_recipient(to, distinct_id, idempotency_key, max_retries) + payload = content(template_id, template_key, fields).merge(to: to) + payload[:distinct_id] = distinct_id unless distinct_id.nil? + body = JSON.generate(payload) + headers = { 'Content-Type' => 'application/json', 'Idempotency-Key' => idempotency_key || SecureRandom.uuid } + + result = call('no email was sent', SEND_KEY_NEEDED) do |secret_key| + uri = URI(@app_host + SEND_PATH) + with_retries(max_retries || @max_retries) do + parse_send_reply(request(Net::HTTP::Post.new(uri), uri, secret_key, headers, body)) + end + end + result.warnings.each { |warning| logger.warn("PostHog email warning #{warning.code}: #{warning.message}") } + result + end + + # Reads what happened to an email that {#send} queued, for 30 days after the send. + # + # @param message_id [String] The {SendResult#id} that {#send} returned. + # @return [Status] The email's latest status. + # @raise [EmailError] PostHog did not answer with a status. {EmailError#code} is `not_found` for an unknown ID, + # an email older than 30 days, or for a few seconds after the send. + # @raise [ArgumentError] +message_id+ is not a message ID. + def get(message_id) + raise ArgumentError, 'message_id must be the message ID that send returned.' unless uuid?(message_id) + + call('no email status was read', READ_KEY_NEEDED) do |secret_key| + uri = URI(@app_host + format(STATUS_PATH, message_id: message_id.downcase)) + with_retries(@max_retries) { parse_status_reply(request(Net::HTTP::Get.new(uri), uri, secret_key, {})) } + end + end + + # Refuses new sends and waits for the sends and status reads in flight, retries included, to settle. + # + # @api private + # @param timeout [Numeric, nil] Maximum seconds to wait, or +nil+ to wait until they settle. + # @return [Boolean] +true+ if nothing is in flight any more. + def close(timeout: nil) + deadline = timeout && (monotonic_time + timeout) + @mutex.synchronize do + @closed = true + until @in_flight.zero? + remaining = deadline && (deadline - monotonic_time) + break if remaining && remaining <= 0 + + @idle.wait(@mutex, remaining) + end + @in_flight.zero? + end + end + + private + + def check_options(timeout, max_retries, migrated_from) + raise ArgumentError, 'email_request_timeout_seconds must be a positive number.' unless positive?(timeout) + raise ArgumentError, 'email_request_max_retries must be a nonnegative integer.' unless retry_count?(max_retries) + return if migrated_from.nil? || (migrated_from.is_a?(String) && HEADER_TOKEN_PATTERN.match?(migrated_from)) + + raise ArgumentError, "migrated_from must be visible ASCII characters, such as 'resend'." + end + + def positive?(value) + value.is_a?(Numeric) && value.real? && value.positive? && value.finite? + end + + def retry_count?(value) + value.is_a?(Integer) && value >= 0 + end + + def uuid?(value) + value.is_a?(String) && UUID_PATTERN.match?(value) + end + + def resolve_app_host(host, app_host) + explicit = app_host.is_a?(String) ? app_host.strip.chomp('/') : '' + return CLOUD_APP_HOSTS.fetch(explicit, explicit) unless explicit.empty? + + CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] + end + + def check_recipient(to, distinct_id, idempotency_key, max_retries) + raise ArgumentError, 'to must be one email address as a nonempty string.' unless nonempty_string?(to) + unless distinct_id.nil? || nonempty_string?(distinct_id) + raise ArgumentError, 'distinct_id must be a nonempty string.' + end + unless idempotency_key.nil? || (idempotency_key.is_a?(String) && IDEMPOTENCY_KEY_PATTERN.match?(idempotency_key)) + raise ArgumentError, 'idempotency_key must be 1 to 255 visible ASCII characters without spaces.' + end + return if max_retries.nil? || retry_count?(max_retries) + + raise ArgumentError, 'max_retries must be a nonnegative integer.' + end + + def nonempty_string?(value) + value.is_a?(String) && !value.strip.empty? + end + + def content(template_id, template_key, fields) + if template_id.nil? == template_key.nil? + raise ArgumentError, 'Send either template_id, or template_key with subject and html or text.' + end + + template_key.nil? ? template_content(template_id, fields) : code_content(template_key, fields) + end + + def template_content(template_id, fields) + refuse_fields(fields, allowed: TEMPLATE_FIELDS, form: :template_id, other_form: :template_key, + why: TEMPLATE_FORM_WHY) + raise ArgumentError, "template_id must be the template's UUID." unless uuid?(template_id) + + template_data = fields.fetch(:template_data, {}) + raise ArgumentError, 'template_data must be a Hash.' unless template_data.is_a?(Hash) + + { template_id: template_id.downcase, message_data: isoify_dates(template_data) } + end + + def code_content(template_key, fields) + refuse_fields(fields, allowed: CODE_FIELDS, form: :template_key, other_form: :template_id, + why: CODE_FORM_WHY) + unless template_key.is_a?(String) && TEMPLATE_KEY_PATTERN.match?(template_key) + raise ArgumentError, + 'template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.' + end + raise ArgumentError, 'A send with template_key needs a subject.' unless fields[:subject].is_a?(String) + unless fields.key?(:html) || fields.key?(:text) + raise ArgumentError, + 'A send with template_key needs html, text or both.' + end + + %i[html text from].each do |name| + raise ArgumentError, "#{name} must be a string." if fields.key?(name) && !fields[name].is_a?(String) + end + unless [nil, true, false].include?(fields[:capture_engagement]) + raise ArgumentError, 'capture_engagement must be true or false.' + end + + code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key) + code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to) + code[:capture_engagement] = fields.fetch(:capture_engagement, true) + code + end + + def refuse_fields(fields, allowed:, form:, other_form:, why:) + field = fields.keys.find { |name| !allowed.include?(name) } + return if field.nil? + + message = "A send with #{form} doesn't take #{field}, so no email was sent." + raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}." + end + + def addresses(reply_to) + list = reply_to.is_a?(String) ? [reply_to] : reply_to + return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) } + + raise ArgumentError, 'reply_to must be one address or a list of addresses.' + end + + def call(nothing_done, key_needed) + admit(nothing_done) + begin + unless @app_host + # A custom host is often an ingestion proxy, which must not receive the secret key. + raise EmailError.new( + code: 'missing_app_host', + message: "The client's host is not PostHog Cloud, so the client can't tell where to send email. " \ + 'Set app_host to your PostHog app host on the client.' + ) + end + raise EmailError.new(code: 'missing_secret_key', message: key_needed) if @secret_key.nil? || @secret_key.empty? + + yield @secret_key + ensure + finish + end + end + + def admit(nothing_done) + if @disabled + raise EmailError.new(code: 'client_disabled', + message: "The PostHog client is disabled, so #{nothing_done}.") + end + + @mutex.synchronize do + if @closed + raise EmailError.new(code: 'client_closed', + message: "The PostHog client is shut down, so #{nothing_done}.") + end + + @in_flight += 1 + end + end + + def finish + @mutex.synchronize do + @in_flight -= 1 + @idle.broadcast if @in_flight.zero? + end + end + + def with_retries(max_retries) + attempt = 0 + begin + yield + rescue EmailError => e + delay = retry_delay(e, attempt, max_retries) + raise if delay.nil? + + sleep(delay) + attempt += 1 + retry + end + end + + def retry_delay(error, attempt, max_retries) + return nil if attempt >= max_retries || !retryable?(error) + # Retrying before a longer Retry-After would break the server's rate limit. + return nil if error.retry_after && error.retry_after > MAX_RETRY_DELAY_SECONDS + + backoff = [MAX_RETRY_DELAY_SECONDS, (2**attempt) * rand(0.8..1.2)].min + [backoff, error.retry_after || 0].max + end + + def retryable?(error) + # A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. + case error.status_code + when nil then error.code == 'network_error' + when 409 then error.code == 'idempotency_key_in_flight' + when 429 then error.code != 'quota_limited' + else error.status_code == 503 + end + end + + def request(request, uri, secret_key, headers, body = nil) + request['Authorization'] = "Bearer #{secret_key}" + request['Accept'] = 'application/json' + request['User-Agent'] = @user_agent if @user_agent + request['X-PostHog-Migrated-From'] = @migrated_from if @migrated_from + headers.each { |name, value| request[name] = value } + request.body = body if body + + response = Net::HTTP.start( + uri.hostname, uri.port, + use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout, + max_retries: 0 + ) { |http| http.request(request) } + [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])] + rescue *NETWORK_ERRORS + raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.') + end + + def parse_json(body) + JSON.parse(body.to_s) + rescue JSON::ParserError + nil + end + + def parse_send_reply((status, payload, retry_after)) + fields = payload.is_a?(Hash) ? payload : {} + raise reply_error(fields, status, retry_after) unless status.between?(200, 299) + + id = text(fields['message_id']) + unless id + raise EmailError.new(code: 'api_error', message: 'PostHog answered without a message ID.', + status_code: status) + end + + SendResult.new(id: id, replayed: fields['replayed'] == true, warnings: warnings(fields['warnings'])) + end + + def warnings(entries) + return [] unless entries.is_a?(Array) + + entries.grep(Hash).map do |entry| + Warning.new( + code: text(entry['code']) || 'warning', + message: text(entry['detail']) || text(entry['message']) || '', + fix: text(entry['fix']) + ) + end + end + + def parse_status_reply((status, payload, retry_after)) + fields = payload.is_a?(Hash) ? payload : {} + raise reply_error(fields, status, retry_after) unless status.between?(200, 299) + + record = { + id: text(fields['id']), + status: text(fields['status']), + created_at: timestamp(fields['created_at']), + status_at: timestamp(fields['status_at']) + } + if record.value?(nil) + raise EmailError.new(code: 'api_error', message: 'PostHog answered without an email status.', + status_code: status) + end + + Status.new(**record, reason: text(fields['reason'])) + end + + def timestamp(value) + text(value) && Time.iso8601(value) + rescue ArgumentError + nil + end + + def reply_error(fields, status, retry_after) + EmailError.new( + code: text(fields['code']) || 'api_error', + message: text(fields['detail']) || "PostHog answered HTTP #{status}.", + status_code: status, + why: text(fields['why']), + fix: text(fields['fix']), + docs_url: text(fields['docs_url']), + field: text(fields['attr']), + retry_after: retry_after + ) + end + + def text(value) + value if value.is_a?(String) && !value.empty? + end + end +end diff --git a/lib/posthog/transport.rb b/lib/posthog/transport.rb index 0198a758..c2db0fc9 100644 --- a/lib/posthog/transport.rb +++ b/lib/posthog/transport.rb @@ -151,19 +151,6 @@ def retry_delay_seconds @backoff_policy.next_interval.to_f / 1000 end - def parse_retry_after(value) - return nil if value.nil? || value.empty? - - seconds = Float(value, exception: false) - return seconds if seconds && seconds >= 0 - - parsed_time = Time.httpdate(value) - delay = parsed_time - Time.now - delay.positive? ? delay : nil - rescue ArgumentError - nil - end - # Sends a request for the batch, returns [status_code, body] def send_request(api_key, batch) @last_retry_after = nil diff --git a/lib/posthog/utils.rb b/lib/posthog/utils.rb index 141ee77a..2ae66f1a 100644 --- a/lib/posthog/utils.rb +++ b/lib/posthog/utils.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'time' + require 'posthog/flag_evaluation_errors' module PostHog @@ -135,6 +137,22 @@ def get_by_symbol_or_string_key(hash, key) end end + # public: Seconds to wait from a Retry-After header, given as seconds or as an HTTP date, or nil when the + # header is absent, malformed or in the past + # + def parse_retry_after(value) + return nil if value.nil? || value.empty? + + seconds = Float(value, exception: false) + return seconds if seconds&.finite? && seconds >= 0 + + parsed_time = Time.httpdate(value) + delay = parsed_time - Time.now + delay.positive? ? delay : nil + rescue ArgumentError + nil + end + # public: Current monotonic clock time in seconds, for measuring durations # def monotonic_time diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 97e7eb53..16679caa 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -10,6 +10,7 @@ instance_method PostHog::Client#capture(attrs) instance_method PostHog::Client#capture_exception(exception, distinct_id = ..., additional_properties = ..., flags: ..., mechanism: ...) instance_method PostHog::Client#clear() instance_method PostHog::Client#dequeue_last_message() +instance_method PostHog::Client#emails() instance_method PostHog::Client#enabled?() instance_method PostHog::Client#evaluate_flags(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., disable_geoip: ..., flag_keys: ...) instance_method PostHog::Client#feature_flags_loaded?() @@ -39,6 +40,9 @@ constant PostHog::Defaults::BackoffPolicy::MAX_TIMEOUT_MS: Integer constant PostHog::Defaults::BackoffPolicy::MIN_TIMEOUT_MS: Integer constant PostHog::Defaults::BackoffPolicy::MULTIPLIER: Float constant PostHog::Defaults::BackoffPolicy::RANDOMIZATION_FACTOR: Float +module PostHog::Defaults::Emails +constant PostHog::Defaults::Emails::REQUEST_MAX_RETRIES: Integer +constant PostHog::Defaults::Emails::REQUEST_TIMEOUT_SECONDS: Integer module PostHog::Defaults::FeatureFlags constant PostHog::Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES: Integer constant PostHog::Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS: Integer @@ -59,6 +63,59 @@ constant PostHog::Defaults::Request::PATH: String constant PostHog::Defaults::Request::PORT: Integer constant PostHog::Defaults::Request::RETRIES: Integer constant PostHog::Defaults::Request::SSL: Boolean +class PostHog::EmailError < StandardError +instance_method PostHog::EmailError#code() +instance_method PostHog::EmailError#docs_url() +instance_method PostHog::EmailError#field() +instance_method PostHog::EmailError#fix() +instance_method PostHog::EmailError#name() +instance_method PostHog::EmailError#retry_after() +instance_method PostHog::EmailError#status_code() +instance_method PostHog::EmailError#why() +class PostHog::Emails +instance_method PostHog::Emails#get(message_id) +instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) +class_method PostHog::Emails.logger() +class PostHog::Emails::SendResult < Struct +instance_method PostHog::Emails::SendResult#id() +instance_method PostHog::Emails::SendResult#id=(_) +instance_method PostHog::Emails::SendResult#replayed() +instance_method PostHog::Emails::SendResult#replayed=(_) +instance_method PostHog::Emails::SendResult#warnings() +instance_method PostHog::Emails::SendResult#warnings=(_) +class_method PostHog::Emails::SendResult.[](*arg0) +class_method PostHog::Emails::SendResult.inspect() +class_method PostHog::Emails::SendResult.keyword_init?() +class_method PostHog::Emails::SendResult.members() +class_method PostHog::Emails::SendResult.new(*arg0) +class PostHog::Emails::Status < Struct +instance_method PostHog::Emails::Status#created_at() +instance_method PostHog::Emails::Status#created_at=(_) +instance_method PostHog::Emails::Status#id() +instance_method PostHog::Emails::Status#id=(_) +instance_method PostHog::Emails::Status#reason() +instance_method PostHog::Emails::Status#reason=(_) +instance_method PostHog::Emails::Status#status() +instance_method PostHog::Emails::Status#status=(_) +instance_method PostHog::Emails::Status#status_at() +instance_method PostHog::Emails::Status#status_at=(_) +class_method PostHog::Emails::Status.[](*arg0) +class_method PostHog::Emails::Status.inspect() +class_method PostHog::Emails::Status.keyword_init?() +class_method PostHog::Emails::Status.members() +class_method PostHog::Emails::Status.new(*arg0) +class PostHog::Emails::Warning < Struct +instance_method PostHog::Emails::Warning#code() +instance_method PostHog::Emails::Warning#code=(_) +instance_method PostHog::Emails::Warning#fix() +instance_method PostHog::Emails::Warning#fix=(_) +instance_method PostHog::Emails::Warning#message() +instance_method PostHog::Emails::Warning#message=(_) +class_method PostHog::Emails::Warning.[](*arg0) +class_method PostHog::Emails::Warning.inspect() +class_method PostHog::Emails::Warning.keyword_init?() +class_method PostHog::Emails::Warning.members() +class_method PostHog::Emails::Warning.new(*arg0) class PostHog::FeatureFlagEvaluations instance_method PostHog::FeatureFlagEvaluations#distinct_id() instance_method PostHog::FeatureFlagEvaluations#enabled?(key, default_value: ...) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb new file mode 100644 index 00000000..96f16327 --- /dev/null +++ b/spec/posthog/emails_spec.rb @@ -0,0 +1,482 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module PostHog + describe Emails do + let(:send_url) { 'https://us.posthog.com/api/projects/@current/messaging/send/email/' } + let(:template_id) { '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b' } + let(:message_id) { '01a18356-c56c-0001-e494-7666bc915188' } + let(:status_url) { "https://us.posthog.com/api/projects/@current/messaging/emails/#{message_id}/" } + let(:uuid_v4) { /\A\h{8}-\h{4}-4\h{3}-[89ab]\h{3}-\h{12}\z/ } + let(:options) { { api_key: API_KEY, secret_key: ' phs_test ', test_mode: true } } + let(:client) { Client.new(options) } + let(:emails) { client.emails.tap { |emails| allow(emails).to receive(:sleep) { |seconds| delays << seconds } } } + let(:delays) { [] } + + before do + stub_request(:get, %r{/flags/definitions}).to_return(status: 200, body: '{"flags":[]}') + end + + after { client.shutdown(timeout: 1) } + + def accept(message_id = 'msg_1', **extra) + { status: 202, body: JSON.generate({ message_id: message_id }.merge(extra)) } + end + + def refuse(status, code, detail: nil, headers: {}, **fields) + { status: status, body: JSON.generate({ code: code, detail: detail }.merge(fields).compact), headers: headers } + end + + def sent_bodies + bodies = [] + expect(a_request(:post, send_url).with do |request| + bodies << JSON.parse(request.body) + end).to have_been_made.at_least_once + bodies + end + + def send_template(**overrides) + emails.send(to: 'user@example.com', template_id: template_id, **overrides) + end + + def send_code(**overrides) + emails.send(to: 'user@example.com', template_key: 'password-reset', subject: 'Reset your password', + html: '

Reset

', **overrides) + end + + describe 'a send of a template made in PostHog' do + it 'posts the template and its data to the app host with the secret key' do + stub_request(:post, send_url).to_return(accept) + + result = send_template(template_id: template_id.upcase, template_data: { name: 'Ada' }, distinct_id: 'user_123') + + expect(result.id).to eq('msg_1') + expect(result.replayed).to be(false) + expect( + a_request(:post, send_url).with( + body: { template_id: template_id, message_data: { name: 'Ada' }, to: 'user@example.com', + distinct_id: 'user_123' }, + headers: { + 'Authorization' => 'Bearer phs_test', + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'User-Agent' => "posthog-ruby/#{PostHog::VERSION}" + } + ) + ).to have_been_made.once + end + + it 'sends an empty message_data and no distinct_id when the caller passes neither' do + stub_request(:post, send_url).to_return(accept) + + send_template + + expect(sent_bodies).to eq([{ 'template_id' => template_id, 'message_data' => {}, 'to' => 'user@example.com' }]) + end + + it 'sends dates in template_data as ISO 8601' do + stub_request(:post, send_url).to_return(accept) + + send_template(template_data: { renews_at: Time.utc(2026, 11, 2, 9, 30) }) + + expect(sent_bodies.first['message_data']).to eq('renews_at' => '2026-11-02T09:30:00.000Z') + end + + it 'refuses code content, because the template sets its own' do + expect { send_template(subject: 'Hi') } + .to raise_error(ArgumentError, /template_id doesn't take subject.*send with template_key/) + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + end + + describe 'a send of content from your code' do + it 'posts the content under its key and captures engagement by default' do + stub_request(:post, send_url).to_return(accept) + + send_code(text: 'Reset', from: 'Acme ', reply_to: 'help@example.com', + distinct_id: 'user_123') + + expect(sent_bodies).to eq([{ + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => '

Reset

', + 'text' => 'Reset', + 'from' => 'Acme ', + 'reply_to' => ['help@example.com'], + 'capture_engagement' => true, + 'to' => 'user@example.com', + 'distinct_id' => 'user_123' + }]) + end + + it 'turns engagement capture off when the caller asks' do + stub_request(:post, send_url).to_return(accept) + + send_code(capture_engagement: false, reply_to: %w[a@example.com b@example.com]) + + expect(sent_bodies.first).to include('capture_engagement' => false, + 'reply_to' => %w[a@example.com b@example.com]) + end + + it 'sends text alone' do + stub_request(:post, send_url).to_return(accept) + + emails.send(to: 'user@example.com', template_key: 'digest', subject: 'Digest', text: 'Hello') + + expect(sent_bodies.first.keys).to contain_exactly('template_key', 'subject', 'text', 'capture_engagement', 'to') + end + + it 'refuses template_data, because local templates are not supported' do + expect { send_code(template_data: { name: 'Ada' }) } + .to raise_error(ArgumentError, /template_key doesn't take template_data/) + end + + it 'needs a subject and html or text' do + expect { emails.send(to: 'user@example.com', template_key: 'k', html: '

') } + .to raise_error(ArgumentError, /needs a subject/) + expect { emails.send(to: 'user@example.com', template_key: 'k', subject: 'Hi') } + .to raise_error(ArgumentError, /needs html, text or both/) + end + + it 'refuses a template_key with characters PostHog does not take' do + expect { send_code(template_key: 'password reset') }.to raise_error(ArgumentError, /template_key can only/) + end + end + + describe 'argument checks' do + it 'needs exactly one form' do + expect { emails.send(to: 'user@example.com') }.to raise_error(ArgumentError, /either template_id, or/) + expect { send_code(template_id: template_id) }.to raise_error(ArgumentError, /either template_id, or/) + end + + it 'refuses bad arguments before any request' do + expect { send_template(template_id: 'welcome') }.to raise_error(ArgumentError, /template's UUID/) + expect { send_template(to: ' ') }.to raise_error(ArgumentError, /to must be/) + expect { send_template(distinct_id: '') }.to raise_error(ArgumentError, /distinct_id/) + expect { send_template(idempotency_key: 'has space') }.to raise_error(ArgumentError, /idempotency_key/) + expect { send_template(max_retries: -1) }.to raise_error(ArgumentError, /max_retries/) + expect { send_template(template_data: []) }.to raise_error(ArgumentError, /template_data must be a Hash/) + expect { send_code(capture_engagement: 'no') }.to raise_error(ArgumentError, /capture_engagement/) + expect { send_code(reply_to: [1]) }.to raise_error(ArgumentError, /reply_to/) + expect { send_template(cc: 'x@example.com') }.to raise_error(ArgumentError, /unknown keyword: :cc/) + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + end + + describe 'client setup' do + it 'maps an EU ingestion host to the EU app host' do + options[:host] = 'https://eu.i.posthog.com/' + stub_request(:post, 'https://eu.posthog.com/api/projects/@current/messaging/send/email/').to_return(accept) + + expect(send_template.id).to eq('msg_1') + end + + it 'refuses a custom host without app_host, so a proxy never receives the secret key' do + options[:host] = 'https://proxy.example.com' + + expect { send_template }.to raise_error(EmailError) { |error| + expect(error.code).to eq('missing_app_host') + expect(error.name).to eq('application_error') + } + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + + it 'sends to app_host when the client sets it' do + options.merge!(host: 'https://proxy.example.com', app_host: 'https://posthog.example.com/') + stub_request(:post, 'https://posthog.example.com/api/projects/@current/messaging/send/email/').to_return(accept) + + expect(send_template.id).to eq('msg_1') + end + + it 'refuses a send without a secret key' do + options.delete(:secret_key) + + expect { send_template }.to raise_error(EmailError) { |error| + expect(error.code).to eq('missing_secret_key') + expect(error.name).to eq('missing_api_key') + } + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + + it 'sends nothing from a disabled client' do + options[:api_key] = nil + + expect { send_template }.to raise_error(EmailError) { |error| expect(error.code).to eq('client_disabled') } + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + + it 'names the provider the app moved from on every request' do + options[:migrated_from] = 'resend' + stub_request(:post, send_url).to_return(accept) + + send_template + + expect(a_request(:post, send_url).with(headers: { 'X-PostHog-Migrated-From' => 'resend' })).to have_been_made + end + + it 'refuses email options it cannot use' do + expect { Client.new(options.merge(email_request_max_retries: -1)) }.to raise_error(ArgumentError, /max_retries/) + expect { Client.new(options.merge(email_request_timeout_seconds: 0)) }.to raise_error(ArgumentError, /timeout/) + expect do + Client.new(options.merge(migrated_from: 'my provider')) + end.to raise_error(ArgumentError, /migrated_from/) + end + end + + describe 'retries' do + it 'retries an unavailable service with the same generated idempotency key' do + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable'), accept) + + expect(send_template.id).to eq('msg_1') + + keys = [] + expect(a_request(:post, send_url).with { |request| keys << request.headers['Idempotency-Key'] }) + .to have_been_made.twice + expect(keys.uniq.size).to eq(1) + expect(keys.first).to match(uuid_v4) + expect(delays.first).to be_between(0.8, 1.2) + end + + it 'keeps the caller idempotency key' do + stub_request(:post, send_url).to_return(accept) + + send_template(idempotency_key: 'welcome:user_123') + + expect(a_request(:post, send_url).with(headers: { 'Idempotency-Key' => 'welcome:user_123' })).to have_been_made + end + + it 'makes 3 attempts by default and reports the last error' do + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable', detail: 'Try later.')) + + expect { send_template }.to raise_error(EmailError, 'Try later.') { |error| + expect(error.name).to eq('service_unavailable') + expect(error.status_code).to eq(503) + } + expect(a_request(:post, send_url)).to have_been_made.times(3) + expect(delays.size).to eq(2) + end + + it 'makes 1 attempt when the send turns retries off' do + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable')) + + expect { send_template(max_retries: 0) }.to raise_error(EmailError) + expect(a_request(:post, send_url)).to have_been_made.once + end + + it 'uses the client retry limit' do + options[:email_request_max_retries] = 0 + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable')) + + expect { send_template }.to raise_error(EmailError) + expect(a_request(:post, send_url)).to have_been_made.once + end + + [ + [503, 'delivery_unavailable', 2], + [429, 'throttled', 2], + [409, 'idempotency_key_in_flight', 2], + [429, 'quota_limited', 1], + [422, 'recipient_suppressed', 1] + ].each do |status, code, attempts| + it "makes #{attempts} attempts for a #{status} #{code}" do + stub_request(:post, send_url).to_return(refuse(status, code), accept) + + begin + send_template + rescue EmailError + nil + end + expect(a_request(:post, send_url)).to have_been_made.times(attempts) + end + end + + it 'retries a network error, and reports it as service_unavailable' do + stub_request(:post, send_url).to_raise(Errno::ECONNREFUSED) + + expect { send_template }.to raise_error(EmailError) { |error| + expect(error.code).to eq('network_error') + expect(error.name).to eq('service_unavailable') + expect(error.cause).to be_a(Errno::ECONNREFUSED) + } + expect(a_request(:post, send_url)).to have_been_made.times(3) + end + + it 'retries a timeout' do + stub_request(:post, send_url).to_timeout.then.to_return(accept) + + expect(send_template.id).to eq('msg_1') + end + + it 'waits for a longer Retry-After' do + stub_request(:post, send_url).to_return(refuse(429, 'throttled', headers: { 'Retry-After' => '5' }), accept) + + send_template + + expect(delays).to eq([5.0]) + end + + it 'fails at once on a Retry-After over 30 seconds' do + stub_request(:post, send_url).to_return(refuse(429, 'throttled', headers: { 'Retry-After' => '60' })) + + expect { send_template }.to raise_error(EmailError) { |error| + expect(error.code).to eq('throttled') + expect(error.retry_after).to eq(60.0) + } + expect(a_request(:post, send_url)).to have_been_made.once + end + + it 'does not retry an acceptance without a message ID' do + stub_request(:post, send_url).to_return(status: 202, body: '{}') + + expect { send_template }.to raise_error(EmailError) { |error| + expect(error.code).to eq('api_error') + expect(error.status_code).to eq(202) + } + expect(a_request(:post, send_url)).to have_been_made.once + end + end + + describe 'results and errors' do + it 'returns a replay and logs each warning' do + stub_request(:post, send_url).to_return( + accept('msg_1', replayed: true, warnings: [{ code: 'unverified_link', detail: 'Check links.', fix: 'Fix.' }]) + ) + expect(emails.logger).to receive(:warn).with('PostHog email warning unverified_link: Check links.') + + result = send_template + + expect(result.replayed).to be(true) + expect(result.warnings).to eq([Emails::Warning.new(code: 'unverified_link', message: 'Check links.', + fix: 'Fix.')]) + end + + it "keeps PostHog's error fields" do + stub_request(:post, send_url).to_return( + refuse(400, 'sender_not_verified', detail: 'Verify it.', attr: 'from', why: 'Unverified.', + fix: 'Verify the domain.', docs_url: 'https://posthog.com/docs/x') + ) + + expect { send_code }.to raise_error(EmailError, 'Verify it.') { |error| + expect([error.code, error.name, error.status_code]).to eq(['sender_not_verified', 'validation_error', 400]) + expect([error.field, error.why, error.fix, error.docs_url]) + .to eq(['from', 'Unverified.', 'Verify the domain.', 'https://posthog.com/docs/x']) + } + end + + it 'reports an answer without a code as api_error with the name of its status' do + stub_request(:post, send_url).to_return(status: 403, body: 'Forbidden') + + expect { send_template }.to raise_error(EmailError, 'PostHog answered HTTP 403.') { |error| + expect([error.code, error.name]).to eq(%w[api_error invalid_permission]) + } + end + + [ + [401, 'authentication_failed', 'missing_api_key'], + [404, 'not_found', 'not_found'], + [409, 'template_outdated', 'validation_error'], + [409, 'idempotency_key_in_flight', 'concurrent_idempotent_requests'], + [422, 'idempotency_key_reused', 'invalid_idempotent_request'], + [422, 'recipient_suppressed', 'validation_error'], + [429, 'quota_limited', 'monthly_quota_exceeded'], + [429, 'throttled', 'rate_limit_exceeded'], + [500, 'server_error', 'application_error'] + ].each do |status, code, name| + it "names a #{status} #{code} #{name}" do + stub_request(:post, send_url).to_return(refuse(status, code)) + + expect { send_template(max_retries: 0) }.to raise_error(EmailError) { |error| + expect([error.code, error.name, error.status_code]).to eq([code, name, status]) + } + end + end + end + + describe '#get' do + let(:record) do + { id: message_id, status: 'bounced', created_at: '2026-10-28T16:27:44.364000Z', + status_at: '2026-10-28T16:30:02.100000Z', reason: 'hard' } + end + + it "reads the email's status" do + stub_request(:get, status_url).to_return(status: 200, body: JSON.generate(record)) + + status = emails.get(message_id.upcase) + + expect(status.to_h).to eq( + id: message_id, status: 'bounced', reason: 'hard', + created_at: Time.utc(2026, 10, 28, 16, 27, 44.364r), status_at: Time.utc(2026, 10, 28, 16, 30, 2.1r) + ) + expect(a_request(:get, + status_url).with(headers: { 'Authorization' => 'Bearer phs_test' })).to have_been_made.once + end + + it 'passes a status it does not know through' do + stub_request(:get, status_url).to_return(status: 200, + body: JSON.generate(record.merge( + status: 'archived', reason: nil + ))) + + expect(emails.get(message_id)).to have_attributes(status: 'archived', reason: nil) + end + + it 'does not retry an unknown message ID' do + stub_request(:get, status_url).to_return(refuse(404, 'not_found')) + + expect { emails.get(message_id) }.to raise_error(EmailError) { |error| expect(error.code).to eq('not_found') } + expect(a_request(:get, status_url)).to have_been_made.once + end + + it 'reports a record without its times as api_error' do + stub_request(:get, status_url).to_return(status: 200, body: JSON.generate(record.merge(status_at: 'soon'))) + + expect { emails.get(message_id) }.to raise_error(EmailError) { |error| expect(error.code).to eq('api_error') } + end + + it 'refuses an ID that is not a message ID' do + expect { emails.get('msg_1') }.to raise_error(ArgumentError, /message_id/) + end + end + + describe 'shutdown' do + it 'refuses sends after the client shuts down' do + client.shutdown + + expect { send_template }.to raise_error(EmailError) { |error| expect(error.code).to eq('client_closed') } + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + + context 'with a send in its retry backoff' do + let(:backoff_started) { Queue.new } + + before do + allow(emails).to receive(:sleep) do |_seconds| + backoff_started << true + Kernel.sleep(0.3) + end + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable'), accept) + end + + it 'waits for the retry to settle' do + sender = Thread.new { send_template } + backoff_started.pop + + expect(client.shutdown(timeout: 5)).to be(true) + expect(a_request(:post, send_url)).to have_been_made.twice + expect(sender.value.id).to eq('msg_1') + end + + it 'keeps to its timeout' do + sender = Thread.new { send_template } + backoff_started.pop + + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expect(client.shutdown(timeout: 0.05)).to be(false) + expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 0.25 + sender.join + end + end + end + end +end From 16a2c6258003208238cf44b0fe4b5b60ef1ad780 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:54:07 +0000 Subject: [PATCH 02/10] fix(emails): forget the parent's requests in a forked child A child forked while another thread sent email inherited a positive in-flight count that no thread could settle, so shutdown waited for its whole timeout, or forever without one. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 11 +++++++++++ spec/posthog/emails_spec.rb | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index f9faf438..2388c48b 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -111,6 +111,7 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f @disabled = disabled @closed = false @in_flight = 0 + @pid = Process.pid @mutex = Mutex.new @idle = ConditionVariable.new end @@ -193,6 +194,7 @@ def get(message_id) def close(timeout: nil) deadline = timeout && (monotonic_time + timeout) @mutex.synchronize do + forget_parent_requests @closed = true until @in_flight.zero? remaining = deadline && (deadline - monotonic_time) @@ -336,6 +338,7 @@ def admit(nothing_done) end @mutex.synchronize do + forget_parent_requests if @closed raise EmailError.new(code: 'client_closed', message: "The PostHog client is shut down, so #{nothing_done}.") @@ -345,6 +348,14 @@ def admit(nothing_done) end end + # A forked child inherits the parent's count, but not the threads that would settle those requests. + def forget_parent_requests + return if @pid == Process.pid + + @pid = Process.pid + @in_flight = 0 + end + def finish @mutex.synchronize do @in_flight -= 1 diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 96f16327..f0d2ec34 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -447,6 +447,25 @@ def send_code(**overrides) expect(a_request(:any, %r{/messaging/})).not_to have_been_made end + it 'does not wait in a forked child for a send that runs in the parent', skip: !Process.respond_to?(:fork) do + request_started = Queue.new + release = Queue.new + stub_request(:post, send_url).to_return do + request_started << true + release.pop + accept + end + sender = Thread.new { send_template } + request_started.pop + + child = fork { exit!(client.shutdown(timeout: 5) ? 0 : 1) } + _, status = Process.wait2(child) + release << true + + expect(status.exitstatus).to eq(0) + expect(sender.value.id).to eq('msg_1') + end + context 'with a send in its retry backoff' do let(:backoff_started) { Queue.new } From e33feda0c14c96beaf38bb91ff15f115d6e02a2f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:04:23 +0000 Subject: [PATCH 03/10] fix(emails): retry a send whose response body was cut off Net::HTTP treats a connection closed partway through a body as the end of the body, so a truncated 202 raised a non-retryable api_error. Turn EOF suppression off so the cut-off read raises a network error and the send retries with the same idempotency key. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 4 +++- spec/posthog/emails_spec.rb | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 2388c48b..cf8953e4 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -407,7 +407,9 @@ def request(request, uri, secret_key, headers, body = nil) response = Net::HTTP.start( uri.hostname, uri.port, use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout, - max_retries: 0 + # Net::HTTP returns a body cut off by a closed connection as if it were whole. Raise instead, so the + # send retries with the same idempotency key. + max_retries: 0, ignore_eof: false ) { |http| http.request(request) } [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])] rescue *NETWORK_ERRORS diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index f0d2ec34..232b1323 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -308,6 +308,29 @@ def send_code(**overrides) expect(send_template.id).to eq('msg_1') end + it 'retries an acceptance whose body was cut off' do + server = TCPServer.new('127.0.0.1', 0) + replies = ['{"message_id":"msg', JSON.generate(message_id: 'msg_1')] + responder = Thread.new do + Thread.current.report_on_exception = false + replies.each do |body| + connection = server.accept + connection.readpartial(65_536) + connection.write("HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\n" \ + "Content-Length: 22\r\nConnection: close\r\n\r\n#{body}") + connection.close + end + end + options.merge!(host: 'http://127.0.0.1:9', app_host: "http://127.0.0.1:#{server.addr[1]}") + WebMock.disable_net_connect!(allow_localhost: true) + + expect(send_template.id).to eq('msg_1') + expect(responder.join(5)).to be_truthy + ensure + WebMock.disable_net_connect! + server&.close + end + it 'waits for a longer Retry-After' do stub_request(:post, send_url).to_return(refuse(429, 'throttled', headers: { 'Retry-After' => '5' }), accept) From d945d7514138ecf0ee38d2391b9d25336bb09094 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:59:00 +0000 Subject: [PATCH 04/10] feat(emails): send cc, bcc and attachments with emails.send The code form takes cc and bcc (one address or a list) and attachments: Hashes with filename, content, and an optional content_type and content_id. A binary String, such as File.binread returns, or a list of byte values is encoded as Base64 once, before the retry loop; any other String is taken as Base64 already and must look like it. Empty lists are left out, so a PostHog version without the fields still sends. A file the client can't send raises EmailError invalid_attachment with the field, before any request. Mirrors posthog-node (posthog-js#5514) and posthog-python#1172; the server holds the limits. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-attachments.md | 5 ++ lib/posthog/email_error.rb | 5 +- lib/posthog/emails.rb | 99 ++++++++++++++++++++++++++--- public_api_snapshot.txt | 2 +- spec/posthog/emails_spec.rb | 104 ++++++++++++++++++++++++++++++- 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 .changeset/emails-attachments.md diff --git a/.changeset/emails-attachments.md b/.changeset/emails-attachments.md new file mode 100644 index 00000000..e3de20b4 --- /dev/null +++ b/.changeset/emails-attachments.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +`client.emails.send` with `template_key:` takes `cc:`, `bcc:` and `attachments:`. `cc` and `bcc` take one address or a list. Each attachment is a Hash with `filename`, `content`, and an optional `content_type` and `content_id` (`content_id` shows the file inline). Pass the file's bytes as `content`, such as `File.binread('invoice.pdf')` or a list of byte values, and the client encodes them as Base64; any other String is taken as the file already encoded as Base64. A file the client can't send, such as one given by `path`, raises `PostHog::EmailError` with code `invalid_attachment` before any request. diff --git a/lib/posthog/email_error.rb b/lib/posthog/email_error.rb index 0133b7b1..30cf8471 100644 --- a/lib/posthog/email_error.rb +++ b/lib/posthog/email_error.rb @@ -37,10 +37,13 @@ def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url: @retry_after = retry_after end + SDK_ERROR_NAMES = { 'network_error' => 'service_unavailable', 'invalid_attachment' => 'validation_error' }.freeze + private_constant :SDK_ERROR_NAMES + # @api private def self.name_for(status_code, code) return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401 - return code == 'network_error' ? 'service_unavailable' : 'application_error' if status_code.nil? + return SDK_ERROR_NAMES.fetch(code, 'application_error') if status_code.nil? case status_code when 400 then 'validation_error' diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index cf8953e4..81375724 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -89,7 +89,9 @@ class Emails "project's engagement setting." CODE_FORM_WHY = 'A send with template_key takes its content from your code.' TEMPLATE_FIELDS = %i[template_data].freeze - CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze + CODE_FIELDS = %i[subject html text from reply_to cc bcc attachments capture_engagement].freeze + ATTACHMENT_FIELDS = %w[filename content content_type content_id].freeze + BASE64_PATTERN = %r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z} NETWORK_ERRORS = [ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError @@ -97,7 +99,8 @@ class Emails private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS, :MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN, :HEADER_TOKEN_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY, - :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS + :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :ATTACHMENT_FIELDS, :BASE64_PATTERN, + :NETWORK_ERRORS # @api private def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:) @@ -133,6 +136,18 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @param from [String] Code form: a verified sender, such as `Acme `. Without it, the # project's default sender. # @param reply_to [String, Array] Code form: one address or a list of addresses. + # @param cc [String, Array] Code form: one address or a list of addresses that get a visible copy, at + # most 10. An empty list sends no copy. + # @param bcc [String, Array] Code form: one address or a list of addresses that get a hidden copy, at + # most 10. An empty list sends no copy. + # @param attachments [Array] Code form: files sent with the email, at most 10 and 512 KB in total. Each + # file is a Hash with: + # - +filename+ [String] the name the recipient sees, such as `invoice.pdf`; + # - +content+ [String, Array] the file's bytes, as a binary String such as `File.binread(path)` + # returns or as a list of byte values, which the client encodes as Base64. Any other String is taken as the + # file already Base64 encoded. PostHog can't fetch a file from a path or URL; + # - +content_type+ [String] the MIME type, optional. Without it, the type follows the file name; + # - +content_id+ [String] optional: shows the file inline where `html` has `` with this ID. # @param capture_engagement [Boolean] Code form: whether PostHog captures the email's opens and clicks as events # of the recipient. Defaults to +true+. A project whose admin turned engagement capture off captures nothing. # @param distinct_id [String] The recipient's distinct ID in your product. The email's events go to this @@ -145,14 +160,16 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # retries off. # @return [SendResult] The queued email. # @raise [EmailError] PostHog did not queue the email, or the client could not tell whether it did. + # {EmailError#code} is `invalid_attachment`, before any request, for a file the client can't send. # @raise [ArgumentError] An argument has the wrong type or format, or the call mixes the two forms. def send( to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil, - reply_to: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil + reply_to: nil, cc: nil, bcc: nil, attachments: nil, capture_engagement: nil, distinct_id: nil, + idempotency_key: nil, max_retries: nil ) fields = { template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to, - capture_engagement: capture_engagement + cc: cc, bcc: bcc, attachments: attachments, capture_engagement: capture_engagement }.compact check_recipient(to, distinct_id, idempotency_key, max_retries) payload = content(template_id, template_key, fields).merge(to: to) @@ -292,7 +309,14 @@ def code_content(template_key, fields) end code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key) - code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to) + code[:reply_to] = addresses(:reply_to, fields[:reply_to]) if fields.key?(:reply_to) + # An empty list goes out as no list, because a PostHog version without the field refuses it even empty. + %i[cc bcc].each do |name| + list = addresses(name, fields[name]) if fields.key?(name) + code[name] = list unless list.nil? || list.empty? + end + files = attachment_list(fields[:attachments]) if fields.key?(:attachments) + code[:attachments] = files unless files.nil? || files.empty? code[:capture_engagement] = fields.fetch(:capture_engagement, true) code end @@ -305,11 +329,70 @@ def refuse_fields(fields, allowed:, form:, other_form:, why:) raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}." end - def addresses(reply_to) - list = reply_to.is_a?(String) ? [reply_to] : reply_to + def addresses(name, value) + list = value.is_a?(String) ? [value] : value return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) } - raise ArgumentError, 'reply_to must be one address or a list of addresses.' + raise ArgumentError, "#{name} must be one address or a list of addresses." + end + + def attachment_list(files) + raise invalid_attachment('attachments must be a list of files.', 'attachments') unless files.is_a?(Array) + + files.each_with_index.map { |file, index| attachment(file, "attachments__#{index}") } + end + + def attachment(file, field) + unless file.is_a?(Hash) + raise invalid_attachment("An attachment is a Hash with filename and content. Pass the file's bytes as its " \ + 'content.', field) + end + + file = file.transform_keys(&:to_s).compact + if file.key?('path') + raise invalid_attachment("PostHog can't fetch an attachment from a path or URL. Read the file with " \ + 'File.binread and pass it as content instead.', "#{field}__path") + end + unknown = file.keys.find { |key| !ATTACHMENT_FIELDS.include?(key) } + if unknown + raise invalid_attachment("An attachment takes filename, content, content_type and content_id, not #{unknown}.", + "#{field}__#{unknown}") + end + + %w[filename content_type content_id].each do |name| + next if name != 'filename' && !file.key?(name) + next if file[name].is_a?(String) + + raise invalid_attachment("An attachment's #{name} must be a String.", "#{field}__#{name}") + end + file.merge('content' => base64_content(file['content'], "#{field}__content")) + end + + # A binary String or a list of byte values is the file itself. Any other String is the file already in Base64: + # Ruby has one String type for bytes and text, and File.binread returns a binary one. + def base64_content(content, field) + bytes = file_bytes(content) + return [bytes].pack('m0') if bytes + return content if content.is_a?(String) && BASE64_PATTERN.match?(content) + + if content.is_a?(String) + raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \ + 'with File.binread, or the file encoded as Base64.', field) + end + raise invalid_attachment('An attachment needs content: the bytes File.binread returns, a list of byte values, ' \ + 'or the file encoded as Base64.', field) + end + + def file_bytes(content) + return content if content.is_a?(String) && content.encoding == Encoding::BINARY + return unless content.is_a?(Array) && content.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) } + + content.pack('C*') + end + + # The message never holds the file's content, so the error is safe to log. + def invalid_attachment(message, field) + EmailError.new(code: 'invalid_attachment', message: message, field: field) end def call(nothing_done, key_needed) diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16679caa..4417a854 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -74,7 +74,7 @@ instance_method PostHog::EmailError#status_code() instance_method PostHog::EmailError#why() class PostHog::Emails instance_method PostHog::Emails#get(message_id) -instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) +instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., cc: ..., bcc: ..., attachments: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) class_method PostHog::Emails.logger() class PostHog::Emails::SendResult < Struct instance_method PostHog::Emails::SendResult#id() diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 232b1323..6616e364 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -3,6 +3,12 @@ require 'spec_helper' module PostHog + module Base64Check + def self.decode(text) + text.unpack1('m0') + end + end + describe Emails do let(:send_url) { 'https://us.posthog.com/api/projects/@current/messaging/send/email/' } let(:template_id) { '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b' } @@ -144,6 +150,102 @@ def send_code(**overrides) end end + describe 'copies and attachments' do + let(:pdf) { "%PDF-1.7\n\xFF\x00\xE2".b } + + def refused_attachment(**overrides) + send_code(**overrides) + rescue EmailError => e + e + end + + it 'sends cc and bcc as lists' do + stub_request(:post, send_url).to_return(accept) + + send_code(cc: 'finance@example.com', bcc: %w[audit@example.com archive@example.com]) + + expect(sent_bodies.first).to include('cc' => ['finance@example.com'], + 'bcc' => %w[audit@example.com archive@example.com]) + end + + it 'sends the bytes of each file as Base64, with its type and content ID' do + stub_request(:post, send_url).to_return(accept) + + send_code(attachments: [ + { filename: 'invoice.pdf', content: pdf, content_type: 'application/pdf' }, + { 'filename' => 'logo.png', 'content' => [137, 80, 78, 71], 'content_id' => 'logo' }, + { filename: 'notes.txt', content: 'aGVsbG8=' } + ]) + + expect(sent_bodies.first['attachments']).to eq([ + { 'filename' => 'invoice.pdf', + 'content' => 'JVBERi0xLjcK/wDi', + 'content_type' => 'application/pdf' }, + { 'filename' => 'logo.png', 'content' => 'iVBORw==', + 'content_id' => 'logo' }, + { 'filename' => 'notes.txt', 'content' => 'aGVsbG8=' } + ]) + expect(Base64Check.decode(sent_bodies.first['attachments'][0]['content'])).to eq(pdf) + end + + it 'leaves out empty lists, so a PostHog version without these fields still sends the email' do + stub_request(:post, send_url).to_return(accept) + + send_code(cc: [], bcc: [], attachments: []) + + expect(sent_bodies.first.keys).not_to include('cc', 'bcc', 'attachments') + end + + it 'sends the same encoded files on every attempt' do + stub_request(:post, send_url).to_return(refuse(503, 'service_unavailable'), accept) + + send_code(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect(a_request(:post, send_url)).to have_been_made.twice + expect(sent_bodies.map { |body| body['attachments'] }) + .to eq([[{ 'filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjcK/wDi' }]]) + end + + it 'refuses a file it cannot send before any request, naming the field and never the content' do + cases = { + { attachments: { filename: 'a.pdf', content: pdf } } => ['attachments', /list of files/], + { attachments: ['a.pdf'] } => ['attachments__0', /Hash with filename and content/], + { attachments: [{ filename: 'a.pdf', path: '/tmp/a.pdf' }] } => ['attachments__0__path', /File.binread/], + { attachments: [{ filename: 'a.pdf', content: pdf, headers: {} }] } => + ['attachments__0__headers', /not headers/], + { attachments: [{ filename: 'a.pdf' }] } => ['attachments__0__content', /needs content/], + { attachments: [{ filename: 'a.pdf', content: [256] }] } => ['attachments__0__content', /needs content/], + { attachments: [{ filename: 'a.pdf', content: 'secret text, not Base64' }] } => + ['attachments__0__content', /isn't Base64/], + { attachments: [{ filename: :a, content: pdf }] } => ['attachments__0__filename', /filename/], + { attachments: [{ filename: 'a.pdf', content: pdf, content_type: 1 }] } => + ['attachments__0__content_type', /content_type/] + } + + cases.each do |arguments, (field, message)| + error = refused_attachment(**arguments) + expect(error).to be_a(EmailError), "#{arguments} raised #{error.inspect}" + expect([error.code, error.name, error.field]).to eq(['invalid_attachment', 'validation_error', field]) + expect(error.message).to match(message) + expect(error.message).not_to include('secret text') + end + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + + it 'refuses an address list that is not one', :aggregate_failures do + expect { send_code(cc: [nil]) }.to raise_error(ArgumentError, /cc must be one address or a list/) + expect { send_code(bcc: 3) }.to raise_error(ArgumentError, /bcc must be one address or a list/) + end + + it 'refuses them on a template send, because the template sets its own recipients and files' do + %i[cc bcc attachments].each do |field| + expect { send_template(field => []) } + .to raise_error(ArgumentError, /template_id doesn't take #{field}.*send with template_key/) + end + expect(a_request(:any, %r{/messaging/})).not_to have_been_made + end + end + describe 'argument checks' do it 'needs exactly one form' do expect { emails.send(to: 'user@example.com') }.to raise_error(ArgumentError, /either template_id, or/) @@ -159,7 +261,7 @@ def send_code(**overrides) expect { send_template(template_data: []) }.to raise_error(ArgumentError, /template_data must be a Hash/) expect { send_code(capture_engagement: 'no') }.to raise_error(ArgumentError, /capture_engagement/) expect { send_code(reply_to: [1]) }.to raise_error(ArgumentError, /reply_to/) - expect { send_template(cc: 'x@example.com') }.to raise_error(ArgumentError, /unknown keyword: :cc/) + expect { send_template(headers: {}) }.to raise_error(ArgumentError, /unknown keyword: :headers/) expect(a_request(:any, %r{/messaging/})).not_to have_been_made end end From 90c1afbd3f5ef537b7d4748a31581cd768bb1ae1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:07:18 +0000 Subject: [PATCH 05/10] fix(emails): answer the round 3 review - Explicit nil email options fall back to the defaults, and the client checks them before the worker, instance count and flags poller start. - app_host must be an http or https URL with a host. - A refusal whose body broke off keeps its status and Retry-After; a corrupt compressed body is a network error and retries. - skip_ssl_verification also applies to email requests. - Specs: real-socket server reads whole requests and runs without WebMock, the backoff spec waits on a queue instead of a fixed sleep, and the warning spec reads the logger output. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/client.rb | 23 ++++--- lib/posthog/emails.rb | 48 ++++++++++--- spec/posthog/emails_spec.rb | 134 ++++++++++++++++++++++++++++++------ 3 files changed, 164 insertions(+), 41 deletions(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 5292f44d..33aea387 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -163,6 +163,19 @@ def initialize(opts = {}) @lib = opts[:_lib] || 'posthog-ruby' @lib_version = (opts[:_lib_version] || PostHog::VERSION).to_s @headers = Defaults::Request::HEADERS.merge('User-Agent' => "#{@lib}/#{@lib_version}") + # Built before the worker, the instance count and the flags poller start, so a bad email option raises + # before anything runs or sends the secret key. + @emails = Emails.new( + secret_key: secret_key, + host: opts[:host], + app_host: opts[:app_host], + timeout: opts[:email_request_timeout_seconds] || Defaults::Emails::REQUEST_TIMEOUT_SECONDS, + max_retries: opts[:email_request_max_retries] || Defaults::Emails::REQUEST_MAX_RETRIES, + migrated_from: opts[:migrated_from], + user_agent: @headers['User-Agent'], + disabled: @disabled, + skip_ssl_verification: opts[:skip_ssl_verification] == true + ) @worker_mutex = Mutex.new @shutdown_mutex = Mutex.new @shutdown_condition = ConditionVariable.new @@ -236,16 +249,6 @@ def initialize(opts = {}) ) end - @emails = Emails.new( - secret_key: secret_key, - host: opts[:host], - app_host: opts[:app_host], - timeout: opts.fetch(:email_request_timeout_seconds, Defaults::Emails::REQUEST_TIMEOUT_SECONDS), - max_retries: opts.fetch(:email_request_max_retries, Defaults::Emails::REQUEST_MAX_RETRIES), - migrated_from: opts[:migrated_from], - user_agent: @headers['User-Agent'], - disabled: @disabled - ) @before_send = opts[:before_send] @is_server = opts.fetch(:is_server, true) != false @release_id = normalize_string_option(ENV.fetch(RELEASE_ID_ENV_VAR, nil), blank_as_nil: true) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index cf8953e4..be011cf1 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -5,6 +5,8 @@ require 'openssl' require 'securerandom' require 'time' +require 'uri' +require 'zlib' require 'posthog/email_error' require 'posthog/logging' @@ -92,7 +94,7 @@ class Emails CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze NETWORK_ERRORS = [ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, - Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError + Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error ].freeze private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS, :MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN, @@ -100,7 +102,8 @@ class Emails :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS # @api private - def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:) + def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:, + skip_ssl_verification: false) check_options(timeout, max_retries, migrated_from) @secret_key = secret_key.is_a?(String) ? secret_key.strip : nil @app_host = resolve_app_host(host, app_host) @@ -109,6 +112,7 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f @migrated_from = migrated_from @user_agent = user_agent @disabled = disabled + @skip_ssl_verification = skip_ssl_verification @closed = false @in_flight = 0 @pid = Process.pid @@ -230,9 +234,20 @@ def uuid?(value) def resolve_app_host(host, app_host) explicit = app_host.is_a?(String) ? app_host.strip.chomp('/') : '' - return CLOUD_APP_HOSTS.fetch(explicit, explicit) unless explicit.empty? + return CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] if explicit.empty? + # A host without a scheme would send the secret key to the wrong place, such as localhost. + unless web_url?(explicit) + raise ArgumentError, 'app_host must be an http or https URL, such as https://us.posthog.com.' + end + + CLOUD_APP_HOSTS.fetch(explicit, explicit) + end - CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] + def web_url?(value) + uri = URI(value) + %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? + rescue URI::InvalidURIError + false end def check_recipient(to, distinct_id, idempotency_key, max_retries) @@ -404,18 +419,29 @@ def request(request, uri, secret_key, headers, body = nil) headers.each { |name, value| request[name] = value } request.body = body if body - response = Net::HTTP.start( - uri.hostname, uri.port, - use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout, - # Net::HTTP returns a body cut off by a closed connection as if it were whole. Raise instead, so the - # send retries with the same idempotency key. - max_retries: 0, ignore_eof: false - ) { |http| http.request(request) } + head = nil + response = Net::HTTP.start(uri.hostname, uri.port, connection_options(uri)) do |http| + http.request(request) { |started| head = started } + end [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])] rescue *NETWORK_ERRORS + # A refusal whose body broke off still carries its status and Retry-After. An acceptance whose body broke off + # has no message ID, so it retries with the same idempotency key. + return [head.code.to_i, nil, parse_retry_after(head['Retry-After'])] if head && !head.is_a?(Net::HTTPSuccess) + raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.') end + def connection_options(uri) + options = { + use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout, + # Net::HTTP returns a body cut off by a closed connection as if it were whole. Raise instead. + max_retries: 0, ignore_eof: false + } + options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if @skip_ssl_verification + options + end + def parse_json(body) JSON.parse(body.to_s) rescue JSON::ParserError diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 232b1323..19763ae7 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -36,6 +36,60 @@ def sent_bodies bodies end + # Serves each raw HTTP response once, in order, on a local socket, and points the client's app host at it. WebMock + # is off meanwhile: its Net::HTTP wrapper reads the whole body before it hands over the response. + def serve(*responses, tls: nil) + server = TCPServer.new('127.0.0.1', 0) + listener = tls ? OpenSSL::SSL::SSLServer.new(server, tls) : server + responder = Thread.new do + Thread.current.report_on_exception = false + responses.each do |response| + connection = listener.accept + read_request(connection) + connection.write(response) + connection.close + end + end + options.merge!(host: 'http://127.0.0.1:9', app_host: "#{tls ? 'https' : 'http'}://127.0.0.1:#{server.addr[1]}") + WebMock.disable! + yield + expect(responder.join(5)).to be_truthy + ensure + WebMock.enable! + server&.close + end + + # Reads one whole request, so closing the connection never resets a client still writing its body. + def read_request(connection) + request = +'' + request << connection.readpartial(65_536) until request.include?("\r\n\r\n") + head, body = request.split("\r\n\r\n", 2) + length = head[/^content-length: *(\d+)/i, 1].to_i + body << connection.readpartial(65_536) while body.bytesize < length + end + + def http_response(status, body, headers = {}) + head = headers.merge('Content-Type' => 'application/json', 'Connection' => 'close') + head['Content-Length'] ||= body.bytesize.to_s + "HTTP/1.1 #{status}\r\n#{head.map { |name, value| "#{name}: #{value}\r\n" }.join}\r\n#{body}" + end + + def self_signed_tls + key = OpenSSL::PKey::RSA.new(2048) + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1 + cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=127.0.0.1') + cert.public_key = key.public_key + cert.not_before = Time.now - 60 + cert.not_after = Time.now + 3600 + cert.sign(key, OpenSSL::Digest.new('SHA256')) + OpenSSL::SSL::SSLContext.new.tap do |context| + context.cert = cert + context.key = key + end + end + def send_template(**overrides) emails.send(to: 'user@example.com', template_id: template_id, **overrides) end @@ -221,6 +275,31 @@ def send_code(**overrides) expect do Client.new(options.merge(migrated_from: 'my provider')) end.to raise_error(ArgumentError, /migrated_from/) + %w[posthog.example.com ftp://posthog.example.com https://].each do |app_host| + expect { Client.new(options.merge(app_host: app_host)) }.to raise_error(ArgumentError, /app_host/) + end + end + + it 'refuses a bad email option before it polls flags with the secret key' do + expect do + Client.new(options.merge(test_mode: false, email_request_timeout_seconds: 0)) + end.to raise_error(ArgumentError, /timeout/) + expect(a_request(:get, %r{/flags/definitions})).not_to have_been_made + end + + it 'uses the defaults for email options passed as nil' do + stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable'), refuse(503, 'x'), accept) + options.merge!(email_request_timeout_seconds: nil, email_request_max_retries: nil) + + expect(send_template.id).to eq('msg_1') + end + + it 'skips certificate checks when the client sets skip_ssl_verification' do + options[:skip_ssl_verification] = true + + serve(http_response('202 Accepted', JSON.generate(message_id: 'msg_1')), tls: self_signed_tls) do + expect(send_template.id).to eq('msg_1') + end end end @@ -309,26 +388,32 @@ def send_code(**overrides) end it 'retries an acceptance whose body was cut off' do - server = TCPServer.new('127.0.0.1', 0) - replies = ['{"message_id":"msg', JSON.generate(message_id: 'msg_1')] - responder = Thread.new do - Thread.current.report_on_exception = false - replies.each do |body| - connection = server.accept - connection.readpartial(65_536) - connection.write("HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\n" \ - "Content-Length: 22\r\nConnection: close\r\n\r\n#{body}") - connection.close - end + cut_off = http_response('202 Accepted', '{"message_id":"msg', 'Content-Length' => '22') + + serve(cut_off, http_response('202 Accepted', JSON.generate(message_id: 'msg_1'))) do + expect(send_template.id).to eq('msg_1') end - options.merge!(host: 'http://127.0.0.1:9', app_host: "http://127.0.0.1:#{server.addr[1]}") - WebMock.disable_net_connect!(allow_localhost: true) + end - expect(send_template.id).to eq('msg_1') - expect(responder.join(5)).to be_truthy - ensure - WebMock.disable_net_connect! - server&.close + it 'retries an acceptance whose compressed body is corrupt' do + # A gzip header, then a deflate block of a type that does not exist. + corrupt = http_response('202 Accepted', "\x1F\x8B\x08\x00\x00\x00\x00\x00\x00\x03\xFF\xFF".b, + 'Content-Encoding' => 'gzip') + + serve(corrupt, http_response('202 Accepted', JSON.generate(message_id: 'msg_1'))) do + expect(send_template.id).to eq('msg_1') + end + end + + it "keeps a refusal's status and Retry-After when its body was cut off" do + cut_off = http_response('429 Too Many Requests', '{"code":"thro', + 'Content-Length' => '40', 'Retry-After' => '60') + + serve(cut_off) do + expect { send_template }.to raise_error(EmailError) { |error| + expect([error.status_code, error.name, error.retry_after]).to eq([429, 'rate_limit_exceeded', 60.0]) + } + end end it 'waits for a longer Retry-After' do @@ -365,10 +450,13 @@ def send_code(**overrides) stub_request(:post, send_url).to_return( accept('msg_1', replayed: true, warnings: [{ code: 'unverified_link', detail: 'Check links.', fix: 'Fix.' }]) ) - expect(emails.logger).to receive(:warn).with('PostHog email warning unverified_link: Check links.') + log = StringIO.new + PostHog::Logging.logger = Logger.new(log, level: Logger::WARN) result = send_template + expect(log.string).to include('PostHog email warning unverified_link: Check links.') + expect(result.replayed).to be(true) expect(result.warnings).to eq([Emails::Warning.new(code: 'unverified_link', message: 'Check links.', fix: 'Fix.')]) @@ -491,11 +579,12 @@ def send_code(**overrides) context 'with a send in its retry backoff' do let(:backoff_started) { Queue.new } + let(:release_retry) { Queue.new } before do allow(emails).to receive(:sleep) do |_seconds| backoff_started << true - Kernel.sleep(0.3) + release_retry.pop end stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable'), accept) end @@ -503,6 +592,10 @@ def send_code(**overrides) it 'waits for the retry to settle' do sender = Thread.new { send_template } backoff_started.pop + Thread.new do + Kernel.sleep(0.05) + release_retry << true + end expect(client.shutdown(timeout: 5)).to be(true) expect(a_request(:post, send_url)).to have_been_made.twice @@ -516,6 +609,7 @@ def send_code(**overrides) started = Process.clock_gettime(Process::CLOCK_MONOTONIC) expect(client.shutdown(timeout: 0.05)).to be(false) expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 0.25 + release_retry << true sender.join end end From e94874ceeb6ae34fa8c5da1b02cf1f6ddb7eb60d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:16:27 +0000 Subject: [PATCH 06/10] fix(emails): retry a refusal whose body broke off A reply whose body broke off is a network error again, so a cut-off 409 idempotency_key_in_flight retries as before. It now keeps the head's Retry-After: the retry waits for it, and one over 30 seconds raises at once. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 8 +++----- spec/posthog/emails_spec.rb | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index be011cf1..c1a9cebc 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -425,11 +425,9 @@ def request(request, uri, secret_key, headers, body = nil) end [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])] rescue *NETWORK_ERRORS - # A refusal whose body broke off still carries its status and Retry-After. An acceptance whose body broke off - # has no message ID, so it retries with the same idempotency key. - return [head.code.to_i, nil, parse_retry_after(head['Retry-After'])] if head && !head.is_a?(Net::HTTPSuccess) - - raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.') + # A reply whose body broke off retries with the same idempotency key, after its Retry-After when it sent one. + raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.', + retry_after: head && parse_retry_after(head['Retry-After'])) end def connection_options(uri) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 19763ae7..274a6256 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -405,17 +405,35 @@ def send_code(**overrides) end end - it "keeps a refusal's status and Retry-After when its body was cut off" do + it 'retries a refusal whose body was cut off, as a network error' do + cut_off = http_response('409 Conflict', '{"code":"idempotency_key_in', 'Content-Length' => '60') + + serve(cut_off, http_response('202 Accepted', JSON.generate(message_id: 'msg_1'))) do + expect(send_template.id).to eq('msg_1') + end + end + + it 'keeps the Retry-After of a refusal whose body was cut off' do cut_off = http_response('429 Too Many Requests', '{"code":"thro', 'Content-Length' => '40', 'Retry-After' => '60') serve(cut_off) do expect { send_template }.to raise_error(EmailError) { |error| - expect([error.status_code, error.name, error.retry_after]).to eq([429, 'rate_limit_exceeded', 60.0]) + expect([error.code, error.retry_after]).to eq(['network_error', 60.0]) } end end + it 'waits out the Retry-After of a cut-off refusal before it retries' do + cut_off = http_response('503 Service Unavailable', '{"code":"deliv', + 'Content-Length' => '40', 'Retry-After' => '3') + serve(cut_off, http_response('202 Accepted', JSON.generate(message_id: 'msg_1'))) do + emails + expect(send_template.id).to eq('msg_1') + end + expect(delays).to eq([3.0]) + end + it 'waits for a longer Retry-After' do stub_request(:post, send_url).to_return(refuse(429, 'throttled', headers: { 'Retry-After' => '5' }), accept) From c843d535cd5f8c127429ac1c212458dfd4257f1f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:23:44 +0000 Subject: [PATCH 07/10] test(emails): prove shutdown waits for a send in its retry backoff The spec released the retry on a 50 ms timer, so it passed even when shutdown returned at once. It now holds the retry until it has seen shutdown still running, then releases it and expects true. Co-Authored-By: Claude Opus 5.5 --- spec/posthog/emails_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 274a6256..9d2fea35 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -610,12 +610,12 @@ def send_code(**overrides) it 'waits for the retry to settle' do sender = Thread.new { send_template } backoff_started.pop - Thread.new do - Kernel.sleep(0.05) - release_retry << true - end + shutdown = Thread.new { client.shutdown(timeout: 5) } + + expect(shutdown.join(0.2)).to be_nil + release_retry << true - expect(client.shutdown(timeout: 5)).to be(true) + expect(shutdown.value).to be(true) expect(a_request(:post, send_url)).to have_been_made.twice expect(sender.value.id).to eq('msg_1') end From a3a1aab0703b822a56b2d64e92572acdef02b0fa Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:40:03 +0000 Subject: [PATCH 08/10] fix(emails): refuse non-ASCII content as invalid_attachment, take wrapped Base64 - A String that isn't ASCII, such as a PDF read with File.read or UTF-16 text, raised a bare ArgumentError from the Base64 match. It now raises invalid_attachment with the field and the File.binread hint. - Base64 with line breaks, as Base64.encode64 writes it, is sent without them instead of being refused. - The retry spec checks both attempts carry the encoded file, and the Base64Check helper is inlined. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 18 +++++++++++------- spec/posthog/emails_spec.rb | 28 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 2a46c93a..186c7f85 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -388,14 +388,18 @@ def attachment(file, field) def base64_content(content, field) bytes = file_bytes(content) return [bytes].pack('m0') if bytes - return content if content.is_a?(String) && BASE64_PATTERN.match?(content) - - if content.is_a?(String) - raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \ - 'with File.binread, or the file encoded as Base64.', field) + unless content.is_a?(String) + raise invalid_attachment('An attachment needs content: the bytes File.binread returns, a list of byte ' \ + 'values, or the file encoded as Base64.', field) end - raise invalid_attachment('An attachment needs content: the bytes File.binread returns, a list of byte values, ' \ - 'or the file encoded as Base64.', field) + + # Base64.encode64 breaks lines every 60 characters, and PostHog takes Base64 without them. A String that isn't + # ASCII, such as a PDF read with File.read, can't be Base64. + encoded = content.ascii_only? && content.delete("\r\n") + return encoded if encoded && BASE64_PATTERN.match?(encoded) + + raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \ + 'with File.binread, or the file encoded as Base64.', field) end def file_bytes(content) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 5abb19b0..37ed20ad 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -3,12 +3,6 @@ require 'spec_helper' module PostHog - module Base64Check - def self.decode(text) - text.unpack1('m0') - end - end - describe Emails do let(:send_url) { 'https://us.posthog.com/api/projects/@current/messaging/send/email/' } let(:template_id) { '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b' } @@ -239,7 +233,7 @@ def refused_attachment(**overrides) 'content_id' => 'logo' }, { 'filename' => 'notes.txt', 'content' => 'aGVsbG8=' } ]) - expect(Base64Check.decode(sent_bodies.first['attachments'][0]['content'])).to eq(pdf) + expect(sent_bodies.first['attachments'][0]['content'].unpack1('m0')).to eq(pdf) end it 'leaves out empty lists, so a PostHog version without these fields still sends the email' do @@ -255,9 +249,19 @@ def refused_attachment(**overrides) send_code(attachments: [{ filename: 'invoice.pdf', content: pdf }]) - expect(a_request(:post, send_url)).to have_been_made.twice - expect(sent_bodies.map { |body| body['attachments'] }) - .to eq([[{ 'filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjcK/wDi' }]]) + encoded_once = [{ 'filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjcK/wDi' }] + expect(a_request(:post, send_url).with { |request| JSON.parse(request.body)['attachments'] == encoded_once }) + .to have_been_made.twice + end + + it 'takes Base64 with line breaks, as Base64.encode64 writes it' do + stub_request(:post, send_url).to_return(accept) + file = (0..99).to_a.pack('C*') + + send_code(attachments: [{ filename: 'data.bin', content: [file].pack('m') }]) + + expect([file].pack('m')).to include("\n") + expect(sent_bodies.first['attachments'][0]['content']).to eq([file].pack('m0')) end it 'refuses a file it cannot send before any request, naming the field and never the content' do @@ -271,6 +275,10 @@ def refused_attachment(**overrides) { attachments: [{ filename: 'a.pdf', content: [256] }] } => ['attachments__0__content', /needs content/], { attachments: [{ filename: 'a.pdf', content: 'secret text, not Base64' }] } => ['attachments__0__content', /isn't Base64/], + { attachments: [{ filename: 'a.pdf', content: "%PDF secret text \xE2\xE3".dup.force_encoding('UTF-8') }] } => + ['attachments__0__content', /File.binread/], + { attachments: [{ filename: 'a.txt', content: 'aGVsbG8='.encode('UTF-16LE') }] } => + ['attachments__0__content', /isn't Base64/], { attachments: [{ filename: :a, content: pdf }] } => ['attachments__0__filename', /filename/], { attachments: [{ filename: 'a.pdf', content: pdf, content_type: 1 }] } => ['attachments__0__content_type', /content_type/] From e29b8a735c96434a3d3b733d9ef36cf552183dc8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:44:58 +0000 Subject: [PATCH 09/10] fix(emails): take line-broken Base64 only in Base64.encode64's layout Removing every line break let a text file read with File.read, such as "ABCD1234\nEFGH5678\n", pass as Base64, so the recipient got a garbled file. Line breaks are now removed only when the content is exactly what Base64.encode64 writes; other text is refused as invalid_attachment. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 11 +++++++---- spec/posthog/emails_spec.rb | 4 +++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 186c7f85..c7d29461 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -393,10 +393,13 @@ def base64_content(content, field) 'values, or the file encoded as Base64.', field) end - # Base64.encode64 breaks lines every 60 characters, and PostHog takes Base64 without them. A String that isn't - # ASCII, such as a PDF read with File.read, can't be Base64. - encoded = content.ascii_only? && content.delete("\r\n") - return encoded if encoded && BASE64_PATTERN.match?(encoded) + # A String that isn't ASCII, such as a PDF read with File.read, can't be Base64. + if content.ascii_only? + return content if BASE64_PATTERN.match?(content) + # PostHog takes Base64 without line breaks. Only Base64.encode64's own layout loses them, so a text file + # that happens to hold Base64 characters on several lines is still refused. + return content.delete("\n") if content.include?("\n") && [content.unpack1('m')].pack('m') == content + end raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \ 'with File.binread, or the file encoded as Base64.', field) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 37ed20ad..e455a603 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -276,7 +276,9 @@ def refused_attachment(**overrides) { attachments: [{ filename: 'a.pdf', content: 'secret text, not Base64' }] } => ['attachments__0__content', /isn't Base64/], { attachments: [{ filename: 'a.pdf', content: "%PDF secret text \xE2\xE3".dup.force_encoding('UTF-8') }] } => - ['attachments__0__content', /File.binread/], + ['attachments__0__content', /isn't Base64/], + { attachments: [{ filename: 'codes.txt', content: "ABCD1234\nEFGH5678\n" }] } => + ['attachments__0__content', /isn't Base64/], { attachments: [{ filename: 'a.txt', content: 'aGVsbG8='.encode('UTF-16LE') }] } => ['attachments__0__content', /isn't Base64/], { attachments: [{ filename: :a, content: pdf }] } => ['attachments__0__filename', /filename/], From df3ff9200273011a7c1079b4d097252d5b2b985d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:16:35 +0000 Subject: [PATCH 10/10] refactor(emails): name the attachment rules instead of commenting them Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.rb | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index c7d29461..59e2e13c 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -325,13 +325,8 @@ def code_content(template_key, fields) code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key) code[:reply_to] = addresses(:reply_to, fields[:reply_to]) if fields.key?(:reply_to) - # An empty list goes out as no list, because a PostHog version without the field refuses it even empty. - %i[cc bcc].each do |name| - list = addresses(name, fields[name]) if fields.key?(name) - code[name] = list unless list.nil? || list.empty? - end - files = attachment_list(fields[:attachments]) if fields.key?(:attachments) - code[:attachments] = files unless files.nil? || files.empty? + %i[cc bcc].each { |name| put_unless_empty(code, name, addresses(name, fields[name])) if fields.key?(name) } + put_unless_empty(code, :attachments, attachment_list(fields[:attachments])) if fields.key?(:attachments) code[:capture_engagement] = fields.fetch(:capture_engagement, true) code end @@ -344,6 +339,10 @@ def refuse_fields(fields, allowed:, form:, other_form:, why:) raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}." end + def put_unless_empty(code, name, list) + code[name] = list unless list.empty? + end + def addresses(name, value) list = value.is_a?(String) ? [value] : value return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) } @@ -383,8 +382,6 @@ def attachment(file, field) file.merge('content' => base64_content(file['content'], "#{field}__content")) end - # A binary String or a list of byte values is the file itself. Any other String is the file already in Base64: - # Ruby has one String type for bytes and text, and File.binread returns a binary one. def base64_content(content, field) bytes = file_bytes(content) return [bytes].pack('m0') if bytes @@ -392,19 +389,21 @@ def base64_content(content, field) raise invalid_attachment('An attachment needs content: the bytes File.binread returns, a list of byte ' \ 'values, or the file encoded as Base64.', field) end - - # A String that isn't ASCII, such as a PDF read with File.read, can't be Base64. - if content.ascii_only? - return content if BASE64_PATTERN.match?(content) - # PostHog takes Base64 without line breaks. Only Base64.encode64's own layout loses them, so a text file - # that happens to hold Base64 characters on several lines is still refused. - return content.delete("\n") if content.include?("\n") && [content.unpack1('m')].pack('m') == content - end + return content if single_line_base64?(content) + return content.delete("\n") if encode64_layout?(content) raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \ 'with File.binread, or the file encoded as Base64.', field) end + def single_line_base64?(content) + content.ascii_only? && BASE64_PATTERN.match?(content) + end + + def encode64_layout?(content) + content.ascii_only? && content.include?("\n") && [content.unpack1('m')].pack('m') == content + end + def file_bytes(content) return content if content.is_a?(String) && content.encoding == Encoding::BINARY return unless content.is_a?(Array) && content.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) } @@ -412,7 +411,6 @@ def file_bytes(content) content.pack('C*') end - # The message never holds the file's content, so the error is safe to log. def invalid_attachment(message, field) EmailError.new(code: 'invalid_attachment', message: message, field: field) end