ahoy_email 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,65 +5,40 @@ module Ahoy
5
5
  skip_after_action(*filters, raise: false)
6
6
  skip_around_action(*filters, raise: false)
7
7
 
8
- before_action :set_message
9
-
8
+ # legacy
10
9
  def open
11
- # TODO move to MessageSubscriber in 2.0
12
- if @message && !@message.opened_at
13
- @message.opened_at = Time.now
14
- @message.save!
15
- end
16
-
17
- publish :open
18
-
19
10
  send_data Base64.decode64("R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="), type: "image/gif", disposition: "inline"
20
11
  end
21
12
 
22
13
  def click
23
- # TODO move to MessageSubscriber in 2.0
24
- if @message && !@message.clicked_at
25
- @message.clicked_at = Time.now
26
- @message.opened_at ||= @message.clicked_at if @message.respond_to?(:opened_at=)
27
- @message.save!
14
+ if params[:id]
15
+ # legacy
16
+ token = params[:id].to_s
17
+ url = params[:url].to_s
18
+ signature = params[:signature].to_s
19
+ expected_signature = OpenSSL::HMAC.hexdigest("SHA1", AhoyEmail::Utils.secret_token, url)
20
+ else
21
+ token = params[:t].to_s
22
+ campaign = params[:c].to_s
23
+ url = params[:u].to_s
24
+ signature = params[:s].to_s
25
+ expected_signature = AhoyEmail::Utils.signature(token: token, campaign: campaign, url: url)
28
26
  end
29
27
 
30
- user_signature = params[:signature].to_s
31
- url = params[:url].to_s
32
-
33
- # TODO sign more than just url and transition to HMAC-SHA256
34
- digest = "SHA1"
35
- signature = OpenSSL::HMAC.hexdigest(digest, AhoyEmail.secret_token, url)
36
-
37
- if ActiveSupport::SecurityUtils.secure_compare(user_signature, signature)
38
- publish :click, url: params[:url]
28
+ if ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
29
+ data = {}
30
+ data[:campaign] = campaign if campaign
31
+ data[:token] = token
32
+ data[:url] = url
33
+ data[:controller] = self
34
+ AhoyEmail::Utils.publish(:click, data)
39
35
 
40
36
  redirect_to url
41
37
  else
42
- # TODO show link expired page with link to invalid redirect url in 2.0
43
- redirect_to AhoyEmail.invalid_redirect_url || main_app.root_url
44
- end
45
- end
46
-
47
- protected
48
-
49
- def set_message
50
- @token = params[:id]
51
-
52
- model = AhoyEmail.message_model
53
-
54
- return if model.respond_to?(:column_names) && !model.column_names.include?("token")
55
-
56
- @message = model.where(token: @token).first
57
- end
58
-
59
- def publish(name, event = {})
60
- AhoyEmail.subscribers.each do |subscriber|
61
- subscriber = subscriber.new if subscriber.is_a?(Class) && !subscriber.respond_to?(name)
62
- if subscriber.respond_to?(name)
63
- event[:message] = @message
64
- event[:controller] = self
65
- event[:token] = @token
66
- subscriber.send name, event
38
+ if AhoyEmail.invalid_redirect_url
39
+ redirect_to AhoyEmail.invalid_redirect_url
40
+ else
41
+ render plain: "Link expired", status: :not_found
67
42
  end
68
43
  end
69
44
  end
data/config/routes.rb CHANGED
@@ -4,6 +4,9 @@ end
4
4
 
5
5
  AhoyEmail::Engine.routes.draw do
6
6
  scope module: "ahoy" do
7
+ get "click" => "messages#click"
8
+
9
+ # legacy
7
10
  resources :messages, only: [] do
8
11
  get :open, on: :member
9
12
  get :click, on: :member
data/lib/ahoy_email.rb CHANGED
@@ -2,37 +2,51 @@
2
2
  require "active_support"
3
3
  require "addressable/uri"
4
4
  require "nokogiri"
5
- require "openssl"
6
5
  require "safely/core"
7
6
 
7
+ # stdlib
8
+ require "openssl"
9
+
8
10
  # modules
9
11
  require "ahoy_email/processor"
10
12
  require "ahoy_email/tracker"
11
13
  require "ahoy_email/observer"
12
14
  require "ahoy_email/mailer"
15
+ require "ahoy_email/utils"
13
16
  require "ahoy_email/version"
17
+
18
+ # subscribers
19
+ require "ahoy_email/message_subscriber"
20
+ require "ahoy_email/redis_subscriber"
21
+
22
+ # integrations
14
23
  require "ahoy_email/engine" if defined?(Rails)
15
24
 
16
25
  module AhoyEmail
17
- mattr_accessor :secret_token, :default_options, :subscribers, :invalid_redirect_url, :track_method, :api, :preserve_callbacks
26
+ mattr_accessor :secret_token, :default_options, :subscribers, :invalid_redirect_url, :track_method, :api, :preserve_callbacks, :save_token
18
27
  mattr_writer :message_model
19
28
 
20
29
  self.api = false
21
30
 
22
31
  self.default_options = {
23
- message: true,
24
- open: false,
25
- click: false,
32
+ # message history
33
+ message: false,
34
+ user: -> { (defined?(@user) && @user) || (respond_to?(:params) && params && params[:user]) || (message.to.try(:size) == 1 ? (User.find_by(email: message.to.first) rescue nil) : nil) },
35
+ mailer: -> { "#{self.class.name}##{action_name}" },
36
+ extra: {},
37
+
38
+ # utm params
26
39
  utm_params: false,
27
40
  utm_source: -> { mailer_name },
28
41
  utm_medium: "email",
29
42
  utm_term: nil,
30
43
  utm_content: nil,
31
44
  utm_campaign: -> { action_name },
32
- user: -> { (defined?(@user) && @user) || (respond_to?(:params) && params && params[:user]) || (message.to.try(:size) == 1 ? (User.find_by(email: message.to.first) rescue nil) : nil) },
33
- mailer: -> { "#{self.class.name}##{action_name}" },
45
+
46
+ # click analytics
47
+ click: false,
48
+ campaign: nil,
34
49
  url_options: {},
35
- extra: {},
36
50
  unsubscribe_links: false
37
51
  }
38
52
 
@@ -52,6 +66,7 @@ module AhoyEmail
52
66
  end
53
67
 
54
68
  ahoy_message.token = data[:token] if ahoy_message.respond_to?(:token=)
69
+ ahoy_message.campaign = data[:campaign] if ahoy_message.respond_to?(:campaign=)
55
70
 
56
71
  ahoy_message.assign_attributes(data[:extra] || {})
57
72
 
@@ -61,6 +76,8 @@ module AhoyEmail
61
76
  ahoy_message
62
77
  end
63
78
 
79
+ self.save_token = false
80
+
64
81
  self.subscribers = []
65
82
 
66
83
  self.preserve_callbacks = []
@@ -72,10 +89,17 @@ module AhoyEmail
72
89
  model = model.call if model.respond_to?(:call)
73
90
  model
74
91
  end
92
+
93
+ # shortcut for first subscriber with stats method
94
+ def self.stats(*args)
95
+ subscriber = subscribers.find { |s| s.is_a?(Class) ? s.method_defined?(:stats) : s.respond_to?(:stats) }
96
+ subscriber = subscriber.new if subscriber.is_a?(Class)
97
+ subscriber.stats(*args) if subscriber
98
+ end
75
99
  end
76
100
 
77
101
  ActiveSupport.on_load(:action_mailer) do
78
102
  include AhoyEmail::Mailer
79
103
  register_observer AhoyEmail::Observer
80
- Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message)
104
+ Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message, :ahoy_options)
81
105
  end
@@ -8,15 +8,35 @@ module AhoyEmail
8
8
  end
9
9
 
10
10
  class_methods do
11
- def track(**options)
12
- before_action(options.slice(:only, :except)) do
13
- self.ahoy_options = ahoy_options.merge(message: true).merge(options.except(:only, :except))
14
- end
11
+ def has_history(**options)
12
+ set_ahoy_options(options, :message)
15
13
  end
16
- end
17
14
 
18
- def track(**options)
19
- self.ahoy_options = ahoy_options.merge(message: true).merge(options)
15
+ def utm_params(**options)
16
+ set_ahoy_options(options, :utm_params)
17
+ end
18
+
19
+ def track_clicks(**options)
20
+ raise ArgumentError, "missing keyword: :campaign" unless options.key?(:campaign)
21
+ set_ahoy_options(options, :click)
22
+ end
23
+
24
+ private
25
+
26
+ def set_ahoy_options(options, key)
27
+ allowed_keywords = AhoyEmail::Utils::OPTION_KEYS[key]
28
+ action_keywords = [:only, :except, :if, :unless]
29
+
30
+ unknown_keywords = options.keys - allowed_keywords - action_keywords
31
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.map(&:inspect).join(", ")}" if unknown_keywords.any?
32
+
33
+ # use before_action, since after_action reverses order
34
+ # https://github.com/rails/rails/issues/27261
35
+ # callable options aren't run until save_ahoy_options after_action
36
+ before_action(options.slice(*action_keywords)) do
37
+ self.ahoy_options = ahoy_options.merge(key => true).merge(options.slice(*allowed_keywords))
38
+ end
39
+ end
20
40
  end
21
41
 
22
42
  def ahoy_options
@@ -25,20 +45,28 @@ module AhoyEmail
25
45
 
26
46
  def save_ahoy_options
27
47
  Safely.safely do
28
- # do message first for performance
29
- message = ahoy_options[:message]
30
- message = message.respond_to?(:call) ? instance_exec(&message) : message
31
-
32
- if message
33
- options = {}
34
- ahoy_options.except(:message).each do |k, v|
35
- # execute options in mailer content
36
- options[k] = v.respond_to?(:call) ? instance_exec(&v) : v
37
- end
48
+ options = {}
49
+ call_ahoy_options(options, :message)
50
+ call_ahoy_options(options, :utm_params)
51
+ call_ahoy_options(options, :click)
38
52
 
53
+ if options[:message] || options[:utm_params] || options[:click]
39
54
  AhoyEmail::Processor.new(self, options).perform
40
55
  end
41
56
  end
42
57
  end
58
+
59
+ def call_ahoy_options(options, key)
60
+ v = ahoy_options[key]
61
+ options[key] = v.respond_to?(:call) ? instance_exec(&v) : v
62
+
63
+ # only call other options if needed
64
+ if options[key]
65
+ AhoyEmail::Utils::OPTION_KEYS[key].each do |k|
66
+ v = ahoy_options[k]
67
+ options[k] = v.respond_to?(:call) ? instance_exec(&v) : v
68
+ end
69
+ end
70
+ end
43
71
  end
44
72
  end
@@ -0,0 +1,12 @@
1
+ module AhoyEmail
2
+ class MessageSubscriber
3
+ def track_click(event)
4
+ message = AhoyEmail.message_model.find_by(token: event[:token])
5
+ if message
6
+ message.clicked ||= true if message.respond_to?(:clicked=)
7
+ message.clicked_at ||= Time.now if message.respond_to?(:clicked_at=)
8
+ message.save! if message.changed?
9
+ end
10
+ end
11
+ end
12
+ end
@@ -13,9 +13,9 @@ module AhoyEmail
13
13
  end
14
14
 
15
15
  def perform
16
- track_open if options[:open]
17
16
  track_links if options[:utm_params] || options[:click]
18
- track_message
17
+ track_message if options[:message]
18
+ message.ahoy_options = options
19
19
  end
20
20
 
21
21
  protected
@@ -35,16 +35,9 @@ module AhoyEmail
35
35
  user: options[:user]
36
36
  }
37
37
 
38
- # legacy, remove in next major version
39
- user = options[:user]
40
- if user
41
- data[:user_type] = user.model_name.name
42
- id = user.id
43
- data[:user_id] = id.is_a?(Integer) ? id : id.to_s
44
- end
45
-
46
- if options[:open] || options[:click]
47
- data[:token] = token
38
+ if options[:click]
39
+ data[:token] = token if AhoyEmail.save_token
40
+ data[:campaign] = campaign
48
41
  end
49
42
 
50
43
  if options[:utm_params]
@@ -56,36 +49,11 @@ module AhoyEmail
56
49
  mailer.message.ahoy_data = data
57
50
  end
58
51
 
59
- def track_open
60
- if html_part?
61
- part = message.html_part || message
62
- raw_source = part.body.raw_source
63
-
64
- regex = /<\/body>/i
65
- url =
66
- url_for(
67
- controller: "ahoy/messages",
68
- action: "open",
69
- id: token,
70
- format: "gif"
71
- )
72
- pixel = ActionController::Base.helpers.image_tag(url, size: "1x1", alt: "")
73
-
74
- # try to add before body tag
75
- if raw_source.match(regex)
76
- part.body = raw_source.gsub(regex, "#{pixel}\\0")
77
- else
78
- part.body = raw_source + pixel
79
- end
80
- end
81
- end
82
-
83
52
  def track_links
84
53
  if html_part?
85
54
  part = message.html_part || message
86
55
 
87
- # TODO use Nokogiri::HTML::DocumentFragment.parse in 2.0
88
- doc = Nokogiri::HTML(part.body.raw_source)
56
+ doc = Nokogiri::HTML::DocumentFragment.parse(part.body.raw_source)
89
57
  doc.css("a[href]").each do |link|
90
58
  uri = parse_uri(link["href"])
91
59
  next unless trackable?(uri)
@@ -101,17 +69,15 @@ module AhoyEmail
101
69
  end
102
70
 
103
71
  if options[:click] && !skip_attribute?(link, "click")
104
- raise "Secret token is empty" unless AhoyEmail.secret_token
105
-
106
- # TODO sign more than just url and transition to HMAC-SHA256
107
- signature = OpenSSL::HMAC.hexdigest("SHA1", AhoyEmail.secret_token, link["href"])
72
+ signature = Utils.signature(token: token, campaign: campaign, url: link["href"])
108
73
  link["href"] =
109
74
  url_for(
110
75
  controller: "ahoy/messages",
111
76
  action: "click",
112
- id: token,
113
- url: link["href"],
114
- signature: signature
77
+ t: token,
78
+ c: campaign,
79
+ u: link["href"],
80
+ s: signature
115
81
  )
116
82
  end
117
83
  end
@@ -162,5 +128,10 @@ module AhoyEmail
162
128
  .merge(opt)
163
129
  AhoyEmail::Engine.routes.url_helpers.url_for(opt)
164
130
  end
131
+
132
+ # return nil if false
133
+ def campaign
134
+ options[:campaign] || nil
135
+ end
165
136
  end
166
137
  end
@@ -0,0 +1,79 @@
1
+ module AhoyEmail
2
+ class RedisSubscriber
3
+ attr_reader :redis, :prefix
4
+
5
+ def initialize(redis: nil, prefix: "ahoy_email")
6
+ @redis = redis || Redis.new
7
+ @prefix = prefix
8
+ end
9
+
10
+ def track_send(event)
11
+ campaign_prefix = campaign_key(event[:campaign])
12
+ redis.pipelined do
13
+ redis.incr("#{campaign_prefix}:sends")
14
+ redis.sadd(campaigns_key, event[:campaign])
15
+ end
16
+ end
17
+
18
+ def track_click(event)
19
+ campaign_prefix = campaign_key(event[:campaign])
20
+ redis.pipelined do
21
+ redis.incr("#{campaign_prefix}:clicks")
22
+ redis.pfadd("#{campaign_prefix}:unique_clicks", event[:token])
23
+ end
24
+ end
25
+
26
+ def stats(campaign = nil)
27
+ if campaign
28
+ # return nil instead of zeros if not a campaign
29
+ if campaign_exists?(campaign)
30
+ campaign_stats(campaign)
31
+ end
32
+ else
33
+ campaigns.inject({}) do |memo, campaign|
34
+ memo[campaign] = campaign_stats(campaign)
35
+ memo
36
+ end
37
+ end
38
+ end
39
+
40
+ def campaigns
41
+ redis.smembers(campaigns_key)
42
+ end
43
+
44
+ def campaign_exists?(campaign)
45
+ redis.sismember(campaigns_key, campaign)
46
+ end
47
+
48
+ private
49
+
50
+ def campaigns_key
51
+ "#{prefix}:campaigns"
52
+ end
53
+
54
+ def campaign_key(campaign)
55
+ "#{prefix}:campaigns:#{campaign}"
56
+ end
57
+
58
+ def campaign_stats(campaign)
59
+ # scope
60
+ sends = nil
61
+ clicks = nil
62
+ unique_clicks = nil
63
+
64
+ campaign_prefix = campaign_key(campaign)
65
+ redis.pipelined do
66
+ sends = redis.get("#{campaign_prefix}:sends")
67
+ clicks = redis.get("#{campaign_prefix}:clicks")
68
+ unique_clicks = redis.pfcount("#{campaign_prefix}:unique_clicks")
69
+ end
70
+
71
+ {
72
+ sends: sends.value.to_i,
73
+ clicks: clicks.value.to_i,
74
+ unique_clicks: unique_clicks.value,
75
+ ctr: (100.0 * unique_clicks.value / sends.value.to_f).round(1)
76
+ }
77
+ end
78
+ end
79
+ end