Skip to content
Merged
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-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

A send with your own content now takes `sensitive:` and `tracking:`. Pass `sensitive: true` when the email carries login links, reset tokens or codes: PostHog then keeps its links unchanged unless `tracking:` turns clicks on, leaves its subject off engagement events, and keeps its content out of logs. Left out, the email takes the setting of the template made in PostHog whose key matches `template_key`, and is sensitive when no template matches. `tracking:` chooses what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and tracks clicks only when the email isn't sensitive. `true` turns on opens, clicks and engagement events, and `false` turns them all off. A Hash such as `{ clicks: true }` sets `:opens`, `:clicks` and `:engagement` one by one. `capture_engagement:` is deprecated in favor of `tracking: { engagement: false }`; it still works, but a send can't set both. The client now sends it only when you set it; PostHog already reads a missing value as on. The new fields need PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets them.
44 changes: 36 additions & 8 deletions lib/posthog/emails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ 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 sensitive tracking capture_engagement].freeze
NETWORK_ERRORS = [
Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error
Expand Down Expand Up @@ -140,8 +140,22 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f
# @param from [String] Code form: a verified sender, such as `Acme <hello@example.com>`. Without it, the
# project's default sender.
# @param reply_to [String, Array<String>] 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 sensitive [Boolean] Code form: +true+ when the email carries login links, reset tokens or codes. A
# sensitive email keeps its links unchanged unless +tracking+ turns clicks on, leaves its subject off engagement
# events, and keeps its content out of logs. Left out, the email takes the setting of the template made in
# PostHog whose key matches +template_key+, and is sensitive when no template matches. Sent only when you set
# it, and needs a PostHog version that takes it.
# @param tracking [Boolean, Hash] Code form: what PostHog tracks for the email. Left out, PostHog counts opens and
# captures engagement events, and tracks clicks only when the email isn't sensitive. +true+ turns on opens,
# clicks and engagement events, even for a sensitive email. +false+ turns them all off: the email has no open
# pixel and no rewritten links, and PostHog captures no engagement events for it. A Hash such as
# +{ clicks: true }+ sets +:opens+ (default +true+), +:clicks+ (default: on unless the email is sensitive) and
# +:engagement+ (default +true+) one by one. Turn clicks on for a sensitive email only when its links are safe to
# rewrite. Click tracking needs open tracking. A project whose admin turned engagement capture off captures
# nothing, whatever the send says. Sent only when you set it, and needs a PostHog version that takes it.
# @param capture_engagement [Boolean] Deprecated: use +tracking: { engagement: false }+ instead. Code form:
# whether PostHog captures the email's engagement events. It still works, but a send can't set both. Sent
# only when you set it.
# @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
Expand All @@ -155,11 +169,12 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f
# @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, sensitive: nil, tracking: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil,
Comment thread
Silthus marked this conversation as resolved.
Resolved
max_retries: nil
)
fields = {
template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to,
capture_engagement: capture_engagement
sensitive: sensitive, tracking: tracking, capture_engagement: capture_engagement
}.compact
check_recipient(to, distinct_id, idempotency_key, max_retries)
payload = content(template_id, template_key, fields).merge(to: to)
Expand Down Expand Up @@ -305,16 +320,29 @@ def code_content(template_key, fields)
%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.'
%i[capture_engagement sensitive].each do |name|
raise ArgumentError, "#{name} must be true or false." unless [nil, true, false].include?(fields[name])
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[:capture_engagement] = fields[:capture_engagement] if fields.key?(:capture_engagement)
code[:sensitive] = fields[:sensitive] if fields.key?(:sensitive)
Comment thread
Silthus marked this conversation as resolved.
Resolved
code[:tracking] = tracking(fields[:tracking]) if fields.key?(:tracking)
Comment thread
Silthus marked this conversation as resolved.
Resolved
code
end

def tracking(value)
return value if [true, false].include?(value)
raise ArgumentError, 'tracking must be true, false or a Hash such as { clicks: true }.' unless value.is_a?(Hash)

kinds = value.compact
kinds.each do |kind, on|
raise ArgumentError, "tracking[#{kind.inspect}] must be true or false." unless [true, false].include?(on)
end
kinds
end

def refuse_fields(fields, allowed:, form:, other_form:, why:)
field = fields.keys.find { |name| !allowed.include?(name) }
return if field.nil?
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: ..., 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: ..., sensitive: ..., tracking: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...)
class_method PostHog::Emails.logger()
class PostHog::Emails::SendResult < Struct
instance_method PostHog::Emails::SendResult#id()
Expand Down
47 changes: 44 additions & 3 deletions spec/posthog/emails_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,16 @@ def send_code(**overrides)
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 { send_template(tracking: false) }
.to raise_error(ArgumentError, /template_id doesn't take tracking/)
expect { send_template(sensitive: true) }
.to raise_error(ArgumentError, /template_id doesn't take sensitive/)
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
it 'posts the content under its key and leaves unset fields out' do
stub_request(:post, send_url).to_return(accept)

send_code(text: 'Reset', from: 'Acme <hello@example.com>', reply_to: 'help@example.com',
Expand All @@ -158,7 +162,6 @@ def send_code(**overrides)
'text' => 'Reset',
'from' => 'Acme <hello@example.com>',
'reply_to' => ['help@example.com'],
'capture_engagement' => true,
'to' => 'user@example.com',
'distinct_id' => 'user_123'
}])
Expand All @@ -173,12 +176,47 @@ def send_code(**overrides)
'reply_to' => %w[a@example.com b@example.com])
end

{
true => true,
false => false,
{ clicks: true } => { 'clicks' => true },
{ opens: false, clicks: nil, engagement: false } => { 'opens' => false, 'engagement' => false },
{} => {}
}.each do |tracking, sent|
it "sends tracking: #{tracking.inspect} alone" do
stub_request(:post, send_url).to_return(accept)

send_code(tracking: tracking)

expect(sent_bodies.first).to include('tracking' => sent)
expect(sent_bodies.first).not_to have_key('capture_engagement')
end
end

[true, false].each do |sensitive|
it "sends sensitive: #{sensitive} next to the tracking the caller set" do
stub_request(:post, send_url).to_return(accept)

send_code(sensitive: sensitive, tracking: { clicks: false })

expect(sent_bodies.first).to include('sensitive' => sensitive, 'tracking' => { 'clicks' => false })
end
end

it 'sends capture_engagement next to tracking when the caller sets both' do
stub_request(:post, send_url).to_return(accept)

send_code(tracking: true, capture_engagement: false)

expect(sent_bodies.first).to include('tracking' => true, 'capture_engagement' => false)
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')
expect(sent_bodies.first.keys).to contain_exactly('template_key', 'subject', 'text', 'to')
end

it 'refuses template_data, because local templates are not supported' do
Expand Down Expand Up @@ -212,6 +250,9 @@ def send_code(**overrides)
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(sensitive: 'yes') }.to raise_error(ArgumentError, /sensitive must be true or false/)
expect { send_code(tracking: 'no') }.to raise_error(ArgumentError, /tracking must be/)
expect { send_code(tracking: { clicks: 'yes' }) }.to raise_error(ArgumentError, /tracking\[:clicks\]/)
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
Expand Down
Loading