Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emails-integration-option.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions lib/posthog/emails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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:,
Expand Down Expand Up @@ -162,24 +163,29 @@ 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.
# @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, 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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions spec/posthog/emails_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
Loading