caffeinate 0.14.0 → 2.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8ae5ac5375188ce5a7bc8960ac7d7b392ccc7dc7de71070e88a2285fa76e7af
4
- data.tar.gz: dd06d837c2c1477bf8e48484e028016f00203066f0b2383c6f0a7a3cb5b5af1f
3
+ metadata.gz: f16a6861b725b37b7b98bda070586ae794875a171af2b7ea4582a05b7d9aacaa
4
+ data.tar.gz: c6ff565b6792fdf4ed193d3bd478b445a55835262d11118530f69d1aef08e8af
5
5
  SHA512:
6
- metadata.gz: 5c71b3ba2196a9a9f7ae66074b8eeb76ad6fe1a6b2ecff521e7f92c06da411c4722e0bc8b4a49fcfc9eaf0fde921c76fd4b13fbb5c7b59cfe1ec173ea69e5b29
7
- data.tar.gz: 498848318785a2f8f4ab669cada385760f63663b1c6bf254235a7f640c189eaa170391aff58990c05c6a4d8ee6a6dc860399ea24745431dec7dfebaa8d760533
6
+ metadata.gz: c2e5c6cc7c7af6f37c270f883d74e1eff9f48ac4b16226b4bf3c31729c116d7790ba198f7657a272458cf66d9993a2814e8bf2e183fd8830876eee50bf26ba65
7
+ data.tar.gz: 7c9519545b4183787b71422b706a059bab5b73df0f872677f9fdb2dc0f10f60f4a3c2eacbc64eabf1246815bd5574dcadc0750d30a018075945e87bbc9f25254
data/README.md CHANGED
@@ -2,18 +2,32 @@
2
2
  <img width="450" src="https://github.com/joshmn/caffeinate/raw/master/logo.png" alt="Caffeinate logo" />
3
3
  </div>
4
4
 
5
- ---
5
+ <div align="center">
6
+ <a href="https://codecov.io/gh/joshmn/caffeinate">
7
+ <img src="https://codecov.io/gh/joshmn/caffeinate/branch/master/graph/badge.svg?token=5LCOB4ESHL" alt="Coverage"/>
8
+ </a>
9
+ <a href="https://codeclimate.com/github/joshmn/caffeinate/maintainability">
10
+ <img src="https://api.codeclimate.com/v1/badges/9c075416ce74985d5c6c/maintainability" alt="Maintainability"/>
11
+ </a>
12
+ <a href="https://inch-ci.org/github/joshmn/caffeinate">
13
+ <img src="https://inch-ci.org/github/joshmn/caffeinate.svg?branch=master" alt="Docs"/>
14
+ </a>
15
+ </div>
6
16
 
7
17
  # Caffeinate
8
18
 
9
- Caffeinate is a drip campaign engine for Ruby on Rails applications.
19
+ Caffeinate is a drip email engine for managing, creating, and sending scheduled email sequences from your Ruby on Rails application.
10
20
 
11
- Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
12
- and has everything you need to get started and to successfully manage campaigns. It's only dependency is the stack you're
13
- already familiar with: Ruby on Rails.
21
+ Caffeinate provides a simple DSL to create scheduled email sequences which can be used by ActionMailer without any additional configuration.
14
22
 
15
23
  There's a cool demo with all the things included at [caffeinate.email](https://caffeinate.email). You can view the [marketing site source code here](https://github.com/joshmn/caffeinate-marketing).
16
24
 
25
+ ## Is this thing dead?
26
+
27
+ No! Not at all!
28
+
29
+ There's not a lot of activity here because it's stable and working! I am more than happy to entertain new features.
30
+
17
31
  ## Do you suffer from ActionMailer tragedies?
18
32
 
19
33
  If you have _anything_ like this is your codebase, **you need Caffeinate**:
@@ -30,19 +44,16 @@ end
30
44
 
31
45
  ```ruby
32
46
  class OnboardingMailer < ActionMailer::Base
33
- # Send on account creation
34
47
  def welcome_to_my_cool_app(user)
35
48
  mail(to: user.email, subject: "Welcome to CoolApp!")
36
49
  end
37
50
 
38
- # Send 2 days after the user signs up
39
51
  def some_cool_tips(user)
40
52
  return if user.unsubscribed_from_onboarding_campaign?
41
53
 
42
54
  mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
43
55
  end
44
56
 
45
- # Sends 3 days after the user signs up and hasn't added a company profile yet
46
57
  def help_getting_started(user)
47
58
  return if user.unsubscribed_from_onboarding_campaign?
48
59
  return if user.onboarding_completed?
@@ -56,22 +67,12 @@ end
56
67
 
57
68
  * You're checking state in a mailer
58
69
  * The unsubscribe feature is, most likely, tied to a `User`, which means...
59
- * It's going to be _so fun_ to scale horizontally
70
+ * It's going to be _so fun_ to scale when you finally want to add more unsubscribe links for different types of sequences
71
+ - "one of your projects has expired", but which one? Then you have to add a column to `projects` and manage all that state... ew
60
72
 
61
- ## Caffeinate to the rescue
73
+ ## Do this all better in five minutes
62
74
 
63
- Caffeinate combines a simple scheduling DSL, ActionMailer, and your data models to create scheduled email sequences.
64
-
65
- What can you do with drip campaigns?
66
- * Onboard new customers with cool tips and tricks
67
- * Remind customers to use your product
68
- * Nag customers about using your product
69
- * Reach their spam folder after you fail to handle their unsubscribe request
70
- * And more!
71
-
72
- ## Onboarding in Caffeinate
73
-
74
- In five minutes you can implement this onboarding campaign, and it won't even hijack your entire app!
75
+ In five minutes you can implement this onboarding campaign:
75
76
 
76
77
  ### Install it
77
78
 
@@ -83,11 +84,11 @@ $ rails g caffeinate:install
83
84
  $ rake db:migrate
84
85
  ```
85
86
 
86
- ### Remove that ActionMailer logic
87
+ ### Clean up the mailer logic
87
88
 
88
- Just delete it. Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
89
+ Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
89
90
 
90
- The only other change you need to make is the argument that the mailer action receives:
91
+ The only other change you need to make is the argument that the mailer action receives. It will now receive a `Caffeinate::Mailing`. [Learn more about the data models](docs/2-data-models.md):
91
92
 
92
93
  ```ruby
93
94
  class OnboardingMailer < ActionMailer::Base
@@ -108,27 +109,36 @@ class OnboardingMailer < ActionMailer::Base
108
109
  end
109
110
  ```
110
111
 
111
- While we're there, let's add an unsubscribe link to the views or layout:
112
-
113
- ```erb
114
- <%= link_to "Stop receiving onboarding tips :(", caffeinate_unsubscribe_url %>
115
- ```
116
-
117
112
  ### Create a Dripper
118
113
 
119
- A Dripper has all the logic for your Campaign and coordinates with ActionMailer on what to send.
114
+ A Dripper has all the logic for your sequence and coordinates with ActionMailer on what to send.
120
115
 
121
116
  In `app/drippers/onboarding_dripper.rb`:
122
117
 
123
118
  ```ruby
124
119
  class OnboardingDripper < ApplicationDripper
120
+ # each sequence is a campaign. This will dynamically create one by the given slug
121
+ self.campaign = :onboarding
122
+
123
+ # gets called before every time we process a drip
124
+ before_drip do |_drip, mailing|
125
+ if mailing.subscription.subscriber.onboarding_completed?
126
+ mailing.subscription.unsubscribe!("Completed onboarding")
127
+ throw(:abort)
128
+ end
129
+ end
130
+
131
+ # map drips to the mailer
125
132
  drip :welcome_to_my_cool_app, mailer: 'OnboardingMailer', delay: 0.hours
126
133
  drip :some_cool_tips, mailer: 'OnboardingMailer', delay: 2.days
127
134
  drip :help_getting_started, mailer: 'OnboardingMailer', delay: 3.days
128
135
  end
129
136
  ```
130
137
 
131
- The `drip` syntax is `def drip(mailer_action, options = {})`.
138
+ We want to skip sending the `mailing` if the `subscriber` (`User`) completed onboarding. Let's unsubscribe
139
+ with `#unsubscribe!` and give it an optional reason of `Completed onboarding` so we can reference it later
140
+ when we look at analytics. `throw(:abort)` halts the callback chain just like regular Rails callbacks, stopping the
141
+ mailing from being sent.
132
142
 
133
143
  ### Add a subscriber to the Campaign
134
144
 
@@ -138,48 +148,37 @@ a `Caffeinate::CampaignSubscription`.
138
148
  ```ruby
139
149
  class User < ApplicationRecord
140
150
  after_commit on: :create do
141
- OnboardingDripper.subscribe(self)
142
- end
143
-
144
- after_commit on: :update do
145
- if onboarding_completed? && onboarding_completed_changed?
146
- if OnboardingDripper.subscribed?(self)
147
- OnboardingDripper.unsubscribe(self)
148
- end
149
- end
151
+ OnboardingDripper.subscribe!(self)
150
152
  end
151
153
  end
152
154
  ```
153
155
 
154
- When a `Caffeinate::CampaignSubscription` is created, the relevant Dripper is parsed and `Caffeinate::Mailing` records
155
- are created from the `drip` DSL. A `Caffeinate::Mailing` record has a `send_at` attribute which tells Caffeinate when we
156
- can send the mail, which we get from `Caffeiate::Mailing#mailer_class` and `Caffeinate::Mailing#mailer_action`.
157
-
158
156
  ### Run the Dripper
159
157
 
160
- Running `OnboardingDripper.perform!` every `x` minutes will call `Caffeinate::Mailing#process!` on `Caffeinate::Mailing`
161
- records that have `send_at < Time.now`.
162
-
163
158
  ```ruby
164
159
  OnboardingDripper.perform!
165
160
  ```
166
161
 
167
- ### Done. But wait, there's more fun if you want
162
+ ### Done
168
163
 
169
- * Automatic subscriptions
170
- * Campaign-specific unsubscribe links
171
- * Reasons for unsubscribing so you can have some sort of analytics
172
- * Periodical emails (daily, weekly, monthly digests, anyone?)
173
- * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
164
+ You're done.
174
165
 
175
- ### Done. But wait, there's more fun if you want
166
+ [Check out the docs](/docs/README.md) for a more in-depth guide that includes all the options you can use for more complex setups,
167
+ tips, tricks, and shortcuts.
176
168
 
177
- * Automatic subscriptions
178
- * Campaign-specific unsubscribe links
179
- * Reasons for unsubscribing so you can have some sort of analytics
180
- * Periodical emails (daily, weekly, monthly digests, anyone?)
181
- * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
169
+ ## But wait, there's more
182
170
 
171
+ Caffeinate also...
172
+
173
+ * ✅ Allows hyper-precise scheduled times. 9:19AM _in the user's timezone_? Sure! **Only on business days**? YES!
174
+ * ✅ Periodicals
175
+ * ✅ Manages unsubscribes
176
+ * ✅ Works with singular and multiple associations
177
+ * ✅ Compatible with every background processor
178
+ * ✅ Tested against large databases at AngelList and is performant as hell
179
+ * ✅ Effortlessly handles complex workflows
180
+ - Need to skip a certain mailing? You can!
181
+
183
182
  ## Documentation
184
183
 
185
184
  * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
@@ -191,7 +190,7 @@ OnboardingDripper.perform!
191
190
 
192
191
  ## Alternatives
193
192
 
194
- Not a fan? There are some alternatives!
193
+ Not a fan of Caffeinate? I built it because I wasn't a fan of the alternatives. To each their own:
195
194
 
196
195
  * https://github.com/honeybadger-io/heya
197
196
  * https://github.com/tarr11/dripper
@@ -199,11 +198,14 @@ Not a fan? There are some alternatives!
199
198
 
200
199
  ## Contributing
201
200
 
202
- Just do it.
201
+ There's so much more that can be done with this. I'd love to see what you're thinking.
202
+
203
+ If you have general feedback, I'd love to know what you're using Caffeinate for! Please email me (any-thing [at] josh.mn) or [tweet me @joshmn](https://twitter.com/joshmn) or create an issue! I'd love to chat.
203
204
 
204
205
  ## Contributors & thanks
205
206
 
206
207
  * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
208
+ * Thanks to [markokajzer](https://github.com/markokajzer) for listening to me talk about this most mornings.
207
209
 
208
210
  ## License
209
211
 
@@ -14,12 +14,15 @@ module Caffeinate
14
14
  # Campaign ties together subscribers and mailings, and provides one core model for handling your Drippers.
15
15
  class Campaign < ApplicationRecord
16
16
  self.table_name = 'caffeinate_campaigns'
17
+ class NoSubscription < ::ActiveRecord::RecordInvalid; end
17
18
 
18
19
  has_many :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
19
20
  has_many :subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
20
21
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
21
22
  has_many :mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
22
23
 
24
+ scope :active, -> { where(active: true) }
25
+
23
26
  # Poorly-named Campaign class resolver
24
27
  def to_dripper
25
28
  ::Caffeinate.dripper_collection.resolve(self)
@@ -64,7 +67,7 @@ module Caffeinate
64
67
  def unsubscribe!(subscriber, **args)
65
68
  reason = args.delete(:reason)
66
69
  subscription = subscriber(subscriber, **args)
67
- raise ::ActiveRecord::RecordInvalid, subscription if subscription.nil?
70
+ raise NoSubscription, subscription if subscription.nil?
68
71
 
69
72
  subscription.unsubscribe!(reason)
70
73
  end
@@ -72,12 +75,12 @@ module Caffeinate
72
75
  # Creates a `CampaignSubscription` object for the present Campaign. Allows passing `**args` to
73
76
  # delegate additional arguments to the record. Uses `find_or_create_by`.
74
77
  def subscribe(subscriber, **args)
75
- caffeinate_campaign_subscriptions.create(subscriber: subscriber, **args)
78
+ caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
76
79
  end
77
80
 
78
81
  # Subscribes an object to a campaign. Raises `ActiveRecord::RecordInvalid` if the record was invalid.
79
82
  def subscribe!(subscriber, **args)
80
- caffeinate_campaign_subscriptions.create!(subscriber: subscriber, **args)
83
+ caffeinate_campaign_subscriptions.find_or_create_by!(subscriber: subscriber, **args)
81
84
  end
82
85
  end
83
86
  end
@@ -55,7 +55,7 @@ module Caffeinate
55
55
 
56
56
  before_validation :call_dripper_before_subscribe_blocks!, on: :create
57
57
 
58
- after_commit :create_mailings!, on: :create
58
+ after_create :create_mailings!
59
59
 
60
60
  after_commit :on_complete, if: :completed?
61
61
 
@@ -131,18 +131,7 @@ module Caffeinate
131
131
  true
132
132
  end
133
133
 
134
- # Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
135
- # Use `force` to forcefully reset. Does not create the mailings.
136
- def resubscribe!(force = false)
137
- return false if ended? && !force
138
- return false if unsubscribed? && !force
139
-
140
- result = update(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
141
-
142
- caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
143
- result
144
- end
145
-
134
+ # Checks if the record is not new and if mailings are all gone.
146
135
  def completed?
147
136
  caffeinate_mailings.unsent.count.zero?
148
137
  end
@@ -24,7 +24,7 @@ module Caffeinate
24
24
  has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
25
25
  alias_attribute :campaign, :caffeinate_campaign
26
26
 
27
- scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
27
+ scope :upcoming, -> { joins(:caffeinate_campaign_subscription).where(caffeinate_campaign_subscription: ::Caffeinate::CampaignSubscription.active).unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
28
28
  scope :unsent, -> { unskipped.where(sent_at: nil) }
29
29
  scope :sent, -> { unskipped.where.not(sent_at: nil) }
30
30
  scope :skipped, -> { where.not(skipped_at: nil) }
@@ -112,7 +112,7 @@ module Caffeinate
112
112
 
113
113
  # Delivers the Mailing in the background
114
114
  def deliver_later!
115
- klass = ::Caffeinate.config.mailing_job_class
115
+ klass = ::Caffeinate.config.async_delivery_class
116
116
  if klass.respond_to?(:perform_later)
117
117
  klass.perform_later(id)
118
118
  elsif klass.respond_to?(:perform_async)
@@ -1,3 +1,3 @@
1
1
  <%= t("caffeinate.campaign_subscriptions.subscribe") %>
2
2
  <p><%= t("caffeinate.campaign_subscriptions.changed_your_mind")%></p>
3
- <p><%= link_to "Unsubscribe", caffeinate_subscribe_url %></p>
3
+ <p><%= link_to "Unsubscribe", caffeinate_unsubscribe_url %></p>
@@ -1,3 +1,3 @@
1
1
  <h5><%= t("caffeinate.campaign_subscriptions.unsubscribe")%></h5>
2
2
  <p><%= t("caffeinate.campaign_subscriptions.changed_your_mind")%></p>
3
- <p><%= link_to "Resubscribe", caffeinate_subscribe_url %></p>
3
+ <p><%= link_to "Resubscribe", caffeinate_resubscribe_url %></p>
@@ -3,14 +3,44 @@
3
3
  module Caffeinate
4
4
  # Global configuration
5
5
  class Configuration
6
- attr_accessor :now, :async_delivery, :deliver_later,:mailing_job, :batch_size, :drippers_path, :implicit_campaigns,
7
- :default_ended_reason, :default_unsubscribe_reason
6
+
7
+ # Used for relation to a lot of things. If you have a weird time setup, set this.
8
+ # Accepts anything that responds to `#call`; you'll probably use a block.
9
+ attr_accessor :now
10
+
11
+ # If true, enqueues the processing of a `Caffeinate::Mailing` to the background worker class
12
+ # as defined in `async_delivery_class`
13
+ #
14
+ # Default is false
15
+ attr_accessor :async_delivery
16
+
17
+ # The background worker class for `async_delivery`.
18
+ attr_accessor :async_delivery_class
19
+
20
+ # If true, uses `deliver_later` instead of `deliver`
21
+ attr_accessor :deliver_later
22
+
23
+ # The number of `Caffeinate::Mailing` records we find in a batch at once.
24
+ attr_accessor :batch_size
25
+
26
+ # The path to the drippers
27
+ attr_accessor :drippers_path
28
+
29
+ # Automatically creates a `Caffeinate::Campaign` record by the named slug of the campaign from a dripper
30
+ # if none is found by the slug.
31
+ attr_accessor :implicit_campaigns
32
+
33
+ # The default reason for an ended `Caffeinate::CampaignSubscription`
34
+ attr_accessor :default_ended_reason
35
+
36
+ # The default reason for an unsubscribed `Caffeinate::CampaignSubscription`
37
+ attr_accessor :default_unsubscribe_reason
8
38
 
9
39
  def initialize
10
40
  @now = -> { Time.current }
11
41
  @async_delivery = false
12
42
  @deliver_later = false
13
- @mailing_job = nil
43
+ @async_delivery_class = nil
14
44
  @batch_size = 1_000
15
45
  @drippers_path = 'app/drippers'
16
46
  @implicit_campaigns = true
@@ -24,29 +54,24 @@ module Caffeinate
24
54
  @now = val
25
55
  end
26
56
 
27
- # Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
28
57
  def implicit_campaigns?
29
- @implicit_campaigns == true
58
+ @implicit_campaigns
30
59
  end
31
60
 
32
- # The current time, for database calls
33
61
  def time_now
34
62
  @now.call
35
63
  end
36
64
 
37
- # If delivery is asyncronous
38
65
  def async_delivery?
39
66
  @async_delivery
40
67
  end
41
68
 
42
- # If we should use `#deliver_later` instead of `#deliver`
43
69
  def deliver_later?
44
70
  @deliver_later
45
71
  end
46
72
 
47
- # The @mailing_job constantized. Only used if `async_delivery = true`
48
- def mailing_job_class
49
- @mailing_job.constantize
73
+ def async_delivery_class
74
+ @async_delivery_class.constantize
50
75
  end
51
76
  end
52
77
  end
@@ -10,7 +10,7 @@ module Caffeinate
10
10
  #
11
11
  # To use this, make sure your initializer is configured correctly:
12
12
  # config.async_delivery = true
13
- # config.mailing_job = 'MyWorker'
13
+ # config.async_delivery_class = 'MyWorker'
14
14
  module DeliverAsync
15
15
  def perform(mailing_id)
16
16
  mailing = ::Caffeinate::Mailing.find(mailing_id)
@@ -1,31 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'caffeinate/drip_evaluator'
4
+ require 'caffeinate/schedule_evaluator'
5
+
4
6
  module Caffeinate
5
7
  # A Drip object
6
8
  #
7
9
  # Handles the block and provides convenience methods for the drip
8
10
  class Drip
9
- class OptionEvaluator
10
- def initialize(thing, drip, mailing)
11
- @thing = thing
12
- @drip = drip
13
- @mailing = mailing
14
- end
15
-
16
- def call
17
- if @thing.is_a?(Symbol)
18
- @drip.dripper.new.send(@thing, @drip, @mailing)
19
- elsif @thing.is_a?(Proc)
20
- @mailing.instance_exec(&@thing)
21
- elsif @thing.is_a?(String)
22
- Time.parse(@thing)
23
- else
24
- @thing
25
- end
26
- end
27
- end
28
-
29
11
  attr_reader :dripper, :action, :options, :block
30
12
 
31
13
  def initialize(dripper, action, options, &block)
@@ -41,29 +23,7 @@ module Caffeinate
41
23
  end
42
24
 
43
25
  def send_at(mailing = nil)
44
- if periodical?
45
- start = mailing.instance_exec(&options[:start])
46
- start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
47
- date = start.from_now
48
- elsif options[:on]
49
- date = OptionEvaluator.new(options[:on], self, mailing).call
50
- else
51
- date = OptionEvaluator.new(options[:delay], self, mailing).call
52
- if date.respond_to?(:from_now)
53
- date = date.from_now
54
- end
55
- end
56
-
57
- if options[:at]
58
- time = OptionEvaluator.new(options[:at], self, mailing).call
59
- return date.change(hour: time.hour, min: time.min, sec: time.sec)
60
- end
61
-
62
- date
63
- end
64
-
65
- def periodical?
66
- options[:every].present?
26
+ ::Caffeinate::ScheduleEvaluator.call(self, mailing)
67
27
  end
68
28
 
69
29
  # Checks if the drip is enabled
@@ -30,7 +30,22 @@ module Caffeinate
30
30
  # @option options [String] :mailer_class The mailer_class
31
31
  # @option options [Integer] :step The order in which the drip is executed
32
32
  # @option options [ActiveSupport::Duration] :delay When the drip should be ran
33
- # @option options [Symbol] :using set to :parameters if the mailer action uses ActionMailer::Parameters
33
+ # @option options [Block|Symbol|String] :at Alternative to `:delay` option, allowing for more fine-tuned timing.
34
+ # Accepts a block, symbol (an instance method on the Dripper that accepts two arguments: drip, mailing), or a string
35
+ # to be later parsed into a Time object.
36
+ #
37
+ # drip :mailer_action_name, mailer_class: "MailerClass", at: -> { 3.days.from_now.in_time_zone(mailing.subscriber.timezone) }
38
+ #
39
+ # class MyDripper
40
+ # drip :mailer_action_name, mailer_class: "MailerClass", at: :generate_date
41
+ # def generate_date(drip, mailing)
42
+ # 3.days.from_now.in_time_zone(mailing.subscriber.timezone)
43
+ # end
44
+ # end
45
+ #
46
+ # drip :mailer_action_name, mailer_class: "MailerClass", at: 'January 1, 2022'
47
+ #
48
+ # @option options [Symbol] :using Set to `:parameters` if the mailer action uses ActionMailer::Parameters
34
49
  def drip(action_name, options = {}, &block)
35
50
  drip_collection.register(action_name, options, &block)
36
51
  end
@@ -4,6 +4,8 @@ module Caffeinate
4
4
  module Dripper
5
5
  # A collection of Drip objects for a `Caffeinate::Dripper`
6
6
  class DripCollection
7
+ VALID_DRIP_OPTIONS = [:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on].freeze
8
+
7
9
  include Enumerable
8
10
 
9
11
  def initialize(dripper)
@@ -42,7 +44,7 @@ module Caffeinate
42
44
 
43
45
  def validate_drip_options(action, options)
44
46
  options.symbolize_keys!
45
- options.assert_valid_keys(:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on)
47
+ options.assert_valid_keys(*VALID_DRIP_OPTIONS)
46
48
  options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
47
49
  options[:using] ||= @dripper.defaults[:using]
48
50
  options[:step] ||= @dripper.drips.size + 1
@@ -10,14 +10,7 @@ module Caffeinate
10
10
  end
11
11
 
12
12
  module ClassMethods
13
- # Runs the subscriber_block
14
- #
15
- # OrderDripper.subscribe!
16
- def subscribe!
17
- subscribes_block.call
18
- end
19
-
20
- # Returns the campaign's `Caffeinate::CampaignSubscriber`
13
+ # Returns the Campaign's `Caffeinate::CampaignSubscriber`
21
14
  def subscriptions
22
15
  caffeinate_campaign.caffeinate_campaign_subscriptions
23
16
  end
@@ -57,33 +50,6 @@ module Caffeinate
57
50
  def unsubscribe!(subscriber, **args)
58
51
  caffeinate_campaign.unsubscribe!(subscriber, **args)
59
52
  end
60
-
61
- # :nodoc:
62
- def subscribes_block
63
- raise(NotImplementedError, 'Define subscribes') unless @subscribes_block
64
-
65
- @subscribes_block
66
- end
67
-
68
- # The subscriber block. Used to create `::Caffeinate::CampaignSubscribers` subscribers.
69
- #
70
- # subscribes do
71
- # Cart.left_joins(:cart_items)
72
- # .includes(:user)
73
- # .where(completed_at: nil)
74
- # .where(updated_at: 1.day.ago..2.days.ago)
75
- # .having('count(cart_items.id) > 0').each do |cart|
76
- # subscribe(cart, user: cart.user)
77
- # end
78
- # end
79
- #
80
- # No need to worry about checking if the given subscriber being already subscribed.
81
- # The `subscribe` method does that for you.
82
- #
83
- # Optionally, can subscribe a user manually via `Caffeinate::Campaign#subscribe`
84
- def subscribes(&block)
85
- @subscribes_block = block
86
- end
87
53
  end
88
54
  end
89
55
  end
@@ -0,0 +1,70 @@
1
+ module Caffeinate
2
+ class OptionEvaluator
3
+ def initialize(thing, drip, mailing)
4
+ @thing = thing
5
+ @drip = drip
6
+ @mailing = mailing
7
+ end
8
+
9
+ def call
10
+ if @thing.is_a?(Symbol)
11
+ @drip.dripper.new.send(@thing, @drip, @mailing)
12
+ elsif @thing.is_a?(Proc)
13
+ @mailing.instance_exec(&@thing)
14
+ elsif @thing.is_a?(String)
15
+ Time.parse(@thing)
16
+ else
17
+ @thing
18
+ end
19
+ end
20
+ end
21
+
22
+ class ScheduleEvaluator
23
+ def self.call(drip, mailing)
24
+ new(drip, mailing).call
25
+ end
26
+
27
+ attr_reader :mailing
28
+ def initialize(drip, mailing)
29
+ @drip = drip
30
+ @mailing = mailing
31
+ end
32
+
33
+ # todo: test this decision tree.
34
+ def call
35
+ if periodical?
36
+ start = mailing.instance_exec(&options[:start])
37
+ start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
38
+ date = start.from_now
39
+ elsif options[:on]
40
+ date = OptionEvaluator.new(options[:on], self, mailing).call
41
+ else
42
+ date = OptionEvaluator.new(options[:delay], self, mailing).call
43
+ if date.respond_to?(:from_now)
44
+ date = date.from_now
45
+ end
46
+ end
47
+
48
+ if options[:at]
49
+ time = OptionEvaluator.new(options[:at], self, mailing).call
50
+ return date.change(hour: time.hour, min: time.min, sec: time.sec)
51
+ end
52
+
53
+ date
54
+ end
55
+
56
+ def respond_to_missing?(name, include_private = false)
57
+ @drip.respond_to?(name, include_private)
58
+ end
59
+
60
+ def method_missing(method, *args, &block)
61
+ @drip.send(method, *args, &block)
62
+ end
63
+
64
+ private
65
+
66
+ def periodical?
67
+ options[:every].present?
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- VERSION = '0.14.0'
4
+ VERSION = '2.0.1'
5
5
  end
@@ -17,10 +17,10 @@ Caffeinate.setup do |config|
17
17
  #
18
18
  # Default:
19
19
  # config.async_delivery = false
20
- # config.mailing_job = nil
20
+ # config.async_delivery_class = nil
21
21
  #
22
22
  # config.async_delivery = true
23
- # config.mailing_job = 'MyCustomCaffeinateJob'
23
+ # config.async_delivery_class = 'MyCustomCaffeinateJob'
24
24
  #
25
25
  # == Batching
26
26
  #
@@ -4,7 +4,7 @@ class CreateCaffeinateMailings < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
5
  create_table :caffeinate_mailings do |t|
6
6
  t.references :caffeinate_campaign_subscription, null: false, foreign_key: true, index: { name: 'index_caffeinate_mailings_on_campaign_subscription' }
7
- t.datetime :send_at
7
+ t.datetime :send_at, null: false
8
8
  t.datetime :sent_at
9
9
  t.datetime :skipped_at
10
10
  t.string :mailer_class, null: false
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: caffeinate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-01-18 00:00:00.000000000 Z
11
+ date: 2021-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -122,7 +122,22 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
- description: Ruby on Rails drip campaign engine. Buzzwords!
125
+ - !ruby/object:Gem::Dependency
126
+ name: codecov
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: Create, manage, and send scheduled email sequences and drip campaigns
140
+ from your Rails app.
126
141
  email:
127
142
  - josh@josh.mn
128
143
  executables: []
@@ -169,6 +184,7 @@ files:
169
184
  - lib/caffeinate/engine.rb
170
185
  - lib/caffeinate/helpers.rb
171
186
  - lib/caffeinate/mail_ext.rb
187
+ - lib/caffeinate/schedule_evaluator.rb
172
188
  - lib/caffeinate/url_helpers.rb
173
189
  - lib/caffeinate/version.rb
174
190
  - lib/generators/caffeinate/install_generator.rb
@@ -184,7 +200,7 @@ homepage: https://github.com/joshmn/caffeinate
184
200
  licenses:
185
201
  - MIT
186
202
  metadata: {}
187
- post_install_message:
203
+ post_install_message:
188
204
  rdoc_options: []
189
205
  require_paths:
190
206
  - lib
@@ -199,8 +215,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
199
215
  - !ruby/object:Gem::Version
200
216
  version: '0'
201
217
  requirements: []
202
- rubygems_version: 3.2.0.rc.2
203
- signing_key:
218
+ rubygems_version: 3.1.4
219
+ signing_key:
204
220
  specification_version: 4
205
- summary: Ruby on Rails drip campaign engine. Buzzwords!
221
+ summary: Create, manage, and send scheduled email sequences and drip campaigns from
222
+ your Rails app.
206
223
  test_files: []