Customize ahoy email to track clicks async without redirect (#20809)

* Initial controller creation

* Ahoy custom clicks initial WIP

* Add test for success and failure

* Adjust some tests

* Adjust test

* Adjust tests and some code

* Update app/views/mailers/digest_mailer/digest_email.html.erb

* Remove s/t/u from safe params

* Add simple test

* Update app/controllers/ahoy/custom_email_clicks_controller.rb

Co-authored-by: Mac Siri <mac@forem.com>

* Change to just email clicks

* Email clicks controller test

---------

Co-authored-by: Mac Siri <mac@forem.com>
This commit is contained in:
Ben Halpern 2024-04-01 15:05:04 -04:00 committed by GitHub
parent 75b7121b12
commit b6fa936e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 184 additions and 10 deletions

View file

@ -0,0 +1,35 @@
module Ahoy
class EmailClicksController < ApplicationController
skip_before_action :verify_authenticity_token # Signitures are used to verify requests here
before_action :verify_signature
def create
data = {
token: @token,
campaign: @campaign,
url: @url,
controller: self
}
AhoyEmail::Utils.publish(:click, data)
head :ok # Renders a blank response with a 200 OK status
end
private
def verify_signature
@token = ahoy_params[:t].to_s
@campaign = ahoy_params[:c].to_s
@url = ahoy_params[:u].to_s
@signature = ahoy_params[:s].to_s
expected_signature = AhoyEmail::Utils.signature(token: @token, campaign: @campaign, url: @url)
return if ActiveSupport::SecurityUtils.secure_compare(@signature, expected_signature)
render plain: "Invalid signature", status: :forbidden
end
def ahoy_params
params.permit(:t, :c, :u, :s)
end
end
end

View file

@ -6,6 +6,7 @@ function initializeBaseTracking() {
trackGoogleAnalytics3();
trackGoogleAnalytics4();
trackCustomImpressions();
trackEmailClicks();
}
// Google Anlytics 3 is deprecated, and mostly not supported, but some sites may still be using it for now.
@ -187,6 +188,35 @@ function trackCustomImpressions() {
}, 1800)
}
function trackEmailClicks() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('ahoy_click') === 'true' && urlParams.get('t') && urlParams.get('s') && urlParams.get('u')){
const dataBody = {
t: urlParams.get('t'),
c: urlParams.get('c'),
u: decodeURIComponent(urlParams.get('u')),
s: urlParams.get('s'),
};
window.fetch('/ahoy/email_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dataBody),
credentials: 'same-origin'
});
// Remove t,c,u,s params and ahoy_click param from url without modifying the history
urlParams.delete('t');
urlParams.delete('c');
urlParams.delete('u');
urlParams.delete('s');
urlParams.delete('ahoy_click');
const newUrl = `${window.location.pathname }?${ urlParams.toString()}`;
window.history.replaceState({}, null, newUrl);
}
}
function showCookieConsentBanner() {
// if current url includes ?cookietest=true
if (shouldShowCookieBanner()) {

View file

@ -2,6 +2,6 @@ import querystring;
sub vcl_recv {
# return this URL with only the parameters that match this regular expression
if (req.url !~ "/admin/" && req.url !~ "/search/" && req.url !~ "/bulk_show") {
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id|s|t|u)$");
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id)$");
}
}

View file

@ -3,3 +3,55 @@ AhoyEmail.api = false
AhoyEmail.default_options[:click] = Rails.env.production? ? ENV["AHOY_EMAIL_CLICK_ON"] == "YES" : true
AhoyEmail.default_options[:utm_params] = false
AhoyEmail.default_options[:message] = true
# Monkeypatch to make track_links work async instead of as a blocking route.
require "ahoy_email"
module AhoyEmail
class Processor
protected
# rubocop:disable Metrics/CyclomaticComplexity
def track_links
return unless html_part?
part = message.html_part || message
doc = Nokogiri::HTML::Document.parse(part.body.raw_source)
doc.css("a[href]").each do |link|
uri = parse_uri(link["href"])
next unless trackable?(uri)
if options[:utm_params] && !skip_attribute?(link, "utm-params")
existing_params = uri.query_values(Array) || []
UTM_PARAMETERS.each do |key|
next if existing_params.any? { |k, _v| k == key } || !options[key.to_sym]
existing_params << [key, options[key.to_sym]]
end
uri.query_values = existing_params
end
if options[:click] && !skip_attribute?(link, "click")
signature = Utils.signature(token: token, campaign: campaign, url: link["href"])
tracking_params = {
"ahoy_click" => true,
"t" => token,
"s" => signature,
"u" => CGI.escape(link["href"]),
"c" => campaign
}.reject { |_k, v| v.nil? || v.to_s.empty? }
# Merge the existing and new tracking params
all_params = (uri.query_values(Array) || []) + tracking_params.to_a
uri.query_values = all_params
# Preserve the port if present, especially for localhost in development
port_part = uri.port ? ":#{uri.port}" : ""
link["href"] = "#{uri.scheme}://#{uri.host}#{port_part}#{uri.path}"
link["href"] += "?#{uri.query}" unless uri.query.nil? || uri.query.empty?
end
end
part.body = doc.to_s.gsub("&amp;", "&")
end
end
end

View file

@ -16,8 +16,17 @@ Rails.application.routes.draw do
get "/confirm-email", to: "confirmations#new"
delete "/sign_out", to: "devise/sessions#destroy"
end
# This route makes default Ahoy Email redirect URLs available to us
# However, we monkeypatch this behavior in config/initializers/ahoy_email.rb so this
# routes is not currently where emails pass through.
mount AhoyEmail::Engine => "/ahoy"
# Custom controller for tracking clicks asynchronously
namespace :ahoy do
post "email_clicks", to: "email_clicks#create"
end
get "/r/mobile", to: "deep_links#mobile"
get "/.well-known/apple-app-site-association", to: "deep_links#aasa"

View file

@ -40,7 +40,7 @@ RSpec.describe DeviseMailer do
end
it "renders proper URL" do
expect(email.body.to_s).to include("confirmation_token%3Dfaketoken") # encoded URL
expect(email.body.to_s).to include("confirmation_token=faketoken") # encoded URL
end
end
@ -52,7 +52,7 @@ RSpec.describe DeviseMailer do
end
it "renders proper URL" do
expect(email.body.to_s).to include("confirmation_token%3Dfaketoken")
expect(email.body.to_s).to include("confirmation_token=faketoken")
end
end
end

View file

@ -124,7 +124,7 @@ RSpec.describe NotifyMailer do
context "when rendering the HTML email for badge w/o credits" do
it "includes the user URL" do
expect(email.html_part.body).to include("%2F#{user.username}")
expect(email.html_part.body).to include("/#{user.username}")
end
it "doesn't include the listings URL" do
@ -141,23 +141,27 @@ RSpec.describe NotifyMailer do
)
end
it "includes the click tracking parameters" do
expect(email.html_part.body).to include("/hey?ahoy_click=true&t=")
end
it "includes the rewarding_context_message in the email" do
expect(email.html_part.body).to include("Hello <a")
expect(email.html_part.body).to include("%2Fhey")
expect(email.html_part.body).to include("/hey")
end
it "does not include the nil rewarding_context_message in the email" do
allow(badge_achievement).to receive(:rewarding_context_message).and_return(nil)
expect(email.html_part.body).not_to include("Hello <a")
expect(email.html_part.body).not_to include("%2Fhey")
expect(email.html_part.body).not_to include("/hey")
end
it "does not include the empty rewarding_context_message in the email" do
allow(badge_achievement).to receive(:rewarding_context_message).and_return("")
expect(email.html_part.body).not_to include("Hello <a")
expect(email.html_part.body).not_to include("%2Fhey")
expect(email.html_part.body).not_to include("/hey")
end
end
@ -180,21 +184,21 @@ RSpec.describe NotifyMailer do
it "includes the rewarding_context_message in the email" do
expect(email.text_part.body).to include("Hello Yoho")
expect(email.text_part.body).not_to include("%2Fhey")
expect(email.text_part.body).not_to include("/hey")
end
it "does not include the nil rewarding_context_message in the email" do
allow(badge_achievement).to receive(:rewarding_context_message).and_return(nil)
expect(email.text_part.body).not_to include("Hello Yoho")
expect(email.text_part.body).not_to include("%2Fhey")
expect(email.text_part.body).not_to include("/hey")
end
it "does not include the empty rewarding_context_message in the email" do
allow(badge_achievement).to receive(:rewarding_context_message).and_return("")
expect(email.text_part.body).not_to include("Hello Yoho")
expect(email.text_part.body).not_to include("%2Fhey")
expect(email.text_part.body).not_to include("/hey")
end
end
end

View file

@ -0,0 +1,44 @@
require "rails_helper"
RSpec.describe "AhoyEmails" do
describe "POST /email_clicks" do
let(:token) { "test_token" }
let(:campaign) { "test_campaign" }
let(:url) { "http://example.com" }
let(:signature) { AhoyEmail::Utils.signature(token: token, campaign: campaign, url: url) }
before do
# Stub the publish method
allow(AhoyEmail::Utils).to receive(:publish).and_return(true)
end
context "with a valid signature" do
it "publishes a click event and returns http ok" do
# Stub the publish method to prevent external calls
controller = an_instance_of(Ahoy::EmailClicksController)
allow(AhoyEmail::Utils).to receive(:publish).and_return(true)
post ahoy_email_clicks_path, params: { t: token, c: campaign, u: url, s: signature }
expect(response).to have_http_status(:ok)
expect(AhoyEmail::Utils).to have_received(:publish)
.with(:click,
hash_including(token: token, campaign: campaign, url: url, controller: controller))
end
end
context "with an invalid signature" do
it "returns http forbidden" do
# Use a clearly invalid signature
invalid_signature = "invalid"
post ahoy_email_clicks_path, params: { t: token, c: campaign, u: url, s: invalid_signature }
expect(response).to have_http_status(:forbidden)
expect(response.body).to eq("Invalid signature")
# Ensure publish method was not called
expect(AhoyEmail::Utils).not_to have_received(:publish)
end
end
end
end