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:
parent
75b7121b12
commit
b6fa936e36
8 changed files with 184 additions and 10 deletions
35
app/controllers/ahoy/email_clicks_controller.rb
Normal file
35
app/controllers/ahoy/email_clicks_controller.rb
Normal 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
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)$");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
spec/requests/ahoy/email_clicks_spec.rb
Normal file
44
spec/requests/ahoy/email_clicks_spec.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue