caffeinate 0.2.1 → 0.7.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +142 -70
  3. data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +3 -3
  4. data/app/models/caffeinate/application_record.rb +0 -1
  5. data/app/models/caffeinate/campaign.rb +25 -7
  6. data/app/models/caffeinate/campaign_subscription.rb +44 -14
  7. data/app/models/caffeinate/mailing.rb +14 -6
  8. data/app/views/layouts/{caffeinate.html.erb → _caffeinate.html.erb} +0 -0
  9. data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +1 -0
  10. data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +5 -3
  11. data/db/migrate/20201124183419_create_caffeinate_mailings.rb +2 -1
  12. data/lib/caffeinate.rb +4 -18
  13. data/lib/caffeinate/action_mailer/extension.rb +1 -1
  14. data/lib/caffeinate/action_mailer/interceptor.rb +2 -2
  15. data/lib/caffeinate/action_mailer/observer.rb +4 -3
  16. data/lib/caffeinate/active_record/extension.rb +15 -10
  17. data/lib/caffeinate/configuration.rb +9 -2
  18. data/lib/caffeinate/drip.rb +22 -2
  19. data/lib/caffeinate/drip_evaluator.rb +1 -0
  20. data/lib/caffeinate/dripper/base.rb +4 -0
  21. data/lib/caffeinate/dripper/batching.rb +13 -11
  22. data/lib/caffeinate/dripper/callbacks.rb +46 -18
  23. data/lib/caffeinate/dripper/campaign.rb +18 -4
  24. data/lib/caffeinate/dripper/defaults.rb +1 -0
  25. data/lib/caffeinate/dripper/delivery.rb +7 -7
  26. data/lib/caffeinate/dripper/drip.rb +2 -41
  27. data/lib/caffeinate/dripper/drip_collection.rb +62 -0
  28. data/lib/caffeinate/dripper/inferences.rb +3 -1
  29. data/lib/caffeinate/dripper/perform.rb +12 -10
  30. data/lib/caffeinate/dripper/periodical.rb +26 -0
  31. data/lib/caffeinate/dripper/subscriber.rb +14 -2
  32. data/lib/caffeinate/dripper_collection.rb +17 -0
  33. data/lib/caffeinate/engine.rb +9 -2
  34. data/lib/caffeinate/mail_ext.rb +12 -0
  35. data/lib/caffeinate/version.rb +1 -1
  36. data/lib/generators/caffeinate/install_generator.rb +1 -1
  37. data/lib/generators/caffeinate/templates/caffeinate.rb +10 -0
  38. metadata +21 -3
@@ -17,12 +17,12 @@
17
17
  module Caffeinate
18
18
  # Records of the mails sent and to be sent for a given `::Caffeinate::CampaignSubscriber`
19
19
  class Mailing < ApplicationRecord
20
- CURRENT_THREAD_KEY = :current_caffeinate_mailing
21
-
22
20
  self.table_name = 'caffeinate_mailings'
23
21
 
24
22
  belongs_to :caffeinate_campaign_subscription, class_name: 'Caffeinate::CampaignSubscription'
23
+ alias_attribute :subscription, :caffeinate_campaign_subscription
25
24
  has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
25
+ alias_attribute :campaign, :caffeinate_campaign
26
26
 
27
27
  scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
28
28
  scope :unsent, -> { unskipped.where(sent_at: nil) }
@@ -30,6 +30,13 @@ module Caffeinate
30
30
  scope :skipped, -> { where.not(skipped_at: nil) }
31
31
  scope :unskipped, -> { where(skipped_at: nil) }
32
32
 
33
+ def initialize_dup(args)
34
+ super
35
+ self.send_at = nil
36
+ self.sent_at = nil
37
+ self.skipped_at = nil
38
+ end
39
+
33
40
  # Checks if the Mailing is not skipped and not sent
34
41
  def pending?
35
42
  unskipped? && unsent?
@@ -59,13 +66,12 @@ module Caffeinate
59
66
  def skip!
60
67
  update!(skipped_at: Caffeinate.config.time_now)
61
68
 
62
- caffeinate_campaign.to_dripper.run_callbacks(:on_skip, caffeinate_campaign_subscription, self)
69
+ caffeinate_campaign.to_dripper.run_callbacks(:on_skip, self)
63
70
  end
64
71
 
65
72
  # The associated drip
66
- # @todo This can be optimized with a better cache
67
73
  def drip
68
- @drip ||= caffeinate_campaign.to_dripper.drip_collection[mailer_action]
74
+ @drip ||= caffeinate_campaign.to_dripper.drip_collection.for(mailer_action)
69
75
  end
70
76
 
71
77
  # The associated Subscriber from `::Caffeinate::CampaignSubscription`
@@ -80,7 +86,7 @@ module Caffeinate
80
86
 
81
87
  # Assigns attributes to the Mailing from the Drip
82
88
  def from_drip(drip)
83
- self.send_at = drip.send_at
89
+ self.send_at = drip.send_at(self)
84
90
  self.mailer_class = drip.options[:mailer_class]
85
91
  self.mailer_action = drip.action
86
92
  self
@@ -93,6 +99,8 @@ module Caffeinate
93
99
  else
94
100
  deliver!
95
101
  end
102
+
103
+ caffeinate_campaign_subscription.touch
96
104
  end
97
105
 
98
106
  # Delivers the Mailing in the foreground
@@ -6,6 +6,7 @@ class CreateCaffeinateCampaigns < ActiveRecord::Migration[6.0]
6
6
  create_table :caffeinate_campaigns do |t|
7
7
  t.string :name, null: false
8
8
  t.string :slug, null: false
9
+ t.boolean :active, default: true, null: false
9
10
 
10
11
  t.timestamps
11
12
  end
@@ -7,17 +7,19 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
7
7
  create_table :caffeinate_campaign_subscriptions do |t|
8
8
  t.references :caffeinate_campaign, null: false, index: { name: :caffeineate_campaign_subscriptions_on_campaign }, foreign_key: true
9
9
  t.string :subscriber_type, null: false
10
- t.string :subscriber_id, null: false
10
+ t.integer :subscriber_id, null: false
11
11
  t.string :user_type
12
- t.string :user_id
12
+ t.integer :user_id
13
13
  t.string :token, null: false
14
14
  t.datetime :ended_at
15
+ t.string :ended_reason
15
16
  t.datetime :resubscribed_at
16
17
  t.datetime :unsubscribed_at
18
+ t.string :unsubscribe_reason
17
19
 
18
20
  t.timestamps
19
21
  end
20
22
  add_index :caffeinate_campaign_subscriptions, :token, unique: true
21
- add_index :caffeinate_campaign_subscriptions, %i[subscriber_id subscriber_type user_id user_type], name: :index_caffeinate_campaign_subscriptions
23
+ add_index :caffeinate_campaign_subscriptions, %i[caffeinate_campaign_id subscriber_id subscriber_type user_id user_type ended_at resubscribed_at unsubscribed_at], name: :index_caffeinate_campaign_subscriptions
22
24
  end
23
25
  end
@@ -14,6 +14,7 @@ class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
14
14
 
15
15
  t.timestamps
16
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
17
+
18
+ add_index :caffeinate_mailings, %i[caffeinate_campaign_subscription_id send_at sent_at skipped_at], name: :index_caffeinate_mailings
18
19
  end
19
20
  end
@@ -1,22 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails/all'
4
+ require 'caffeinate/mail_ext'
4
5
  require 'caffeinate/engine'
5
6
  require 'caffeinate/drip'
6
7
  require 'caffeinate/url_helpers'
7
8
  require 'caffeinate/configuration'
8
9
  require 'caffeinate/dripper/base'
9
10
  require 'caffeinate/deliver_async'
11
+ require 'caffeinate/dripper_collection'
10
12
 
11
13
  module Caffeinate
12
- # Caches the campaign to the campaign class
13
- def self.dripper_to_campaign_class
14
- @dripper_to_campaign_class ||= {}
15
- end
16
-
17
- # Convenience method for `dripper_to_campaign_class`
18
- def self.register_dripper(name, klass)
19
- dripper_to_campaign_class[name.to_sym] = klass
14
+ def self.dripper_collection
15
+ @drippers ||= DripperCollection.new
20
16
  end
21
17
 
22
18
  # Global configuration
@@ -28,14 +24,4 @@ module Caffeinate
28
24
  def self.setup
29
25
  yield config
30
26
  end
31
-
32
- # The current mailing
33
- def self.current_mailing=(val)
34
- Thread.current[::Caffeinate::Mailing::CURRENT_THREAD_KEY] = val
35
- end
36
-
37
- # The current mailing
38
- def self.current_mailing
39
- Thread.current[::Caffeinate::Mailing::CURRENT_THREAD_KEY]
40
- end
41
27
  end
@@ -7,7 +7,7 @@ module Caffeinate
7
7
  module Extension
8
8
  def self.included(klass)
9
9
  klass.before_action do
10
- @mailing = Caffeinate.current_mailing if Caffeinate.current_mailing
10
+ @mailing = params[:mailing] if params
11
11
  end
12
12
 
13
13
  klass.helper_method :caffeinate_unsubscribe_url, :caffeinate_subscribe_url
@@ -7,10 +7,10 @@ module Caffeinate
7
7
  class Interceptor
8
8
  # Handles `before_send` callbacks for a `Caffeinate::Dripper`
9
9
  def self.delivering_email(message)
10
- mailing = Caffeinate.current_mailing
10
+ mailing = message.caffeinate_mailing
11
11
  return unless mailing
12
12
 
13
- mailing.caffeinate_campaign.to_dripper.run_callbacks(:before_send, mailing.caffeinate_campaign_subscription, mailing, message)
13
+ mailing.caffeinate_campaign.to_dripper.run_callbacks(:before_send, mailing, message)
14
14
  drip = mailing.drip
15
15
  message.perform_deliveries = drip.enabled?(mailing)
16
16
  end
@@ -2,15 +2,16 @@
2
2
 
3
3
  module Caffeinate
4
4
  module ActionMailer
5
- # Handles updating the Caffeinate::Message if it's available in Caffeinate.current_mailing
5
+ # Handles updating the Caffeinate::Message if it's available in Mail::Message.caffeinate_mailing
6
6
  # and runs any associated callbacks
7
7
  class Observer
8
8
  def self.delivered_email(message)
9
- mailing = Caffeinate.current_mailing
9
+ mailing = message.caffeinate_mailing
10
10
  return unless mailing
11
11
 
12
12
  mailing.update!(sent_at: Caffeinate.config.time_now, skipped_at: nil) if message.perform_deliveries
13
- mailing.caffeinate_campaign.to_dripper.run_callbacks(:after_send, mailing.caffeinate_campaign_subscription, mailing, message)
13
+ mailing.caffeinate_campaign.to_dripper.run_callbacks(:after_send, mailing, message)
14
+ true
14
15
  end
15
16
  end
16
17
  end
@@ -5,30 +5,35 @@ module Caffeinate
5
5
  # Includes the ActiveRecord association and relevant scopes for an ActiveRecord-backed model
6
6
  module Extension
7
7
  # Adds the associations for a subscriber
8
- def caffeinate_subscriber
8
+ def acts_as_caffeinate_subscriber
9
9
  has_many :caffeinate_campaign_subscriptions, as: :subscriber, class_name: '::Caffeinate::CampaignSubscription', dependent: :destroy
10
10
  has_many :caffeinate_campaigns, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Campaign'
11
11
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Mailing'
12
12
 
13
13
  scope :not_subscribed_to_campaign, lambda { |list|
14
- subscribed = ::Caffeinate::CampaignSubscription.select(:subscriber_id).joins(:caffeinate_campaign).where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
15
- where.not(id: subscribed)
14
+ where.not(id: ::Caffeinate::CampaignSubscription
15
+ .select(:subscriber_id)
16
+ .joins(:caffeinate_campaign)
17
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
16
18
  }
17
19
 
18
20
  scope :unsubscribed_from_campaign, lambda { |list|
19
- unsubscribed = ::Caffeinate::CampaignSubscription
20
- .unsubscribed
21
- .select(:subscriber_id)
22
- .joins(:caffeinate_campaign)
23
- .where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
24
- where(id: unsubscribed)
21
+ where(id: ::Caffeinate::CampaignSubscription
22
+ .unsubscribed
23
+ .select(:subscriber_id)
24
+ .joins(:caffeinate_campaign)
25
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
25
26
  }
26
27
  end
28
+ alias caffeinate_subscriber acts_as_caffeinate_subscriber
27
29
 
28
30
  # Adds the associations for a user
29
- def caffeinate_user
31
+ def acts_as_caffeinate_user
30
32
  has_many :caffeinate_campaign_subscriptions_as_user, as: :user, class_name: '::Caffeinate::CampaignSubscription'
33
+ has_many :caffeinate_campaigns_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
34
+ has_many :caffeinate_mailings_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
31
35
  end
36
+ alias caffeinate_user acts_as_caffeinate_user
32
37
  end
33
38
  end
34
39
  end
@@ -3,19 +3,26 @@
3
3
  module Caffeinate
4
4
  # Global configuration
5
5
  class Configuration
6
- attr_accessor :now, :async_delivery, :mailing_job, :batch_size
6
+ attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path, :implicit_campaigns
7
7
 
8
8
  def initialize
9
9
  @now = -> { Time.current }
10
10
  @async_delivery = false
11
11
  @mailing_job = nil
12
12
  @batch_size = 1_000
13
+ @drippers_path = 'app/drippers'
14
+ @implicit_campaigns = true
13
15
  end
14
16
 
15
17
  def now=(val)
16
18
  raise ArgumentError, '#now must be a proc' unless val.respond_to?(:call)
17
19
 
18
- super
20
+ @now = val
21
+ end
22
+
23
+ # Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
24
+ def implicit_campaigns?
25
+ @implicit_campaigns == true
19
26
  end
20
27
 
21
28
  # The current time, for database calls
@@ -7,6 +7,7 @@ module Caffeinate
7
7
  # Handles the block and provides convenience methods for the drip
8
8
  class Drip
9
9
  attr_reader :dripper, :action, :options, :block
10
+
10
11
  def initialize(dripper, action, options, &block)
11
12
  @dripper = dripper
12
13
  @action = action
@@ -19,12 +20,31 @@ module Caffeinate
19
20
  options[:using] == :parameterized
20
21
  end
21
22
 
22
- def send_at
23
- options[:delay].from_now
23
+ def send_at(mailing = nil)
24
+ if periodical?
25
+ start = mailing.instance_exec(&options[:start])
26
+ start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
27
+ date = start.from_now
28
+ else
29
+ date = options[:delay].from_now
30
+ end
31
+
32
+ if options[:at]
33
+ time = Time.parse(options[:at])
34
+ return date.change(hour: time.hour, min: time.min, sec: time.sec)
35
+ end
36
+
37
+ date
38
+ end
39
+
40
+ def periodical?
41
+ options[:every].present?
24
42
  end
25
43
 
26
44
  # Checks if the drip is enabled
27
45
  def enabled?(mailing)
46
+ dripper.run_callbacks(:before_drip, self, mailing)
47
+
28
48
  DripEvaluator.new(mailing).call(&@block)
29
49
  end
30
50
  end
@@ -4,6 +4,7 @@ module Caffeinate
4
4
  # Handles evaluating the `drip` block and provides convenience methods for handling the mailing or its campaign.
5
5
  class DripEvaluator
6
6
  attr_reader :mailing
7
+
7
8
  def initialize(mailing)
8
9
  @mailing = mailing
9
10
  end
@@ -8,6 +8,7 @@ require 'caffeinate/dripper/delivery'
8
8
  require 'caffeinate/dripper/drip'
9
9
  require 'caffeinate/dripper/inferences'
10
10
  require 'caffeinate/dripper/perform'
11
+ require 'caffeinate/dripper/periodical'
11
12
  require 'caffeinate/dripper/subscriber'
12
13
 
13
14
  module Caffeinate
@@ -22,7 +23,10 @@ module Caffeinate
22
23
  include Drip
23
24
  include Inferences
24
25
  include Perform
26
+ include Periodical
25
27
  include Subscriber
26
28
  end
27
29
  end
28
30
  end
31
+
32
+ ActiveSupport.run_load_hooks :caffeinate, Caffeinate::Dripper::Base
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- # Includes batch support for setting the batch size for Perform
5
- module Batching
6
- def self.included(klass)
7
- klass.extend ClassMethods
8
- end
9
-
10
- module ClassMethods
11
- def batch_size(num)
12
- @_batch_size = num
4
+ module Dripper
5
+ # Includes batch support for setting the batch size for Perform
6
+ module Batching
7
+ def self.included(klass)
8
+ klass.extend ClassMethods
13
9
  end
14
10
 
15
- def _batch_size
16
- @_batch_size || ::Caffeinate.config.batch_size
11
+ module ClassMethods
12
+ def batch_size=(num)
13
+ @batch_size = num
14
+ end
15
+
16
+ def batch_size
17
+ @batch_size || ::Caffeinate.config.batch_size
18
+ end
17
19
  end
18
20
  end
19
21
  end
@@ -38,20 +38,36 @@ module Caffeinate
38
38
  @on_subscribe_blocks ||= []
39
39
  end
40
40
 
41
+ # Callback after a Caffeinate::CampaignSubscription is `#resubscribed!`
42
+ #
43
+ # on_resubscribe do |campaign_subscription|
44
+ # Slack.notify(:caffeinate, "Someone resubscribed to #{campaign_subscription.campaign.name}!")
45
+ # end
46
+ #
47
+ # @yield Caffeinate::CampaignSubscription
48
+ def on_resubscribe(&block)
49
+ on_resubscribe_blocks << block
50
+ end
51
+
52
+ # :nodoc:
53
+ def on_resubscribe_blocks
54
+ @on_resubscribe_blocks ||= []
55
+ end
56
+
41
57
  # Callback before the mailings get processed.
42
58
  #
43
- # before_process do |dripper|
59
+ # before_perform do |dripper|
44
60
  # Slack.notify(:caffeinate, "Dripper is getting ready for mailing! #{dripper.caffeinate_campaign.name}!")
45
61
  # end
46
62
  #
47
63
  # @yield Caffeinate::Dripper
48
- def before_process(&block)
49
- before_process_blocks << block
64
+ def before_perform(&block)
65
+ before_perform_blocks << block
50
66
  end
51
67
 
52
68
  # :nodoc:
53
- def before_process_blocks
54
- @before_process_blocks ||= []
69
+ def before_perform_blocks
70
+ @before_perform_blocks ||= []
55
71
  end
56
72
 
57
73
  # Callback before the mailings get processed in a batch.
@@ -62,13 +78,13 @@ module Caffeinate
62
78
  #
63
79
  # @yield Caffeinate::Dripper
64
80
  # @yield Caffeinate::Mailing [Array]
65
- def on_process(&block)
66
- on_process_blocks << block
81
+ def on_perform(&block)
82
+ on_perform_blocks << block
67
83
  end
68
84
 
69
85
  # :nodoc:
70
- def on_process_blocks
71
- @on_process_blocks ||= []
86
+ def on_perform_blocks
87
+ @on_perform_blocks ||= []
72
88
  end
73
89
 
74
90
  # Callback after the all the mailings have been sent.
@@ -79,13 +95,13 @@ module Caffeinate
79
95
  #
80
96
  # @yield Caffeinate::Dripper
81
97
  # @yield Caffeinate::Mailing [Array]
82
- def after_process(&block)
83
- after_process_blocks << block
98
+ def after_perform(&block)
99
+ after_perform_blocks << block
84
100
  end
85
101
 
86
102
  # :nodoc:
87
- def after_process_blocks
88
- @after_process_blocks ||= []
103
+ def after_perform_blocks
104
+ @after_perform_blocks ||= []
89
105
  end
90
106
 
91
107
  # Callback before a Drip has called the mailer.
@@ -94,9 +110,8 @@ module Caffeinate
94
110
  # Slack.notify(:caffeinate, "#{drip.action_name} is starting")
95
111
  # end
96
112
  #
97
- # @yield Caffeinate::CampaignSubscription
98
- # @yield Caffeinate::Mailing
99
113
  # @yield Caffeinate::Drip current drip
114
+ # @yield Caffeinate::Mailing
100
115
  def before_drip(&block)
101
116
  before_drip_blocks << block
102
117
  end
@@ -112,7 +127,6 @@ module Caffeinate
112
127
  # Slack.notify(:caffeinate, "A new subscriber to #{campaign_subscription.campaign.name}!")
113
128
  # end
114
129
  #
115
- # @yield Caffeinate::CampaignSubscription
116
130
  # @yield Caffeinate::Mailing
117
131
  # @yield Mail::Message
118
132
  def before_send(&block)
@@ -130,7 +144,6 @@ module Caffeinate
130
144
  # Slack.notify(:caffeinate, "A new subscriber to #{campaign_subscription.campaign.name}!")
131
145
  # end
132
146
  #
133
- # @yield Caffeinate::CampaignSubscription
134
147
  # @yield Caffeinate::Mailing
135
148
  # @yield Mail::Message
136
149
  def after_send(&block)
@@ -174,13 +187,28 @@ module Caffeinate
174
187
  @on_unsubscribe_blocks ||= []
175
188
  end
176
189
 
190
+ # Callback after a CampaignSubscriber has ended.
191
+ #
192
+ # on_end do |campaign_sub|
193
+ # Slack.notify(:caffeinate, "#{campaign_sub.id} has ended... sad day.")
194
+ # end
195
+ #
196
+ # @yield Caffeinate::CampaignSubscription
197
+ def on_end(&block)
198
+ on_end_blocks << block
199
+ end
200
+
201
+ # :nodoc:
202
+ def on_end_blocks
203
+ @on_end_blocks ||= []
204
+ end
205
+
177
206
  # Callback after a `Caffeinate::Mailing` is skipped.
178
207
  #
179
208
  # on_skip do |campaign_subscription, mailing, message|
180
209
  # Slack.notify(:caffeinate, "#{campaign_sub.id} has unsubscribed... sad day.")
181
210
  # end
182
211
  #
183
- # @yield `Caffeinate::CampaignSubscription`
184
212
  # @yield `Caffeinate::Mailing`
185
213
  def on_skip(&block)
186
214
  on_skip_blocks << block