boutique 0.0.9 → 0.0.10
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/LICENSE.md +20 -16
- data/README.md +88 -56
- data/boutique +16 -59
- data/config.ru +21 -20
- data/lib/boutique.rb +241 -255
- data/test/app_test.rb +23 -69
- data/test/config_test.rb +14 -12
- data/test/emailer_test.rb +67 -0
- data/test/helper.rb +24 -16
- data/test/model_test.rb +74 -66
- metadata +127 -18
data/LICENSE.md
CHANGED
@@ -1,19 +1,23 @@
|
|
1
|
-
Copyright (
|
1
|
+
Copyright (c) 2012, Hugh Bien
|
2
|
+
All rights reserved.
|
2
3
|
|
3
|
-
|
4
|
-
|
5
|
-
in the Software without restriction, including without limitation the rights
|
6
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
-
copies of the Software, and to permit persons to whom the Software is
|
8
|
-
furnished to do so, subject to the following conditions:
|
4
|
+
Redistribution and use in source and binary forms, with or without modification,
|
5
|
+
are permitted provided that the following conditions are met:
|
9
6
|
|
10
|
-
|
11
|
-
|
7
|
+
Redistributions of source code must retain the above copyright notice, this list
|
8
|
+
of conditions and the following disclaimer.
|
12
9
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
10
|
+
Redistributions in binary form must reproduce the above copyright notice, this
|
11
|
+
list of conditions and the following disclaimer in the documentation and/or
|
12
|
+
other materials provided with the distribution.
|
13
|
+
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
15
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
16
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
17
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
18
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
19
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
20
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
21
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
22
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
23
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
CHANGED
@@ -1,57 +1,34 @@
|
|
1
1
|
Description
|
2
2
|
===========
|
3
3
|
|
4
|
-
Boutique is a Sinatra
|
4
|
+
Boutique is a Sinatra app for drip emails (and soon-to-be product checkouts).
|
5
|
+
Still in development!
|
5
6
|
|
6
7
|
Installation
|
7
8
|
============
|
8
9
|
|
9
|
-
|
10
|
+
$ gem install boutique
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
% openssl genrsa -out private.pem 1024
|
14
|
-
% openssl req -new -key private.pem -x509 -days 1095 -out public.pem
|
15
|
-
|
16
|
-
Make sure the email you use is the same as your PayPal merchant email, or else
|
17
|
-
PayPal will reject it.
|
18
|
-
|
19
|
-
You can download PayPal's public certificate under `Profile > Encrypted
|
20
|
-
Payment Settings`. Click the download button for PayPal Public Certificate.
|
21
|
-
Rename this certificate to something like `paypal.pem`.
|
22
|
-
|
23
|
-
While you're there, upload the `public.pem` certificate you just generated. Copy
|
24
|
-
the `Cert ID`, you'll use it for the `pem_cert_id` field in your config. You
|
25
|
-
can block unencrypted requests from `Website Payment Preferences`.
|
26
|
-
|
27
|
-
Setup a `config.ru` file and run it like any other Sinatra application. You
|
28
|
-
can configure what products you sell here:
|
12
|
+
Setup a `config.ru` file and run it like any other Sinatra app:
|
29
13
|
|
30
14
|
require 'rubygems'
|
31
15
|
require 'boutique'
|
32
16
|
|
33
17
|
Boutique.configure do |c|
|
34
18
|
c.dev_email 'dev@mailinator.com'
|
35
|
-
c.
|
36
|
-
c.pem_private '/path/to/private.pem'
|
37
|
-
c.pem_public '/path/to/public.pem'
|
38
|
-
c.pem_paypal '/path/to/paypal.pem'
|
19
|
+
c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
|
39
20
|
c.download_dir '/path/to/download'
|
40
21
|
c.download_path '/download'
|
41
|
-
|
42
|
-
c.
|
43
|
-
|
44
|
-
c.
|
45
|
-
c.db_database 'boutique'
|
46
|
-
c.pp_email 'paypal_biz@mailinator.com'
|
47
|
-
c.pp_url 'https://www.sandbox.paypal.com/cgi-bin/webscr'
|
22
|
+
|
23
|
+
c.db_options(adapter: 'postgresql', host: 'localhost',
|
24
|
+
username: 'root', password: 'secret', database: 'boutique')
|
25
|
+
c.email_options(via: :smtp, via_options: {host: 'smtp.example.org'})
|
48
26
|
end
|
49
27
|
|
50
|
-
Boutique.
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
p.return_url 'http://zincmade.com/thankyou'
|
28
|
+
Boutique.list('learn-ruby') do |l|
|
29
|
+
l.from 'Hugh <hugh@mailinator.com>'
|
30
|
+
l.emails '/path/to/emails-dir'
|
31
|
+
l.url 'http://example.com'
|
55
32
|
end
|
56
33
|
|
57
34
|
run Boutique::App if !ENV['BOUTIQUE_CMD']
|
@@ -61,29 +38,77 @@ Stick this in your `bashrc` or `zshrc`:
|
|
61
38
|
BOUTIQUE_CONFIG='/path/to/config.ru'
|
62
39
|
|
63
40
|
Now setup the database tables (assuming you've already created the database and
|
64
|
-
credentials)
|
41
|
+
credentials) and stick the `.css` and `.js` files in your project. Note that
|
42
|
+
`boutique.js` is dependent on jQuery.
|
43
|
+
|
44
|
+
$ boutique --migrate
|
45
|
+
$ boutique --assets
|
46
|
+
new -- boutique.js
|
47
|
+
new -- boutique.css
|
48
|
+
$ mv boutique.js boutique.css /path/to/project/assets/.
|
49
|
+
|
50
|
+
Drip Emails
|
51
|
+
===========
|
52
|
+
|
53
|
+
Emails can be written in any templating format that `Tilt` accepts. Stick them
|
54
|
+
in `/path/to/emails-dir` (configured above in `config.ru`). Emails use
|
55
|
+
front-matter YAML for passing information to Boutique. The required fields are
|
56
|
+
`day`, `subject`, and `key`. The day is how many days should pass until the
|
57
|
+
email is sent. The key is a unique key assigned to each email to guard against
|
58
|
+
sending multiples to the same recipient. You'll also have access to three
|
59
|
+
local variables:
|
60
|
+
|
61
|
+
* `subscribe_url` - URL to open subscribe UI
|
62
|
+
* `confirm_url` - URL for double opt-in confirmation
|
63
|
+
* `unsubscribe_url` - URL to unsubscribe in one click
|
65
64
|
|
66
|
-
|
65
|
+
Here's an example email:
|
67
66
|
|
68
|
-
|
67
|
+
---
|
68
|
+
day: 1
|
69
|
+
subject: First Email
|
70
|
+
key: first-email
|
71
|
+
---
|
69
72
|
|
70
|
-
|
71
|
-
2. User is redirected to paypal
|
72
|
-
3. After completing paypal, user is redirected to
|
73
|
-
`http://zincmade.com/thankyou?b=order-id`
|
74
|
-
4. On this page, issue an AJAX request to `/purchases/order-id`.
|
75
|
-
The `downloads` field of the JSON will include the download URLs.
|
73
|
+
Hi,
|
76
74
|
|
77
|
-
|
78
|
-
=====
|
75
|
+
This is the first email in the series.
|
79
76
|
|
80
|
-
|
81
|
-
|
77
|
+
Thanks,
|
78
|
+
- Hugh
|
82
79
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
80
|
+
[Click here to unsubscribe.](<%= unsubscribe_url %>)
|
81
|
+
|
82
|
+
This will be in the file `/path/to/emails-dir/first-email.md.erb`. Based on
|
83
|
+
the file extensions, Boutique will run it through ERB first followed by Markdown.
|
84
|
+
|
85
|
+
Also note, the directory should contain a special **zero day** email. This is
|
86
|
+
the email used to confirm when a new person signs up, also called the double
|
87
|
+
opt-in email:
|
88
|
+
|
89
|
+
---
|
90
|
+
day: 0
|
91
|
+
subject: Please confirm your email address
|
92
|
+
key: confirm-email
|
93
|
+
---
|
94
|
+
|
95
|
+
Hi,
|
96
|
+
|
97
|
+
Thanks for signing up. But wait, you're not done yet! Please
|
98
|
+
[click here to confirm your email address.](<%= confirm_url %>)
|
99
|
+
|
100
|
+
If you subscribed or received this email by mistake, please feel free to
|
101
|
+
ignore it. You will not receive any further emails.
|
102
|
+
|
103
|
+
Thanks!
|
104
|
+
- Hugh
|
105
|
+
|
106
|
+
Emails are sent out using the command line tool `boutique --drip`. This should
|
107
|
+
be run everyday. It's idempotent, so it's fine if it gets run multiple times a
|
108
|
+
day by mistake. Use cron to schedule drips:
|
109
|
+
|
110
|
+
$ crontab -e
|
111
|
+
0 8 * * * boutique --drip
|
87
112
|
|
88
113
|
Development
|
89
114
|
===========
|
@@ -91,13 +116,20 @@ Development
|
|
91
116
|
Tests are setup to run individually via `ruby test/*_test.rb` or run them all
|
92
117
|
via `rake`.
|
93
118
|
|
119
|
+
To start the server for local development:
|
120
|
+
|
121
|
+
$ BOUTIQUE_DEV=1 shotgun
|
122
|
+
|
94
123
|
TODO
|
95
124
|
====
|
96
125
|
|
97
|
-
*
|
126
|
+
* implement UI opening on subscribe_url
|
127
|
+
* switch to Stripe
|
128
|
+
* add customizable? email integration for purchase receipts + recover
|
129
|
+
* add re-usable UI for purchasing, downloading, recover
|
98
130
|
|
99
131
|
License
|
100
132
|
=======
|
101
133
|
|
102
|
-
Copyright
|
103
|
-
Released under
|
134
|
+
Copyright Hugh Bien - http://hughbien.com.
|
135
|
+
Released under BSD License, see LICENSE.md for more info.
|
data/boutique
CHANGED
@@ -9,36 +9,24 @@ ENV['BOUTIQUE_CMD'] = '1'
|
|
9
9
|
|
10
10
|
module Boutique
|
11
11
|
class Command
|
12
|
-
|
13
|
-
|
14
|
-
|
12
|
+
JS_FILE = File.join(File.dirname(__FILE__), 'public/boutique/script.js')
|
13
|
+
CSS_FILE = File.join(File.dirname(__FILE__), 'public/boutique/styles.css')
|
14
|
+
|
15
|
+
def self.assets
|
16
|
+
File.exist?('boutique.js') ?
|
17
|
+
puts("exists -- boutique.js") :
|
18
|
+
FileUtils.copy(JS_FILE, "boutique.js") || puts(" new -- boutique.js")
|
19
|
+
File.exist?('boutique.css') ?
|
20
|
+
puts("exists -- boutique.css") :
|
21
|
+
FileUtils.copy(CSS_FILE, "boutique.css") || puts(" new -- boutique.css")
|
15
22
|
end
|
16
23
|
|
17
|
-
def self.
|
24
|
+
def self.drip
|
18
25
|
load_config
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
return
|
26
|
+
Boutique::List.to_a.each do |list_key|
|
27
|
+
emailer = Boutique::Emailer.new(List[list_key])
|
28
|
+
emailer.drip
|
23
29
|
end
|
24
|
-
purchase.downloads.each do |download|
|
25
|
-
FileUtils.rm_rf(File.dirname(
|
26
|
-
"#{Boutique.config.download_dir}#{download.sub(Boutique.config.download_path, '')}"))
|
27
|
-
end
|
28
|
-
purchase
|
29
|
-
end
|
30
|
-
|
31
|
-
def self.expire_old
|
32
|
-
load_config
|
33
|
-
threshold = (Date.today - 1).strftime("%Y%m%d")
|
34
|
-
dirs = Dir.glob("#{Boutique.config.download_dir}/*").
|
35
|
-
map {|f| File.basename(f) }.
|
36
|
-
select {|f| f < threshold }
|
37
|
-
dirs.each {|d| FileUtils.rm_rf("#{Boutique.config.download_dir}/#{d}") }
|
38
|
-
|
39
|
-
Boutique::Purchase.all(
|
40
|
-
:transaction_id => nil,
|
41
|
-
:created_at.lt => (Date.today - 1)).destroy
|
42
30
|
end
|
43
31
|
|
44
32
|
def self.migrate
|
@@ -49,35 +37,6 @@ module Boutique
|
|
49
37
|
load_config
|
50
38
|
end
|
51
39
|
|
52
|
-
def self.stats(code)
|
53
|
-
load_config
|
54
|
-
product = Boutique::Product.first(:code => code)
|
55
|
-
if product.nil?
|
56
|
-
puts "Product #{code} not found"
|
57
|
-
return
|
58
|
-
end
|
59
|
-
|
60
|
-
puts "Code: #{product.code}"
|
61
|
-
puts "Name: #{product.name}"
|
62
|
-
puts "Files: #{product.files.join("\n ")}"
|
63
|
-
printf("Price: $%.2f\n" % product.price)
|
64
|
-
puts "Return: #{product.return_url}"
|
65
|
-
puts "Email: #{product.support_email}"
|
66
|
-
|
67
|
-
puts ('-' * 40)
|
68
|
-
|
69
|
-
purchases = product.purchases
|
70
|
-
puts "* #{purchases.select {|p| p.completed?}.size} purchases have been completed"
|
71
|
-
puts "* #{purchases.reduce(0) {|sum,p| sum + p.counter}} downloads linked"
|
72
|
-
|
73
|
-
puts ('-' * 40)
|
74
|
-
|
75
|
-
puts "id, links, email" if purchases.size > 0
|
76
|
-
purchases.sort_by {|p| p.counter }.reverse.each do |purchase|
|
77
|
-
puts "* #{purchase.id}, #{purchase.counter}, #{purchase.email}" if purchase.completed?
|
78
|
-
end
|
79
|
-
end
|
80
|
-
|
81
40
|
private
|
82
41
|
def self.load_config
|
83
42
|
load(ENV['BOUTIQUE_CONFIG'] || 'config.ru')
|
@@ -89,11 +48,9 @@ ARGV.options do |o|
|
|
89
48
|
o.set_summary_indent(' ')
|
90
49
|
o.banner = "Usage: #{File.basename($0)} [OPTION]"
|
91
50
|
o.define_head "Admin for boutique"
|
92
|
-
o.on('-
|
93
|
-
o.on('-
|
94
|
-
o.on('-E', '--expire-old', 'expire old data') { Boutique::Command.expire_old; exit }
|
51
|
+
o.on('-a', '--assets', 'create css/js assets') { Boutique::Command.assets; exit }
|
52
|
+
o.on('-d', '--drip', 'drip out emails') { Boutique::Command.drip; exit }
|
95
53
|
o.on('-m', '--migrate', 'initial migration') { Boutique::Command.migrate; exit }
|
96
|
-
o.on('-s', '--stats [product]', String, 'show stats on product') { |p| Boutique::Command.stats(p); exit }
|
97
54
|
o.on('-h', '--help', 'show this help message') { puts o; exit }
|
98
55
|
o.parse!
|
99
56
|
puts o
|
data/config.ru
CHANGED
@@ -1,28 +1,29 @@
|
|
1
1
|
require File.expand_path('lib/boutique', File.dirname(__FILE__))
|
2
2
|
|
3
|
-
Boutique.configure(!ENV['BOUTIQUE_CMD'].nil?) do |c|
|
4
|
-
c.dev_email
|
5
|
-
c.
|
6
|
-
c.
|
7
|
-
c.
|
8
|
-
c.
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
c.
|
15
|
-
c.db_database 'db.sqlite3'
|
16
|
-
c.pp_email 'paypal_biz@mailinator.com'
|
17
|
-
c.pp_url 'http://localhost'
|
3
|
+
Boutique.configure(ENV['BOUTIQUE_DEV'] || !ENV['BOUTIQUE_CMD'].nil?) do |c|
|
4
|
+
#c.dev_email 'dev@localhost'
|
5
|
+
c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
|
6
|
+
c.download_path '/download'
|
7
|
+
c.download_dir File.expand_path('temp', File.dirname(__FILE__))
|
8
|
+
c.db_options(
|
9
|
+
adapter: 'sqlite3',
|
10
|
+
host: 'localhost',
|
11
|
+
username: 'root',
|
12
|
+
password: 'secret',
|
13
|
+
database: 'db.sqlite3')
|
14
|
+
c.email_options(via: :sendmail)
|
18
15
|
end
|
19
16
|
|
20
17
|
Boutique.product('readme') do |p|
|
21
|
-
p.
|
22
|
-
p.files
|
23
|
-
p.price
|
24
|
-
|
25
|
-
|
18
|
+
p.from 'support@localhost'
|
19
|
+
p.files File.expand_path('README.md', File.dirname(__FILE__))
|
20
|
+
p.price 1.5
|
21
|
+
end
|
22
|
+
|
23
|
+
Boutique.list('learn-ruby') do |l|
|
24
|
+
l.from 'Hugh <hugh@localhost>'
|
25
|
+
l.emails File.expand_path('emails', File.dirname(__FILE__))
|
26
|
+
l.url 'http://example.com'
|
26
27
|
end
|
27
28
|
|
28
29
|
run Boutique::App if !ENV['BOUTIQUE_CMD']
|
data/lib/boutique.rb
CHANGED
@@ -1,291 +1,303 @@
|
|
1
1
|
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
2
3
|
require 'sinatra/base'
|
3
4
|
require 'dm-core'
|
4
5
|
require 'dm-types'
|
5
6
|
require 'dm-timestamps'
|
7
|
+
require 'dm-validations'
|
6
8
|
require 'date'
|
7
9
|
require 'digest/sha1'
|
8
10
|
require 'json'
|
9
11
|
require 'openssl'
|
10
12
|
require 'pony'
|
13
|
+
require 'preamble'
|
14
|
+
require 'tilt'
|
15
|
+
require 'tempfile'
|
16
|
+
require 'uri'
|
17
|
+
require 'cgi'
|
11
18
|
|
12
19
|
DataMapper::Model.raise_on_save_failure = true
|
13
20
|
|
14
21
|
module Boutique
|
15
|
-
VERSION = '0.0.
|
22
|
+
VERSION = '0.0.10'
|
16
23
|
|
17
24
|
class << self
|
18
25
|
def configure(setup_db=true)
|
19
|
-
yield
|
20
|
-
DataMapper.setup(:default,
|
21
|
-
|
22
|
-
:host => config.db_host,
|
23
|
-
:username => config.db_username,
|
24
|
-
:password => config.db_password,
|
25
|
-
:database => config.db_database
|
26
|
-
) if setup_db
|
26
|
+
yield config
|
27
|
+
DataMapper.setup(:default, config.db_options) if setup_db
|
28
|
+
Pony.options = config.email_options if !config.email_options.nil?
|
27
29
|
end
|
28
30
|
|
29
31
|
def config
|
30
|
-
Config
|
32
|
+
@config ||= Config.new('config')
|
31
33
|
end
|
32
34
|
|
33
|
-
def product(
|
34
|
-
|
35
|
-
builder.code(code)
|
36
|
-
yield builder
|
37
|
-
product = Product.first_or_create({:code => code}, builder.to_hash)
|
38
|
-
product.save
|
39
|
-
product
|
35
|
+
def product(key)
|
36
|
+
yield Product.new(key)
|
40
37
|
end
|
41
|
-
end
|
42
38
|
|
43
|
-
|
44
|
-
|
45
|
-
@dev_email = value if !value.nil?
|
46
|
-
@dev_email
|
39
|
+
def list(key)
|
40
|
+
yield List.new(key)
|
47
41
|
end
|
42
|
+
end
|
48
43
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
44
|
+
module MemoryResource
|
45
|
+
def self.included(base)
|
46
|
+
base.extend(ClassMethods)
|
47
|
+
base.attr_resource :key
|
48
|
+
base.reset_db
|
49
|
+
end
|
50
|
+
|
51
|
+
module ClassMethods
|
52
|
+
def attr_resource(*names)
|
53
|
+
names.each do |name|
|
54
|
+
define_method(name) do |*args|
|
55
|
+
value = args[0]
|
56
|
+
instance_variable_set("@#{name}".to_sym, value) if !value.nil?
|
57
|
+
instance_variable_get("@#{name}".to_sym)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
53
61
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
end
|
62
|
+
def reset_db
|
63
|
+
@db = {}
|
64
|
+
end
|
58
65
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
end
|
66
|
+
def [](key)
|
67
|
+
@db[key]
|
68
|
+
end
|
63
69
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
end
|
70
|
+
def []=(key, value)
|
71
|
+
@db[key] = value
|
72
|
+
end
|
68
73
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
end
|
74
|
+
def include?(key)
|
75
|
+
self.to_a.include?(key)
|
76
|
+
end
|
73
77
|
|
74
|
-
|
75
|
-
|
76
|
-
|
78
|
+
def to_a
|
79
|
+
@db.keys
|
80
|
+
end
|
77
81
|
end
|
78
82
|
|
79
|
-
def
|
80
|
-
@
|
81
|
-
|
83
|
+
def initialize(key)
|
84
|
+
@key = key
|
85
|
+
self.class[key] = self
|
82
86
|
end
|
87
|
+
end
|
83
88
|
|
84
|
-
|
85
|
-
|
86
|
-
@
|
89
|
+
class Emailer
|
90
|
+
def initialize(list)
|
91
|
+
@list = list
|
87
92
|
end
|
88
93
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
end
|
94
|
+
def render(path, locals = {}, preamble = false)
|
95
|
+
path = full_path(path)
|
96
|
+
raise "File not found: #{path}" if !File.exist?(path)
|
93
97
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
+
yaml, body = Preamble.load(path)
|
99
|
+
templates_for(path).each do |template|
|
100
|
+
blk = proc { body }
|
101
|
+
body = template.new(path, &blk).render(self, locals)
|
102
|
+
end
|
98
103
|
|
99
|
-
|
100
|
-
|
101
|
-
|
104
|
+
preamble ? [yaml, body] : body
|
105
|
+
end
|
106
|
+
|
107
|
+
def deliver(subscriber, path, locals = {})
|
108
|
+
locals = locals.merge(
|
109
|
+
subscribe_url: @list.subscribe_url,
|
110
|
+
confirm_url: subscriber.confirm_url,
|
111
|
+
unsubscribe_url: subscriber.unsubscribe_url)
|
112
|
+
yaml, body = self.render(path, locals, true)
|
113
|
+
if yaml['day'] == 0
|
114
|
+
ymd = Date.today.strftime("%Y-%m-%d")
|
115
|
+
Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
|
116
|
+
else
|
117
|
+
raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed?
|
118
|
+
Email.create(email_key: yaml['key'], subscriber: subscriber)
|
119
|
+
end
|
120
|
+
Pony.mail(
|
121
|
+
to: subscriber.email,
|
122
|
+
from: @list.from,
|
123
|
+
subject: yaml['subject'],
|
124
|
+
headers: {'Content-Type' => 'text/html'},
|
125
|
+
body: body)
|
126
|
+
rescue DataMapper::SaveFailureError
|
127
|
+
raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
|
128
|
+
end
|
129
|
+
|
130
|
+
def deliver_zero(subscriber)
|
131
|
+
self.deliver(subscriber, emails[0])
|
132
|
+
end
|
133
|
+
|
134
|
+
def blast(path, locals = {})
|
135
|
+
yaml, body = Preamble.load(full_path(path))
|
136
|
+
email_key = yaml['key']
|
137
|
+
@list.subscribers.all(confirmed: true).each do |subscriber|
|
138
|
+
# TODO: speed up by moving filter outside of loop
|
139
|
+
if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
|
140
|
+
self.deliver(subscriber, path, locals)
|
141
|
+
end
|
142
|
+
end
|
102
143
|
end
|
103
144
|
|
104
|
-
def
|
105
|
-
|
106
|
-
|
145
|
+
def drip
|
146
|
+
today = Date.today
|
147
|
+
max_day = emails.keys.max || 0
|
148
|
+
subscribers = @list.subscribers.all(
|
149
|
+
:confirmed => true,
|
150
|
+
:drip_on.lt => today,
|
151
|
+
:drip_day.lt => max_day)
|
152
|
+
subscribers.each do |subscriber|
|
153
|
+
subscriber.drip_on = today
|
154
|
+
subscriber.drip_day += 1
|
155
|
+
subscriber.save
|
156
|
+
if (email_path = emails[subscriber.drip_day])
|
157
|
+
self.deliver(subscriber, email_path)
|
158
|
+
end
|
159
|
+
end
|
107
160
|
end
|
108
161
|
|
109
|
-
|
110
|
-
|
111
|
-
@
|
162
|
+
private
|
163
|
+
def full_path(path)
|
164
|
+
File.join(@list.emails, path)
|
165
|
+
end
|
166
|
+
|
167
|
+
def templates_for(path)
|
168
|
+
basename = File.basename(path)
|
169
|
+
basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
|
170
|
+
end
|
171
|
+
|
172
|
+
def emails
|
173
|
+
@emails ||= begin
|
174
|
+
emails = {}
|
175
|
+
Dir.entries(@list.emails).each do |filename|
|
176
|
+
next if File.directory?(filename)
|
177
|
+
# TODO: stop duplicating calls to Preamble, store in memory
|
178
|
+
yaml, body = Preamble.load(full_path(filename))
|
179
|
+
if yaml && yaml['day'] && yaml['key']
|
180
|
+
emails[yaml['day']] = filename
|
181
|
+
end
|
182
|
+
end
|
183
|
+
emails
|
184
|
+
end
|
112
185
|
end
|
113
186
|
end
|
114
187
|
|
115
|
-
class
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
end
|
188
|
+
class Config
|
189
|
+
include MemoryResource
|
190
|
+
attr_resource :dev_email,
|
191
|
+
:stripe_api_key,
|
192
|
+
:download_dir,
|
193
|
+
:download_path,
|
194
|
+
:db_options,
|
195
|
+
:email_options
|
196
|
+
end
|
125
197
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
198
|
+
class Product
|
199
|
+
include MemoryResource
|
200
|
+
attr_resource :from, :files, :price
|
201
|
+
end
|
130
202
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
end
|
203
|
+
class List
|
204
|
+
include MemoryResource
|
205
|
+
attr_resource :from, :emails, :url
|
135
206
|
|
136
|
-
def
|
137
|
-
|
138
|
-
@return_url
|
207
|
+
def subscribers
|
208
|
+
Subscriber.all(list_key: self.key)
|
139
209
|
end
|
140
210
|
|
141
|
-
def
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
{:code => @code,
|
148
|
-
:name => @name,
|
149
|
-
:files => @files,
|
150
|
-
:price => @price,
|
151
|
-
:return_url => @return_url,
|
152
|
-
:support_email => @support_email}
|
211
|
+
def subscribe_url
|
212
|
+
url = URI.parse(self.url)
|
213
|
+
params = [url.query]
|
214
|
+
params << "boutique=subscribe/#{CGI.escape(self.key)}"
|
215
|
+
url.query = params.compact.join('&')
|
216
|
+
url.to_s
|
153
217
|
end
|
154
218
|
end
|
155
219
|
|
156
|
-
class Product
|
157
|
-
include DataMapper::Resource
|
158
|
-
|
159
|
-
property :id, Serial
|
160
|
-
property :code, String, :required => true, :unique => true
|
161
|
-
property :name, String, :required => true, :unique => true
|
162
|
-
property :files, CommaSeparatedList, :required => true
|
163
|
-
property :price, Decimal, :required => true
|
164
|
-
property :return_url, String, :required => true
|
165
|
-
property :support_email, String, :required => true
|
166
|
-
|
167
|
-
has n, :purchases
|
168
|
-
end
|
169
|
-
|
170
220
|
class Purchase
|
171
221
|
include DataMapper::Resource
|
172
222
|
|
173
223
|
property :id, Serial
|
224
|
+
property :product_key, String, required: true
|
174
225
|
property :created_at, DateTime
|
175
|
-
property :counter, Integer, :
|
176
|
-
property :secret, String, :
|
226
|
+
property :counter, Integer, required: true
|
227
|
+
property :secret, String, required: true
|
177
228
|
property :transaction_id, String
|
178
229
|
property :email, String
|
179
|
-
property :name, String
|
230
|
+
property :name, String, format: :email_address
|
180
231
|
property :completed_at, DateTime
|
181
232
|
property :downloads, CommaSeparatedList
|
233
|
+
end
|
182
234
|
|
183
|
-
|
235
|
+
class Subscriber
|
236
|
+
include DataMapper::Resource
|
184
237
|
|
185
|
-
|
186
|
-
|
187
|
-
|
238
|
+
property :id, Serial
|
239
|
+
property :list_key, String, required: true, unique_index: :list_key_email
|
240
|
+
property :email, String, required: true, unique_index: :list_key_email, format: :email_address
|
241
|
+
property :secret, String, required: true
|
242
|
+
property :confirmed, Boolean
|
243
|
+
property :created_at, DateTime
|
244
|
+
property :drip_on, Date, required: true
|
245
|
+
property :drip_day, Integer, required: true, default: 0
|
246
|
+
|
247
|
+
validates_within :list_key, set: List
|
248
|
+
validates_uniqueness_of :email, scope: :list_key
|
249
|
+
|
250
|
+
has n, :emails
|
251
|
+
|
252
|
+
def initialize(*args)
|
188
253
|
super
|
254
|
+
self.secret ||= Digest::SHA1.hexdigest("#{rand(1000)}-#{Time.now}")[0..6]
|
255
|
+
self.drip_on ||= Date.today
|
189
256
|
end
|
190
257
|
|
191
|
-
def
|
192
|
-
self.
|
193
|
-
self.email = email
|
194
|
-
self.name = name
|
195
|
-
self.completed_at = DateTime.now
|
196
|
-
link_downloads!
|
258
|
+
def list
|
259
|
+
@list ||= List[self.list_key]
|
197
260
|
end
|
198
261
|
|
199
|
-
def
|
200
|
-
|
262
|
+
def confirm!(secret)
|
263
|
+
self.confirmed = true if self.secret == secret
|
264
|
+
self.save
|
201
265
|
end
|
202
266
|
|
203
|
-
def
|
204
|
-
if self.
|
205
|
-
|
206
|
-
self.downloads.any? {|d| !File.exist?(d) })
|
207
|
-
self.link_downloads!
|
208
|
-
self.save
|
209
|
-
end
|
267
|
+
def unconfirm!(secret)
|
268
|
+
self.confirmed = false if self.secret == secret
|
269
|
+
self.save
|
210
270
|
end
|
211
271
|
|
212
|
-
def
|
213
|
-
|
214
|
-
self.downloads = product.files.map do |file|
|
215
|
-
linked_file = "/#{Date.today.strftime('%Y%m%d')}-#{random_hash}/#{File.basename(file)}"
|
216
|
-
full_dir = File.dirname("#{Boutique.config.download_dir}#{linked_file}")
|
217
|
-
`mkdir -p #{full_dir}`
|
218
|
-
`ln -s #{file} #{Boutique.config.download_dir}#{linked_file}`
|
219
|
-
"#{Boutique.config.download_path}#{linked_file}"
|
220
|
-
end
|
221
|
-
self.counter += 1
|
272
|
+
def confirm_url
|
273
|
+
secret_url("confirm")
|
222
274
|
end
|
223
275
|
|
224
|
-
def
|
225
|
-
(
|
226
|
-
raise('Cannot get boutique_id for unsaved purchase') :
|
227
|
-
"#{self.id}-#{self.secret}"
|
276
|
+
def unsubscribe_url
|
277
|
+
secret_url("unsubscribe")
|
228
278
|
end
|
229
279
|
|
230
|
-
|
231
|
-
|
280
|
+
private
|
281
|
+
def secret_url(action)
|
282
|
+
url = URI.parse(self.list.url)
|
283
|
+
params = [url.query]
|
284
|
+
params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
|
285
|
+
url.query = params.compact.join('&')
|
286
|
+
url.to_s
|
232
287
|
end
|
288
|
+
end
|
233
289
|
|
234
|
-
|
235
|
-
|
236
|
-
Pony.mail(
|
237
|
-
:to => self.email,
|
238
|
-
:from => self.product.support_email,
|
239
|
-
:subject => "#{self.product.name} Receipt",
|
240
|
-
:body => "Thanks for purchasing #{self.product.name}! " +
|
241
|
-
"To download it, follow this link:\n\n" +
|
242
|
-
" #{self.product.return_url}?b=#{boutique_id}\n\n" +
|
243
|
-
"Please reply if you have any troubles.\n"
|
244
|
-
)
|
245
|
-
end
|
290
|
+
class Email
|
291
|
+
include DataMapper::Resource
|
246
292
|
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
:counter => counter,
|
251
|
-
:completed => completed?,
|
252
|
-
:name => product.name,
|
253
|
-
:code => product.code,
|
254
|
-
:downloads => downloads
|
255
|
-
}.to_json
|
256
|
-
end
|
293
|
+
property :id, Serial
|
294
|
+
property :email_key, String, required: true
|
295
|
+
property :created_at, DateTime
|
257
296
|
|
258
|
-
|
259
|
-
|
260
|
-
:business => Boutique.config.pp_email,
|
261
|
-
:cmd => '_xclick',
|
262
|
-
:item_name => product.name,
|
263
|
-
:item_number => product.code,
|
264
|
-
:amount => product.price.to_f,
|
265
|
-
:currency_code => 'USD',
|
266
|
-
:notify_url => "#{notify_url}/#{boutique_id}",
|
267
|
-
:return => "#{product.return_url}?b=#{boutique_id}",
|
268
|
-
:cert_id => Boutique.config.pem_cert_id
|
269
|
-
}
|
270
|
-
{'action' => Boutique.config.pp_url,
|
271
|
-
'cmd' => '_s-xclick',
|
272
|
-
'encrypted' => encrypt(values)}
|
273
|
-
end
|
297
|
+
validates_uniqueness_of :email_key, scope: :subscriber
|
298
|
+
validates_presence_of :subscriber
|
274
299
|
|
275
|
-
|
276
|
-
def encrypt(values)
|
277
|
-
signed = OpenSSL::PKCS7::sign(
|
278
|
-
OpenSSL::X509::Certificate.new(File.read(Boutique.config.pem_public)),
|
279
|
-
OpenSSL::PKey::RSA.new(File.read(Boutique.config.pem_private), ''),
|
280
|
-
values.map { |k,v| "#{k.to_s}=#{v.to_s}" }.join("\n"),
|
281
|
-
[],
|
282
|
-
OpenSSL::PKCS7::BINARY)
|
283
|
-
OpenSSL::PKCS7::encrypt(
|
284
|
-
[OpenSSL::X509::Certificate.new(File.read(Boutique.config.pem_paypal))],
|
285
|
-
signed.to_der,
|
286
|
-
OpenSSL::Cipher::Cipher::new("DES3"),
|
287
|
-
OpenSSL::PKCS7::BINARY).to_s.gsub("\n", "")
|
288
|
-
end
|
300
|
+
belongs_to :subscriber
|
289
301
|
end
|
290
302
|
|
291
303
|
DataMapper.finalize
|
@@ -297,70 +309,44 @@ module Boutique
|
|
297
309
|
error do
|
298
310
|
Pony.mail(
|
299
311
|
:to => Boutique.config.dev_email,
|
300
|
-
:from => "boutique@#{Boutique.config.dev_email.split('@')[1..-1].join}",
|
301
312
|
:subject => 'Boutique Error',
|
302
313
|
:body => request.env['sinatra.error'].to_s
|
303
314
|
) if Boutique.config.dev_email
|
304
315
|
end
|
305
316
|
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
product.save
|
314
|
-
form = purchase.paypal_form("http://#{request.host}/notify")
|
315
|
-
"<!doctype html><html><head><title>Redirecting to PayPal...</title>" +
|
316
|
-
"<meta charset='utf-8'></head><body>" +
|
317
|
-
"<form name='paypal' method='post' action='#{form['action']}'>" +
|
318
|
-
"<input type='hidden' name='cmd' value='#{form['cmd']}'>" +
|
319
|
-
"<input type='hidden' name='encrypted' value='#{form['encrypted']}'>" +
|
320
|
-
"<input type='submit' value='submit' style='visibility:hidden'>" +
|
321
|
-
"</form><script>document.paypal.submit();</script></body></html>"
|
317
|
+
post '/subscribe/:list_key' do
|
318
|
+
list = get_list(params[:list_key])
|
319
|
+
subscriber = Subscriber.first_or_create(
|
320
|
+
list_key: list.key,
|
321
|
+
email: params[:email])
|
322
|
+
Emailer.new(list).deliver_zero(subscriber) rescue nil
|
323
|
+
''
|
322
324
|
end
|
323
325
|
|
324
|
-
post '/
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
params['payment_status'] &&
|
329
|
-
params['first_name'] &&
|
330
|
-
params['payer_email'] &&
|
331
|
-
params['receiver_email'] == Boutique.config.pp_email
|
332
|
-
purchase.complete(params['txn_id'], params['payer_email'], params['first_name'])
|
333
|
-
purchase.send_mail
|
334
|
-
purchase.save
|
335
|
-
end
|
326
|
+
post '/confirm/:list_key/:id/:secret' do
|
327
|
+
list = get_list(params[:list_key])
|
328
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
329
|
+
subscriber.confirm!(params[:secret])
|
336
330
|
''
|
337
331
|
end
|
338
332
|
|
339
|
-
post '/
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
end
|
345
|
-
purchase.send_mail
|
346
|
-
purchase.boutique_id
|
333
|
+
post '/unsubscribe/:list_key/:id/:secret' do
|
334
|
+
list = get_list(params[:list_key])
|
335
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
336
|
+
subscriber.unconfirm!(params[:secret])
|
337
|
+
''
|
347
338
|
end
|
348
339
|
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
params['jsonp'].nil? ?
|
353
|
-
purchase.to_json :
|
354
|
-
"#{params['jsonp']}(#{purchase.to_json})"
|
340
|
+
private
|
341
|
+
def get_list(list_key)
|
342
|
+
List[list_key] || halt(404)
|
355
343
|
end
|
356
344
|
|
357
|
-
def
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
halt(404
|
362
|
-
end
|
363
|
-
purchase
|
345
|
+
def get_subscriber(id, list, secret)
|
346
|
+
Subscriber.first(
|
347
|
+
id: params[:id],
|
348
|
+
list_key: list.key,
|
349
|
+
secret: secret) || halt(404)
|
364
350
|
end
|
365
351
|
end
|
366
352
|
end
|