caffeinate 0.12.0 → 2.0.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: 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: []