ahoy_email 1.1.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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