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
@@ -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 * unique_clicks.value / sends.value.to_f
|
76
|
+
}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/ahoy_email/tracker.rb
CHANGED
@@ -9,9 +9,15 @@ module AhoyEmail
|
|
9
9
|
def perform
|
10
10
|
Safely.safely do
|
11
11
|
# perform_deliveries check still needed in observer
|
12
|
-
if message.perform_deliveries
|
13
|
-
|
14
|
-
|
12
|
+
if message.perform_deliveries
|
13
|
+
if message.ahoy_data
|
14
|
+
data = message.ahoy_data.merge(message: message)
|
15
|
+
message.ahoy_message = AhoyEmail.track_method.call(data)
|
16
|
+
end
|
17
|
+
|
18
|
+
if message.ahoy_options && message.ahoy_options[:click]
|
19
|
+
Utils.publish(:send, message.ahoy_options.slice(:campaign))
|
20
|
+
end
|
15
21
|
end
|
16
22
|
end
|
17
23
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module AhoyEmail
|
2
|
+
class Utils
|
3
|
+
OPTION_KEYS = {
|
4
|
+
message: %i(message mailer user extra),
|
5
|
+
utm_params: %i(utm_source utm_medium utm_term utm_content utm_campaign),
|
6
|
+
click: %i(campaign url_options unsubscribe_links)
|
7
|
+
}
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def signature(token:, campaign:, url:)
|
11
|
+
# encode and join with a character outside encoding
|
12
|
+
data = [token, campaign, url].map { |v| Base64.strict_encode64(v.to_s) }.join("|")
|
13
|
+
|
14
|
+
Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", secret_token, data), padding: false)
|
15
|
+
end
|
16
|
+
|
17
|
+
def publish(name, event)
|
18
|
+
method_name = "track_#{name}"
|
19
|
+
AhoyEmail.subscribers.each do |subscriber|
|
20
|
+
subscriber = subscriber.new if subscriber.is_a?(Class)
|
21
|
+
if subscriber.respond_to?(method_name)
|
22
|
+
subscriber.send(method_name, event.dup)
|
23
|
+
elsif name == :click && subscriber.respond_to?(:click)
|
24
|
+
# legacy
|
25
|
+
subscriber.send(:click, event.dup)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def secret_token
|
31
|
+
AhoyEmail.secret_token || (raise "Secret token is empty")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/ahoy_email/version.rb
CHANGED
@@ -0,0 +1,20 @@
|
|
1
|
+
require "rails/generators/active_record"
|
2
|
+
|
3
|
+
module Ahoy
|
4
|
+
module Generators
|
5
|
+
module Clicks
|
6
|
+
class ActiverecordGenerator < Rails::Generators::Base
|
7
|
+
include ActiveRecord::Generators::Migration
|
8
|
+
source_root File.join(__dir__, "templates")
|
9
|
+
|
10
|
+
def copy_migration
|
11
|
+
migration_template "migration.rb", "db/migrate/create_ahoy_clicks.rb", migration_version: migration_version
|
12
|
+
end
|
13
|
+
|
14
|
+
def migration_version
|
15
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def change
|
3
|
+
create_table :ahoy_clicks do |t|
|
4
|
+
t.string :campaign, index: true
|
5
|
+
t.string :token
|
6
|
+
end
|
7
|
+
|
8
|
+
add_column :ahoy_messages, :campaign, :string
|
9
|
+
add_index :ahoy_messages, :campaign
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require "rails/generators/active_record"
|
2
|
+
|
3
|
+
module Ahoy
|
4
|
+
module Generators
|
5
|
+
module Messages
|
6
|
+
class ActiverecordGenerator < Rails::Generators::Base
|
7
|
+
include ActiveRecord::Generators::Migration
|
8
|
+
source_root File.join(__dir__, "templates")
|
9
|
+
|
10
|
+
class_option :unencrypted, type: :boolean
|
11
|
+
|
12
|
+
def copy_migration
|
13
|
+
migration_template "migration.rb", "db/migrate/create_ahoy_messages.rb", migration_version: migration_version
|
14
|
+
end
|
15
|
+
|
16
|
+
def copy_template
|
17
|
+
if encrypted?
|
18
|
+
template "model_encrypted.rb", "app/models/ahoy/message.rb"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def migration_version
|
23
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_column
|
27
|
+
if encrypted?
|
28
|
+
"t.text :to_ciphertext\n t.string :to_bidx, index: true"
|
29
|
+
else
|
30
|
+
"t.string :to, index: true"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def encrypted?
|
35
|
+
!options[:unencrypted]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
|
3
|
+
module Ahoy
|
4
|
+
module Generators
|
5
|
+
module Messages
|
6
|
+
class MongoidGenerator < Rails::Generators::Base
|
7
|
+
source_root File.join(__dir__, "templates")
|
8
|
+
|
9
|
+
class_option :unencrypted, type: :boolean
|
10
|
+
|
11
|
+
def copy_templates
|
12
|
+
if options[:unencrypted]
|
13
|
+
template "mongoid.rb", "app/models/ahoy/message.rb"
|
14
|
+
else
|
15
|
+
template "mongoid_encrypted.rb", "app/models/ahoy/message.rb"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/generators/{ahoy_email/templates/install.rb.tt → ahoy/messages/templates/migration.rb.tt}
RENAMED
@@ -2,10 +2,10 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
|
|
2
2
|
def change
|
3
3
|
create_table :ahoy_messages do |t|
|
4
4
|
t.references :user, polymorphic: true
|
5
|
-
|
5
|
+
<%= to_column %>
|
6
6
|
t.string :mailer
|
7
7
|
t.text :subject
|
8
|
-
t.
|
8
|
+
t.datetime :sent_at
|
9
9
|
end
|
10
10
|
end
|
11
11
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class Ahoy::Message
|
2
|
+
include Mongoid::Document
|
3
|
+
|
4
|
+
belongs_to :user, polymorphic: true, optional: true, index: true
|
5
|
+
|
6
|
+
field :to, type: String
|
7
|
+
field :mailer, type: String
|
8
|
+
field :subject, type: String
|
9
|
+
field :sent_at, type: Time
|
10
|
+
|
11
|
+
index({to: 1})
|
12
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class Ahoy::Message
|
2
|
+
include Mongoid::Document
|
3
|
+
|
4
|
+
belongs_to :user, polymorphic: true, optional: true, index: true
|
5
|
+
|
6
|
+
field :to_ciphertext, type: String
|
7
|
+
field :to_bidx, type: String
|
8
|
+
field :mailer, type: String
|
9
|
+
field :subject, type: String
|
10
|
+
field :sent_at, type: Time
|
11
|
+
|
12
|
+
index({to_bidx: 1})
|
13
|
+
|
14
|
+
encrypts :to
|
15
|
+
blind_index :to
|
16
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
|
3
|
+
module Ahoy
|
4
|
+
module Generators
|
5
|
+
class MessagesGenerator < Rails::Generators::Base
|
6
|
+
class_option :unencrypted, type: :boolean
|
7
|
+
|
8
|
+
def copy_templates
|
9
|
+
activerecord = defined?(ActiveRecord)
|
10
|
+
mongoid = defined?(Mongoid)
|
11
|
+
|
12
|
+
selection =
|
13
|
+
if activerecord && mongoid
|
14
|
+
puts <<-MSG
|
15
|
+
|
16
|
+
Which data store would you like to use?
|
17
|
+
1. Active Record (default)
|
18
|
+
2. Mongoid
|
19
|
+
MSG
|
20
|
+
|
21
|
+
ask(">")
|
22
|
+
elsif activerecord
|
23
|
+
"1"
|
24
|
+
else
|
25
|
+
"2"
|
26
|
+
end
|
27
|
+
|
28
|
+
case selection
|
29
|
+
when "", "1"
|
30
|
+
invoke "ahoy:messages:activerecord"
|
31
|
+
when "2"
|
32
|
+
invoke "ahoy:messages:mongoid"
|
33
|
+
else
|
34
|
+
abort "Error: must enter a number [1-2]"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ahoy_email
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-03-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: actionmailer
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '5'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '5'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: addressable
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -66,106 +66,8 @@ dependencies:
|
|
66
66
|
- - ">="
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: 0.1.1
|
69
|
-
|
70
|
-
|
71
|
-
requirement: !ruby/object:Gem::Requirement
|
72
|
-
requirements:
|
73
|
-
- - ">="
|
74
|
-
- !ruby/object:Gem::Version
|
75
|
-
version: '0'
|
76
|
-
type: :development
|
77
|
-
prerelease: false
|
78
|
-
version_requirements: !ruby/object:Gem::Requirement
|
79
|
-
requirements:
|
80
|
-
- - ">="
|
81
|
-
- !ruby/object:Gem::Version
|
82
|
-
version: '0'
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: rake
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
86
|
-
requirements:
|
87
|
-
- - ">="
|
88
|
-
- !ruby/object:Gem::Version
|
89
|
-
version: '0'
|
90
|
-
type: :development
|
91
|
-
prerelease: false
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
93
|
-
requirements:
|
94
|
-
- - ">="
|
95
|
-
- !ruby/object:Gem::Version
|
96
|
-
version: '0'
|
97
|
-
- !ruby/object:Gem::Dependency
|
98
|
-
name: minitest
|
99
|
-
requirement: !ruby/object:Gem::Requirement
|
100
|
-
requirements:
|
101
|
-
- - ">="
|
102
|
-
- !ruby/object:Gem::Version
|
103
|
-
version: '0'
|
104
|
-
type: :development
|
105
|
-
prerelease: false
|
106
|
-
version_requirements: !ruby/object:Gem::Requirement
|
107
|
-
requirements:
|
108
|
-
- - ">="
|
109
|
-
- !ruby/object:Gem::Version
|
110
|
-
version: '0'
|
111
|
-
- !ruby/object:Gem::Dependency
|
112
|
-
name: activerecord
|
113
|
-
requirement: !ruby/object:Gem::Requirement
|
114
|
-
requirements:
|
115
|
-
- - ">="
|
116
|
-
- !ruby/object:Gem::Version
|
117
|
-
version: '0'
|
118
|
-
type: :development
|
119
|
-
prerelease: false
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
121
|
-
requirements:
|
122
|
-
- - ">="
|
123
|
-
- !ruby/object:Gem::Version
|
124
|
-
version: '0'
|
125
|
-
- !ruby/object:Gem::Dependency
|
126
|
-
name: combustion
|
127
|
-
requirement: !ruby/object:Gem::Requirement
|
128
|
-
requirements:
|
129
|
-
- - ">="
|
130
|
-
- !ruby/object:Gem::Version
|
131
|
-
version: '0'
|
132
|
-
type: :development
|
133
|
-
prerelease: false
|
134
|
-
version_requirements: !ruby/object:Gem::Requirement
|
135
|
-
requirements:
|
136
|
-
- - ">="
|
137
|
-
- !ruby/object:Gem::Version
|
138
|
-
version: '0'
|
139
|
-
- !ruby/object:Gem::Dependency
|
140
|
-
name: rails
|
141
|
-
requirement: !ruby/object:Gem::Requirement
|
142
|
-
requirements:
|
143
|
-
- - ">="
|
144
|
-
- !ruby/object:Gem::Version
|
145
|
-
version: '0'
|
146
|
-
type: :development
|
147
|
-
prerelease: false
|
148
|
-
version_requirements: !ruby/object:Gem::Requirement
|
149
|
-
requirements:
|
150
|
-
- - ">="
|
151
|
-
- !ruby/object:Gem::Version
|
152
|
-
version: '0'
|
153
|
-
- !ruby/object:Gem::Dependency
|
154
|
-
name: sqlite3
|
155
|
-
requirement: !ruby/object:Gem::Requirement
|
156
|
-
requirements:
|
157
|
-
- - ">="
|
158
|
-
- !ruby/object:Gem::Version
|
159
|
-
version: '0'
|
160
|
-
type: :development
|
161
|
-
prerelease: false
|
162
|
-
version_requirements: !ruby/object:Gem::Requirement
|
163
|
-
requirements:
|
164
|
-
- - ">="
|
165
|
-
- !ruby/object:Gem::Version
|
166
|
-
version: '0'
|
167
|
-
description:
|
168
|
-
email: andrew@chartkick.com
|
69
|
+
description:
|
70
|
+
email: andrew@ankane.org
|
169
71
|
executables: []
|
170
72
|
extensions: []
|
171
73
|
extra_rdoc_files: []
|
@@ -175,22 +77,35 @@ files:
|
|
175
77
|
- LICENSE.txt
|
176
78
|
- README.md
|
177
79
|
- app/controllers/ahoy/messages_controller.rb
|
80
|
+
- app/models/ahoy/click.rb
|
178
81
|
- app/models/ahoy/message.rb
|
179
82
|
- config/routes.rb
|
180
83
|
- lib/ahoy_email.rb
|
84
|
+
- lib/ahoy_email/database_subscriber.rb
|
181
85
|
- lib/ahoy_email/engine.rb
|
182
86
|
- lib/ahoy_email/mailer.rb
|
87
|
+
- lib/ahoy_email/message_subscriber.rb
|
183
88
|
- lib/ahoy_email/observer.rb
|
184
89
|
- lib/ahoy_email/processor.rb
|
90
|
+
- lib/ahoy_email/redis_subscriber.rb
|
185
91
|
- lib/ahoy_email/tracker.rb
|
92
|
+
- lib/ahoy_email/utils.rb
|
186
93
|
- lib/ahoy_email/version.rb
|
187
|
-
- lib/generators/
|
188
|
-
- lib/generators/
|
94
|
+
- lib/generators/ahoy/clicks/activerecord_generator.rb
|
95
|
+
- lib/generators/ahoy/clicks/templates/migration.rb.tt
|
96
|
+
- lib/generators/ahoy/clicks_generator.rb
|
97
|
+
- lib/generators/ahoy/messages/activerecord_generator.rb
|
98
|
+
- lib/generators/ahoy/messages/mongoid_generator.rb
|
99
|
+
- lib/generators/ahoy/messages/templates/migration.rb.tt
|
100
|
+
- lib/generators/ahoy/messages/templates/model_encrypted.rb.tt
|
101
|
+
- lib/generators/ahoy/messages/templates/mongoid.rb.tt
|
102
|
+
- lib/generators/ahoy/messages/templates/mongoid_encrypted.rb.tt
|
103
|
+
- lib/generators/ahoy/messages_generator.rb
|
189
104
|
homepage: https://github.com/ankane/ahoy_email
|
190
105
|
licenses:
|
191
106
|
- MIT
|
192
107
|
metadata: {}
|
193
|
-
post_install_message:
|
108
|
+
post_install_message:
|
194
109
|
rdoc_options: []
|
195
110
|
require_paths:
|
196
111
|
- lib
|
@@ -198,16 +113,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
198
113
|
requirements:
|
199
114
|
- - ">="
|
200
115
|
- !ruby/object:Gem::Version
|
201
|
-
version: '2.
|
116
|
+
version: '2.6'
|
202
117
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
203
118
|
requirements:
|
204
119
|
- - ">="
|
205
120
|
- !ruby/object:Gem::Version
|
206
121
|
version: '0'
|
207
122
|
requirements: []
|
208
|
-
|
209
|
-
|
210
|
-
signing_key:
|
123
|
+
rubygems_version: 3.2.3
|
124
|
+
signing_key:
|
211
125
|
specification_version: 4
|
212
|
-
summary:
|
126
|
+
summary: First-party email analytics for Rails
|
213
127
|
test_files: []
|