diff --git a/.changeset/emails-integration-option.md b/.changeset/emails-integration-option.md new file mode 100644 index 00000000..ef342989 --- /dev/null +++ b/.changeset/emails-integration-option.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +`client.emails.send` takes `integration:`, for a library that sends email on the app's behalf, such as a mailer adapter. Pass its name and version, such as `integration: 'action-mailer/3.14.0'`, and the send carries it as the `X-PostHog-Integration` header, so PostHog can tell which integration sent the email. A value outside `name/version` raises `ArgumentError` before any request. The User-Agent doesn't change. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 59e2e13c..e9dc9ace 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -84,6 +84,7 @@ class Emails 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/ + INTEGRATION_PATTERN = %r{\A[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}\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.' @@ -100,9 +101,9 @@ class Emails ].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, :ATTACHMENT_FIELDS, :BASE64_PATTERN, - :NETWORK_ERRORS + :HEADER_TOKEN_PATTERN, :INTEGRATION_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, + :TEMPLATE_FORM_WHY, :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:, @@ -162,6 +163,9 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # retries the send. # @param max_retries [Integer] Overrides the client's `email_request_max_retries` for this send. +0+ turns # retries off. + # @param integration [String] For a library that sends on the app's behalf, such as a mailer adapter: its name + # and version, such as `action-mailer/3.14.0`, sent as the `X-PostHog-Integration` header. The name is lowercase + # letters, digits and hyphens; the version is letters, digits, dots, plus signs and hyphens; each at most 40. # @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. @@ -169,17 +173,19 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f def send( to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil, reply_to: nil, cc: nil, bcc: nil, attachments: nil, capture_engagement: nil, distinct_id: nil, - idempotency_key: nil, max_retries: nil + idempotency_key: nil, max_retries: nil, integration: nil ) fields = { template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to, cc: cc, bcc: bcc, attachments: attachments, capture_engagement: capture_engagement }.compact check_recipient(to, distinct_id, idempotency_key, max_retries) + check_integration(integration) 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 } + headers['X-PostHog-Integration'] = integration if integration result = call('no email was sent', SEND_KEY_NEEDED) do |secret_key| uri = URI(@app_host + SEND_PATH) @@ -280,6 +286,12 @@ def check_recipient(to, distinct_id, idempotency_key, max_retries) raise ArgumentError, 'max_retries must be a nonnegative integer.' end + def check_integration(integration) + return if integration.nil? || (integration.is_a?(String) && INTEGRATION_PATTERN.match?(integration)) + + raise ArgumentError, "integration must be a name and version, such as 'action-mailer/3.14.0'." + end + def nonempty_string?(value) value.is_a?(String) && !value.strip.empty? end diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 4417a854..50d9d7e3 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: ..., cc: ..., bcc: ..., attachments: ..., 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: ..., integration: ...) 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 e455a603..010c455b 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -330,6 +330,34 @@ def refused_attachment(**overrides) end end + describe 'integration marker' do + before { stub_request(:post, send_url).to_return(refuse(503, 'delivery_unavailable'), accept) } + + it 'names the integration that made the send on every attempt, and keeps the User-Agent' do + send_code(integration: 'action-mailer/3.14.0') + + expect(a_request(:post, send_url).with( + headers: { 'X-PostHog-Integration' => 'action-mailer/3.14.0', + 'User-Agent' => "posthog-ruby/#{PostHog::VERSION}" } + )).to have_been_made.twice + end + + it 'sends no marker without the option' do + send_template + + expect(a_request(:post, send_url).with { |request| request.headers.key?('X-Posthog-Integration') }) + .not_to have_been_made + end + + it 'refuses a marker outside name/version before any request' do + ['action-mailer', 'Action-Mailer/1.0', 'action mailer/1.0', 'action-mailer/1.0 beta', "#{'a' * 41}/1.0", 7] + .each do |integration| + expect { send_code(integration: integration) }.to raise_error(ArgumentError, /integration must be/) + end + 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/'