correole 0.0.1 → 0.0.2

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 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