ahoy_email 1.0.2 → 2.0.1

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.
@@ -1,62 +1,44 @@
1
1
  module Ahoy
2
2
  class MessagesController < ApplicationController
3
3
  filters = _process_action_callbacks.map(&:filter) - AhoyEmail.preserve_callbacks
4
- if Rails::VERSION::MAJOR >= 5
5
- skip_before_action(*filters, raise: false)
6
- skip_after_action(*filters, raise: false)
7
- skip_around_action(*filters, raise: false)
8
- else
9
- skip_action_callback *filters
10
- end
11
-
12
- before_action :set_message
4
+ skip_before_action(*filters, raise: false)
5
+ skip_after_action(*filters, raise: false)
6
+ skip_around_action(*filters, raise: false)
13
7
 
8
+ # legacy
14
9
  def open
15
- if @message && !@message.opened_at
16
- @message.opened_at = Time.now
17
- @message.save!
18
- end
19
-
20
- publish :open
21
-
22
10
  send_data Base64.decode64("R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="), type: "image/gif", disposition: "inline"
23
11
  end
24
12
 
25
13
  def click
26
- if @message && !@message.clicked_at
27
- @message.clicked_at = Time.now
28
- @message.opened_at ||= @message.clicked_at
29
- @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)
30
26
  end
31
27
 
32
- user_signature = params[:signature].to_s
33
- url = params[:url].to_s
34
-
35
- # TODO sign more than just url and transition to HMAC-SHA256
36
- digest = "SHA1"
37
- signature = OpenSSL::HMAC.hexdigest(digest, AhoyEmail.secret_token, url)
38
-
39
- if ActiveSupport::SecurityUtils.secure_compare(user_signature, signature)
40
- 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)
41
35
 
42
36
  redirect_to url
43
37
  else
44
- redirect_to AhoyEmail.invalid_redirect_url || main_app.root_url
45
- end
46
- end
47
-
48
- protected
49
-
50
- def set_message
51
- @message = AhoyEmail.message_model.where(token: params[:id]).first
52
- end
53
-
54
- def publish(name, event = {})
55
- AhoyEmail.subscribers.each do |subscriber|
56
- if subscriber.respond_to?(name)
57
- event[:message] = @message
58
- event[:controller] = self
59
- 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
60
42
  end
61
43
  end
62
44
  end
@@ -0,0 +1,5 @@
1
+ module Ahoy
2
+ class Click < ActiveRecord::Base
3
+ self.table_name = "ahoy_clicks"
4
+ end
5
+ end
@@ -2,6 +2,6 @@ module Ahoy
2
2
  class Message < ActiveRecord::Base
3
3
  self.table_name = "ahoy_messages"
4
4
 
5
- belongs_to :user, (ActiveRecord::VERSION::MAJOR >= 5 ? {optional: true} : {}).merge(polymorphic: true)
5
+ belongs_to :user, polymorphic: true, optional: true
6
6
  end
7
7
  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,52 @@
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/database_subscriber"
20
+ require "ahoy_email/message_subscriber"
21
+ require "ahoy_email/redis_subscriber"
22
+
23
+ # integrations
14
24
  require "ahoy_email/engine" if defined?(Rails)
15
25
 
16
26
  module AhoyEmail
17
- mattr_accessor :secret_token, :default_options, :subscribers, :invalid_redirect_url, :track_method, :api, :preserve_callbacks
27
+ mattr_accessor :secret_token, :default_options, :subscribers, :invalid_redirect_url, :track_method, :api, :preserve_callbacks, :save_token
18
28
  mattr_writer :message_model
19
29
 
20
30
  self.api = false
21
31
 
22
32
  self.default_options = {
23
- message: true,
24
- open: false,
25
- click: false,
33
+ # message history
34
+ message: false,
35
+ 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) },
36
+ mailer: -> { "#{self.class.name}##{action_name}" },
37
+ extra: {},
38
+
39
+ # utm params
26
40
  utm_params: false,
27
41
  utm_source: -> { mailer_name },
28
42
  utm_medium: "email",
29
43
  utm_term: nil,
30
44
  utm_content: nil,
31
45
  utm_campaign: -> { action_name },
32
- user: -> { @user || (respond_to?(:params) && params && params[:user]) || (message.to.size == 1 ? (User.find_by(email: message.to.first) rescue nil) : nil) },
33
- mailer: -> { "#{self.class.name}##{action_name}" },
46
+
47
+ # click analytics
48
+ click: false,
49
+ campaign: nil,
34
50
  url_options: {},
35
- extra: {},
36
51
  unsubscribe_links: false
37
52
  }
38
53
 
@@ -41,7 +56,7 @@ module AhoyEmail
41
56
 
42
57
  ahoy_message = AhoyEmail.message_model.new
43
58
  ahoy_message.to = Array(message.to).join(", ") if ahoy_message.respond_to?(:to=)
44
- ahoy_message.user = data[:user]
59
+ ahoy_message.user = data[:user] if ahoy_message.respond_to?(:user=)
45
60
 
46
61
  ahoy_message.mailer = data[:mailer] if ahoy_message.respond_to?(:mailer=)
47
62
  ahoy_message.subject = message.subject if ahoy_message.respond_to?(:subject=)
@@ -52,6 +67,7 @@ module AhoyEmail
52
67
  end
53
68
 
54
69
  ahoy_message.token = data[:token] if ahoy_message.respond_to?(:token=)
70
+ ahoy_message.campaign = data[:campaign] if ahoy_message.respond_to?(:campaign=)
55
71
 
56
72
  ahoy_message.assign_attributes(data[:extra] || {})
57
73
 
@@ -61,19 +77,30 @@ module AhoyEmail
61
77
  ahoy_message
62
78
  end
63
79
 
80
+ self.save_token = false
81
+
64
82
  self.subscribers = []
65
83
 
66
84
  self.preserve_callbacks = []
67
85
 
86
+ self.message_model = -> { ::Ahoy::Message }
87
+
68
88
  def self.message_model
69
- model = (defined?(@message_model) && @message_model) || ::Ahoy::Message
89
+ model = defined?(@@message_model) && @@message_model
70
90
  model = model.call if model.respond_to?(:call)
71
91
  model
72
92
  end
93
+
94
+ # shortcut for first subscriber with stats method
95
+ def self.stats(*args)
96
+ subscriber = subscribers.find { |s| s.is_a?(Class) ? s.method_defined?(:stats) : s.respond_to?(:stats) }
97
+ subscriber = subscriber.new if subscriber.is_a?(Class)
98
+ subscriber.stats(*args) if subscriber
99
+ end
73
100
  end
74
101
 
75
102
  ActiveSupport.on_load(:action_mailer) do
76
103
  include AhoyEmail::Mailer
77
104
  register_observer AhoyEmail::Observer
78
- Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message)
105
+ Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message, :ahoy_options)
79
106
  end
@@ -0,0 +1,31 @@
1
+ module AhoyEmail
2
+ class DatabaseSubscriber
3
+ def track_send(event)
4
+ # use has_history to store on Ahoy::Messages
5
+ end
6
+
7
+ def track_click(event)
8
+ Ahoy::Click.create!(campaign: event[:campaign], token: event[:token])
9
+ end
10
+
11
+ def stats(campaign)
12
+ sends = Ahoy::Message.where(campaign: campaign).count
13
+ result = Ahoy::Click.where(campaign: campaign).select("COUNT(*) AS clicks, COUNT(DISTINCT token) AS unique_clicks").to_a[0]
14
+ clicks = result.clicks
15
+ unique_clicks = result.unique_clicks
16
+
17
+ if sends > 0 || clicks > 0
18
+ {
19
+ sends: sends,
20
+ clicks: clicks,
21
+ unique_clicks: unique_clicks,
22
+ ctr: 100 * unique_clicks / sends.to_f
23
+ }
24
+ end
25
+ end
26
+
27
+ def campaigns
28
+ Ahoy::Message.where.not(campaign: nil).distinct.pluck(:campaign)
29
+ end
30
+ end
31
+ end
@@ -13,7 +13,9 @@ module AhoyEmail
13
13
  app.config
14
14
  end
15
15
 
16
- creds.respond_to?(:secret_key_base) ? creds.secret_key_base : creds.secret_token
16
+ token = creds.respond_to?(:secret_key_base) ? creds.secret_key_base : creds.secret_token
17
+ token ||= app.secret_key_base # should come first, but need to maintain backward compatibility
18
+ token
17
19
  end
18
20
  end
19
21
  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
@@ -24,17 +44,29 @@ module AhoyEmail
24
44
  end
25
45
 
26
46
  def save_ahoy_options
27
- if ahoy_options[:message]
28
- Safely.safely do
29
- options = {}
30
- ahoy_options.each do |k, v|
31
- # execute options in mailer content
32
- options[k] = v.respond_to?(:call) ? instance_exec(&v) : v
33
- end
47
+ Safely.safely do
48
+ options = {}
49
+ call_ahoy_options(options, :message)
50
+ call_ahoy_options(options, :utm_params)
51
+ call_ahoy_options(options, :click)
34
52
 
53
+ if options[:message] || options[:utm_params] || options[:click]
35
54
  AhoyEmail::Processor.new(self, options).perform
36
55
  end
37
56
  end
38
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
39
71
  end
40
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,33 +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
- raw_source = (message.html_part || message).body.raw_source
62
- regex = /<\/body>/i
63
- url =
64
- url_for(
65
- controller: "ahoy/messages",
66
- action: "open",
67
- id: token,
68
- format: "gif"
69
- )
70
- pixel = ActionController::Base.helpers.image_tag(url, size: "1x1", alt: "")
71
-
72
- # try to add before body tag
73
- if raw_source.match(regex)
74
- raw_source.gsub!(regex, "#{pixel}\\0")
75
- else
76
- raw_source << pixel
77
- end
78
- end
79
- end
80
-
81
52
  def track_links
82
53
  if html_part?
83
- body = (message.html_part || message).body
54
+ part = message.html_part || message
84
55
 
85
- doc = Nokogiri::HTML(body.raw_source)
56
+ doc = Nokogiri::HTML::DocumentFragment.parse(part.body.raw_source)
86
57
  doc.css("a[href]").each do |link|
87
58
  uri = parse_uri(link["href"])
88
59
  next unless trackable?(uri)
@@ -98,21 +69,26 @@ module AhoyEmail
98
69
  end
99
70
 
100
71
  if options[:click] && !skip_attribute?(link, "click")
101
- # TODO sign more than just url and transition to HMAC-SHA256
102
- signature = OpenSSL::HMAC.hexdigest("SHA1", AhoyEmail.secret_token, link["href"])
72
+ signature = Utils.signature(token: token, campaign: campaign, url: link["href"])
103
73
  link["href"] =
104
74
  url_for(
105
75
  controller: "ahoy/messages",
106
76
  action: "click",
107
- id: token,
108
- url: link["href"],
109
- signature: signature
77
+ t: token,
78
+ c: campaign,
79
+ u: link["href"],
80
+ s: signature
110
81
  )
111
82
  end
112
83
  end
113
84
 
114
- # hacky
115
- body.raw_source.sub!(body.raw_source, doc.to_s)
85
+ # ampersands converted to &amp;
86
+ # https://github.com/sparklemotion/nokogiri/issues/1127
87
+ # not ideal, but should be equivalent in html5
88
+ # https://stackoverflow.com/questions/15776556/whats-the-difference-between-and-amp-in-html5
89
+ # escaping technically required before html5
90
+ # https://stackoverflow.com/questions/3705591/do-i-encode-ampersands-in-a-href
91
+ part.body = doc.to_s
116
92
  end
117
93
  end
118
94
 
@@ -152,5 +128,10 @@ module AhoyEmail
152
128
  .merge(opt)
153
129
  AhoyEmail::Engine.routes.url_helpers.url_for(opt)
154
130
  end
131
+
132
+ # return nil if false
133
+ def campaign
134
+ options[:campaign] || nil
135
+ end
155
136
  end
156
137
  end