caffeinate 0.9.1 → 0.15.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: 207ba08c8c3c5596615211a20d62807615545d899e8d82fea4b858bfabe9660d
4
- data.tar.gz: 5449537253999aa2b314e89d706e9e99f3b08d9aa55a2a861536f52c2506cb43
3
+ metadata.gz: 2c8294f4e3189cbc99b5a74d31851c1c752bae5acb417b95793aad434781db65
4
+ data.tar.gz: 94cf500c26c578c950474a6e3716b6e689052880c65e87263c5403c1af17d279
5
5
  SHA512:
6
- metadata.gz: 48d8a5eb87c0112a89e82c607132b4f3f4d23bc918b561e59e8b7a4bff9c801295d51795ad68108fe2bb450f2a6d411a5a7aa0957cc5a171b19693f0f290c790
7
- data.tar.gz: e3e105ab3d1a7ae342457f5203b351e4c692b79e2d42eefe3668d8b9d96ddb635a2216b32fdfb1e510ed51ea2fb7d68a213f81f0bbf588b4cfe4405d9ba12d8b
6
+ metadata.gz: 5861ebed028ffcb28df9565dea9fb3bdd068170631133d5ee743351489820dbe0dd86654c5631f98cd14736ca0ac06210e47de992bbda0ed45345029ba9c9141
7
+ data.tar.gz: ce79dd00459d71dbc987063662ddff59b7bb0b6083e55a2821bbbd72dcbf85283daf26d60c8add3e9492010a30c77608642bce3a20695fcf8784a4399296aaa5
data/README.md CHANGED
@@ -2,15 +2,24 @@
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.
10
19
 
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.
20
+ Caffeinate is a drip email engine for managing, creating, and sending scheduled email sequences from your Ruby on Rails application.
21
+
22
+ Caffeinate provides a simple DSL to create scheduled email sequences which can be used by ActionMailer without any additional configuration.
14
23
 
15
24
  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
25
 
@@ -30,19 +39,16 @@ end
30
39
 
31
40
  ```ruby
32
41
  class OnboardingMailer < ActionMailer::Base
33
- # Send on account creation
34
42
  def welcome_to_my_cool_app(user)
35
43
  mail(to: user.email, subject: "Welcome to CoolApp!")
36
44
  end
37
45
 
38
- # Send 2 days after the user signs up
39
46
  def some_cool_tips(user)
40
47
  return if user.unsubscribed_from_onboarding_campaign?
41
48
 
42
49
  mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
43
50
  end
44
51
 
45
- # Sends 3 days after the user signs up and hasn't added a company profile yet
46
52
  def help_getting_started(user)
47
53
  return if user.unsubscribed_from_onboarding_campaign?
48
54
  return if user.onboarding_completed?
@@ -56,22 +62,12 @@ end
56
62
 
57
63
  * You're checking state in a mailer
58
64
  * The unsubscribe feature is, most likely, tied to a `User`, which means...
59
- * It's going to be _so fun_ to scale horizontally
60
-
61
- ## Caffeinate to the rescue
65
+ * It's going to be _so fun_ to scale when you finally want to add more unsubscribe links for different types of sequences
66
+ - "one of your projects has expired", but which one? Then you have to add a column to `projects` and manage all that state... ew
62
67
 
63
- Caffeinate combines a simple scheduling DSL, ActionMailer, and your data models to create scheduled email sequences.
68
+ ## Do this all better in five minutes
64
69
 
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!
70
+ In five minutes you can implement this onboarding campaign:
75
71
 
76
72
  ### Install it
77
73
 
@@ -83,11 +79,11 @@ $ rails g caffeinate:install
83
79
  $ rake db:migrate
84
80
  ```
85
81
 
86
- ### Remove that ActionMailer logic
82
+ ### Clean up the mailer logic
87
83
 
88
- Just delete it. Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
84
+ Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
89
85
 
90
- The only other change you need to make is the argument that the mailer action receives:
86
+ 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
87
 
92
88
  ```ruby
93
89
  class OnboardingMailer < ActionMailer::Base
@@ -108,27 +104,36 @@ class OnboardingMailer < ActionMailer::Base
108
104
  end
109
105
  ```
110
106
 
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
107
  ### Create a Dripper
118
108
 
119
- A Dripper has all the logic for your Campaign and coordinates with ActionMailer on what to send.
109
+ A Dripper has all the logic for your sequence and coordinates with ActionMailer on what to send.
120
110
 
121
111
  In `app/drippers/onboarding_dripper.rb`:
122
112
 
123
113
  ```ruby
124
114
  class OnboardingDripper < ApplicationDripper
115
+ # each sequence is a campaign. This will dynamically create one by the given slug
116
+ self.campaign = :onboarding
117
+
118
+ # gets called before every time we process a drip
119
+ before_drip do |_drip, mailing|
120
+ if mailing.subscription.subscriber.onboarding_completed?
121
+ mailing.subscription.unsubscribe!("Completed onboarding")
122
+ throw(:abort)
123
+ end
124
+ end
125
+
126
+ # map drips to the mailer
125
127
  drip :welcome_to_my_cool_app, mailer: 'OnboardingMailer', delay: 0.hours
126
128
  drip :some_cool_tips, mailer: 'OnboardingMailer', delay: 2.days
127
129
  drip :help_getting_started, mailer: 'OnboardingMailer', delay: 3.days
128
130
  end
129
131
  ```
130
132
 
131
- The `drip` syntax is `def drip(mailer_action, options = {})`.
133
+ We want to skip sending the `mailing` if the `subscriber` (`User`) completed onboarding. Let's unsubscribe
134
+ with `#unsubscribe!` and give it an optional reason of `Completed onboarding` so we can reference it later
135
+ when we look at analytics. `throw(:abort)` halts the callback chain just like regular Rails callbacks, stopping the
136
+ mailing from being sent.
132
137
 
133
138
  ### Add a subscriber to the Campaign
134
139
 
@@ -138,48 +143,37 @@ a `Caffeinate::CampaignSubscription`.
138
143
  ```ruby
139
144
  class User < ApplicationRecord
140
145
  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
146
+ OnboardingDripper.subscribe!(self)
150
147
  end
151
148
  end
152
149
  ```
153
150
 
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
151
  ### Run the Dripper
159
152
 
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
153
  ```ruby
164
154
  OnboardingDripper.perform!
165
155
  ```
166
156
 
167
- ### Done. But wait, there's more fun if you want
157
+ ### Done
168
158
 
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)`
159
+ You're done.
174
160
 
175
- ### Done. But wait, there's more fun if you want
161
+ [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,
162
+ tips, tricks, and shortcuts.
176
163
 
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)`
164
+ ## But wait, there's more
182
165
 
166
+ Caffeinate also...
167
+
168
+ * ✅ Allows hyper-precise scheduled times. 9:19AM _in the user's timezone_? Sure! **Only on business days**? YES!
169
+ * ✅ Periodicals
170
+ * ✅ Manages unsubscribes
171
+ * ✅ Works with singular and multiple associations
172
+ * ✅ Compatible with every background processor
173
+ * ✅ Tested against large databases at AngelList and is performant as hell
174
+ * ✅ Effortlessly handles complex workflows
175
+ - Need to skip a certain mailing? You can!
176
+
183
177
  ## Documentation
184
178
 
185
179
  * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
@@ -191,7 +185,7 @@ OnboardingDripper.perform!
191
185
 
192
186
  ## Alternatives
193
187
 
194
- Not a fan? There are some alternatives!
188
+ Not a fan of Caffeinate? I built it because I wasn't a fan of the alternatives. To each their own:
195
189
 
196
190
  * https://github.com/honeybadger-io/heya
197
191
  * https://github.com/tarr11/dripper
@@ -199,11 +193,14 @@ Not a fan? There are some alternatives!
199
193
 
200
194
  ## Contributing
201
195
 
202
- Just do it.
196
+ There's so much more that can be done with this. I'd love to see what you're thinking.
197
+
198
+ 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
199
 
204
200
  ## Contributors & thanks
205
201
 
206
202
  * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
203
+ * Thanks to [markokajzer](https://github.com/markokajzer) for listening to me talk about this most mornings.
207
204
 
208
205
  ## License
209
206
 
@@ -20,6 +20,8 @@ module Caffeinate
20
20
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
21
21
  has_many :mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
22
22
 
23
+ scope :active, -> { where(active: true) }
24
+
23
25
  # Poorly-named Campaign class resolver
24
26
  def to_dripper
25
27
  ::Caffeinate.dripper_collection.resolve(self)
@@ -28,9 +28,13 @@ module Caffeinate
28
28
 
29
29
  has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
30
30
  has_many :mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
31
+ has_many :future_mailings, -> { upcoming.unsent }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
31
32
 
32
- has_one :next_caffeinate_mailing, -> { upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
33
- has_one :next_mailing, -> { joins(:caffeinate_campaign_subscription).where(Caffeinate::CampaignSubscription.active).upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
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
+ 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
+
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
34
38
 
35
39
  belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
36
40
  alias_attribute :campaign, :caffeinate_campaign
@@ -49,6 +53,8 @@ module Caffeinate
49
53
  before_validation :set_token!, on: [:create]
50
54
  validates :token, uniqueness: true, on: [:create]
51
55
 
56
+ before_validation :call_dripper_before_subscribe_blocks!, on: :create
57
+
52
58
  after_commit :create_mailings!, on: :create
53
59
 
54
60
  after_commit :on_complete, if: :completed?
@@ -74,7 +80,7 @@ module Caffeinate
74
80
  end
75
81
 
76
82
  # Updates `ended_at` and runs `on_complete` callbacks
77
- def end!(reason = nil)
83
+ def end!(reason = ::Caffeinate.config.default_ended_reason)
78
84
  raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
79
85
 
80
86
  update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
@@ -84,7 +90,7 @@ module Caffeinate
84
90
  end
85
91
 
86
92
  # Updates `ended_at` and runs `on_complete` callbacks
87
- def end(reason = nil)
93
+ def end(reason = ::Caffeinate.config.default_ended_reason)
88
94
  return false if unsubscribed?
89
95
 
90
96
  result = update(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
@@ -94,7 +100,7 @@ module Caffeinate
94
100
  end
95
101
 
96
102
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
97
- def unsubscribe!(reason = nil)
103
+ def unsubscribe!(reason = ::Caffeinate.config.default_unsubscribe_reason)
98
104
  raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended?
99
105
 
100
106
  update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
@@ -104,7 +110,7 @@ module Caffeinate
104
110
  end
105
111
 
106
112
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
107
- def unsubscribe(reason = nil)
113
+ def unsubscribe(reason = ::Caffeinate.config.default_unsubscribe_reason)
108
114
  return false if ended?
109
115
 
110
116
  result = update(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
@@ -143,6 +149,10 @@ module Caffeinate
143
149
 
144
150
  private
145
151
 
152
+ def call_dripper_before_subscribe_blocks!
153
+ caffeinate_campaign.to_dripper.run_callbacks(:before_subscribe, self)
154
+ end
155
+
146
156
  def on_complete
147
157
  caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
148
158
  end
@@ -24,12 +24,14 @@ 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) }
31
31
  scope :unskipped, -> { where(skipped_at: nil) }
32
32
 
33
+ after_touch :end_if_no_mailings!
34
+
33
35
  def initialize_dup(args)
34
36
  super
35
37
  self.send_at = nil
@@ -110,7 +112,7 @@ module Caffeinate
110
112
 
111
113
  # Delivers the Mailing in the background
112
114
  def deliver_later!
113
- klass = ::Caffeinate.config.mailing_job_class
115
+ klass = ::Caffeinate.config.async_delivery_class
114
116
  if klass.respond_to?(:perform_later)
115
117
  klass.perform_later(id)
116
118
  elsif klass.respond_to?(:perform_async)
@@ -119,5 +121,9 @@ module Caffeinate
119
121
  raise NoMethodError, "Neither perform_later or perform_async are defined on #{klass}."
120
122
  end
121
123
  end
124
+
125
+ def end_if_no_mailings!
126
+ end! if future_mailings.empty?
127
+ end
122
128
  end
123
129
  end
@@ -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,6 +1,8 @@
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
  #
@@ -21,33 +23,22 @@ module Caffeinate
21
23
  end
22
24
 
23
25
  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
- elsif options[:on]
29
- date = mailing.instance_exec(&options[:on])
30
- else
31
- date = options[:delay].from_now
32
- end
33
-
34
- if options[:at]
35
- time = Time.parse(options[:at])
36
- return date.change(hour: time.hour, min: time.min, sec: time.sec)
37
- end
38
-
39
- date
40
- end
41
-
42
- def periodical?
43
- options[:every].present?
26
+ ::Caffeinate::ScheduleEvaluator.call(self, mailing)
44
27
  end
45
28
 
46
29
  # Checks if the drip is enabled
30
+ #
31
+ # This is kind of messy and could use some love.
32
+ # todo: better.
47
33
  def enabled?(mailing)
48
- dripper.run_callbacks(:before_drip, self, mailing)
49
-
50
- 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
51
42
  end
52
43
  end
53
44
  end
@@ -11,9 +11,9 @@ module Caffeinate
11
11
 
12
12
  def call(&block)
13
13
  return true unless block
14
-
15
14
  catch(:abort) do
16
- return instance_eval(&block)
15
+ result = instance_eval(&block)
16
+ return result.nil? || result === true
17
17
  end
18
18
  false
19
19
  end
@@ -13,12 +13,33 @@ 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")
35
+ end
36
+
37
+ def before_subscribe(&block)
38
+ before_subscribe_blocks << block
39
+ end
40
+
41
+ def before_subscribe_blocks
42
+ @before_subscribe_blocks ||= []
22
43
  end
23
44
 
24
45
  # Callback after a Caffeinate::CampaignSubscription is created, and after the Caffeinate::Mailings have
@@ -106,10 +127,19 @@ module Caffeinate
106
127
 
107
128
  # Callback before a Drip has called the mailer.
108
129
  #
109
- # before_drip do |campaign_subscription, mailing, drip|
130
+ # before_drip do |drip, mailing|
110
131
  # Slack.notify(:caffeinate, "#{drip.action_name} is starting")
111
132
  # end
112
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
+ #
113
143
  # @yield Caffeinate::Drip current drip
114
144
  # @yield Caffeinate::Mailing
115
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
@@ -16,11 +16,7 @@ module Caffeinate
16
16
  # @return nil
17
17
  def perform!
18
18
  run_callbacks(:before_perform, self)
19
- Caffeinate::Mailing
20
- .upcoming
21
- .unsent
22
- .joins(:caffeinate_campaign_subscription)
23
- .merge(Caffeinate::CampaignSubscription.active.where(caffeinate_campaign: self.campaign))
19
+ self.class.upcoming_mailings
24
20
  .in_batches(of: self.class.batch_size)
25
21
  .each do |batch|
26
22
  run_callbacks(:on_perform, self, batch)
@@ -35,6 +31,14 @@ module Caffeinate
35
31
  def perform!
36
32
  new.perform!
37
33
  end
34
+
35
+ def upcoming_mailings
36
+ Caffeinate::Mailing
37
+ .upcoming
38
+ .unsent
39
+ .joins(:caffeinate_campaign_subscription)
40
+ .merge(Caffeinate::CampaignSubscription.active.where(caffeinate_campaign: campaign))
41
+ end
38
42
  end
39
43
  end
40
44
  end
@@ -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,64 @@
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
+ delegate_missing_to :@drip
24
+
25
+ def self.call(drip, mailing)
26
+ new(drip, mailing).call
27
+ end
28
+
29
+ attr_reader :mailing
30
+ def initialize(drip, mailing)
31
+ @drip = drip
32
+ @mailing = mailing
33
+ end
34
+
35
+ # todo: test this decision tree.
36
+ def call
37
+ if periodical?
38
+ start = mailing.instance_exec(&options[:start])
39
+ start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
40
+ date = start.from_now
41
+ elsif options[:on]
42
+ date = OptionEvaluator.new(options[:on], self, mailing).call
43
+ else
44
+ date = OptionEvaluator.new(options[:delay], self, mailing).call
45
+ if date.respond_to?(:from_now)
46
+ date = date.from_now
47
+ end
48
+ end
49
+
50
+ if options[:at]
51
+ time = OptionEvaluator.new(options[:at], self, mailing).call
52
+ return date.change(hour: time.hour, min: time.min, sec: time.sec)
53
+ end
54
+
55
+ date
56
+ end
57
+
58
+ private
59
+
60
+ def periodical?
61
+ options[:every].present?
62
+ end
63
+ end
64
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- VERSION = '0.9.1'
4
+ VERSION = '0.15.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.9.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-24 00:00:00.000000000 Z
11
+ date: 2021-01-24 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,6 +192,9 @@ 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:
@@ -202,5 +218,6 @@ requirements: []
202
218
  rubygems_version: 3.2.0.rc.2
203
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: []