feste 0.2.1
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 +7 -0
- data/.gitignore +35 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +155 -0
- data/Rakefile +21 -0
- data/app/assets/javascripts/feste/application.js +1 -0
- data/app/assets/javascripts/feste/subscription-verification.js +72 -0
- data/app/assets/stylesheets/feste/application.css +4 -0
- data/app/assets/stylesheets/feste/base.css +150 -0
- data/app/assets/stylesheets/feste/modal.css +40 -0
- data/app/assets/stylesheets/feste/subscriptions.css +40 -0
- data/app/controllers/concerns/feste/authenticatable.rb +41 -0
- data/app/controllers/feste/subscriptions_controller.rb +43 -0
- data/app/models/feste/subscription.rb +64 -0
- data/app/views/feste/subscriptions/index.html.erb +42 -0
- data/app/views/layouts/feste/application.html.erb +28 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config/routes.rb +5 -0
- data/feste.gemspec +38 -0
- data/lib/feste/authentication/authentication.rb +11 -0
- data/lib/feste/authentication/clearance.rb +16 -0
- data/lib/feste/authentication/custom.rb +11 -0
- data/lib/feste/authentication/devise.rb +11 -0
- data/lib/feste/engine.rb +18 -0
- data/lib/feste/mailer.rb +127 -0
- data/lib/feste/template_helper.rb +16 -0
- data/lib/feste/user.rb +23 -0
- data/lib/feste/version.rb +3 -0
- data/lib/feste.rb +51 -0
- data/lib/generators/feste/install_generator.rb +40 -0
- data/lib/generators/feste/templates/install.rb +11 -0
- metadata +261 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
module Feste
|
2
|
+
class Subscription < ActiveRecord::Base
|
3
|
+
belongs_to :subscriber, polymorphic: true
|
4
|
+
|
5
|
+
before_create :generate_token
|
6
|
+
|
7
|
+
# Return the propper subscription token based on the propper subscriber and
|
8
|
+
# email category
|
9
|
+
# @param [Subscriber, ActionMailer::Base, Symbol]
|
10
|
+
#
|
11
|
+
# If the subscription does not exist, one is created in order to return the
|
12
|
+
# token. If the action is not categorized, then the subscription is not
|
13
|
+
# created, and nil is returned.
|
14
|
+
#
|
15
|
+
# @return [String, nil], the token or nil if a category cannot be found.
|
16
|
+
def self.get_token_for(subscriber, mailer, action)
|
17
|
+
transaction do
|
18
|
+
category = mailer.action_categories[action.to_sym] ||
|
19
|
+
mailer.action_categories[:all]
|
20
|
+
subscription = Feste::Subscription.find_or_create_by(
|
21
|
+
subscriber: subscriber,
|
22
|
+
category: category
|
23
|
+
)
|
24
|
+
subscription.token
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Return the subscriber based on an email address
|
29
|
+
# @param [String]
|
30
|
+
#
|
31
|
+
# @return [Subscriber, nil], the subscriber if one exists, or nil if none
|
32
|
+
# exists
|
33
|
+
def self.find_subscribed_user(email)
|
34
|
+
user_models.find do |model|
|
35
|
+
model.find_by(Feste.options[:email_source] => email)
|
36
|
+
end&.find_by(Feste.options[:email_source] => email)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Return the human readable version of a category name.
|
40
|
+
#
|
41
|
+
# Checks to see if there is an i18n key corresponding to the category. If
|
42
|
+
# not, then the category is titleized.
|
43
|
+
#
|
44
|
+
# @return [String]
|
45
|
+
def category_name
|
46
|
+
I18n.t("feste.categories.#{category}", default: category.titleize)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def self.user_models
|
52
|
+
ActiveRecord::Base.descendants.select do |klass|
|
53
|
+
klass.included_modules.include?(Feste::User)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def generate_token
|
58
|
+
if !self.token
|
59
|
+
self.token = Base64.
|
60
|
+
urlsafe_encode64("#{category}|#{subscriber_id}|#{subscriber_type}")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
<h3 class="subscriptions-tite">
|
2
|
+
Which emails would you like to keep receiving?
|
3
|
+
</h3>
|
4
|
+
<%= form_for @subscriber, url: subscriptions_path(token: params[:token]), method: :put, html: { id: "edit-subscriptions-form" } do |f| %>
|
5
|
+
<%= f.hidden_field Feste.options[:email_source], id: "subscriber-email" %>
|
6
|
+
<%= f.fields_for :subscriptions do |subscription_form| %>
|
7
|
+
<% @subscriber.subscriptions.order(category: :asc).each do |subscription| %>
|
8
|
+
<div class="subscription-category-container">
|
9
|
+
<%= label_tag do %>
|
10
|
+
<%= subscription_form.check_box(nil, {id: "subscription-#{subscription.category_name.parameterize}", checked: !subscription.canceled?}, subscription.id, nil) %>
|
11
|
+
<span class="subscription-category-name">
|
12
|
+
<%= subscription.category_name %>
|
13
|
+
</span>
|
14
|
+
<% end %>
|
15
|
+
</div>
|
16
|
+
<% end %>
|
17
|
+
<% end %>
|
18
|
+
|
19
|
+
<%= f.submit "Submit", class: "subscription-submit-button button button--submit" %>
|
20
|
+
<% end %>
|
21
|
+
|
22
|
+
<% if params[:token].present? %>
|
23
|
+
<div class="confirmation-modal-container hidden" id="confirmation-modal">
|
24
|
+
<div class="modal-overlay" data-js-modal-close="true">
|
25
|
+
<div class="modal-content">
|
26
|
+
<h4 class="modal-header">Please Confirm Your Email</h4>
|
27
|
+
<p class="modal-body">To continue, please verify your email address.</p>
|
28
|
+
<form action="#" class="cancel-subscription-form" id="cancel-subscription-form">
|
29
|
+
<div class="email-field-container" data-js-required="true">
|
30
|
+
<label class="error-label hidden" data-js-error="true">Please enter a valid email address</label>
|
31
|
+
<input type="text" name="email-confirmation-input" id="email-confirmation-input" class="email-field-input">
|
32
|
+
</div>
|
33
|
+
<button class="button button--submit" id="cofirm-submit-button">Confirm</button>
|
34
|
+
</form>
|
35
|
+
</div>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
|
39
|
+
<script type="text/javascript">
|
40
|
+
subscriptionVerification();
|
41
|
+
</script>
|
42
|
+
<% end %>
|
@@ -0,0 +1,28 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Email Subscriptions</title>
|
5
|
+
<meta charset="utf-8" />
|
6
|
+
<%= stylesheet_link_tag "feste/application.css" %>
|
7
|
+
<%= javascript_include_tag "feste/application.js" %>
|
8
|
+
</head>
|
9
|
+
|
10
|
+
<body>
|
11
|
+
<div class="container">
|
12
|
+
|
13
|
+
<% if flash[:notice] || flash[:success] %>
|
14
|
+
<% message = flash[:notice] || flash[:success] %>
|
15
|
+
<h4 class="flash-message <%= flash[:notice].present? ? 'flash-notice' : 'flash-success' %>"><%= message %></h4>
|
16
|
+
<% end %>
|
17
|
+
|
18
|
+
<main class="main-body">
|
19
|
+
|
20
|
+
<%= yield %>
|
21
|
+
|
22
|
+
</main>
|
23
|
+
|
24
|
+
</div>
|
25
|
+
|
26
|
+
<%= yield to: :javascript %>
|
27
|
+
</body>
|
28
|
+
</html>
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "feste"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/config/routes.rb
ADDED
data/feste.gemspec
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'feste/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "feste"
|
8
|
+
spec.version = Feste::VERSION
|
9
|
+
spec.authors = ["Josh Reinhardt"]
|
10
|
+
spec.email = ["joshua.e.reinhardt@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Email subscription management for Rails applications.}
|
13
|
+
spec.description = %q{Give your users the ability to manage their email subscriptions in your Rails application.}
|
14
|
+
spec.homepage = "https://github.com/jereinhardt/feste"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
spec.add_dependency "rails", "~> 5.0"
|
25
|
+
|
26
|
+
spec.add_development_dependency "bundler", "~> 1.14"
|
27
|
+
spec.add_development_dependency "byebug"
|
28
|
+
spec.add_development_dependency "capybara"
|
29
|
+
spec.add_development_dependency "capybara-webkit"
|
30
|
+
spec.add_development_dependency "devise"
|
31
|
+
spec.add_development_dependency "factory_bot"
|
32
|
+
spec.add_development_dependency "factory_bot_rails"
|
33
|
+
spec.add_development_dependency "pg", "~> 0.18"
|
34
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
35
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
36
|
+
spec.add_development_dependency "rspec-rails"
|
37
|
+
spec.add_development_dependency "shoulda-matchers"
|
38
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Feste
|
2
|
+
module Authentication
|
3
|
+
module Clearance
|
4
|
+
private
|
5
|
+
|
6
|
+
def current_user
|
7
|
+
::Clearance.
|
8
|
+
configuration.
|
9
|
+
user_model.
|
10
|
+
where(remember_token: cookies[::Clearance.configuration.cookie_name]).
|
11
|
+
where.not(remember_token: nil).
|
12
|
+
first
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/feste/engine.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
module Feste
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace Feste
|
4
|
+
|
5
|
+
config.generators do |g|
|
6
|
+
g.test_framework :rspec, fixture: false
|
7
|
+
g.fixture_replacement :factory_bot, dir: "spec/factories"
|
8
|
+
g.assets false
|
9
|
+
g.helper false
|
10
|
+
end
|
11
|
+
|
12
|
+
initializer "feste" do |app|
|
13
|
+
app.config.assets.precompile << proc do |path|
|
14
|
+
path =~ /\Afeste\/application\.(js|css)\z/
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/feste/mailer.rb
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
module Feste
|
2
|
+
module Mailer
|
3
|
+
def self.included(klass)
|
4
|
+
klass.include InstanceMethods
|
5
|
+
klass.extend ClassMethods
|
6
|
+
klass.send(:add_template_helper, TemplateHelper)
|
7
|
+
klass.class_eval do
|
8
|
+
class_attribute :action_categories
|
9
|
+
self.action_categories = {}
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module InstanceMethods
|
14
|
+
# Returns a Mail object or nil based on if the action has been categorized
|
15
|
+
# and if the subscriber is unsubscribed
|
16
|
+
# @param [Hash, &block]
|
17
|
+
#
|
18
|
+
# The subscriber is supplied as an argument in the headers through the
|
19
|
+
# :subscriber key. The :subscriber key is stripped from the headers before
|
20
|
+
# they are given as an argument to the superclass. If no subscriber is
|
21
|
+
# provided, then one will be inferred from the :to header.
|
22
|
+
#
|
23
|
+
# @return [Mail, nil], the Mail object or nil if the subscriber is
|
24
|
+
# unsubscribed
|
25
|
+
def mail(headers = {}, &block)
|
26
|
+
if current_action_category.present?
|
27
|
+
return message if @_mail_was_called && headers.blank? && !block
|
28
|
+
|
29
|
+
email = headers[:to].is_a?(String) ? headers[:to] : headers[:to].first
|
30
|
+
subscriber = headers[:subscriber] ||
|
31
|
+
Feste::Subscription.find_subscribed_user(email)
|
32
|
+
headers = headers.except(:subscriber)
|
33
|
+
|
34
|
+
if recipient_subscribed?(subscriber)
|
35
|
+
generate_subscription_token!(subscriber)
|
36
|
+
message = super(headers, &block)
|
37
|
+
else
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
else
|
41
|
+
super(headers, &block)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def current_action_category
|
48
|
+
self.action_categories[action_name.to_sym] ||
|
49
|
+
self.action_categories[:all]
|
50
|
+
end
|
51
|
+
|
52
|
+
def generate_subscription_token!(subscriber)
|
53
|
+
@_subscription_token ||= Feste::Subscription.
|
54
|
+
get_token_for(subscriber, self, action_name)
|
55
|
+
end
|
56
|
+
|
57
|
+
def recipient_subscribed?(subscriber)
|
58
|
+
!Feste::Subscription.find_or_create_by(
|
59
|
+
category: current_action_category,
|
60
|
+
subscriber: subscriber
|
61
|
+
)&.canceled?
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
module ClassMethods
|
66
|
+
# Assign action(s) to a category
|
67
|
+
# @param [Array, Symbol]
|
68
|
+
#
|
69
|
+
# The actions in the mailer that are included in the given category can be
|
70
|
+
# limited by listing them in an array of symbols.
|
71
|
+
#
|
72
|
+
# class ReminderMailer < ActionMailer::Base
|
73
|
+
#
|
74
|
+
# categorize [:send_reminder, :send_update], as: :reminder_emails
|
75
|
+
#
|
76
|
+
# def send_reminder(user)
|
77
|
+
# ...
|
78
|
+
# end
|
79
|
+
#
|
80
|
+
# def send_update(user)
|
81
|
+
# ...
|
82
|
+
# end
|
83
|
+
#
|
84
|
+
# def send_alert(user)
|
85
|
+
# ...
|
86
|
+
# end
|
87
|
+
# end
|
88
|
+
#
|
89
|
+
# ReminderMailer.action_categories => {
|
90
|
+
# send_reminder: :reminder_emails,
|
91
|
+
# send_update: :reminder_emails
|
92
|
+
# }
|
93
|
+
#
|
94
|
+
# If no array is provided, all actions in the mailer will be categorized.
|
95
|
+
#
|
96
|
+
# class ReminderMailer < ActionMailer::Base
|
97
|
+
#
|
98
|
+
# categorize as: :reminder_emails
|
99
|
+
#
|
100
|
+
# def send_reminder(user)
|
101
|
+
# ...
|
102
|
+
# end
|
103
|
+
#
|
104
|
+
# def send_update(user)
|
105
|
+
# ...
|
106
|
+
# end
|
107
|
+
#
|
108
|
+
# def send_alert(user)
|
109
|
+
# ...
|
110
|
+
# end
|
111
|
+
# end
|
112
|
+
#
|
113
|
+
# ReminderMailer.action_categories => { all: :reminder_emails }
|
114
|
+
def categorize(meths = [], as:)
|
115
|
+
actions = meths.empty? ? [:all] : meths
|
116
|
+
actions.each { |action| self.action_categories[action.to_sym] = as }
|
117
|
+
end
|
118
|
+
|
119
|
+
def action_methods
|
120
|
+
feste_methods = %w[
|
121
|
+
action_categories action_categories= action_categories?
|
122
|
+
]
|
123
|
+
Set.new(super - feste_methods)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Feste
|
2
|
+
module TemplateHelper
|
3
|
+
# Return the absolute path to subscriptions#index with the proper
|
4
|
+
# subscription token to identify the subscriber.
|
5
|
+
#
|
6
|
+
# @return [String]
|
7
|
+
def subscription_url
|
8
|
+
host = Feste.options[:host] ||
|
9
|
+
ActionMailer::Base.default_url_options[:host]
|
10
|
+
Feste::Engine.routes.url_helpers.subscriptions_url(
|
11
|
+
token: @_subscription_token,
|
12
|
+
host: host
|
13
|
+
)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/feste/user.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
module Feste
|
2
|
+
module User
|
3
|
+
def self.included(klass)
|
4
|
+
klass.include InstanceMethods
|
5
|
+
klass.class_eval do
|
6
|
+
has_many(
|
7
|
+
:subscriptions,
|
8
|
+
class_name: "Feste::Subscription",
|
9
|
+
as: :subscriber
|
10
|
+
)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module InstanceMethods
|
15
|
+
# Return the email address of the subscriber.
|
16
|
+
#
|
17
|
+
# @return [String]
|
18
|
+
def email_source
|
19
|
+
send(Feste.options[:email_source])
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/feste.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require "active_record"
|
2
|
+
|
3
|
+
require "feste/version"
|
4
|
+
require "feste/engine" if defined?(Rails)
|
5
|
+
require "feste/authentication/authentication"
|
6
|
+
require "feste/authentication/clearance"
|
7
|
+
require "feste/authentication/custom"
|
8
|
+
require "feste/authentication/devise"
|
9
|
+
require "feste/user"
|
10
|
+
require "feste/template_helper"
|
11
|
+
require "feste/mailer"
|
12
|
+
|
13
|
+
module Feste
|
14
|
+
mattr_accessor :options
|
15
|
+
|
16
|
+
def self.table_name_prefix
|
17
|
+
"feste_"
|
18
|
+
end
|
19
|
+
|
20
|
+
self.options = {
|
21
|
+
categories: [],
|
22
|
+
host: nil,
|
23
|
+
email_source: :email,
|
24
|
+
authenticate_with: nil
|
25
|
+
}
|
26
|
+
|
27
|
+
def self.configure
|
28
|
+
begin
|
29
|
+
yield(Config)
|
30
|
+
rescue NoConfigurationError => e
|
31
|
+
puts "FESTE CONFIGURATION WARNING: #{e}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
module Config
|
36
|
+
def self.method_missing(meth, *args, &block)
|
37
|
+
key = meth.to_s.slice(0, meth.to_s.length - 1).to_sym
|
38
|
+
if Feste.options.has_key?(key)
|
39
|
+
Feste.options[key] = args[0]
|
40
|
+
else
|
41
|
+
raise(
|
42
|
+
NoConfigurationError,
|
43
|
+
"There is no configuration option for #{key}"
|
44
|
+
)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class NoConfigurationError < StandardError
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
require "rails/generators/migration"
|
3
|
+
require "active_record"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module Feste
|
7
|
+
module Generators
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
include Rails::Generators::Migration
|
10
|
+
|
11
|
+
source_root File.expand_path("../templates", __FILE__)
|
12
|
+
|
13
|
+
def self.next_migration_number(dirname)
|
14
|
+
next_migration_number = current_migration_number(dirname) + 1
|
15
|
+
if ActiveRecord::Base.timestamped_migrations
|
16
|
+
[
|
17
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S"),
|
18
|
+
"%.14d" % next_migration_number
|
19
|
+
].max
|
20
|
+
else
|
21
|
+
"%.3d" % next_migration_number
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def copy_migration
|
26
|
+
migration_template(
|
27
|
+
"install.rb",
|
28
|
+
"db/migrate/install_feste.rb",
|
29
|
+
migration_version: migration_version
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
def migration_version
|
34
|
+
if ActiveRecord::VERSION::MAJOR >= 5
|
35
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def change
|
3
|
+
create_table :feste_subscriptions do |t|
|
4
|
+
t.integer :subscriber_id, null: false
|
5
|
+
t.string :subscriber_type, null: false
|
6
|
+
t.string :category, null: false
|
7
|
+
t.boolean :canceled, null: false, default: false
|
8
|
+
t.string :token, null: false
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|