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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +159 -147
- data/app/controllers/ahoy/messages_controller.rb +24 -49
- data/config/routes.rb +3 -0
- data/lib/ahoy_email.rb +33 -9
- data/lib/ahoy_email/mailer.rb +45 -17
- data/lib/ahoy_email/message_subscriber.rb +12 -0
- data/lib/ahoy_email/processor.rb +16 -45
- 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/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} +1 -1
- 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 +41 -0
- metadata +12 -88
- data/lib/generators/ahoy_email/install_generator.rb +0 -18
@@ -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
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
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
|
24
|
-
|
25
|
-
|
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
|
-
|
33
|
-
|
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
|
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
|
@@ -25,20 +45,28 @@ module AhoyEmail
|
|
25
45
|
|
26
46
|
def save_ahoy_options
|
27
47
|
Safely.safely do
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
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,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
|
-
|
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
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
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
|