ahoy_email 1.0.3 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -22
  3. data/LICENSE.txt +1 -1
  4. data/README.md +181 -135
  5. data/app/controllers/ahoy/messages_controller.rb +27 -45
  6. data/app/models/ahoy/click.rb +5 -0
  7. data/app/models/ahoy/message.rb +1 -1
  8. data/config/routes.rb +3 -0
  9. data/lib/ahoy_email.rb +34 -9
  10. data/lib/ahoy_email/database_subscriber.rb +42 -0
  11. data/lib/ahoy_email/engine.rb +3 -1
  12. data/lib/ahoy_email/mailer.rb +45 -17
  13. data/lib/ahoy_email/message_subscriber.rb +12 -0
  14. data/lib/ahoy_email/processor.rb +24 -43
  15. data/lib/ahoy_email/redis_subscriber.rb +79 -0
  16. data/lib/ahoy_email/tracker.rb +9 -3
  17. data/lib/ahoy_email/utils.rb +35 -0
  18. data/lib/ahoy_email/version.rb +1 -1
  19. data/lib/generators/ahoy/clicks/activerecord_generator.rb +20 -0
  20. data/lib/generators/ahoy/clicks/mongoid_generator.rb +15 -0
  21. data/lib/generators/ahoy/clicks/templates/migration.rb.tt +11 -0
  22. data/lib/generators/ahoy/clicks/templates/mongoid.rb.tt +8 -0
  23. data/lib/generators/ahoy/clicks_generator.rb +37 -0
  24. data/lib/generators/ahoy/messages/activerecord_generator.rb +40 -0
  25. data/lib/generators/ahoy/messages/mongoid_generator.rb +21 -0
  26. data/lib/generators/{ahoy_email/templates/install.rb.tt → ahoy/messages/templates/migration.rb.tt} +2 -2
  27. data/lib/generators/ahoy/messages/templates/model_encrypted.rb.tt +8 -0
  28. data/lib/generators/ahoy/messages/templates/mongoid.rb.tt +12 -0
  29. data/lib/generators/ahoy/messages/templates/mongoid_encrypted.rb.tt +16 -0
  30. data/lib/generators/ahoy/messages_generator.rb +39 -0
  31. metadata +29 -113
  32. data/lib/generators/ahoy_email/install_generator.rb +0 -35
@@ -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.try(: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
 
@@ -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,6 +77,8 @@ 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 = []
@@ -72,10 +90,17 @@ module AhoyEmail
72
90
  model = model.call if model.respond_to?(:call)
73
91
  model
74
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
75
100
  end
76
101
 
77
102
  ActiveSupport.on_load(:action_mailer) do
78
103
  include AhoyEmail::Mailer
79
104
  register_observer AhoyEmail::Observer
80
- Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message)
105
+ Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message, :ahoy_options)
81
106
  end
@@ -0,0 +1,42 @@
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
+
14
+ if defined?(ActiveRecord) && Ahoy::Click < ActiveRecord::Base
15
+ result = Ahoy::Click.where(campaign: campaign).select("COUNT(*) AS clicks, COUNT(DISTINCT token) AS unique_clicks").to_a[0]
16
+ clicks = result.clicks
17
+ unique_clicks = result.unique_clicks
18
+ else
19
+ clicks = Ahoy::Click.where(campaign: campaign).count
20
+ # TODO use aggregation framework
21
+ unique_clicks = Ahoy::Click.where(campaign: campaign).distinct(:token).count
22
+ end
23
+
24
+ if sends > 0 || clicks > 0
25
+ {
26
+ sends: sends,
27
+ clicks: clicks,
28
+ unique_clicks: unique_clicks,
29
+ ctr: 100 * unique_clicks / sends.to_f
30
+ }
31
+ end
32
+ end
33
+
34
+ def campaigns
35
+ if defined?(ActiveRecord) && Ahoy::Message < ActiveRecord::Base
36
+ Ahoy::Message.where.not(campaign: nil).distinct.pluck(:campaign)
37
+ else
38
+ Ahoy::Message.where(campaign: {"$ne" => nil}).distinct(:campaign)
39
+ end
40
+ end
41
+ end
42
+ 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
@@ -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,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