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.
@@ -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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Feste::Engine.routes.draw do
2
+ get "/", to: "subscriptions#index", as: :subscriptions
3
+ put "/", to: "subscriptions#update"
4
+ patch "/", to: "subscriptions#update"
5
+ end
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,11 @@
1
+ module Feste
2
+ module Authentication
3
+ module DefaultInstanceMethods
4
+ private
5
+
6
+ def current_user
7
+ nil
8
+ end
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,11 @@
1
+ module Feste
2
+ module Authentication
3
+ module Custom
4
+ private
5
+
6
+ def current_user
7
+ Feste.options[:authenticate_with].call(self)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Feste
2
+ module Authentication
3
+ module Devise
4
+ private
5
+
6
+ def current_user
7
+ main_app.scope.env['warden'].user
8
+ end
9
+ end
10
+ end
11
+ end
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Feste
2
+ VERSION = "0.2.1"
3
+ 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