mixpanel-mail 0.1.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.
- 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
|