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.
@@ -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,3 @@
1
+ module Boutique
2
+ VERSION = '0.1.0'
3
+ 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">&times;</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
+ };