diff --git a/.changeset/emails-tracking.md b/.changeset/emails-tracking.md new file mode 100644 index 00000000..27b7f5dc --- /dev/null +++ b/.changeset/emails-tracking.md @@ -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. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 1ea5ffde..4f0a4b71 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -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 @@ -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 `. 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 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 @@ -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, + 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) @@ -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) + code[:tracking] = tracking(fields[:tracking]) if fields.key?(:tracking) 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? diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16679caa..3fd665f4 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: ..., 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() diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 31ea9d62..3427556c 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -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 ', reply_to: 'help@example.com', @@ -158,7 +162,6 @@ def send_code(**overrides) 'text' => 'Reset', 'from' => 'Acme ', 'reply_to' => ['help@example.com'], - 'capture_engagement' => true, 'to' => 'user@example.com', 'distinct_id' => 'user_123' }]) @@ -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 @@ -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