boutique 0.0.11 → 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.
- checksums.yaml +7 -0
- data/README.md +22 -20
- data/{boutique → bin/boutique} +3 -7
- data/config.ru +5 -4
- data/lib/boutique.rb +9 -352
- data/lib/boutique/app.rb +60 -0
- data/lib/boutique/config.rb +101 -0
- data/lib/boutique/emailer.rb +109 -0
- data/lib/boutique/model.rb +86 -0
- data/lib/boutique/version.rb +3 -0
- data/migrate/001_init_schema.rb +21 -0
- data/public/boutique/script.js +144 -0
- data/public/boutique/styles.css +123 -0
- data/test/app_test.rb +36 -15
- data/test/config_test.rb +3 -9
- data/test/emailer_test.rb +7 -5
- data/test/helper.rb +26 -29
- data/test/model_test.rb +8 -3
- metadata +69 -86
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 60fd1d83530e2f764fa03935ded63d4eb1f776f9
|
4
|
+
data.tar.gz: 9c2561be47adbe33b57ccac1af6db4b1d37e4a29
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d5b45c086596e4ae72cc5977ce98b4db52bae8dd48d2640e57e886884601cea229adb5a4c3b9ebca5deb6edada835d261d4b7dff6cee2dc27be7d4eb0a995901
|
7
|
+
data.tar.gz: 6150e6f5678ec6003418ca1bccb8a91c17b6f62b0f2065e2e01363ff025fb772f9d4bdda066d38a6f68ed7105e9cb9cbf70b6b4c80c3513267f8e002a89b5b90
|
data/README.md
CHANGED
@@ -1,11 +1,9 @@
|
|
1
|
-
Description
|
2
|
-
===========
|
1
|
+
# Description
|
3
2
|
|
4
3
|
Boutique is a Sinatra app for drip emails (and soon-to-be product checkouts).
|
5
4
|
Still in development!
|
6
5
|
|
7
|
-
Installation
|
8
|
-
============
|
6
|
+
# Installation
|
9
7
|
|
10
8
|
$ gem install boutique
|
11
9
|
|
@@ -15,12 +13,12 @@ Setup a `config.ru` file and run it like any other Sinatra app:
|
|
15
13
|
require 'boutique'
|
16
14
|
|
17
15
|
Boutique.configure do |c|
|
18
|
-
c.
|
16
|
+
c.error_email 'dev@mailinator.com'
|
19
17
|
c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
|
20
18
|
c.download_dir '/path/to/download'
|
21
19
|
c.download_path '/download'
|
22
20
|
|
23
|
-
c.db_options(adapter: '
|
21
|
+
c.db_options(adapter: 'postgres', host: 'localhost',
|
24
22
|
username: 'root', password: 'secret', database: 'boutique')
|
25
23
|
c.email_options(via: :smtp, via_options: {host: 'smtp.example.org'})
|
26
24
|
end
|
@@ -41,14 +39,13 @@ Now setup the database tables (assuming you've already created the database and
|
|
41
39
|
credentials) and stick the `.css` and `.js` files in your project. Note that
|
42
40
|
`boutique.js` is dependent on jQuery.
|
43
41
|
|
44
|
-
$ boutique --migrate
|
42
|
+
$ bin/boutique --migrate
|
45
43
|
$ boutique --assets
|
46
44
|
new -- boutique.js
|
47
45
|
new -- boutique.css
|
48
46
|
$ mv boutique.js boutique.css /path/to/project/assets/.
|
49
47
|
|
50
|
-
Drip Emails
|
51
|
-
===========
|
48
|
+
# Drip Emails
|
52
49
|
|
53
50
|
Emails can be written in any templating format that `Tilt` accepts. Stick them
|
54
51
|
in `/path/to/emails-dir` (configured above in `config.ru`). Emails use
|
@@ -110,26 +107,31 @@ day by mistake. Use cron to schedule drips:
|
|
110
107
|
$ crontab -e
|
111
108
|
0 8 * * * boutique --drip
|
112
109
|
|
113
|
-
|
114
|
-
===========
|
110
|
+
# Rack Extensions
|
115
111
|
|
116
|
-
|
117
|
-
|
112
|
+
I recommend using these extensions in production:
|
113
|
+
|
114
|
+
* [Rack Timeout](https://github.com/heroku/rack-timeout)
|
115
|
+
* [Rack Throttle](https://github.com/datagraph/rack-throttle)
|
116
|
+
|
117
|
+
# Development
|
118
|
+
|
119
|
+
Run all tests with `rake`.
|
120
|
+
|
121
|
+
Run individual tests with `ruby path/to/test.rb` or `rake TEST=path/to/test.rb`.
|
118
122
|
|
119
123
|
To start the server for local development:
|
120
124
|
|
121
125
|
$ BOUTIQUE_DEV=1 shotgun
|
122
126
|
|
123
|
-
TODO
|
124
|
-
====
|
127
|
+
# TODO
|
125
128
|
|
126
|
-
* add
|
127
|
-
*
|
128
|
-
* add customizable? email integration for purchase receipts + recover
|
129
|
+
* add Stripe integration
|
130
|
+
* add template-able email integration for purchase receipts + recover
|
129
131
|
* add re-usable UI for purchasing, downloading, recover
|
132
|
+
* upgrade to Tilt 2.0
|
130
133
|
|
131
|
-
License
|
132
|
-
=======
|
134
|
+
# License
|
133
135
|
|
134
136
|
Copyright Hugh Bien - http://hughbien.com.
|
135
137
|
Released under BSD License, see LICENSE.md for more info.
|
data/{boutique → bin/boutique}
RENAMED
@@ -1,9 +1,8 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
require 'optparse'
|
3
|
-
require File.expand_path('lib/boutique', File.dirname(__FILE__))
|
4
|
-
require 'dm-migrations'
|
5
2
|
require 'date'
|
6
3
|
require 'fileutils'
|
4
|
+
require 'optparse'
|
5
|
+
require_relative '../lib/boutique'
|
7
6
|
|
8
7
|
ENV['BOUTIQUE_CMD'] = '1'
|
9
8
|
|
@@ -40,10 +39,7 @@ module Boutique
|
|
40
39
|
|
41
40
|
def self.migrate
|
42
41
|
load_config
|
43
|
-
|
44
|
-
rescue DataObjects::SyntaxError => e
|
45
|
-
DataMapper.auto_upgrade!
|
46
|
-
load_config
|
42
|
+
Boutique::Migrate.run
|
47
43
|
end
|
48
44
|
|
49
45
|
private
|
data/config.ru
CHANGED
@@ -1,18 +1,19 @@
|
|
1
|
+
require 'sqlite3'
|
1
2
|
require File.expand_path('lib/boutique', File.dirname(__FILE__))
|
2
3
|
|
3
|
-
Boutique.configure
|
4
|
-
#c.
|
4
|
+
Boutique.configure do |c|
|
5
|
+
# c.error_email 'dev@localhost'
|
5
6
|
c.stripe_api_key 'sk_test_abcdefghijklmnopqrstuvwxyz'
|
6
7
|
c.download_path '/download'
|
7
8
|
c.download_dir File.expand_path('temp', File.dirname(__FILE__))
|
8
9
|
c.db_options(
|
9
|
-
adapter: '
|
10
|
+
adapter: 'sqlite',
|
10
11
|
host: 'localhost',
|
11
12
|
username: 'root',
|
12
13
|
password: 'secret',
|
13
14
|
database: 'db.sqlite3')
|
14
15
|
c.email_options(via: :sendmail)
|
15
|
-
end
|
16
|
+
end if ENV['RACK_ENV'] != 'test'
|
16
17
|
|
17
18
|
Boutique.product('readme') do |p|
|
18
19
|
p.from 'support@localhost'
|
data/lib/boutique.rb
CHANGED
@@ -1,361 +1,18 @@
|
|
1
|
-
require 'rubygems'
|
2
1
|
require 'bundler/setup'
|
3
|
-
require '
|
4
|
-
require 'dm-core'
|
5
|
-
require 'dm-types'
|
6
|
-
require 'dm-timestamps'
|
7
|
-
require 'dm-validations'
|
2
|
+
require 'cgi'
|
8
3
|
require 'date'
|
9
|
-
require 'digest/sha1'
|
10
4
|
require 'json'
|
11
5
|
require 'openssl'
|
12
6
|
require 'pony'
|
13
7
|
require 'preamble'
|
14
|
-
require '
|
8
|
+
require 'securerandom'
|
9
|
+
require 'sequel'
|
10
|
+
require 'sinatra/base'
|
15
11
|
require 'tempfile'
|
12
|
+
require 'tilt'
|
16
13
|
require 'uri'
|
17
|
-
require 'cgi'
|
18
|
-
|
19
|
-
DataMapper::Model.raise_on_save_failure = true
|
20
|
-
|
21
|
-
module Boutique
|
22
|
-
VERSION = '0.0.11'
|
23
|
-
|
24
|
-
class << self
|
25
|
-
def configure(setup_db=true)
|
26
|
-
yield config
|
27
|
-
DataMapper.setup(:default, config.db_options) if setup_db
|
28
|
-
Pony.options = config.email_options if !config.email_options.nil?
|
29
|
-
end
|
30
|
-
|
31
|
-
def config
|
32
|
-
@config ||= Config.new('config')
|
33
|
-
end
|
34
|
-
|
35
|
-
def product(key)
|
36
|
-
yield Product.new(key)
|
37
|
-
end
|
38
|
-
|
39
|
-
def list(key)
|
40
|
-
yield List.new(key)
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
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
|
61
|
-
|
62
|
-
def reset_db
|
63
|
-
@db = {}
|
64
|
-
end
|
65
|
-
|
66
|
-
def [](key)
|
67
|
-
@db[key]
|
68
|
-
end
|
69
|
-
|
70
|
-
def []=(key, value)
|
71
|
-
@db[key] = value
|
72
|
-
end
|
73
|
-
|
74
|
-
def include?(key)
|
75
|
-
self.to_a.include?(key)
|
76
|
-
end
|
77
|
-
|
78
|
-
def to_a
|
79
|
-
@db.keys
|
80
|
-
end
|
81
|
-
end
|
82
|
-
|
83
|
-
def initialize(key)
|
84
|
-
@key = key
|
85
|
-
self.class[key] = self
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
class Emailer
|
90
|
-
def initialize(list, directory = nil)
|
91
|
-
@list = list
|
92
|
-
@directory = directory
|
93
|
-
end
|
94
|
-
|
95
|
-
def render(path, locals = {}, preamble = false)
|
96
|
-
path = @directory ?
|
97
|
-
File.join(@directory, path) :
|
98
|
-
full_path(path)
|
99
|
-
raise "File not found: #{path}" if !File.exist?(path)
|
100
|
-
|
101
|
-
yaml, body = preamble(path)
|
102
|
-
templates_for(path).each do |template|
|
103
|
-
blk = proc { body }
|
104
|
-
body = template.new(path, &blk).render(self, locals)
|
105
|
-
end
|
106
|
-
|
107
|
-
preamble ? [yaml, body] : body
|
108
|
-
end
|
109
|
-
|
110
|
-
def deliver(subscriber, path, locals = {})
|
111
|
-
locals = locals.merge(
|
112
|
-
subscribe_url: @list.subscribe_url,
|
113
|
-
confirm_url: subscriber.confirm_url,
|
114
|
-
unsubscribe_url: subscriber.unsubscribe_url)
|
115
|
-
yaml, body = self.render(path, locals, true)
|
116
|
-
if yaml['day'] == 0
|
117
|
-
ymd = Date.today.strftime("%Y-%m-%d")
|
118
|
-
Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
|
119
|
-
else
|
120
|
-
raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed?
|
121
|
-
Email.create(email_key: yaml['key'], subscriber: subscriber)
|
122
|
-
end
|
123
|
-
Pony.mail(
|
124
|
-
to: subscriber.email,
|
125
|
-
from: @list.from,
|
126
|
-
subject: yaml['subject'],
|
127
|
-
headers: {'Content-Type' => 'text/html'},
|
128
|
-
body: body)
|
129
|
-
rescue DataMapper::SaveFailureError
|
130
|
-
raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
|
131
|
-
end
|
132
|
-
|
133
|
-
def deliver_zero(subscriber)
|
134
|
-
self.deliver(subscriber, emails[0])
|
135
|
-
end
|
136
|
-
|
137
|
-
def blast(path, locals = {})
|
138
|
-
yaml, body = preamble(full_path(path))
|
139
|
-
email_key = yaml['key']
|
140
|
-
@list.subscribers.all(confirmed: true).each do |subscriber|
|
141
|
-
# TODO: speed up by moving filter outside of loop
|
142
|
-
if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
|
143
|
-
self.deliver(subscriber, path, locals)
|
144
|
-
end
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
def drip
|
149
|
-
today = Date.today
|
150
|
-
max_day = emails.keys.max || 0
|
151
|
-
subscribers = @list.subscribers.all(
|
152
|
-
:confirmed => true,
|
153
|
-
:drip_on.lt => today,
|
154
|
-
:drip_day.lt => max_day)
|
155
|
-
subscribers.each do |subscriber|
|
156
|
-
subscriber.drip_on = today
|
157
|
-
subscriber.drip_day += 1
|
158
|
-
subscriber.save
|
159
|
-
if (email_path = emails[subscriber.drip_day])
|
160
|
-
self.deliver(subscriber, email_path)
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
|
165
|
-
private
|
166
|
-
def full_path(path)
|
167
|
-
File.join(@list.emails, path)
|
168
|
-
end
|
169
|
-
|
170
|
-
def templates_for(path)
|
171
|
-
basename = File.basename(path)
|
172
|
-
basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
|
173
|
-
end
|
174
|
-
|
175
|
-
def emails
|
176
|
-
@emails ||= begin
|
177
|
-
emails = {}
|
178
|
-
Dir.entries(@list.emails).each do |filename|
|
179
|
-
next if File.directory?(filename)
|
180
|
-
# TODO: stop duplicating calls to preamble, store in memory
|
181
|
-
yaml, body = preamble(full_path(filename))
|
182
|
-
if yaml && yaml['day'] && yaml['key']
|
183
|
-
emails[yaml['day']] = filename
|
184
|
-
end
|
185
|
-
end
|
186
|
-
emails
|
187
|
-
end
|
188
|
-
end
|
189
|
-
|
190
|
-
def preamble(path)
|
191
|
-
Preamble.load(path)
|
192
|
-
rescue
|
193
|
-
[{}, File.read(path)]
|
194
|
-
end
|
195
|
-
end
|
196
|
-
|
197
|
-
class Config
|
198
|
-
include MemoryResource
|
199
|
-
attr_resource :dev_email,
|
200
|
-
:stripe_api_key,
|
201
|
-
:download_dir,
|
202
|
-
:download_path,
|
203
|
-
:db_options,
|
204
|
-
:email_options
|
205
|
-
end
|
206
|
-
|
207
|
-
class Product
|
208
|
-
include MemoryResource
|
209
|
-
attr_resource :from, :files, :price
|
210
|
-
end
|
211
|
-
|
212
|
-
class List
|
213
|
-
include MemoryResource
|
214
|
-
attr_resource :from, :emails, :url
|
215
|
-
|
216
|
-
def subscribers
|
217
|
-
Subscriber.all(list_key: self.key)
|
218
|
-
end
|
219
|
-
|
220
|
-
def subscribe_url
|
221
|
-
url = URI.parse(self.url)
|
222
|
-
params = [url.query]
|
223
|
-
params << "boutique=subscribe/#{CGI.escape(self.key)}"
|
224
|
-
url.query = params.compact.join('&')
|
225
|
-
url.to_s
|
226
|
-
end
|
227
|
-
end
|
228
|
-
|
229
|
-
class Purchase
|
230
|
-
include DataMapper::Resource
|
231
|
-
|
232
|
-
property :id, Serial
|
233
|
-
property :product_key, String, required: true
|
234
|
-
property :created_at, DateTime
|
235
|
-
property :counter, Integer, required: true
|
236
|
-
property :secret, String, required: true
|
237
|
-
property :transaction_id, String
|
238
|
-
property :email, String
|
239
|
-
property :name, String, format: :email_address
|
240
|
-
property :completed_at, DateTime
|
241
|
-
property :downloads, CommaSeparatedList
|
242
|
-
end
|
243
|
-
|
244
|
-
class Subscriber
|
245
|
-
include DataMapper::Resource
|
246
|
-
|
247
|
-
property :id, Serial
|
248
|
-
property :list_key, String, required: true, unique_index: :list_key_email
|
249
|
-
property :email, String, required: true, unique_index: :list_key_email, format: :email_address
|
250
|
-
property :secret, String, required: true
|
251
|
-
property :confirmed, Boolean
|
252
|
-
property :created_at, DateTime
|
253
|
-
property :drip_on, Date, required: true
|
254
|
-
property :drip_day, Integer, required: true, default: 0
|
255
|
-
|
256
|
-
validates_within :list_key, set: List
|
257
|
-
validates_uniqueness_of :email, scope: :list_key
|
258
|
-
|
259
|
-
has n, :emails
|
260
|
-
|
261
|
-
def initialize(*args)
|
262
|
-
super
|
263
|
-
self.secret ||= Digest::SHA1.hexdigest("#{rand(1000)}-#{Time.now}")[0..6]
|
264
|
-
self.drip_on ||= Date.today
|
265
|
-
end
|
266
|
-
|
267
|
-
def list
|
268
|
-
@list ||= List[self.list_key]
|
269
|
-
end
|
270
|
-
|
271
|
-
def confirm!(secret)
|
272
|
-
self.confirmed = true if self.secret == secret
|
273
|
-
self.save
|
274
|
-
end
|
275
|
-
|
276
|
-
def unconfirm!(secret)
|
277
|
-
self.confirmed = false if self.secret == secret
|
278
|
-
self.save
|
279
|
-
end
|
280
|
-
|
281
|
-
def confirm_url
|
282
|
-
secret_url("confirm")
|
283
|
-
end
|
284
|
-
|
285
|
-
def unsubscribe_url
|
286
|
-
secret_url("unsubscribe")
|
287
|
-
end
|
288
|
-
|
289
|
-
private
|
290
|
-
def secret_url(action)
|
291
|
-
url = URI.parse(self.list.url)
|
292
|
-
params = [url.query]
|
293
|
-
params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
|
294
|
-
url.query = params.compact.join('&')
|
295
|
-
url.to_s
|
296
|
-
end
|
297
|
-
end
|
298
|
-
|
299
|
-
class Email
|
300
|
-
include DataMapper::Resource
|
301
|
-
|
302
|
-
property :id, Serial
|
303
|
-
property :email_key, String, required: true
|
304
|
-
property :created_at, DateTime
|
305
|
-
|
306
|
-
validates_uniqueness_of :email_key, scope: :subscriber
|
307
|
-
validates_presence_of :subscriber
|
308
|
-
|
309
|
-
belongs_to :subscriber
|
310
|
-
end
|
311
|
-
|
312
|
-
DataMapper.finalize
|
313
|
-
|
314
|
-
class App < Sinatra::Base
|
315
|
-
set :raise_errors, false
|
316
|
-
set :show_exceptions, false
|
317
|
-
|
318
|
-
error do
|
319
|
-
Pony.mail(
|
320
|
-
:to => Boutique.config.dev_email,
|
321
|
-
:subject => 'Boutique Error',
|
322
|
-
:body => request.env['sinatra.error'].to_s
|
323
|
-
) if Boutique.config.dev_email
|
324
|
-
end
|
325
|
-
|
326
|
-
post '/subscribe/:list_key' do
|
327
|
-
list = get_list(params[:list_key])
|
328
|
-
subscriber = Subscriber.first_or_create(
|
329
|
-
list_key: list.key,
|
330
|
-
email: params[:email])
|
331
|
-
Emailer.new(list).deliver_zero(subscriber) rescue nil
|
332
|
-
''
|
333
|
-
end
|
334
|
-
|
335
|
-
post '/confirm/:list_key/:id/:secret' do
|
336
|
-
list = get_list(params[:list_key])
|
337
|
-
subscriber = get_subscriber(params[:id], list, params[:secret])
|
338
|
-
subscriber.confirm!(params[:secret])
|
339
|
-
''
|
340
|
-
end
|
341
|
-
|
342
|
-
post '/unsubscribe/:list_key/:id/:secret' do
|
343
|
-
list = get_list(params[:list_key])
|
344
|
-
subscriber = get_subscriber(params[:id], list, params[:secret])
|
345
|
-
subscriber.unconfirm!(params[:secret])
|
346
|
-
''
|
347
|
-
end
|
348
|
-
|
349
|
-
private
|
350
|
-
def get_list(list_key)
|
351
|
-
List[list_key] || halt(404)
|
352
|
-
end
|
353
14
|
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
secret: secret) || halt(404)
|
359
|
-
end
|
360
|
-
end
|
361
|
-
end
|
15
|
+
require_relative 'boutique/app'
|
16
|
+
require_relative 'boutique/config'
|
17
|
+
require_relative 'boutique/emailer'
|
18
|
+
require_relative 'boutique/version'
|