From daad7afad4cb92cefb0689fe6a2909add63ca25e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:32:00 +0000 Subject: [PATCH 1/5] feat(emails): let a code send turn off open and click tracking A send with template_key takes track_opens_and_clicks. false sends the email without PostHog's open pixel or rewritten links. The SDK sends the field only when the caller sets it, so a default send keeps today's body and still works against an API that predates the field. A send with template_id refuses it, like capture_engagement. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-track-opens-and-clicks.md | 5 +++++ lib/posthog/emails.rb | 17 ++++++++++++----- public_api_snapshot.txt | 2 +- spec/posthog/emails_spec.rb | 13 +++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 .changeset/emails-track-opens-and-clicks.md diff --git a/.changeset/emails-track-opens-and-clicks.md b/.changeset/emails-track-opens-and-clicks.md new file mode 100644 index 00000000..ce4ebb8b --- /dev/null +++ b/.changeset/emails-track-opens-and-clicks.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +A send with your own content now takes `track_opens_and_clicks: false`, which sends that email without PostHog's open pixel or rewritten links, so PostHog can't count its opens and clicks. It defaults to `true`, and a send that leaves it out sends the same request as before. It is independent of `capture_engagement:`, which decides whether engagement events are captured on the recipient's person. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 1ea5ffde..78995080 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 capture_engagement track_opens_and_clicks].freeze NETWORK_ERRORS = [ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error @@ -142,6 +142,10 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @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 track_opens_and_clicks [Boolean] Code form: whether the email carries PostHog's open and click tracking. + # Defaults to +true+. With +false+, the email goes out without PostHog's open pixel or rewritten links, so + # PostHog can't count its opens and clicks. Independent of +capture_engagement+, which decides whether + # engagement events are captured on the recipient's person. Needs a PostHog version that takes this field. # @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 +159,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, capture_engagement: nil, track_opens_and_clicks: 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 + capture_engagement: capture_engagement, track_opens_and_clicks: track_opens_and_clicks }.compact check_recipient(to, distinct_id, idempotency_key, max_retries) payload = content(template_id, template_key, fields).merge(to: to) @@ -305,13 +310,15 @@ 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 track_opens_and_clicks].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) + # An API that predates the field refuses it, so a send that leaves it unset keeps the old body. + code[:track_opens_and_clicks] = fields[:track_opens_and_clicks] if fields.key?(:track_opens_and_clicks) code end diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16679caa..abfd0665 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: ..., capture_engagement: ..., track_opens_and_clicks: ..., 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..5faeb3c5 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -140,6 +140,8 @@ 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(track_opens_and_clicks: false) } + .to raise_error(ArgumentError, /template_id doesn't take track_opens_and_clicks/) expect(a_request(:any, %r{/messaging/})).not_to have_been_made end end @@ -173,6 +175,16 @@ def send_code(**overrides) 'reply_to' => %w[a@example.com b@example.com]) end + [false, true].each do |track| + it "sends track_opens_and_clicks: #{track} when the caller sets it" do + stub_request(:post, send_url).to_return(accept) + + send_code(track_opens_and_clicks: track) + + expect(sent_bodies.first).to include('track_opens_and_clicks' => track, 'capture_engagement' => true) + end + end + it 'sends text alone' do stub_request(:post, send_url).to_return(accept) @@ -212,6 +224,7 @@ 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(track_opens_and_clicks: 'no') }.to raise_error(ArgumentError, /track_opens_and_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 From 55b0043094c383b7134915969a5694136cacebd1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:06:18 +0000 Subject: [PATCH 2/5] docs(emails): say a code send's clicks are never tracked PostHog never rewrites the links of an email sent from code, so track_opens_and_clicks: false removes only the open pixel. The docstring and changeset no longer say it removes rewritten links. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-track-opens-and-clicks.md | 2 +- lib/posthog/emails.rb | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/emails-track-opens-and-clicks.md b/.changeset/emails-track-opens-and-clicks.md index ce4ebb8b..daa374af 100644 --- a/.changeset/emails-track-opens-and-clicks.md +++ b/.changeset/emails-track-opens-and-clicks.md @@ -2,4 +2,4 @@ "posthog-ruby": minor --- -A send with your own content now takes `track_opens_and_clicks: false`, which sends that email without PostHog's open pixel or rewritten links, so PostHog can't count its opens and clicks. It defaults to `true`, and a send that leaves it out sends the same request as before. It is independent of `capture_engagement:`, which decides whether engagement events are captured on the recipient's person. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. +A send with your own content now takes `track_opens_and_clicks: false`, which sends that email without PostHog's open pixel, so PostHog can't count its opens. PostHog doesn't track clicks on emails sent from code, so their links are never rewritten either way. It defaults to `true`, and a send that leaves it out sends the same request as before. It is independent of `capture_engagement:`, which decides whether engagement events are captured on the recipient's person. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 78995080..3948612a 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -142,10 +142,11 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @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 track_opens_and_clicks [Boolean] Code form: whether the email carries PostHog's open and click tracking. - # Defaults to +true+. With +false+, the email goes out without PostHog's open pixel or rewritten links, so - # PostHog can't count its opens and clicks. Independent of +capture_engagement+, which decides whether - # engagement events are captured on the recipient's person. Needs a PostHog version that takes this field. + # @param track_opens_and_clicks [Boolean] Code form: whether PostHog tracks opens and clicks of the email. + # Defaults to +true+. With +false+, the email goes out without PostHog's open pixel, so PostHog can't count its + # opens. PostHog doesn't track clicks on emails sent from code, so their links are never rewritten either way. + # Independent of +capture_engagement+, which decides whether engagement events are captured on the recipient's + # person. Needs a PostHog version that takes this field. # @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 From cfa546030bc74232aa0d13cb9d4ebfe4f3c0d9ae Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:20:41 +0000 Subject: [PATCH 3/5] feat(emails): let a code send choose its tracking Replace track_opens_and_clicks with tracking: true, false, or opens, clicks and engagement one by one. It is left out of the request when unset. capture_engagement is deprecated in favor of tracking's engagement and keeps working; the client leaves it out when tracking is set, unless the caller sets it too. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-track-opens-and-clicks.md | 5 --- .changeset/emails-tracking.md | 5 +++ lib/posthog/emails.rb | 47 ++++++++++++++------- public_api_snapshot.txt | 2 +- spec/posthog/emails_spec.rb | 30 ++++++++++--- 5 files changed, 60 insertions(+), 29 deletions(-) delete mode 100644 .changeset/emails-track-opens-and-clicks.md create mode 100644 .changeset/emails-tracking.md diff --git a/.changeset/emails-track-opens-and-clicks.md b/.changeset/emails-track-opens-and-clicks.md deleted file mode 100644 index daa374af..00000000 --- a/.changeset/emails-track-opens-and-clicks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-ruby": minor ---- - -A send with your own content now takes `track_opens_and_clicks: false`, which sends that email without PostHog's open pixel, so PostHog can't count its opens. PostHog doesn't track clicks on emails sent from code, so their links are never rewritten either way. It defaults to `true`, and a send that leaves it out sends the same request as before. It is independent of `capture_engagement:`, which decides whether engagement events are captured on the recipient's person. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. diff --git a/.changeset/emails-tracking.md b/.changeset/emails-tracking.md new file mode 100644 index 00000000..cc624dcc --- /dev/null +++ b/.changeset/emails-tracking.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +A send with your own content now takes `tracking:` to choose what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and doesn't track clicks. `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. A send that leaves `tracking:` out sends the same request as before. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 3948612a..da324d69 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 track_opens_and_clicks].freeze + CODE_FIELDS = %i[subject html text from reply_to tracking capture_engagement].freeze NETWORK_ERRORS = [ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error @@ -140,13 +140,17 @@ 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 track_opens_and_clicks [Boolean] Code form: whether PostHog tracks opens and clicks of the email. - # Defaults to +true+. With +false+, the email goes out without PostHog's open pixel, so PostHog can't count its - # opens. PostHog doesn't track clicks on emails sent from code, so their links are never rewritten either way. - # Independent of +capture_engagement+, which decides whether engagement events are captured on the recipient's - # person. Needs a PostHog version that takes this field. + # @param tracking [Boolean, Hash] Code form: what PostHog tracks for the email. Left out, PostHog counts opens and + # captures engagement events, and doesn't track clicks. +true+ turns on opens, clicks and engagement events. + # +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 +false+) and +:engagement+ (default +true+) one by one. Clicks are off by default because PostHog + # can't tell whether an email's links carry login or reset tokens: turn them on only when the 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 + # (default +true+) only when +tracking+ is left out, or 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 @@ -160,12 +164,11 @@ 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, track_opens_and_clicks: nil, distinct_id: nil, idempotency_key: nil, - max_retries: nil + reply_to: 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, track_opens_and_clicks: track_opens_and_clicks + 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) @@ -311,18 +314,30 @@ 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 - %i[capture_engagement track_opens_and_clicks].each do |name| - raise ArgumentError, "#{name} must be true or false." unless [nil, true, false].include?(fields[name]) + 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) - # An API that predates the field refuses it, so a send that leaves it unset keeps the old body. - code[:track_opens_and_clicks] = fields[:track_opens_and_clicks] if fields.key?(:track_opens_and_clicks) + if !fields.key?(:tracking) || fields.key?(:capture_engagement) + code[:capture_engagement] = fields.fetch(:capture_engagement, true) + end + 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 abfd0665..9fd68757 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: ..., track_opens_and_clicks: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) +instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., 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 5faeb3c5..24fff55d 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -140,8 +140,8 @@ 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(track_opens_and_clicks: false) } - .to raise_error(ArgumentError, /template_id doesn't take track_opens_and_clicks/) + expect { send_template(tracking: false) } + .to raise_error(ArgumentError, /template_id doesn't take tracking/) expect(a_request(:any, %r{/messaging/})).not_to have_been_made end end @@ -175,16 +175,31 @@ def send_code(**overrides) 'reply_to' => %w[a@example.com b@example.com]) end - [false, true].each do |track| - it "sends track_opens_and_clicks: #{track} when the caller sets it" do + { + 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} in place of capture_engagement" do stub_request(:post, send_url).to_return(accept) - send_code(track_opens_and_clicks: track) + send_code(tracking: tracking) - expect(sent_bodies.first).to include('track_opens_and_clicks' => track, 'capture_engagement' => true) + expect(sent_bodies.first).to include('tracking' => sent) + expect(sent_bodies.first).not_to have_key('capture_engagement') 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) @@ -224,7 +239,8 @@ 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(track_opens_and_clicks: 'no') }.to raise_error(ArgumentError, /track_opens_and_clicks/) + 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 From afb428906621f5775df2afc8924f716c819c19a9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:27:09 +0000 Subject: [PATCH 4/5] fix(emails): send capture_engagement only when the caller sets it A code send that sets tracking must not also carry capture_engagement, or PostHog refuses it. PostHog reads a missing capture_engagement as true, so a default send still captures engagement. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-tracking.md | 2 +- lib/posthog/emails.rb | 6 ++---- spec/posthog/emails_spec.rb | 7 +++---- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.changeset/emails-tracking.md b/.changeset/emails-tracking.md index cc624dcc..19cc47a3 100644 --- a/.changeset/emails-tracking.md +++ b/.changeset/emails-tracking.md @@ -2,4 +2,4 @@ "posthog-ruby": minor --- -A send with your own content now takes `tracking:` to choose what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and doesn't track clicks. `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. A send that leaves `tracking:` out sends the same request as before. The field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. +A send with your own content now takes `tracking:` to choose what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and doesn't track clicks. `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 field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index da324d69..89fc779c 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -150,7 +150,7 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # 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 - # (default +true+) only when +tracking+ is left out, or when you set it. + # 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 @@ -320,9 +320,7 @@ def code_content(template_key, fields) code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key) code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to) - if !fields.key?(:tracking) || fields.key?(:capture_engagement) - code[:capture_engagement] = fields.fetch(:capture_engagement, true) - end + code[:capture_engagement] = fields[:capture_engagement] if fields.key?(:capture_engagement) code[:tracking] = tracking(fields[:tracking]) if fields.key?(:tracking) code end diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 24fff55d..db3d78ef 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -147,7 +147,7 @@ def send_code(**overrides) 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', @@ -160,7 +160,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' }]) @@ -182,7 +181,7 @@ def send_code(**overrides) { opens: false, clicks: nil, engagement: false } => { 'opens' => false, 'engagement' => false }, {} => {} }.each do |tracking, sent| - it "sends tracking: #{tracking.inspect} in place of capture_engagement" do + it "sends tracking: #{tracking.inspect} alone" do stub_request(:post, send_url).to_return(accept) send_code(tracking: tracking) @@ -205,7 +204,7 @@ def send_code(**overrides) 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 From f17a02e99f9db869696d0b3eb38a17e40e3e2e7b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:12:22 +0000 Subject: [PATCH 5/5] feat(emails): let a code send mark itself sensitive A send with template_key now takes an optional sensitive flag. The client sends it only when the caller sets it. Left out, PostHog keeps today's behavior: the matching template's setting, else sensitive. Click tracking follows it unless tracking sets clicks. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-tracking.md | 2 +- lib/posthog/emails.rb | 27 +++++++++++++++++---------- public_api_snapshot.txt | 2 +- spec/posthog/emails_spec.rb | 13 +++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.changeset/emails-tracking.md b/.changeset/emails-tracking.md index 19cc47a3..27b7f5dc 100644 --- a/.changeset/emails-tracking.md +++ b/.changeset/emails-tracking.md @@ -2,4 +2,4 @@ "posthog-ruby": minor --- -A send with your own content now takes `tracking:` to choose what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and doesn't track clicks. `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 field needs PostHog's API support, which is rolling out: until then, PostHog refuses a send that sets it. +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 89fc779c..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 tracking 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,12 +140,17 @@ 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 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 doesn't track clicks. +true+ turns on opens, clicks and engagement events. - # +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 +false+) and +:engagement+ (default +true+) one by one. Clicks are off by default because PostHog - # can't tell whether an email's links carry login or reset tokens: turn them on only when the links are safe to + # 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: @@ -164,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, tracking: 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, - tracking: tracking, 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) @@ -314,13 +320,14 @@ 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[: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 diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 9fd68757..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: ..., tracking: ..., 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 db3d78ef..3427556c 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -142,6 +142,8 @@ def send_code(**overrides) .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 @@ -191,6 +193,16 @@ def send_code(**overrides) 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) @@ -238,6 +250,7 @@ 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/)