caffeinate 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +125 -0
  4. data/Rakefile +24 -0
  5. data/app/controllers/caffeinate/application_controller.rb +8 -0
  6. data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +19 -0
  7. data/app/models/caffeinate/application_record.rb +8 -0
  8. data/app/models/caffeinate/campaign.rb +20 -0
  9. data/app/models/caffeinate/campaign_subscription.rb +82 -0
  10. data/app/models/caffeinate/mailing.rb +99 -0
  11. data/app/views/layouts/caffeinate/application.html.erb +15 -0
  12. data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +1 -0
  13. data/config/routes.rb +10 -0
  14. data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +14 -0
  15. data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +22 -0
  16. data/db/migrate/20201124183419_create_caffeinate_mailings.rb +19 -0
  17. data/lib/caffeinate.rb +30 -0
  18. data/lib/caffeinate/action_mailer.rb +6 -0
  19. data/lib/caffeinate/action_mailer/extension.rb +13 -0
  20. data/lib/caffeinate/action_mailer/helpers.rb +12 -0
  21. data/lib/caffeinate/action_mailer/interceptor.rb +17 -0
  22. data/lib/caffeinate/action_mailer/observer.rb +17 -0
  23. data/lib/caffeinate/active_record/extension.rb +33 -0
  24. data/lib/caffeinate/configuration.rb +34 -0
  25. data/lib/caffeinate/deliver_async.rb +22 -0
  26. data/lib/caffeinate/drip.rb +56 -0
  27. data/lib/caffeinate/dripper/base.rb +33 -0
  28. data/lib/caffeinate/dripper/callbacks.rb +141 -0
  29. data/lib/caffeinate/dripper/campaign.rb +53 -0
  30. data/lib/caffeinate/dripper/defaults.rb +32 -0
  31. data/lib/caffeinate/dripper/delivery.rb +28 -0
  32. data/lib/caffeinate/dripper/drip.rb +65 -0
  33. data/lib/caffeinate/dripper/perform.rb +32 -0
  34. data/lib/caffeinate/dripper/subscriber.rb +66 -0
  35. data/lib/caffeinate/engine.rb +21 -0
  36. data/lib/caffeinate/version.rb +5 -0
  37. data/lib/generators/caffeinate/install_generator.rb +41 -0
  38. data/lib/generators/caffeinate/templates/application_campaign.rb +4 -0
  39. data/lib/generators/caffeinate/templates/caffeinate.rb +24 -0
  40. 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
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'caffeinate/action_mailer/extension'
4
+ require 'caffeinate/action_mailer/helpers'
5
+ require 'caffeinate/action_mailer/interceptor'
6
+ require 'caffeinate/action_mailer/observer'
@@ -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