boutique 0.0.11 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/lib/boutique/app.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
module Boutique
|
2
|
+
class App < Sinatra::Base
|
3
|
+
set :logging, true
|
4
|
+
set :raise_errors, false
|
5
|
+
set :show_exceptions, false
|
6
|
+
|
7
|
+
error do
|
8
|
+
err = request.env['sinatra.error']
|
9
|
+
Pony.mail(
|
10
|
+
:to => Boutique.config.error_email,
|
11
|
+
:subject => "[Boutique Error] #{err.message}",
|
12
|
+
:body => [request.env.to_s, err.message, err.backtrace].compact.flatten.join("\n")
|
13
|
+
) if Boutique.config.error_email
|
14
|
+
raise err
|
15
|
+
end
|
16
|
+
|
17
|
+
get '/subscribe/:list_key' do
|
18
|
+
list = get_list(params[:list_key])
|
19
|
+
begin
|
20
|
+
subscriber = Subscriber.find_or_create(
|
21
|
+
list_key: list.key,
|
22
|
+
email: params[:email])
|
23
|
+
Emailer.new(list).deliver_zero(subscriber) rescue nil
|
24
|
+
jsonp
|
25
|
+
rescue Sequel::ValidationFailed => e
|
26
|
+
halt 400, e.message
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
get '/confirm/:list_key/:id/:secret' do
|
31
|
+
list = get_list(params[:list_key])
|
32
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
33
|
+
subscriber.confirm!(params[:secret])
|
34
|
+
jsonp
|
35
|
+
end
|
36
|
+
|
37
|
+
get '/unsubscribe/:list_key/:id/:secret' do
|
38
|
+
list = get_list(params[:list_key])
|
39
|
+
subscriber = get_subscriber(params[:id], list, params[:secret])
|
40
|
+
subscriber.unconfirm!(params[:secret])
|
41
|
+
jsonp
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
def jsonp
|
46
|
+
params[:jsonp].nil? ? '' : "#{params[:jsonp]}()"
|
47
|
+
end
|
48
|
+
|
49
|
+
def get_list(list_key)
|
50
|
+
List[list_key] || halt(404)
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_subscriber(id, list, secret)
|
54
|
+
Subscriber.first(
|
55
|
+
id: params[:id],
|
56
|
+
list_key: list.key,
|
57
|
+
secret: secret) || halt(404)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module Boutique
|
2
|
+
class << self
|
3
|
+
attr_accessor :database
|
4
|
+
|
5
|
+
def configure
|
6
|
+
yield config
|
7
|
+
Pony.options = config.email_options if !config.email_options.nil?
|
8
|
+
Boutique.database = Sequel.connect(config.db_options)
|
9
|
+
require_relative 'model'
|
10
|
+
end
|
11
|
+
|
12
|
+
def config
|
13
|
+
@config ||= Config.new('config')
|
14
|
+
end
|
15
|
+
|
16
|
+
def product(key)
|
17
|
+
yield Product.new(key)
|
18
|
+
end
|
19
|
+
|
20
|
+
def list(key)
|
21
|
+
yield List.new(key)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
module MemoryResource
|
26
|
+
def self.included(base)
|
27
|
+
base.extend(ClassMethods)
|
28
|
+
base.attr_resource :key
|
29
|
+
base.reset_db
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
def attr_resource(*names)
|
34
|
+
names.each do |name|
|
35
|
+
define_method(name) do |*args|
|
36
|
+
value = args[0]
|
37
|
+
instance_variable_set("@#{name}".to_sym, value) if !value.nil?
|
38
|
+
instance_variable_get("@#{name}".to_sym)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def reset_db
|
44
|
+
@db = {}
|
45
|
+
end
|
46
|
+
|
47
|
+
def [](key)
|
48
|
+
@db[key]
|
49
|
+
end
|
50
|
+
|
51
|
+
def []=(key, value)
|
52
|
+
@db[key] = value
|
53
|
+
end
|
54
|
+
|
55
|
+
def include?(key)
|
56
|
+
self.to_a.include?(key)
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_a
|
60
|
+
@db.keys
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def initialize(key)
|
65
|
+
@key = key
|
66
|
+
self.class[key] = self
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class Config
|
71
|
+
include MemoryResource
|
72
|
+
attr_resource :error_email,
|
73
|
+
:stripe_api_key,
|
74
|
+
:download_dir,
|
75
|
+
:download_path,
|
76
|
+
:db_options,
|
77
|
+
:email_options
|
78
|
+
end
|
79
|
+
|
80
|
+
class Product
|
81
|
+
include MemoryResource
|
82
|
+
attr_resource :from, :files, :price
|
83
|
+
end
|
84
|
+
|
85
|
+
class List
|
86
|
+
include MemoryResource
|
87
|
+
attr_resource :from, :emails, :url
|
88
|
+
|
89
|
+
def subscribers
|
90
|
+
Subscriber.where(list_key: self.key)
|
91
|
+
end
|
92
|
+
|
93
|
+
def subscribe_url
|
94
|
+
url = URI.parse(self.url)
|
95
|
+
params = [url.query]
|
96
|
+
params << "boutique=subscribe/#{CGI.escape(self.key)}"
|
97
|
+
url.query = params.compact.join('&')
|
98
|
+
url.to_s
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module Boutique
|
2
|
+
class Emailer
|
3
|
+
def initialize(list, directory = nil)
|
4
|
+
@list = list
|
5
|
+
@directory = directory
|
6
|
+
end
|
7
|
+
|
8
|
+
def render(path, locals = {}, pre = false)
|
9
|
+
path = @directory ?
|
10
|
+
File.join(@directory, path) :
|
11
|
+
full_path(path)
|
12
|
+
raise "File not found: #{path}" if !File.exist?(path)
|
13
|
+
|
14
|
+
yaml, body = preamble(path)
|
15
|
+
templates_for(path).each do |template|
|
16
|
+
blk = proc { body }
|
17
|
+
body = template.new(path, &blk).render(self, locals)
|
18
|
+
end
|
19
|
+
|
20
|
+
pre ? [yaml, body] : body
|
21
|
+
end
|
22
|
+
|
23
|
+
def deliver(subscriber, path, locals = {})
|
24
|
+
locals = locals.merge(
|
25
|
+
subscribe_url: @list.subscribe_url,
|
26
|
+
confirm_url: subscriber.confirm_url,
|
27
|
+
unsubscribe_url: subscriber.unsubscribe_url)
|
28
|
+
yaml, body = self.render(path, locals, true)
|
29
|
+
if yaml['day'] == 0
|
30
|
+
ymd = Date.today.strftime("%Y-%m-%d")
|
31
|
+
Email.create(email_key: "#{yaml['key']}-#{ymd}", subscriber: subscriber)
|
32
|
+
else
|
33
|
+
raise "Unconfirmed #{subscriber.email} for #{yaml['key']}" if !subscriber.confirmed
|
34
|
+
Email.create(email_key: yaml['key'], subscriber: subscriber)
|
35
|
+
end
|
36
|
+
Pony.mail(
|
37
|
+
to: subscriber.email,
|
38
|
+
from: @list.from,
|
39
|
+
subject: yaml['subject'],
|
40
|
+
headers: {'Content-Type' => 'text/html'},
|
41
|
+
body: body)
|
42
|
+
rescue DataMapper::SaveFailureError
|
43
|
+
raise "Duplicate email #{yaml['key']} to #{subscriber.email}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def deliver_zero(subscriber)
|
47
|
+
self.deliver(subscriber, emails[0])
|
48
|
+
end
|
49
|
+
|
50
|
+
def blast(path, locals = {})
|
51
|
+
yaml, body = preamble(full_path(path))
|
52
|
+
email_key = yaml['key']
|
53
|
+
@list.subscribers.where(confirmed: true).each do |subscriber|
|
54
|
+
# TODO: speed up by moving filter outside of loop
|
55
|
+
if Email.first(email_key: yaml['key'], subscriber: subscriber).nil?
|
56
|
+
self.deliver(subscriber, path, locals)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def drip
|
62
|
+
today = Date.today
|
63
|
+
max_day = emails.keys.max || 0
|
64
|
+
subscribers = @list.subscribers.
|
65
|
+
where(confirmed: true).
|
66
|
+
where { (drip_on < today) & (drip_day < max_day) }
|
67
|
+
subscribers.each do |subscriber|
|
68
|
+
subscriber.drip_on = today
|
69
|
+
subscriber.drip_day += 1
|
70
|
+
subscriber.save
|
71
|
+
if (email_path = emails[subscriber.drip_day])
|
72
|
+
self.deliver(subscriber, email_path)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
def full_path(path)
|
79
|
+
File.join(@list.emails, path)
|
80
|
+
end
|
81
|
+
|
82
|
+
def templates_for(path)
|
83
|
+
basename = File.basename(path)
|
84
|
+
basename.split('.')[1..-1].reverse.map { |ext| Tilt[ext] }
|
85
|
+
end
|
86
|
+
|
87
|
+
def emails
|
88
|
+
@emails ||= begin
|
89
|
+
emails = {}
|
90
|
+
Dir.entries(@list.emails).each do |filename|
|
91
|
+
next if File.directory?(filename)
|
92
|
+
# TODO: stop duplicating calls to preamble, store in memory
|
93
|
+
yaml, body = preamble(full_path(filename))
|
94
|
+
if yaml && yaml['day'] && yaml['key']
|
95
|
+
emails[yaml['day']] = filename
|
96
|
+
end
|
97
|
+
end
|
98
|
+
emails
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def preamble(path)
|
103
|
+
data = Preamble.load(path)
|
104
|
+
[data.metadata, data.content]
|
105
|
+
rescue
|
106
|
+
[{}, File.read(path)]
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Boutique
|
2
|
+
class Migrate
|
3
|
+
def self.run
|
4
|
+
Sequel.extension :migration
|
5
|
+
Sequel::Migrator.run(Boutique.database, File.join(File.dirname(__FILE__), '..', '..', 'migrate'))
|
6
|
+
# re-parse the schema after table changes
|
7
|
+
Subscriber.dataset = Subscriber.dataset
|
8
|
+
Email.dataset = Email.dataset
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class Subscriber < Sequel::Model
|
13
|
+
one_to_many :emails
|
14
|
+
set_allowed_columns :list_key, :email
|
15
|
+
|
16
|
+
def initialize(*args)
|
17
|
+
super
|
18
|
+
self.secret ||= SecureRandom.hex(3)
|
19
|
+
self.drip_on ||= Date.today
|
20
|
+
self.created_at ||= DateTime.now
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate
|
24
|
+
super
|
25
|
+
errors.add(:list_key, 'is invalid') if !List.include?(list_key)
|
26
|
+
errors.add(:email, 'is invalid') if email !~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i
|
27
|
+
|
28
|
+
count = self.class.
|
29
|
+
where(list_key: list_key, email: email).
|
30
|
+
exclude(id: id).
|
31
|
+
count
|
32
|
+
errors.add(:email, 'has already subscribed') if count > 0
|
33
|
+
end
|
34
|
+
|
35
|
+
def list
|
36
|
+
@list ||= List[self.list_key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def confirm!(secret)
|
40
|
+
self.confirmed = true if self.secret == secret
|
41
|
+
self.save
|
42
|
+
end
|
43
|
+
|
44
|
+
def unconfirm!(secret)
|
45
|
+
self.confirmed = false if self.secret == secret
|
46
|
+
self.save
|
47
|
+
end
|
48
|
+
|
49
|
+
def confirm_url
|
50
|
+
secret_url("confirm")
|
51
|
+
end
|
52
|
+
|
53
|
+
def unsubscribe_url
|
54
|
+
secret_url("unsubscribe")
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
def secret_url(action)
|
59
|
+
url = URI.parse(self.list.url)
|
60
|
+
params = [url.query]
|
61
|
+
params << "boutique=#{action}/#{CGI.escape(self.list_key)}/#{self.id}/#{self.secret}"
|
62
|
+
url.query = params.compact.join('&')
|
63
|
+
url.to_s
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Email < Sequel::Model
|
68
|
+
many_to_one :subscriber
|
69
|
+
|
70
|
+
def initialize(*args)
|
71
|
+
super
|
72
|
+
self.created_at ||= DateTime.now
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate
|
76
|
+
super
|
77
|
+
errors.add(:subscriber_id, "can't be blank") if subscriber_id.nil?
|
78
|
+
|
79
|
+
count = self.class.
|
80
|
+
where(subscriber_id: subscriber_id, email_key: email_key).
|
81
|
+
exclude(id: id).
|
82
|
+
count
|
83
|
+
errors.add(:email_key, 'has already been sent') if count > 0
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
Sequel.migration do
|
2
|
+
change do
|
3
|
+
create_table(:subscribers) do
|
4
|
+
primary_key :id
|
5
|
+
column :list_key, String, null: false
|
6
|
+
column :email, String, null: false
|
7
|
+
column :secret, String, null: false
|
8
|
+
column :confirmed, TrueClass, null: false, default: false
|
9
|
+
column :created_at, DateTime, null: false
|
10
|
+
column :drip_on, Date, null: false
|
11
|
+
column :drip_day, Integer, null: false, default: 0
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table(:emails) do
|
15
|
+
primary_key :id
|
16
|
+
foreign_key :subscriber_id, :subscribers
|
17
|
+
column :email_key, String, null: false
|
18
|
+
column :created_at, DateTime, null: false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
var Boutique = {
|
2
|
+
URL: "/boutique/",
|
3
|
+
lists: {},
|
4
|
+
subscribe: function(key) {
|
5
|
+
var modal = this.buildModal(key);
|
6
|
+
modal.show();
|
7
|
+
$("body").css("overflow-y", "hidden");
|
8
|
+
if (modal.hasClass("unsubscribed")) {
|
9
|
+
modal.removeClass("unsubscribed").addClass("start");
|
10
|
+
}
|
11
|
+
return modal;
|
12
|
+
},
|
13
|
+
showModal: function(key, action) {
|
14
|
+
var modal = Boutique.buildModal(key);
|
15
|
+
var state = {confirm: "confirmed", unsubscribe: "unsubscribed"}[action];
|
16
|
+
modal.removeClass("start subscribed confirmed unsubscribed");
|
17
|
+
modal.addClass(state);
|
18
|
+
modal.show();
|
19
|
+
$("body").css("overflow-y", "hidden");
|
20
|
+
},
|
21
|
+
hideModal: function() {
|
22
|
+
$(".boutique").hide();
|
23
|
+
$("body").css("overflow-y", "auto");
|
24
|
+
},
|
25
|
+
list: function(key, configs) {
|
26
|
+
this.lists[key] = configs;
|
27
|
+
var params = this.params("boutique");
|
28
|
+
if (params) {
|
29
|
+
params = params.split("/");
|
30
|
+
var pAction = params[0];
|
31
|
+
var pKey = params[1];
|
32
|
+
var pId = params[2];
|
33
|
+
var pSecret = params[3];
|
34
|
+
if (key == pKey && pAction == "subscribe") {
|
35
|
+
Boutique.subscribe(key);
|
36
|
+
} else if (key == pKey && ($.inArray(pAction, ["subscribe", "unsubscribe"]))) {
|
37
|
+
if (document.domain == "localhost" && pId == "0") {
|
38
|
+
Boutique.showModal(key, pAction);
|
39
|
+
} else {
|
40
|
+
$.ajax(this.URL + pAction + "/" + key + "/" + pId + "/" + pSecret, {
|
41
|
+
dataType: "jsonp",
|
42
|
+
jsonp: "jsonp",
|
43
|
+
success: function() {
|
44
|
+
Boutique.showModal(key, pAction);
|
45
|
+
}
|
46
|
+
});
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
},
|
51
|
+
buildModal: function(key) {
|
52
|
+
var modal = $("#boutique-list-" + key);
|
53
|
+
if (modal.length) { return modal; }
|
54
|
+
|
55
|
+
var html = "";
|
56
|
+
html += '<div class="boutique start">';
|
57
|
+
html += ' <div class="boutique-modal">';
|
58
|
+
html += ' <div class="boutique-close">×</div>';
|
59
|
+
html += ' </div>';
|
60
|
+
html += '</div>';
|
61
|
+
|
62
|
+
modal = $(html);
|
63
|
+
modal.click(function(e) {
|
64
|
+
if (e.target != this) { return; }
|
65
|
+
e.preventDefault();
|
66
|
+
Boutique.hideModal();
|
67
|
+
});
|
68
|
+
modal.find(".boutique-close").click(function(e) {
|
69
|
+
e.preventDefault();
|
70
|
+
Boutique.hideModal();
|
71
|
+
});
|
72
|
+
modal.attr("id", "boutique-list-" + key);
|
73
|
+
|
74
|
+
var states = ["start", "subscribed", "confirmed", "unsubscribed"];
|
75
|
+
for (var i = 0; i < states.length; i++) {
|
76
|
+
var state = states[i];
|
77
|
+
var head = $('<div class="boutique-head '+ state +'"></div>');
|
78
|
+
var body = $('<div class="boutique-body '+ state +'"></div>');
|
79
|
+
head.html(this.lists[key][state]["title"]);
|
80
|
+
body.html(this.lists[key][state]["body"]);
|
81
|
+
if (state == "start") {
|
82
|
+
body.append(this.buildSubscribeForm(key));
|
83
|
+
} else if (this.lists[key][state]["button"]) {
|
84
|
+
body.append(this.buildButton(key, state));
|
85
|
+
}
|
86
|
+
modal.find(".boutique-modal").prepend(head, body);
|
87
|
+
}
|
88
|
+
$("body").append(modal);
|
89
|
+
return modal;
|
90
|
+
},
|
91
|
+
buildSubscribeForm: function(key) {
|
92
|
+
var html = "";
|
93
|
+
html += '<form>';
|
94
|
+
html += ' <p>';
|
95
|
+
html += ' <input type="email" name="email" placeholder="Email Address" /><br>';
|
96
|
+
html += ' <input type="submit" value="Subscribe Now" class="inlined" />';
|
97
|
+
html += ' </p>';
|
98
|
+
html += '</form>';
|
99
|
+
var options = this.lists[key]["start"];
|
100
|
+
|
101
|
+
var form = $(html);
|
102
|
+
form.attr("action", Boutique.URL + "subscribe/" + key);
|
103
|
+
if (options["button"]) { form.find("input[type=submit]").val(options["button"]); }
|
104
|
+
form.submit(function(e) {
|
105
|
+
e.preventDefault();
|
106
|
+
form.find("input[type=submit]").
|
107
|
+
attr("disabled", true).
|
108
|
+
val("Loading...");
|
109
|
+
if (document.domain == "localhost" &&
|
110
|
+
form.find("input[name=email]").val() == "example@example.com") {
|
111
|
+
form.parents(".boutique").removeClass("start").addClass("subscribed");
|
112
|
+
return;
|
113
|
+
}
|
114
|
+
$.ajax(form.attr("action"), {
|
115
|
+
data: form.serialize(),
|
116
|
+
dataType: "jsonp",
|
117
|
+
jsonp: "jsonp",
|
118
|
+
error: function() {
|
119
|
+
form.find("input[type=submit]").
|
120
|
+
attr("disabled", false).
|
121
|
+
val(options["button"] || "Subscribe Now");
|
122
|
+
},
|
123
|
+
success: function() {
|
124
|
+
form.parents(".boutique").
|
125
|
+
removeClass("start").
|
126
|
+
addClass("subscribed");
|
127
|
+
}
|
128
|
+
});
|
129
|
+
});
|
130
|
+
return form;
|
131
|
+
},
|
132
|
+
buildButton: function(key, state) {
|
133
|
+
var options = this.lists[key][state];
|
134
|
+
var button = $('<a class="boutique-button"></a>');
|
135
|
+
button.attr("href", options["href"]);
|
136
|
+
button.html(options["button"]);
|
137
|
+
var container = $("<p></p>");
|
138
|
+
container.append(button);
|
139
|
+
return container;
|
140
|
+
},
|
141
|
+
params: function(name) {
|
142
|
+
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [,""])[1].replace(/\+/g, '%20')) || null;
|
143
|
+
}
|
144
|
+
};
|