mixpanel-mail 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +34 -0
- data/Rakefile +5 -0
- data/lib/action_mailer/mixpanel_interceptor.rb +53 -0
- data/lib/mixpanel-mail.rb +64 -0
- data/lib/mixpanel_mail/vendor/active_support.rb +7 -0
- data/lib/mixpanel_mail/vendor/active_support/core_ext/hash/keys.rb +47 -0
- data/lib/mixpanel_mail/vendor/active_support/core_ext/hash/slice.rb +40 -0
- data/lib/mixpanel_mail/version.rb +5 -0
- data/mixpanel-mail.gemspec +26 -0
- data/spec/mixpanel/mail_spec.rb +112 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/support/webmock.rb +10 -0
- metadata +73 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
mixpanel-mail (0.1.0)
|
5
|
+
multi_json
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: http://rubygems.org/
|
9
|
+
specs:
|
10
|
+
addressable (2.2.6)
|
11
|
+
crack (0.3.1)
|
12
|
+
diff-lcs (1.1.3)
|
13
|
+
multi_json (1.0.3)
|
14
|
+
rake (0.9.2)
|
15
|
+
rspec (2.6.0)
|
16
|
+
rspec-core (~> 2.6.0)
|
17
|
+
rspec-expectations (~> 2.6.0)
|
18
|
+
rspec-mocks (~> 2.6.0)
|
19
|
+
rspec-core (2.6.4)
|
20
|
+
rspec-expectations (2.6.0)
|
21
|
+
diff-lcs (~> 1.1.2)
|
22
|
+
rspec-mocks (2.6.0)
|
23
|
+
webmock (1.7.6)
|
24
|
+
addressable (> 2.2.5, ~> 2.2)
|
25
|
+
crack (>= 0.1.7)
|
26
|
+
|
27
|
+
PLATFORMS
|
28
|
+
ruby
|
29
|
+
|
30
|
+
DEPENDENCIES
|
31
|
+
mixpanel-mail!
|
32
|
+
rake
|
33
|
+
rspec (~> 2.6.0)
|
34
|
+
webmock (~> 1.7.4)
|
data/Rakefile
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
unless defined?(ActionMailer)
|
2
|
+
raise StandardError, "ActionMailer is not loaded"
|
3
|
+
end
|
4
|
+
|
5
|
+
require 'mixpanel-mail'
|
6
|
+
require 'digest/md5'
|
7
|
+
|
8
|
+
module ActionMailer
|
9
|
+
class MixpanelInterceptor
|
10
|
+
cattr_accessor :token
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def delivering_email(mail)
|
14
|
+
# Skip Mixpanel if the campaign is not specified
|
15
|
+
return unless mail.header['mp_campaign']
|
16
|
+
|
17
|
+
# Skip Mixpanel if we don't have HTML
|
18
|
+
html = mail.html_part ? mail.html_part.body : nil
|
19
|
+
return unless html.present?
|
20
|
+
|
21
|
+
# Convert header options to mixpanel options
|
22
|
+
opts = ::Mixpanel::Mail::OPTIONS.inject({}) do |sum, key|
|
23
|
+
if value = pop_mp_header(mail, key)
|
24
|
+
sum[key] = value
|
25
|
+
end
|
26
|
+
sum
|
27
|
+
end
|
28
|
+
|
29
|
+
# Generate email distinct_id for Mixpanel
|
30
|
+
id = Digest::MD5.hexdigest(mail.header['To'].to_s)
|
31
|
+
|
32
|
+
begin
|
33
|
+
mail.html_part.body = mp_mail.add_tracking(id, html, opts)
|
34
|
+
rescue => e
|
35
|
+
Rails.logger.warn("Failed to Mixpanelize Mail: #{e}")
|
36
|
+
mail.html_part.body = html
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def mp_mail
|
42
|
+
@mixpanel_mail ||= Mixpanel::Mail.new(token, 'default')
|
43
|
+
end
|
44
|
+
|
45
|
+
def pop_mp_header(mail, key)
|
46
|
+
header_key = "mp_#{key}"
|
47
|
+
value = mail.header[header_key]
|
48
|
+
mail.header[header_key] = nil if value
|
49
|
+
value
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require "mixpanel_mail/vendor/active_support"
|
2
|
+
require "mixpanel_mail/version"
|
3
|
+
require 'net/http'
|
4
|
+
require 'multi_json'
|
5
|
+
require 'uri'
|
6
|
+
|
7
|
+
#
|
8
|
+
# Adopted from http://mixpanel.com/api/docs/guides/email-analytics
|
9
|
+
#
|
10
|
+
|
11
|
+
module Mixpanel
|
12
|
+
class Mail
|
13
|
+
ENDPOINT = 'http://api.mixpanel.com/email'
|
14
|
+
ENDPOINT_URI = URI.parse(ENDPOINT)
|
15
|
+
OPTIONS = %w(campaign type properties redirect_host click_tracking)
|
16
|
+
|
17
|
+
attr_accessor :params
|
18
|
+
|
19
|
+
def initialize(token, campaign, options = {})
|
20
|
+
@params = {}
|
21
|
+
params['token'] = token
|
22
|
+
params['campaign'] = campaign
|
23
|
+
params.merge!(groom_options(options))
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_tracking(distinct_id, body, options = {})
|
27
|
+
p = params.dup()
|
28
|
+
p['distinct_id'] = distinct_id
|
29
|
+
p['body'] = body
|
30
|
+
p.merge!(groom_options(options)) unless options.empty?
|
31
|
+
response = Net::HTTP.post_form(::Mixpanel::Mail::ENDPOINT_URI, p)
|
32
|
+
case response
|
33
|
+
when Net::HTTPSuccess
|
34
|
+
response.body
|
35
|
+
else
|
36
|
+
response.error!
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def groom_options(options)
|
42
|
+
opts = options.dup
|
43
|
+
opts.stringify_keys!
|
44
|
+
|
45
|
+
# Limit request to include only valid options
|
46
|
+
opts.slice!(*OPTIONS)
|
47
|
+
|
48
|
+
# Default type is HTML, so we only allow TEXT
|
49
|
+
opts.delete('type') unless opts['type'] == 'text'
|
50
|
+
|
51
|
+
# Marshal properties as JSON
|
52
|
+
if opts['properties']
|
53
|
+
opts['properties'] = MultiJson.encode(opts['properties'])
|
54
|
+
end
|
55
|
+
|
56
|
+
# Click tracking is enabled by default
|
57
|
+
if opts['click_tracking'] == false
|
58
|
+
opts['click_tracking'] = '0'
|
59
|
+
end
|
60
|
+
|
61
|
+
opts
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
class Hash
|
2
|
+
# Return a new hash with all keys converted to strings.
|
3
|
+
def stringify_keys
|
4
|
+
dup.stringify_keys!
|
5
|
+
end
|
6
|
+
|
7
|
+
# Destructively convert all keys to strings.
|
8
|
+
def stringify_keys!
|
9
|
+
keys.each do |key|
|
10
|
+
self[key.to_s] = delete(key)
|
11
|
+
end
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
# Return a new hash with all keys converted to symbols, as long as
|
16
|
+
# they respond to +to_sym+.
|
17
|
+
def symbolize_keys
|
18
|
+
dup.symbolize_keys!
|
19
|
+
end
|
20
|
+
|
21
|
+
# Destructively convert all keys to symbols, as long as they respond
|
22
|
+
# to +to_sym+.
|
23
|
+
def symbolize_keys!
|
24
|
+
keys.each do |key|
|
25
|
+
self[(key.to_sym rescue key) || key] = delete(key)
|
26
|
+
end
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method :to_options, :symbolize_keys
|
31
|
+
alias_method :to_options!, :symbolize_keys!
|
32
|
+
|
33
|
+
# Validate all keys in a hash match *valid keys, raising ArgumentError on a mismatch.
|
34
|
+
# Note that keys are NOT treated indifferently, meaning if you use strings for keys but assert symbols
|
35
|
+
# as keys, this will fail.
|
36
|
+
#
|
37
|
+
# ==== Examples
|
38
|
+
# { :name => "Rob", :years => "28" }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: years"
|
39
|
+
# { :name => "Rob", :age => "28" }.assert_valid_keys("name", "age") # => raises "ArgumentError: Unknown key: name"
|
40
|
+
# { :name => "Rob", :age => "28" }.assert_valid_keys(:name, :age) # => passes, raises nothing
|
41
|
+
def assert_valid_keys(*valid_keys)
|
42
|
+
valid_keys.flatten!
|
43
|
+
each_key do |k|
|
44
|
+
raise(ArgumentError, "Unknown key: #{k}") unless valid_keys.include?(k)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class Hash
|
2
|
+
# Slice a hash to include only the given keys. This is useful for
|
3
|
+
# limiting an options hash to valid keys before passing to a method:
|
4
|
+
#
|
5
|
+
# def search(criteria = {})
|
6
|
+
# assert_valid_keys(:mass, :velocity, :time)
|
7
|
+
# end
|
8
|
+
#
|
9
|
+
# search(options.slice(:mass, :velocity, :time))
|
10
|
+
#
|
11
|
+
# If you have an array of keys you want to limit to, you should splat them:
|
12
|
+
#
|
13
|
+
# valid_keys = [:mass, :velocity, :time]
|
14
|
+
# search(options.slice(*valid_keys))
|
15
|
+
def slice(*keys)
|
16
|
+
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
|
17
|
+
hash = self.class.new
|
18
|
+
keys.each { |k| hash[k] = self[k] if has_key?(k) }
|
19
|
+
hash
|
20
|
+
end
|
21
|
+
|
22
|
+
# Replaces the hash with only the given keys.
|
23
|
+
# Returns a hash contained the removed key/value pairs
|
24
|
+
# {:a => 1, :b => 2, :c => 3, :d => 4}.slice!(:a, :b) # => {:c => 3, :d => 4}
|
25
|
+
def slice!(*keys)
|
26
|
+
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
|
27
|
+
omit = slice(*self.keys - keys)
|
28
|
+
hash = slice(*keys)
|
29
|
+
replace(hash)
|
30
|
+
omit
|
31
|
+
end
|
32
|
+
|
33
|
+
# Removes and returns the key/value pairs matching the given keys.
|
34
|
+
# {:a => 1, :b => 2, :c => 3, :d => 4}.extract!(:a, :b) # => {:a => 1, :b => 2}
|
35
|
+
def extract!(*keys)
|
36
|
+
result = {}
|
37
|
+
keys.each {|key| result[key] = delete(key) }
|
38
|
+
result
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "mixpanel_mail/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "mixpanel-mail"
|
7
|
+
s.version = Mixpanel::Mail::VERSION
|
8
|
+
s.authors = ["Michael Rykov"]
|
9
|
+
s.email = ["mrykov@gmail.com"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Helpers for Mixpanel Tracking in Emails}
|
12
|
+
s.description = %q{Helpers for Mixpanel Tracking in Emails}
|
13
|
+
|
14
|
+
s.rubyforge_project = "mixpanel-mail"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
# Runtime depencendies
|
22
|
+
s.add_runtime_dependency "multi_json"
|
23
|
+
|
24
|
+
# Development dependencies
|
25
|
+
# See Gemfile
|
26
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Mixpanel::Mail do
|
4
|
+
TOKEN, CAMPAIGN = 'abcd123', 'my-email'
|
5
|
+
|
6
|
+
it 'should intialize token & campaign' do
|
7
|
+
lambda {
|
8
|
+
mail = mp_mail
|
9
|
+
mail.params['token'].should eq(TOKEN)
|
10
|
+
mail.params['campaign'].should eq(CAMPAIGN)
|
11
|
+
}.should_not raise_exception
|
12
|
+
end
|
13
|
+
|
14
|
+
describe 'options grooming' do
|
15
|
+
it 'should filter out bad options' do
|
16
|
+
mp_option_check('bad', 'woot' => nil)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should recognize "type" option' do
|
20
|
+
mp_option_check('type', {
|
21
|
+
'text' => 'text',
|
22
|
+
'html' => nil,
|
23
|
+
'bad_bad' => nil
|
24
|
+
})
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should recognize "click_tracking" option' do
|
28
|
+
mp_option_check('click_tracking', {
|
29
|
+
true => true,
|
30
|
+
false => '0'
|
31
|
+
})
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should recognize "properties" option' do
|
35
|
+
example = { :hello => :world }
|
36
|
+
mp_option_check('properties', {
|
37
|
+
nil => nil,
|
38
|
+
{} => '{}',
|
39
|
+
example => MultiJson.encode(example)
|
40
|
+
})
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should recognize "redirect_host" option' do
|
44
|
+
mp_option_check('redirect_host', {
|
45
|
+
nil => nil, 'anything' => 'anything'
|
46
|
+
})
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should recognize "campaign" option' do
|
50
|
+
mp_option_check('campaign', 'alt-campaign' => 'alt-campaign')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
describe 'add tracking' do
|
55
|
+
it 'should make a simple request' do
|
56
|
+
verify_request_with_options
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'should make a request with "type" request' do
|
60
|
+
verify_request_with_options(
|
61
|
+
{ :type => 'text' },
|
62
|
+
{ 'type' => 'text' }
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'should make a request with "click_tracking" request' do
|
67
|
+
verify_request_with_options(
|
68
|
+
{ :click_tracking => false },
|
69
|
+
{ 'click_tracking' => '0' }
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'should make a request with "properties" request' do
|
74
|
+
props = { :foo => 'bar' }
|
75
|
+
verify_request_with_options(
|
76
|
+
{ :properties => props },
|
77
|
+
{ 'properties' => MultiJson.encode(props) }
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'should make a request with "redirect_host" request' do
|
82
|
+
verify_request_with_options(
|
83
|
+
{ :redirect_host => 'http://www.google.com' },
|
84
|
+
{ 'redirect_host' => 'http://www.google.com' }
|
85
|
+
)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
def mp_mail(options = {})
|
91
|
+
::Mixpanel::Mail.new(TOKEN, CAMPAIGN, options)
|
92
|
+
end
|
93
|
+
|
94
|
+
def mp_option_check(key, value_expectations = {})
|
95
|
+
value_expectations.each do |value, expectation|
|
96
|
+
mp_mail(key => value).params[key].should eq(expectation)
|
97
|
+
mp_mail(key.to_sym => value).params[key].should eq(expectation)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def verify_request_with_options(options = {}, expectations = {})
|
102
|
+
stub_post
|
103
|
+
mp_mail(options).add_tracking('my-dist-id', 'Hello World!')
|
104
|
+
mp_mail.add_tracking('my-dist-id', 'Hello World!', options)
|
105
|
+
a_post.with(:body => expectations.merge(
|
106
|
+
'token' => TOKEN,
|
107
|
+
'campaign' => CAMPAIGN,
|
108
|
+
'distinct_id' => 'my-dist-id',
|
109
|
+
'body' => 'Hello World!'
|
110
|
+
)).should have_been_made.twice
|
111
|
+
end
|
112
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'webmock/rspec'
|
2
|
+
require 'mixpanel-mail'
|
3
|
+
|
4
|
+
# Requires supporting ruby files with custom matchers and macros, etc,
|
5
|
+
# in spec/support/ and its subdirectories.
|
6
|
+
Dir[File.expand_path("../support/**/*.rb", __FILE__)].each { |f| require f }
|
7
|
+
|
8
|
+
RSpec.configure do |config|
|
9
|
+
# == Mock Framework
|
10
|
+
config.mock_with :rspec
|
11
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Define a_get, a_post, etc and stub_get, stub_post, etc
|
2
|
+
[:delete, :get, :post, :put].each do |method|
|
3
|
+
self.class.send(:define_method, "a_#{method}") do
|
4
|
+
a_request(method, Mixpanel::Mail::ENDPOINT)
|
5
|
+
end
|
6
|
+
|
7
|
+
self.class.send(:define_method, "stub_#{method}") do
|
8
|
+
stub_request(method, Mixpanel::Mail::ENDPOINT)
|
9
|
+
end
|
10
|
+
end
|
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mixpanel-mail
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Michael Rykov
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-09-27 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: multi_json
|
16
|
+
requirement: &70183731286560 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70183731286560
|
25
|
+
description: Helpers for Mixpanel Tracking in Emails
|
26
|
+
email:
|
27
|
+
- mrykov@gmail.com
|
28
|
+
executables: []
|
29
|
+
extensions: []
|
30
|
+
extra_rdoc_files: []
|
31
|
+
files:
|
32
|
+
- .gitignore
|
33
|
+
- Gemfile
|
34
|
+
- Gemfile.lock
|
35
|
+
- Rakefile
|
36
|
+
- lib/action_mailer/mixpanel_interceptor.rb
|
37
|
+
- lib/mixpanel-mail.rb
|
38
|
+
- lib/mixpanel_mail/vendor/active_support.rb
|
39
|
+
- lib/mixpanel_mail/vendor/active_support/core_ext/hash/keys.rb
|
40
|
+
- lib/mixpanel_mail/vendor/active_support/core_ext/hash/slice.rb
|
41
|
+
- lib/mixpanel_mail/version.rb
|
42
|
+
- mixpanel-mail.gemspec
|
43
|
+
- spec/mixpanel/mail_spec.rb
|
44
|
+
- spec/spec_helper.rb
|
45
|
+
- spec/support/webmock.rb
|
46
|
+
homepage: ''
|
47
|
+
licenses: []
|
48
|
+
post_install_message:
|
49
|
+
rdoc_options: []
|
50
|
+
require_paths:
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ! '>='
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
none: false
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
requirements: []
|
65
|
+
rubyforge_project: mixpanel-mail
|
66
|
+
rubygems_version: 1.8.10
|
67
|
+
signing_key:
|
68
|
+
specification_version: 3
|
69
|
+
summary: Helpers for Mixpanel Tracking in Emails
|
70
|
+
test_files:
|
71
|
+
- spec/mixpanel/mail_spec.rb
|
72
|
+
- spec/spec_helper.rb
|
73
|
+
- spec/support/webmock.rb
|