caffeinate 0.9.1 → 0.15.0

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