caffeinate 0.2.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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