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