correole 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 2a600945ac9a19b338201ce5b6098c74cac2a1fa
4
- data.tar.gz: 2e1c5a6c6e7a765dd8b6c66ad54526fa38cd6e3f
3
+ metadata.gz: fa914631ebd1483e1e4ec3f2e72b2e6ad8b0842c
4
+ data.tar.gz: 48491bc95bdfaaf363f337dfce859af9310225a6
5
5
  SHA512:
6
- metadata.gz: bf5ce5f7f5befad4c24719a8a32f79ff6f03fa39c8796879c094908807dadfcc773a029d59e10a963c8b5690324d3b0f81791cad8d6377c4ea0ed7a8004d2f44
7
- data.tar.gz: b81547d79a84dcf4acbfbce6a18c15accd2ac7feb39602b3e297d5ca0793477770c224662dec2c102622499140b7bb992f3c92436be73c44078d7e0237f66b32
6
+ metadata.gz: 5ff71315753d357a1db73ca235e18d1f58c1c443e0b8f543439c00ba0210eedfb8ad5a9567fb492d362d8639273d2a2b21eec5a6712f2e87f2a6330edc4149ca
7
+ data.tar.gz: d2c0e6bbeb236291e188e6e14009cb0fb9668b50c3b0f09303239efb52a5ed76c90ccb9adb216e755ba26fc29fe121d25d799299f24de2a1ccdbb948c82c6058
@@ -0,0 +1,70 @@
1
+ DEFAULT_ENV = 'production'
2
+ DEFAULT_CONFIG_FILE = 'config.yml'
3
+
4
+ ENV['RACK_ENV'] ||= DEFAULT_ENV
5
+ ENV['CONFIG_FILE'] ||= DEFAULT_CONFIG_FILE
6
+
7
+ class Configuration
8
+
9
+ CONFIG_KEYS = [
10
+ 'QUIET',
11
+ 'FEED',
12
+ 'CONFIRMATION_URI',
13
+ 'BASE_URI',
14
+ 'SUBJECT',
15
+ 'FROM',
16
+ 'HTML_TEMPLATE',
17
+ 'PLAIN_TEMPLATE',
18
+ 'SMTP_HOST',
19
+ 'SMTP_PORT',
20
+ 'SMTP_USER',
21
+ 'SMTP_PASS',
22
+ 'SMTP_AUTH',
23
+ 'SMTP_TTLS' ]
24
+
25
+ class << self
26
+ CONFIG_KEYS.each do |k|
27
+ attr_accessor k.downcase.to_sym
28
+ end
29
+ end
30
+
31
+ def self.load!
32
+
33
+ config_file = File.expand_path "../#{ENV['CONFIG_FILE']}", __FILE__
34
+ YAML.load_file(config_file)[ENV['RACK_ENV']].each_pair do |k, v|
35
+ ENV[k.upcase] ||= v.to_s rescue abort "Cannot load configuration key #{k}."
36
+ end rescue qputs "Could not load configuration file #{config_file}."
37
+
38
+ CONFIG_KEYS.each do |k|
39
+ case k
40
+ when 'QUIET', 'SMTP_TTLS'
41
+ # Cannot store boolean values in ENV, thus this.
42
+ self.send("#{k.downcase}=".to_sym, ENV[k] == 'true')
43
+ when 'HTML_TEMPLATE', 'PLAIN_TEMPLATE'
44
+ file = File.expand_path "../#{ENV[k]}", __FILE__
45
+ template = File.read file rescue abort "Cannot load template #{ENV[k]}."
46
+ self.send("#{k.downcase}=".to_sym, template)
47
+ when 'SMTP_USER', 'SMTP_PASS'
48
+ # For user name and password, Mail interprets '' as an input.
49
+ # It doesn't do the same with nil.
50
+ self.send("#{k.downcase}=".to_sym, ENV[k] == '' ? nil : ENV[k])
51
+ else
52
+ self.send("#{k.downcase}=".to_sym, ENV[k])
53
+ end
54
+ end
55
+
56
+ Mail.defaults do
57
+ delivery_method :smtp,
58
+ address: Configuration.smtp_host,
59
+ port: Configuration.smtp_port,
60
+ user_name: Configuration.smtp_user,
61
+ password: Configuration.smtp_pass,
62
+ authentication: Configuration.smtp_auth,
63
+ enable_starttls_auto: Configuration.smtp_ttls
64
+ end
65
+
66
+ end
67
+
68
+ end
69
+
70
+ Configuration.load!
@@ -0,0 +1,10 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: test.db
4
+
5
+ development:
6
+ adapter: sqlite3
7
+ database: development.db
8
+
9
+ production:
10
+ adapter: postgresql
@@ -0,0 +1,12 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/activerecord'
3
+ require 'correole/qputs'
4
+ require 'correole/subscriber'
5
+ require 'correole/item'
6
+ require 'correole/feed'
7
+ require 'correole/api'
8
+ require 'correole/send'
9
+ require 'correole/purge'
10
+ require 'net/http'
11
+ require 'mail'
12
+ require 'configuration'
@@ -0,0 +1,18 @@
1
+ production: &prod
2
+ quiet: false
3
+ base_uri: http://newsletter.ruslanledesma.com
4
+ feed: http://ruslanledesma.com/feed.xml
5
+ confirmation_uri: http://ruslanledesma.com/unsubscribed/
6
+ subject: '<%= title %>: newsletter for <%= date %>'
7
+ from: '<%= title %> <no-reply@ruslanledesma.com>'
8
+ html_template: production.html.erb
9
+ plain_template: production.txt.erb
10
+ smtp_host:
11
+ smtp_port:
12
+ smtp_user:
13
+ smtp_pass:
14
+ smtp_auth:
15
+ smtp_ttls:
16
+
17
+ development:
18
+ <<: *prod
@@ -0,0 +1,21 @@
1
+ <html>
2
+ <body>
3
+ <h1><%= title %></h1>
4
+ <h2>New posts</h2>
5
+ <ul>
6
+ <% for item in unsent_items %>
7
+ <li>
8
+ <h3>
9
+ <a href="<%= item.link %>"><%= item.title %></a>
10
+ </h3>
11
+ <% if item.pub_date -%>
12
+ <p><i><%= item.pub_date.to_date %></i></p>
13
+ <% end -%>
14
+ <p><%= item.description %></p>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+
19
+ <a href="<%= unsubscribe_uri %>">Unsubscribe here.</a>
20
+ </body>
21
+ </html>
@@ -0,0 +1,13 @@
1
+ <%= title %>
2
+
3
+ New posts
4
+ <% for item in unsent_items %>
5
+ - <%= item.title %>
6
+ <% if item.pub_date -%>
7
+ <%= item.pub_date.to_date %>
8
+ <% end -%>
9
+ <%= item.link %>
10
+
11
+ <%= item.description %>
12
+ <% end %>
13
+ Unsubscribe here: <%= unsubscribe_uri %>
@@ -0,0 +1,15 @@
1
+ test:
2
+ quiet: true
3
+ base_uri: http://test.ruslanledesma.com
4
+ feed: http://ruslanledesma.com/feed.xml # reset by env for end-to-end test
5
+ confirmation_uri: http://newsletter.ruslanledesma.com
6
+ subject: 'Test <%= title %> - <%= date %>'
7
+ from: '<%= title %> <no-reply@ruslanledesma.com>'
8
+ html_template: test.html.erb
9
+ plain_template: test.txt.erb
10
+ smtp_host: localhost # reset by env for end-to-end test
11
+ smtp_port: 25 # reset by env for end-to-end test
12
+ smtp_user:
13
+ smtp_pass:
14
+ smtp_auth:
15
+ smtp_ttls: false
@@ -0,0 +1,21 @@
1
+ <html>
2
+ <body>
3
+ <h1><%= title %></h1>
4
+ <h2>Items</h2>
5
+ <ul>
6
+ <% for item in unsent_items %>
7
+ <li>
8
+ <% if item.pub_date -%>
9
+ <i><%= item.pub_date.to_date %></i>
10
+ <% end -%>
11
+ <h3>
12
+ <a href="<%= item.link %>"><%= item.title %></a>
13
+ </h3>
14
+ <p><%= item.description %></p>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+
19
+ <a href="<%= unsubscribe_uri %>">Unsubscribe here.</a>
20
+ </body>
21
+ </html>
@@ -0,0 +1,13 @@
1
+ <%= title %>
2
+
3
+ Items
4
+ <% for item in unsent_items %>
5
+ - <%= item.title %>
6
+ <% if item.pub_date -%>
7
+ <%= item.pub_date.to_date %>
8
+ <% end -%>
9
+ <%= item.link %>
10
+
11
+ <%= item.description %>
12
+ <% end %>
13
+ Unsubscribe here: <%= unsubscribe_uri %>
@@ -0,0 +1,9 @@
1
+ class CreateDatabase < ActiveRecord::Migration
2
+ def change
3
+ create_table :subscribers do |t|
4
+ t.string :email, null: false
5
+ t.index :email, unique: true
6
+ t.timestamps null: false
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ class CreateItems < ActiveRecord::Migration
2
+ def change
3
+ create_table :items do |t|
4
+ t.string :title, null: false
5
+ t.string :description, null: false
6
+ t.string :link, null: false
7
+ t.timestamp :pub_date, null: true
8
+ t.index :link, unique: true
9
+ t.timestamps null: false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,118 @@
1
+ class Api < Sinatra::Base
2
+
3
+ UNSUBSCRIBE_PATH = '/unsubscribe'
4
+ SUBSCRIBERS_ALLOWED_METHODS = 'PUT, DELETE, OPTIONS'
5
+ SUBSCRIBERS_ALLOWED_ORIGIN = '*'
6
+ UNSUBSCRIBE_ALLOWED_METHODS = 'GET, OPTIONS'
7
+ UNSUBSCRIBE_ALLOWED_ORIGIN = '*'
8
+
9
+ set :server, :thin
10
+ enable :logging
11
+ disable :show_exceptions
12
+ use ActiveRecord::ConnectionAdapters::ConnectionManagement
13
+
14
+ before do
15
+ content_type 'text/plain'
16
+ end
17
+
18
+ def subscribe(params)
19
+ response.headers['Access-Control-Allow-Origin'] = SUBSCRIBERS_ALLOWED_ORIGIN
20
+ s = Subscriber.new(email: params[:email])
21
+ return 400 if not s.valid?
22
+ begin
23
+ s.save
24
+ logger.info("Subscribed #{params[:email]}.")
25
+ rescue ActiveRecord::RecordNotUnique
26
+ logger.info("Already subscribed #{params[:email]}.")
27
+ Subscriber.find_by_email(params[:email]).touch
28
+ end
29
+ "#{params[:email]}\n"
30
+ end
31
+
32
+ def unsubscribe(params)
33
+ response.headers['Access-Control-Allow-Origin'] = UNSUBSCRIBE_ALLOWED_ORIGIN
34
+ s = Subscriber.new(email: params[:email])
35
+ return 400 if not s.valid?
36
+ s = Subscriber.find_by_email(params[:email])
37
+ if s != nil
38
+ s.delete
39
+ logger.info("Unsubscribed #{params[:email]}.")
40
+ else
41
+ logger.info("Tried to unsubscribe #{params[:email]} but address is not subscribed.")
42
+ end
43
+ "#{params[:email]}\n"
44
+ end
45
+
46
+ def subscribers_method_not_allowed
47
+ response.headers['Access-Control-Allow-Methods'] = SUBSCRIBERS_ALLOWED_METHODS
48
+ 405
49
+ end
50
+
51
+ def unsubscribe_method_not_allowed
52
+ response.headers['Access-Control-Allow-Methods'] = UNSUBSCRIBE_ALLOWED_METHODS
53
+ 405
54
+ end
55
+
56
+ options '/subscribers/:email' do
57
+ response.headers['Access-Control-Allow-Methods'] = SUBSCRIBERS_ALLOWED_METHODS
58
+ response.headers['Access-Control-Allow-Origin'] = SUBSCRIBERS_ALLOWED_ORIGIN
59
+ 200
60
+ end
61
+
62
+ put '/subscribers/:email' do
63
+ subscribe(params)
64
+ end
65
+
66
+ delete '/subscribers/:email' do
67
+ unsubscribe(params)
68
+ end
69
+
70
+ [ :get,
71
+ :post,
72
+ :patch
73
+ ].each do |verb|
74
+ send verb, '/subscribers/:email' do
75
+ subscribers_method_not_allowed
76
+ end
77
+ end
78
+
79
+ options "#{UNSUBSCRIBE_PATH}/:email" do
80
+ response.headers['Access-Control-Allow-Methods'] = UNSUBSCRIBE_ALLOWED_METHODS
81
+ response.headers['Access-Control-Allow-Origin'] = UNSUBSCRIBE_ALLOWED_ORIGIN
82
+ 200
83
+ end
84
+
85
+ get "#{UNSUBSCRIBE_PATH}/:email" do
86
+ r = unsubscribe(params)
87
+ return r if r.is_a? Integer
88
+ response.headers['Location'] = Configuration.confirmation_uri
89
+ [302, r]
90
+ end
91
+
92
+ [ :put,
93
+ :delete,
94
+ :post,
95
+ :patch
96
+ ].each do |verb|
97
+ send verb, "#{UNSUBSCRIBE_PATH}/:email" do
98
+ unsubscribe_method_not_allowed
99
+ end
100
+ end
101
+
102
+ not_found do
103
+ [404, "Not found\n"]
104
+ end
105
+
106
+ error 400 do
107
+ [400, "Bad request\n"]
108
+ end
109
+
110
+ error 405 do
111
+ [405, "Method not allowed\n"]
112
+ end
113
+
114
+ error 500 do
115
+ [500, "Internal server error\n"]
116
+ end
117
+
118
+ end
@@ -0,0 +1,37 @@
1
+ class Feed
2
+
3
+ def self.get
4
+ uri = URI Configuration.feed
5
+ xml = Net::HTTP.get uri
6
+ hash = Hash.from_xml xml
7
+ return {
8
+ :title => hash['rss']['channel']['title'],
9
+ :item => hash['rss']['channel']['item'].map { |i|
10
+ pub_date = nil
11
+ pub_date = Time.parse(i['pubDate']) if i.has_key? 'pubDate'
12
+ Item.new(title: i['title'],
13
+ description: i['description'],
14
+ link: i['link'],
15
+ pub_date: pub_date)
16
+ }
17
+ }
18
+ end
19
+
20
+ def self.split_items(feed)
21
+ split_feed = {
22
+ :title => feed[:title],
23
+ :unsent_item => [],
24
+ :sent_item => []
25
+ }
26
+ feed[:item].each do |i|
27
+ if Item.where(:link => i.link).any?
28
+ split_feed[:sent_item] << i
29
+ else
30
+ split_feed[:unsent_item] << i
31
+ end
32
+ end
33
+ return split_feed
34
+ end
35
+
36
+
37
+ end
@@ -0,0 +1,17 @@
1
+ class Item < ActiveRecord::Base
2
+ validates :title, presence: true
3
+ validates :description, presence: true
4
+ validates :link, presence: true, format: /http.+/
5
+
6
+ def ==(o)
7
+ return o.class == self.class &&
8
+ o.title == self.title &&
9
+ o.description == self.description &&
10
+ o.link == self.link &&
11
+ o.pub_date == self.pub_date &&
12
+ o.id == self.id &&
13
+ o.created_at == self.created_at &&
14
+ o.updated_at == self.updated_at
15
+ end
16
+
17
+ end
@@ -0,0 +1,18 @@
1
+ class Purge
2
+
3
+ def self.run!
4
+ qputs "Fetch feed from #{Configuration.feed}."
5
+ feed = Feed.get
6
+ unsent_items = Feed.split_items(feed)[:unsent_item]
7
+ if unsent_items.empty?
8
+ qputs 'There are no new items, exiting.'
9
+ return
10
+ end
11
+ qputs "There are #{unsent_items.length} new items. The items are the following."
12
+ unsent_items.each_with_index { |i, j| qputs "[#{j+1}] #{i.link}" }
13
+ qputs 'Purge the new items by remembering them.'
14
+ unsent_items.each { |i| i.save }
15
+ qputs 'Done.'
16
+ end
17
+
18
+ end
@@ -0,0 +1,3 @@
1
+ def qputs(s)
2
+ puts s if not Configuration.quiet
3
+ end
@@ -0,0 +1,81 @@
1
+ class Send
2
+
3
+ def self.run!
4
+ qputs "Fetch feed from #{Configuration.feed}."
5
+ feed = Feed.get
6
+ split_feed = Feed.split_items feed
7
+ if split_feed[:unsent_item].empty?
8
+ qputs 'There are no new items, exiting.'
9
+ return
10
+ end
11
+ qputs "There are #{split_feed[:unsent_item].length} new items. The items are the following."
12
+ split_feed[:unsent_item].each_with_index { |i, j| qputs "[#{j+1}] #{i.link}" }
13
+ html = compose_html split_feed
14
+ plain = compose_plain split_feed
15
+ count = Subscriber.count
16
+ Subscriber.find_each.with_index do |s, i|
17
+ html_s = personalize html, s.email
18
+ plain_s = personalize plain, s.email
19
+ qputs "[#{i+1}/#{count}] Send newsletter to #{s.email}."
20
+ begin
21
+ send_out feed[:title], html_s, plain_s, s.email
22
+ rescue => exc
23
+ qputs "Could not send newsletter to #{s.email} for the following reason."
24
+ qputs exc.message
25
+ end
26
+ end
27
+ qputs 'Remember new items.'
28
+ split_feed[:unsent_item].each { |i| i.save }
29
+ qputs 'Done.'
30
+ end
31
+
32
+ private
33
+
34
+ def self.template_bindings(split_feed)
35
+ title = split_feed[:title]
36
+ unsent_items = split_feed[:unsent_item]
37
+ sent_items = split_feed[:sent_item]
38
+ unsubscribe_uri = nil # supress unused variable warning
39
+ unsubscribe_uri = "#{Configuration.base_uri}#{Api::UNSUBSCRIBE_PATH}/<%= recipient %>"
40
+ title = '' if !title.is_a?(String)
41
+ unsent_items = [] if !unsent_items.is_a?(Array)
42
+ sent_items = [] if !unsent_items.is_a?(Array)
43
+ return binding
44
+ end
45
+
46
+ def self.compose_html(split_feed)
47
+ template = Configuration.html_template
48
+ bindings = template_bindings(split_feed)
49
+ return ERB.new(template, nil, '-').result(bindings)
50
+ end
51
+
52
+ def self.compose_plain(split_feed)
53
+ template = Configuration.plain_template
54
+ bindings = template_bindings(split_feed)
55
+ return ERB.new(template, nil, '-').result(bindings)
56
+ end
57
+
58
+ def self.personalize(message, recipient)
59
+ return ERB.new(message).result(binding)
60
+ end
61
+
62
+ def self.send_out(title, html, plain, recipient)
63
+ date = nil # supress unused variable warning
64
+ date = Date.today.strftime('%a, %d %b %Y')
65
+ Mail.deliver do
66
+ to recipient
67
+ from ERB.new(Configuration.from).result(binding)
68
+ subject ERB.new(Configuration.subject).result(binding)
69
+
70
+ text_part do
71
+ body plain
72
+ end
73
+
74
+ html_part do
75
+ content_type 'text/html; charset=UTF-8'
76
+ body html
77
+ end
78
+ end
79
+ end
80
+
81
+ end
@@ -0,0 +1,3 @@
1
+ class Subscriber < ActiveRecord::Base
2
+ validates :email, presence: true, format: /.+@.+/
3
+ end
@@ -0,0 +1,4 @@
1
+ module Correole
2
+ VERSION = '0.0.2'
3
+ DATE = '2016-07-26'
4
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: correole
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Ledesma Garza
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-25 00:00:00.000000000 Z
11
+ date: 2016-07-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sinatra
@@ -202,6 +202,25 @@ extensions: []
202
202
  extra_rdoc_files: []
203
203
  files:
204
204
  - bin/correole
205
+ - config/configuration.rb
206
+ - config/database.yml
207
+ - config/dependencies.rb
208
+ - config/example.config.yml
209
+ - config/production.html.erb
210
+ - config/production.txt.erb
211
+ - config/test.config.yml
212
+ - config/test.html.erb
213
+ - config/test.txt.erb
214
+ - db/migrate/0001_create_database.rb
215
+ - db/migrate/0002_create_items.rb
216
+ - lib/correole/api.rb
217
+ - lib/correole/feed.rb
218
+ - lib/correole/item.rb
219
+ - lib/correole/purge.rb
220
+ - lib/correole/qputs.rb
221
+ - lib/correole/send.rb
222
+ - lib/correole/subscriber.rb
223
+ - lib/correole/version.rb
205
224
  homepage: http://ruslanledesma.com/
206
225
  licenses:
207
226
  - MIT