caffeinate 0.12.0 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80e462cd99749a8f1352d481fec74d6a053a1660d200888a93844205eafb984c
4
- data.tar.gz: 1c709353c202c7a97eb2fd8067f31ee40ee2753d93f8771232494867d47ce9c0
3
+ metadata.gz: 267d93d746f23525a65cf5607ef0b4c67195cd9c14260d3f8b9b346cfdeb8ffe
4
+ data.tar.gz: c3851e78e0ddd35f5735aaa70ce9360cf242ab9ede3e6c6a74700b07c59ad16e
5
5
  SHA512:
6
- metadata.gz: 9d4261e176299efc975f8d11791c95a58740bd94eaa6ae2e931fb262240f9e24a27cc1c88230965386ff4658e037dcbeb00ebf2100399d077872be0196e3ba8c
7
- data.tar.gz: 208bc056825501459d0247e4fa91f64b3d9c4408f8219d7f47aa6dd492e9b183e614adeb97b5746f3e9c686d31aa38035667cc003142c93417f1d6cb8935b906
6
+ metadata.gz: f81b1310248f890a74c37b290d5860f6562763d567ec9fe07ca3aeff27f8aa7a48ff0bb071797f8cef022cdb76ea403a12bdce8f76afae5d1a929c805209cbdf
7
+ data.tar.gz: efe9b9a8306b3c832d04218b11c10c5772319b38206de5e1c141e1f15a494ab73ec069ea19cddfe2d570edb155d14520f4bdf1525b3eb8a849718bc7e895ab05
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
@@ -33,6 +33,9 @@ module Caffeinate
33
33
  has_one :next_caffeinate_mailing, -> { joins(:caffeinate_campaign_subscription).where(caffeinate_campaign_subscriptions: { ended_at: nil, unsubscribed_at: nil }).upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
34
34
  has_one :next_mailing, -> { joins(:caffeinate_campaign_subscription).where(caffeinate_campaign_subscriptions: { ended_at: nil, unsubscribed_at: nil }).upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
35
35
 
36
+ has_one :previous_caffeinate_mailing, -> { sent.order(sent_at: :desc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
37
+ has_one :previous_mailing, -> { sent.order(sent_at: :desc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
38
+
36
39
  belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
37
40
  alias_attribute :campaign, :caffeinate_campaign
38
41
 
@@ -52,7 +55,7 @@ module Caffeinate
52
55
 
53
56
  before_validation :call_dripper_before_subscribe_blocks!, on: :create
54
57
 
55
- after_commit :create_mailings!, on: :create
58
+ after_create :create_mailings!
56
59
 
57
60
  after_commit :on_complete, if: :completed?
58
61
 
@@ -77,7 +80,7 @@ module Caffeinate
77
80
  end
78
81
 
79
82
  # Updates `ended_at` and runs `on_complete` callbacks
80
- def end!(reason = nil)
83
+ def end!(reason = ::Caffeinate.config.default_ended_reason)
81
84
  raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
82
85
 
83
86
  update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
@@ -87,7 +90,7 @@ module Caffeinate
87
90
  end
88
91
 
89
92
  # Updates `ended_at` and runs `on_complete` callbacks
90
- def end(reason = nil)
93
+ def end(reason = ::Caffeinate.config.default_ended_reason)
91
94
  return false if unsubscribed?
92
95
 
93
96
  result = update(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
@@ -97,7 +100,7 @@ module Caffeinate
97
100
  end
98
101
 
99
102
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
100
- def unsubscribe!(reason = nil)
103
+ def unsubscribe!(reason = ::Caffeinate.config.default_unsubscribe_reason)
101
104
  raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended?
102
105
 
103
106
  update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
@@ -107,7 +110,7 @@ module Caffeinate
107
110
  end
108
111
 
109
112
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
110
- def unsubscribe(reason = nil)
113
+ def unsubscribe(reason = ::Caffeinate.config.default_unsubscribe_reason)
111
114
  return false if ended?
112
115
 
113
116
  result = update(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
@@ -128,18 +131,7 @@ module Caffeinate
128
131
  true
129
132
  end
130
133
 
131
- # Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
132
- # Use `force` to forcefully reset. Does not create the mailings.
133
- def resubscribe!(force = false)
134
- return false if ended? && !force
135
- return false if unsubscribed? && !force
136
-
137
- result = update(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
138
-
139
- caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
140
- result
141
- end
142
-
134
+ # Checks if the record is not new and if mailings are all gone.
143
135
  def completed?
144
136
  caffeinate_mailings.unsent.count.zero?
145
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)
@@ -5,7 +5,6 @@ module Caffeinate
5
5
  # Handles the evaluation of a drip against a mailing to determine if it ultimately gets delivered.
6
6
  # Also invokes the `before_send` callbacks.
7
7
  class Interceptor
8
- # Handles `before_send` callbacks for a `Caffeinate::Dripper`
9
8
  def self.delivering_email(message)
10
9
  mailing = message.caffeinate_mailing
11
10
  return unless mailing
@@ -3,15 +3,49 @@
3
3
  module Caffeinate
4
4
  # Global configuration
5
5
  class Configuration
6
- attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path, :implicit_campaigns
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
7
38
 
8
39
  def initialize
9
40
  @now = -> { Time.current }
10
41
  @async_delivery = false
11
- @mailing_job = nil
42
+ @deliver_later = false
43
+ @async_delivery_class = nil
12
44
  @batch_size = 1_000
13
45
  @drippers_path = 'app/drippers'
14
46
  @implicit_campaigns = true
47
+ @default_ended_reason = nil
48
+ @default_unsubscribe_reason = nil
15
49
  end
16
50
 
17
51
  def now=(val)
@@ -20,24 +54,24 @@ module Caffeinate
20
54
  @now = val
21
55
  end
22
56
 
23
- # Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
24
57
  def implicit_campaigns?
25
- @implicit_campaigns == true
58
+ @implicit_campaigns
26
59
  end
27
60
 
28
- # The current time, for database calls
29
61
  def time_now
30
62
  @now.call
31
63
  end
32
64
 
33
- # If delivery is asyncronous
34
65
  def async_delivery?
35
66
  @async_delivery
36
67
  end
37
68
 
38
- # The @mailing_job constantized. Only used if `async_delivery = true`
39
- def mailing_job_class
40
- @mailing_job.constantize
69
+ def deliver_later?
70
+ @deliver_later
71
+ end
72
+
73
+ def async_delivery_class
74
+ @async_delivery_class.constantize
41
75
  end
42
76
  end
43
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,36 +23,22 @@ 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
+ #
31
+ # This is kind of messy and could use some love.
32
+ # todo: better.
70
33
  def enabled?(mailing)
71
- dripper.run_callbacks(:before_drip, self, mailing)
72
-
73
- DripEvaluator.new(mailing).call(&@block)
34
+ catch(:abort) do
35
+ if dripper.run_callbacks(:before_drip, self, mailing)
36
+ return DripEvaluator.new(mailing).call(&@block)
37
+ else
38
+ return false
39
+ end
40
+ end
41
+ false
74
42
  end
75
43
  end
76
44
  end
@@ -13,12 +13,25 @@ module Caffeinate
13
13
  self.class.run_callbacks(name, *args)
14
14
  end
15
15
 
16
+ def callbacks_for(name)
17
+ self.class.callbacks_for(name)
18
+ end
19
+
16
20
  module ClassMethods
17
21
  # :nodoc:
18
22
  def run_callbacks(name, *args)
19
- send("#{name}_blocks").each do |callback|
20
- callback.call(*args)
23
+ catch(:abort) do
24
+ callbacks_for(name).each do |callback|
25
+ callback.call(*args)
26
+ end
27
+ return true
21
28
  end
29
+ false
30
+ end
31
+
32
+ # :nodoc:
33
+ def callbacks_for(name)
34
+ send("#{name}_blocks")
22
35
  end
23
36
 
24
37
  def before_subscribe(&block)
@@ -114,10 +127,19 @@ module Caffeinate
114
127
 
115
128
  # Callback before a Drip has called the mailer.
116
129
  #
117
- # before_drip do |campaign_subscription, mailing, drip|
130
+ # before_drip do |drip, mailing|
118
131
  # Slack.notify(:caffeinate, "#{drip.action_name} is starting")
119
132
  # end
120
133
  #
134
+ # Note: If you want to bail on the mailing for some reason, you need invoke `throw(:abort)`
135
+ #
136
+ # before_drip do |drip, mailing|
137
+ # if mailing.caffeinate_campaign_subscription.subscriber.trial_ended?
138
+ # unsubscribe!("Trial ended")
139
+ # throw(:abort)
140
+ # end
141
+ # end
142
+ #
121
143
  # @yield Caffeinate::Drip current drip
122
144
  # @yield Caffeinate::Mailing
123
145
  def before_drip(&block)
@@ -20,7 +20,12 @@ module Caffeinate
20
20
  mailing.mailer_class.constantize.send(mailing.mailer_action, mailing)
21
21
  end
22
22
  message.caffeinate_mailing = mailing
23
- message.deliver
23
+ if ::Caffeinate.config.deliver_later?
24
+ message.deliver_later
25
+ else
26
+ message.deliver
27
+ end
28
+
24
29
  end
25
30
  end
26
31
  end
@@ -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.12.0'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -5,7 +5,6 @@ module Caffeinate
5
5
  # Installs Caffeinate
6
6
  class InstallGenerator < Rails::Generators::Base
7
7
  source_root File.expand_path('templates', __dir__)
8
- include ::Rails::Generators::Migration
9
8
 
10
9
  desc 'Creates a Caffeinate initializer and copies migrations to your application.'
11
10
 
@@ -33,12 +32,21 @@ module Caffeinate
33
32
  @prev_migration_nr.to_s
34
33
  end
35
34
 
35
+ def migration_version
36
+ if rails5_and_up?
37
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
38
+ end
39
+ end
40
+
41
+ def rails5_and_up?
42
+ Rails::VERSION::MAJOR >= 5
43
+ end
44
+
36
45
  # :nodoc:
37
46
  def copy_migrations
38
- require 'rake'
39
- Rails.application.load_tasks
40
- Rake::Task['railties:install:migrations'].reenable
41
- Rake::Task['caffeinate:install:migrations'].invoke
47
+ template 'migrations/create_caffeinate_campaigns.rb', "db/migrate/#{self.class.next_migration_number("")}_create_caffeinate_campaigns.rb"
48
+ template 'migrations/create_caffeinate_campaign_subscriptions.rb', "db/migrate/#{self.class.next_migration_number("")}_create_caffeinate_campaign_subscriptions.rb"
49
+ template 'migrations/create_caffeinate_mailings.rb', "db/migrate/#{self.class.next_migration_number("")}_create_caffeinate_mailings.rb"
42
50
  end
43
51
  end
44
52
  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
  #
@@ -41,4 +41,15 @@ Caffeinate.setup do |config|
41
41
  # config.implicit_campaigns = true
42
42
  #
43
43
  # config.implicit_campaigns = false
44
+ #
45
+ # == Default reasons
46
+ #
47
+ # The default unsubscribe and end reasons.
48
+ #
49
+ # Default:
50
+ # config.default_unsubscribe_reason = nil
51
+ # config.default_ended_reason = nil
52
+ #
53
+ # config.default_unsubscribe_reason = "User unsubscribed"
54
+ # config.default_ended_reason = "User ended"
44
55
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
3
+ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
5
  drop_table :caffeinate_campaign_subscriptions if table_exists?(:caffeinate_campaign_subscriptions)
6
6
 
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateCaffeinateCampaigns < ActiveRecord::Migration[6.0]
3
+ class CreateCaffeinateCampaigns < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- drop_table :caffeinate_campaigns if table_exists?(:caffeinate_campaigns)
6
5
  create_table :caffeinate_campaigns do |t|
7
6
  t.string :name, null: false
8
7
  t.string :slug, null: false
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
3
+ class CreateCaffeinateMailings < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- drop_table :caffeinate_mailings if table_exists?(:caffeinate_mailings)
6
-
7
5
  create_table :caffeinate_mailings do |t|
8
6
  t.references :caffeinate_campaign_subscription, null: false, foreign_key: true, index: { name: 'index_caffeinate_mailings_on_campaign_subscription' }
9
- t.datetime :send_at
7
+ t.datetime :send_at, null: false
10
8
  t.datetime :sent_at
11
9
  t.datetime :skipped_at
12
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.12.0
4
+ version: 2.0.0
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-04 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: []
@@ -143,9 +158,6 @@ files:
143
158
  - app/views/layouts/_caffeinate.html.erb
144
159
  - config/locales/en.yml
145
160
  - config/routes.rb
146
- - db/migrate/20201124183102_create_caffeinate_campaigns.rb
147
- - db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb
148
- - db/migrate/20201124183419_create_caffeinate_mailings.rb
149
161
  - lib/caffeinate.rb
150
162
  - lib/caffeinate/action_mailer.rb
151
163
  - lib/caffeinate/action_mailer/extension.rb
@@ -172,6 +184,7 @@ files:
172
184
  - lib/caffeinate/engine.rb
173
185
  - lib/caffeinate/helpers.rb
174
186
  - lib/caffeinate/mail_ext.rb
187
+ - lib/caffeinate/schedule_evaluator.rb
175
188
  - lib/caffeinate/url_helpers.rb
176
189
  - lib/caffeinate/version.rb
177
190
  - lib/generators/caffeinate/install_generator.rb
@@ -179,12 +192,15 @@ files:
179
192
  - lib/generators/caffeinate/templates/application_dripper.rb
180
193
  - lib/generators/caffeinate/templates/caffeinate.rb
181
194
  - lib/generators/caffeinate/templates/mailer.rb.tt
195
+ - lib/generators/caffeinate/templates/migrations/create_caffeinate_campaign_subscriptions.rb.tt
196
+ - lib/generators/caffeinate/templates/migrations/create_caffeinate_campaigns.rb.tt
197
+ - lib/generators/caffeinate/templates/migrations/create_caffeinate_mailings.rb.tt
182
198
  - lib/generators/caffeinate/views_generator.rb
183
199
  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: []