boutique 0.0.11 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ };