caffeinate 0.1.0
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/MIT-LICENSE +20 -0
- data/README.md +125 -0
- data/Rakefile +24 -0
- data/app/controllers/caffeinate/application_controller.rb +8 -0
- data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +19 -0
- data/app/models/caffeinate/application_record.rb +8 -0
- data/app/models/caffeinate/campaign.rb +20 -0
- data/app/models/caffeinate/campaign_subscription.rb +82 -0
- data/app/models/caffeinate/mailing.rb +99 -0
- data/app/views/layouts/caffeinate/application.html.erb +15 -0
- data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +1 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +14 -0
- data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +22 -0
- data/db/migrate/20201124183419_create_caffeinate_mailings.rb +19 -0
- data/lib/caffeinate.rb +30 -0
- data/lib/caffeinate/action_mailer.rb +6 -0
- data/lib/caffeinate/action_mailer/extension.rb +13 -0
- data/lib/caffeinate/action_mailer/helpers.rb +12 -0
- data/lib/caffeinate/action_mailer/interceptor.rb +17 -0
- data/lib/caffeinate/action_mailer/observer.rb +17 -0
- data/lib/caffeinate/active_record/extension.rb +33 -0
- data/lib/caffeinate/configuration.rb +34 -0
- data/lib/caffeinate/deliver_async.rb +22 -0
- data/lib/caffeinate/drip.rb +56 -0
- data/lib/caffeinate/dripper/base.rb +33 -0
- data/lib/caffeinate/dripper/callbacks.rb +141 -0
- data/lib/caffeinate/dripper/campaign.rb +53 -0
- data/lib/caffeinate/dripper/defaults.rb +32 -0
- data/lib/caffeinate/dripper/delivery.rb +28 -0
- data/lib/caffeinate/dripper/drip.rb +65 -0
- data/lib/caffeinate/dripper/perform.rb +32 -0
- data/lib/caffeinate/dripper/subscriber.rb +66 -0
- data/lib/caffeinate/engine.rb +21 -0
- data/lib/caffeinate/version.rb +5 -0
- data/lib/generators/caffeinate/install_generator.rb +41 -0
- data/lib/generators/caffeinate/templates/application_campaign.rb +4 -0
- data/lib/generators/caffeinate/templates/caffeinate.rb +24 -0
- metadata +185 -0
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateCaffeinateCampaigns < ActiveRecord::Migration[6.0]
|
4
|
+
def change
|
5
|
+
drop_table :caffeinate_campaigns if table_exists?(:caffeinate_campaigns)
|
6
|
+
create_table :caffeinate_campaigns do |t|
|
7
|
+
t.string :name, null: false
|
8
|
+
t.string :slug, null: false
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
add_index :caffeinate_campaigns, :slug, unique: true
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
|
4
|
+
def change
|
5
|
+
drop_table :caffeinate_campaign_subscriptions if table_exists?(:caffeinate_campaign_subscriptions)
|
6
|
+
|
7
|
+
create_table :caffeinate_campaign_subscriptions do |t|
|
8
|
+
t.references :caffeinate_campaign, null: false, index: { name: :caffeineate_campaign_subscriptions_on_campaign }, foreign_key: true
|
9
|
+
t.string :subscriber_type, null: false
|
10
|
+
t.string :subscriber_id, null: false
|
11
|
+
t.string :user_type
|
12
|
+
t.string :user_id
|
13
|
+
t.string :token, null: false
|
14
|
+
t.datetime :ended_at
|
15
|
+
t.datetime :unsubscribed_at
|
16
|
+
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
add_index :caffeinate_campaign_subscriptions, :token, unique: true
|
20
|
+
add_index :caffeinate_campaign_subscriptions, %i[subscriber_id subscriber_type user_id user_type], name: :index_caffeinate_campaign_subscriptions
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
|
4
|
+
def change
|
5
|
+
drop_table :caffeinate_mailings if table_exists?(:caffeinate_mailings)
|
6
|
+
|
7
|
+
create_table :caffeinate_mailings do |t|
|
8
|
+
t.references :caffeinate_campaign_subscription, null: false, foreign_key: true, index: { name: 'index_caffeinate_mailings_on_campaign_subscription' }
|
9
|
+
t.datetime :send_at
|
10
|
+
t.datetime :sent_at
|
11
|
+
t.datetime :skipped_at
|
12
|
+
t.string :mailer_class, null: false
|
13
|
+
t.string :mailer_action, null: false
|
14
|
+
|
15
|
+
t.timestamps
|
16
|
+
end
|
17
|
+
add_index :caffeinate_mailings, %i[campaign_subscription_id mailer_class mailer_action sent_at send_at skipped_at], name: :index_caffeinate_mailings
|
18
|
+
end
|
19
|
+
end
|
data/lib/caffeinate.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/all'
|
4
|
+
require 'caffeinate/engine'
|
5
|
+
require 'caffeinate/drip'
|
6
|
+
require 'caffeinate/configuration'
|
7
|
+
require 'caffeinate/dripper/base'
|
8
|
+
require 'caffeinate/deliver_async'
|
9
|
+
|
10
|
+
module Caffeinate
|
11
|
+
# Caches the campaign to the campaign class
|
12
|
+
def self.dripper_to_campaign_class
|
13
|
+
@dripper_to_campaign_class ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Convenience method for `dripper_to_campaign_class`
|
17
|
+
def self.register_dripper(name, klass)
|
18
|
+
dripper_to_campaign_class[name.to_sym] = klass
|
19
|
+
end
|
20
|
+
|
21
|
+
# Global configuration
|
22
|
+
def self.config
|
23
|
+
@config ||= Configuration.new
|
24
|
+
end
|
25
|
+
|
26
|
+
# Yields the configuration
|
27
|
+
def self.setup
|
28
|
+
yield config
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module ActionMailer
|
5
|
+
module Extension
|
6
|
+
def self.included(klass)
|
7
|
+
klass.before_action do
|
8
|
+
@mailing = Thread.current[:current_caffeinate_mailing] if Thread.current[:current_caffeinate_mailing]
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module ActionMailer
|
5
|
+
module Helpers
|
6
|
+
def caffeinate_unsubscribe_url(caffeinate_campaign_subscription, **options)
|
7
|
+
opts = (::ActionMailer::Base.default_url_options || {}).merge(options)
|
8
|
+
Caffeinate::Engine.routes.url_helpers.caffeinate_unsubscribe_url(token: caffeinate_campaign_subscription.token, **opts)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module ActionMailer
|
5
|
+
class Interceptor
|
6
|
+
# Handles `before_send` callbacks for a `Caffeinate::Dripper`
|
7
|
+
def self.delivering_email(message)
|
8
|
+
mailing = Thread.current[:current_caffeinate_mailing]
|
9
|
+
return unless mailing
|
10
|
+
|
11
|
+
mailing.caffeinate_campaign.to_dripper.run_callbacks(:before_send, mailing.caffeinate_campaign_subscription, mailing, message)
|
12
|
+
drip = mailing.drip
|
13
|
+
message.perform_deliveries = drip.enabled?(mailing)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module ActionMailer
|
5
|
+
# Handles updating the Caffeinate::Message if it's available in Thread.current[:current_caffeinate_mailing]
|
6
|
+
# and runs any associated callbacks
|
7
|
+
class Observer
|
8
|
+
def self.delivered_email(message)
|
9
|
+
mailing = Thread.current[:current_caffeinate_mailing]
|
10
|
+
return unless mailing
|
11
|
+
|
12
|
+
mailing.update!(sent_at: Caffeinate.config.time_now) if message.perform_deliveries
|
13
|
+
mailing.caffeinate_campaign.to_dripper.run_callbacks(:after_send, mailing.caffeinate_campaign_subscription, mailing, message)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module ActiveRecord
|
5
|
+
module Extension
|
6
|
+
# Adds the associations for a subscriber
|
7
|
+
def caffeinate_subscriber
|
8
|
+
has_many :caffeinate_campaign_subscriptions, as: :subscriber, class_name: '::Caffeinate::CampaignSubscription'
|
9
|
+
has_many :caffeinate_campaigns, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Campaign'
|
10
|
+
has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Mailing'
|
11
|
+
|
12
|
+
scope :not_subscribed_to, lambda { |list|
|
13
|
+
subscribed = ::Caffeinate::CampaignSubscription.select(:subscriber_id).joins(:caffeinate_campaign).where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
|
14
|
+
where.not(id: subscribed)
|
15
|
+
}
|
16
|
+
|
17
|
+
scope :unsubscribed_from_campaign, lambda { |list|
|
18
|
+
unsubscribed = ::Caffeinate::CampaignSubscription
|
19
|
+
.unsubscribed
|
20
|
+
.select(:subscriber_id)
|
21
|
+
.joins(:caffeinate_campaign)
|
22
|
+
.where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
|
23
|
+
where(id: unsubscribed)
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Adds the associations for a user
|
28
|
+
def caffeinate_user
|
29
|
+
has_many :caffeinate_campaign_subscriptions_as_user, as: :user, class_name: '::Caffeinate::CampaignSubscription'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :now, :async_delivery, :mailing_job
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@now = -> { Time.current }
|
9
|
+
@async_delivery = false
|
10
|
+
@mailing_job = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def now=(val)
|
14
|
+
raise ArgumentError, '#now must be a proc' unless val.respond_to?(:call)
|
15
|
+
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
# The current time, for database calls
|
20
|
+
def time_now
|
21
|
+
@now.call
|
22
|
+
end
|
23
|
+
|
24
|
+
# If delivery is asyncronous
|
25
|
+
def async_delivery?
|
26
|
+
@async_delivery
|
27
|
+
end
|
28
|
+
|
29
|
+
# The @mailing_job constantized. Only used if `async_delivery = true`
|
30
|
+
def mailing_job_class
|
31
|
+
@mailing_job.constantize
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
# Method for handling async delivery. `include` it for plug-and-play.
|
5
|
+
#
|
6
|
+
# class MyWorker
|
7
|
+
# include Sidekiq::Worker
|
8
|
+
# include Caffeinate::AsyncMailing
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# To use this, make sure your initializer is configured correctly:
|
12
|
+
# config.async_delivery = true
|
13
|
+
# config.mailing_job = 'MyWorker'
|
14
|
+
module DeliverAsync
|
15
|
+
def perform(mailing_id)
|
16
|
+
mailing = ::Caffeinate::Mailing.find(mailing_id)
|
17
|
+
return unless mailing.pending?
|
18
|
+
|
19
|
+
mailing.deliver!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
# A Drip object
|
5
|
+
class Drip
|
6
|
+
# Handles the block and provides convenience methods for the drip
|
7
|
+
class Evaluator
|
8
|
+
attr_reader :mailing
|
9
|
+
def initialize(mailing)
|
10
|
+
@mailing = mailing
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(&block)
|
14
|
+
return true unless block
|
15
|
+
|
16
|
+
instance_eval(&block)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Ends the CampaignSubscription
|
20
|
+
def end!
|
21
|
+
mailing.caffeinate_campaign_subscription.end!
|
22
|
+
false
|
23
|
+
end
|
24
|
+
|
25
|
+
# Unsubscribes the CampaignSubscription
|
26
|
+
def unsubscribe!
|
27
|
+
mailing.caffeinate_campaign_subscription.unsubscribe!
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
# Skips the mailing
|
32
|
+
def skip!
|
33
|
+
mailing.skip!
|
34
|
+
false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
attr_reader :dripper, :action, :options, :block
|
39
|
+
def initialize(dripper, action, options, &block)
|
40
|
+
@dripper = dripper
|
41
|
+
@action = action
|
42
|
+
@options = options
|
43
|
+
@block = block
|
44
|
+
end
|
45
|
+
|
46
|
+
# If the associated ActionMailer uses `ActionMailer::Parameterized` initialization
|
47
|
+
def parameterized?
|
48
|
+
options[:using] == :parameterized
|
49
|
+
end
|
50
|
+
|
51
|
+
# If the drip is enabled.
|
52
|
+
def enabled?(mailing)
|
53
|
+
Evaluator.new(mailing).call(&@block)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'caffeinate/dripper/drip'
|
4
|
+
require 'caffeinate/dripper/callbacks'
|
5
|
+
require 'caffeinate/dripper/defaults'
|
6
|
+
require 'caffeinate/dripper/subscriber'
|
7
|
+
require 'caffeinate/dripper/campaign'
|
8
|
+
require 'caffeinate/dripper/perform'
|
9
|
+
require 'caffeinate/dripper/delivery'
|
10
|
+
|
11
|
+
module Caffeinate
|
12
|
+
module Dripper
|
13
|
+
class Base
|
14
|
+
include Callbacks
|
15
|
+
include Campaign
|
16
|
+
include Defaults
|
17
|
+
include Delivery
|
18
|
+
include Drip
|
19
|
+
include Perform
|
20
|
+
include Subscriber
|
21
|
+
|
22
|
+
# The inferred mailer class
|
23
|
+
def self.inferred_mailer_class
|
24
|
+
klass_name = "#{name.delete_suffix('Dripper')}Mailer"
|
25
|
+
klass = klass_name.safe_constantize
|
26
|
+
return nil unless klass
|
27
|
+
return klass_name if klass < ::ActionMailer::Base
|
28
|
+
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
module Dripper
|
5
|
+
module Callbacks
|
6
|
+
# :nodoc:
|
7
|
+
def self.included(klass)
|
8
|
+
klass.extend ClassMethods
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# :nodoc:
|
13
|
+
def run_callbacks(name, *args)
|
14
|
+
send("#{name}_blocks").each do |callback|
|
15
|
+
callback.call(*args)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Callback after a Caffeinate::CampaignSubscription is created, and after the Caffeinate::Mailings have
|
20
|
+
# been created.
|
21
|
+
#
|
22
|
+
# on_subscribe do |campaign_subscription|
|
23
|
+
# Slack.notify(:caffeinate, "A new subscriber to #{campaign_subscription.campaign.name}!")
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# @yield Caffeinate::CampaignSubscription
|
27
|
+
def on_subscribe(&block)
|
28
|
+
on_subscribe_blocks << block
|
29
|
+
end
|
30
|
+
|
31
|
+
# :nodoc:
|
32
|
+
def on_subscribe_blocks
|
33
|
+
@on_subscribe_blocks ||= []
|
34
|
+
end
|
35
|
+
|
36
|
+
# Callback before a Drip has called the mailer.
|
37
|
+
#
|
38
|
+
# before_drip do |campaign_subscription, mailing, drip|
|
39
|
+
# Slack.notify(:caffeinate, "#{drip.action_name} is starting")
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
# @yield Caffeinate::CampaignSubscription
|
43
|
+
# @yield Caffeinate::Mailing
|
44
|
+
# @yield Caffeinate::Drip current drip
|
45
|
+
def before_drip(&block)
|
46
|
+
before_drip_blocks << block
|
47
|
+
end
|
48
|
+
|
49
|
+
# :nodoc:
|
50
|
+
def before_drip_blocks
|
51
|
+
@before_drip_blocks ||= []
|
52
|
+
end
|
53
|
+
|
54
|
+
# Callback before a Mailing has been sent.
|
55
|
+
#
|
56
|
+
# before_send do |campaign_subscription, mailing, message|
|
57
|
+
# Slack.notify(:caffeinate, "A new subscriber to #{campaign_subscription.campaign.name}!")
|
58
|
+
# end
|
59
|
+
#
|
60
|
+
# @yield Caffeinate::CampaignSubscription
|
61
|
+
# @yield Caffeinate::Mailing
|
62
|
+
# @yield Mail::Message
|
63
|
+
def before_send(&block)
|
64
|
+
before_send_blocks << block
|
65
|
+
end
|
66
|
+
|
67
|
+
# :nodoc:
|
68
|
+
def before_send_blocks
|
69
|
+
@before_send_blocks ||= []
|
70
|
+
end
|
71
|
+
|
72
|
+
# Callback after a Mailing has been sent.
|
73
|
+
#
|
74
|
+
# after_send do |campaign_subscription, mailing, message|
|
75
|
+
# Slack.notify(:caffeinate, "A new subscriber to #{campaign_subscription.campaign.name}!")
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
# @yield Caffeinate::CampaignSubscription
|
79
|
+
# @yield Caffeinate::Mailing
|
80
|
+
# @yield Mail::Message
|
81
|
+
def after_send(&block)
|
82
|
+
after_send_blocks << block
|
83
|
+
end
|
84
|
+
|
85
|
+
# :nodoc:
|
86
|
+
def after_send_blocks
|
87
|
+
@after_send_blocks ||= []
|
88
|
+
end
|
89
|
+
|
90
|
+
# Callback after a CampaignSubscriber has exhausted all their mailings.
|
91
|
+
#
|
92
|
+
# on_complete do |campaign_sub|
|
93
|
+
# Slack.notify(:caffeinate, "A subscriber completed #{campaign_sub.campaign.name}!")
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# @yield Caffeinate::CampaignSubscription
|
97
|
+
def on_complete(&block)
|
98
|
+
on_complete_blocks << block
|
99
|
+
end
|
100
|
+
|
101
|
+
# :nodoc:
|
102
|
+
def on_complete_blocks
|
103
|
+
@on_complete_blocks ||= []
|
104
|
+
end
|
105
|
+
|
106
|
+
# Callback after a CampaignSubscriber has unsubscribed.
|
107
|
+
#
|
108
|
+
# on_unsubscribe do |campaign_sub|
|
109
|
+
# Slack.notify(:caffeinate, "#{campaign_sub.id} has unsubscribed... sad day.")
|
110
|
+
# end
|
111
|
+
#
|
112
|
+
# @yield Caffeinate::CampaignSubscription
|
113
|
+
def on_unsubscribe(&block)
|
114
|
+
on_unsubscribe_blocks << block
|
115
|
+
end
|
116
|
+
|
117
|
+
# :nodoc:
|
118
|
+
def on_unsubscribe_blocks
|
119
|
+
@on_unsubscribe_blocks ||= []
|
120
|
+
end
|
121
|
+
|
122
|
+
# Callback after a `Caffeinate::Mailing` is skipped.
|
123
|
+
#
|
124
|
+
# on_skip do |campaign_subscription, mailing, message|
|
125
|
+
# Slack.notify(:caffeinate, "#{campaign_sub.id} has unsubscribed... sad day.")
|
126
|
+
# end
|
127
|
+
#
|
128
|
+
# @yield Caffeinate::CampaignSubscription
|
129
|
+
# @yield Caffeinate::Mailing
|
130
|
+
def on_skip(&block)
|
131
|
+
on_skip_blocks << block
|
132
|
+
end
|
133
|
+
|
134
|
+
# :nodoc:
|
135
|
+
def on_skip_blocks
|
136
|
+
@on_skip_blocks ||= []
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|