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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -21
- data/LICENSE.txt +1 -1
- data/README.md +188 -133
- data/app/controllers/ahoy/messages_controller.rb +27 -45
- data/app/models/ahoy/click.rb +5 -0
- data/app/models/ahoy/message.rb +1 -1
- data/config/routes.rb +3 -0
- data/lib/ahoy_email.rb +38 -11
- data/lib/ahoy_email/database_subscriber.rb +31 -0
- data/lib/ahoy_email/engine.rb +3 -1
- data/lib/ahoy_email/mailer.rb +46 -14
- data/lib/ahoy_email/message_subscriber.rb +12 -0
- data/lib/ahoy_email/processor.rb +24 -43
- data/lib/ahoy_email/redis_subscriber.rb +79 -0
- data/lib/ahoy_email/tracker.rb +9 -3
- data/lib/ahoy_email/utils.rb +35 -0
- data/lib/ahoy_email/version.rb +1 -1
- data/lib/generators/ahoy/clicks/activerecord_generator.rb +20 -0
- data/lib/generators/ahoy/clicks/templates/migration.rb.tt +11 -0
- data/lib/generators/ahoy/clicks_generator.rb +11 -0
- data/lib/generators/ahoy/messages/activerecord_generator.rb +40 -0
- data/lib/generators/ahoy/messages/mongoid_generator.rb +21 -0
- data/lib/generators/{ahoy_email/templates/install.rb.tt → ahoy/messages/templates/migration.rb.tt} +2 -2
- data/lib/generators/ahoy/messages/templates/model_encrypted.rb.tt +8 -0
- data/lib/generators/ahoy/messages/templates/mongoid.rb.tt +12 -0
- data/lib/generators/ahoy/messages/templates/mongoid_encrypted.rb.tt +16 -0
- data/lib/generators/ahoy/messages_generator.rb +39 -0
- metadata +27 -113
- 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
|
-
|
5
|
-
|
6
|
-
|
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
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
45
|
-
|
46
|
-
|
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
|
data/app/models/ahoy/message.rb
CHANGED
data/config/routes.rb
CHANGED
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
|
24
|
-
|
25
|
-
|
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
|
-
|
33
|
-
|
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 =
|
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
|
data/lib/ahoy_email/engine.rb
CHANGED
@@ -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
|
data/lib/ahoy_email/mailer.rb
CHANGED
@@ -8,15 +8,35 @@ module AhoyEmail
|
|
8
8
|
end
|
9
9
|
|
10
10
|
class_methods do
|
11
|
-
def
|
12
|
-
|
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
|
-
|
19
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
data/lib/ahoy_email/processor.rb
CHANGED
@@ -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
|
-
|
39
|
-
|
40
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
108
|
-
|
109
|
-
|
77
|
+
t: token,
|
78
|
+
c: campaign,
|
79
|
+
u: link["href"],
|
80
|
+
s: signature
|
110
81
|
)
|
111
82
|
end
|
112
83
|
end
|
113
84
|
|
114
|
-
#
|
115
|
-
|
85
|
+
# ampersands converted to &
|
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
|