caffeinate 0.14.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +65 -63
- data/app/models/caffeinate/campaign.rb +6 -3
- data/app/models/caffeinate/campaign_subscription.rb +2 -13
- data/app/models/caffeinate/mailing.rb +2 -2
- data/app/views/caffeinate/campaign_subscriptions/subscribe.html.erb +1 -1
- data/app/views/caffeinate/campaign_subscriptions/unsubscribe.html.erb +1 -1
- data/lib/caffeinate/configuration.rb +36 -11
- data/lib/caffeinate/deliver_async.rb +1 -1
- data/lib/caffeinate/drip.rb +3 -43
- data/lib/caffeinate/dripper/drip.rb +16 -1
- data/lib/caffeinate/dripper/drip_collection.rb +3 -1
- data/lib/caffeinate/dripper/subscriber.rb +1 -35
- data/lib/caffeinate/schedule_evaluator.rb +70 -0
- data/lib/caffeinate/version.rb +1 -1
- data/lib/generators/caffeinate/templates/caffeinate.rb +2 -2
- data/lib/generators/caffeinate/templates/migrations/create_caffeinate_mailings.rb.tt +1 -1
- metadata +25 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f16a6861b725b37b7b98bda070586ae794875a171af2b7ea4582a05b7d9aacaa
|
4
|
+
data.tar.gz: c6ff565b6792fdf4ed193d3bd478b445a55835262d11118530f69d1aef08e8af
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c2e5c6cc7c7af6f37c270f883d74e1eff9f48ac4b16226b4bf3c31729c116d7790ba198f7657a272458cf66d9993a2814e8bf2e183fd8830876eee50bf26ba65
|
7
|
+
data.tar.gz: 7c9519545b4183787b71422b706a059bab5b73df0f872677f9fdb2dc0f10f60f4a3c2eacbc64eabf1246815bd5574dcadc0750d30a018075945e87bbc9f25254
|
data/README.md
CHANGED
@@ -2,18 +2,32 @@
|
|
2
2
|
<img width="450" src="https://github.com/joshmn/caffeinate/raw/master/logo.png" alt="Caffeinate logo" />
|
3
3
|
</div>
|
4
4
|
|
5
|
-
|
5
|
+
<div align="center">
|
6
|
+
<a href="https://codecov.io/gh/joshmn/caffeinate">
|
7
|
+
<img src="https://codecov.io/gh/joshmn/caffeinate/branch/master/graph/badge.svg?token=5LCOB4ESHL" alt="Coverage"/>
|
8
|
+
</a>
|
9
|
+
<a href="https://codeclimate.com/github/joshmn/caffeinate/maintainability">
|
10
|
+
<img src="https://api.codeclimate.com/v1/badges/9c075416ce74985d5c6c/maintainability" alt="Maintainability"/>
|
11
|
+
</a>
|
12
|
+
<a href="https://inch-ci.org/github/joshmn/caffeinate">
|
13
|
+
<img src="https://inch-ci.org/github/joshmn/caffeinate.svg?branch=master" alt="Docs"/>
|
14
|
+
</a>
|
15
|
+
</div>
|
6
16
|
|
7
17
|
# Caffeinate
|
8
18
|
|
9
|
-
Caffeinate is a drip
|
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
|
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
|
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
|
-
##
|
73
|
+
## Do this all better in five minutes
|
62
74
|
|
63
|
-
|
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
|
-
###
|
87
|
+
### Clean up the mailer logic
|
87
88
|
|
88
|
-
|
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
|
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
|
-
|
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
|
162
|
+
### Done
|
168
163
|
|
169
|
-
|
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
|
-
|
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
|
-
|
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?
|
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
|
-
|
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
|
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.
|
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.
|
83
|
+
caffeinate_campaign_subscriptions.find_or_create_by!(subscriber: subscriber, **args)
|
81
84
|
end
|
82
85
|
end
|
83
86
|
end
|
@@ -55,7 +55,7 @@ module Caffeinate
|
|
55
55
|
|
56
56
|
before_validation :call_dripper_before_subscribe_blocks!, on: :create
|
57
57
|
|
58
|
-
|
58
|
+
after_create :create_mailings!
|
59
59
|
|
60
60
|
after_commit :on_complete, if: :completed?
|
61
61
|
|
@@ -131,18 +131,7 @@ module Caffeinate
|
|
131
131
|
true
|
132
132
|
end
|
133
133
|
|
134
|
-
#
|
135
|
-
# Use `force` to forcefully reset. Does not create the mailings.
|
136
|
-
def resubscribe!(force = false)
|
137
|
-
return false if ended? && !force
|
138
|
-
return false if unsubscribed? && !force
|
139
|
-
|
140
|
-
result = update(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
|
141
|
-
|
142
|
-
caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
|
143
|
-
result
|
144
|
-
end
|
145
|
-
|
134
|
+
# Checks if the record is not new and if mailings are all gone.
|
146
135
|
def completed?
|
147
136
|
caffeinate_mailings.unsent.count.zero?
|
148
137
|
end
|
@@ -24,7 +24,7 @@ module Caffeinate
|
|
24
24
|
has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
|
25
25
|
alias_attribute :campaign, :caffeinate_campaign
|
26
26
|
|
27
|
-
scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
|
27
|
+
scope :upcoming, -> { joins(:caffeinate_campaign_subscription).where(caffeinate_campaign_subscription: ::Caffeinate::CampaignSubscription.active).unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
|
28
28
|
scope :unsent, -> { unskipped.where(sent_at: nil) }
|
29
29
|
scope :sent, -> { unskipped.where.not(sent_at: nil) }
|
30
30
|
scope :skipped, -> { where.not(skipped_at: nil) }
|
@@ -112,7 +112,7 @@ module Caffeinate
|
|
112
112
|
|
113
113
|
# Delivers the Mailing in the background
|
114
114
|
def deliver_later!
|
115
|
-
klass = ::Caffeinate.config.
|
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)
|
@@ -3,14 +3,44 @@
|
|
3
3
|
module Caffeinate
|
4
4
|
# Global configuration
|
5
5
|
class Configuration
|
6
|
-
|
7
|
-
|
6
|
+
|
7
|
+
# Used for relation to a lot of things. If you have a weird time setup, set this.
|
8
|
+
# Accepts anything that responds to `#call`; you'll probably use a block.
|
9
|
+
attr_accessor :now
|
10
|
+
|
11
|
+
# If true, enqueues the processing of a `Caffeinate::Mailing` to the background worker class
|
12
|
+
# as defined in `async_delivery_class`
|
13
|
+
#
|
14
|
+
# Default is false
|
15
|
+
attr_accessor :async_delivery
|
16
|
+
|
17
|
+
# The background worker class for `async_delivery`.
|
18
|
+
attr_accessor :async_delivery_class
|
19
|
+
|
20
|
+
# If true, uses `deliver_later` instead of `deliver`
|
21
|
+
attr_accessor :deliver_later
|
22
|
+
|
23
|
+
# The number of `Caffeinate::Mailing` records we find in a batch at once.
|
24
|
+
attr_accessor :batch_size
|
25
|
+
|
26
|
+
# The path to the drippers
|
27
|
+
attr_accessor :drippers_path
|
28
|
+
|
29
|
+
# Automatically creates a `Caffeinate::Campaign` record by the named slug of the campaign from a dripper
|
30
|
+
# if none is found by the slug.
|
31
|
+
attr_accessor :implicit_campaigns
|
32
|
+
|
33
|
+
# The default reason for an ended `Caffeinate::CampaignSubscription`
|
34
|
+
attr_accessor :default_ended_reason
|
35
|
+
|
36
|
+
# The default reason for an unsubscribed `Caffeinate::CampaignSubscription`
|
37
|
+
attr_accessor :default_unsubscribe_reason
|
8
38
|
|
9
39
|
def initialize
|
10
40
|
@now = -> { Time.current }
|
11
41
|
@async_delivery = false
|
12
42
|
@deliver_later = false
|
13
|
-
@
|
43
|
+
@async_delivery_class = nil
|
14
44
|
@batch_size = 1_000
|
15
45
|
@drippers_path = 'app/drippers'
|
16
46
|
@implicit_campaigns = true
|
@@ -24,29 +54,24 @@ module Caffeinate
|
|
24
54
|
@now = val
|
25
55
|
end
|
26
56
|
|
27
|
-
# Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
|
28
57
|
def implicit_campaigns?
|
29
|
-
@implicit_campaigns
|
58
|
+
@implicit_campaigns
|
30
59
|
end
|
31
60
|
|
32
|
-
# The current time, for database calls
|
33
61
|
def time_now
|
34
62
|
@now.call
|
35
63
|
end
|
36
64
|
|
37
|
-
# If delivery is asyncronous
|
38
65
|
def async_delivery?
|
39
66
|
@async_delivery
|
40
67
|
end
|
41
68
|
|
42
|
-
# If we should use `#deliver_later` instead of `#deliver`
|
43
69
|
def deliver_later?
|
44
70
|
@deliver_later
|
45
71
|
end
|
46
72
|
|
47
|
-
|
48
|
-
|
49
|
-
@mailing_job.constantize
|
73
|
+
def async_delivery_class
|
74
|
+
@async_delivery_class.constantize
|
50
75
|
end
|
51
76
|
end
|
52
77
|
end
|
@@ -10,7 +10,7 @@ module Caffeinate
|
|
10
10
|
#
|
11
11
|
# To use this, make sure your initializer is configured correctly:
|
12
12
|
# config.async_delivery = true
|
13
|
-
# config.
|
13
|
+
# config.async_delivery_class = 'MyWorker'
|
14
14
|
module DeliverAsync
|
15
15
|
def perform(mailing_id)
|
16
16
|
mailing = ::Caffeinate::Mailing.find(mailing_id)
|
data/lib/caffeinate/drip.rb
CHANGED
@@ -1,31 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'caffeinate/drip_evaluator'
|
4
|
+
require 'caffeinate/schedule_evaluator'
|
5
|
+
|
4
6
|
module Caffeinate
|
5
7
|
# A Drip object
|
6
8
|
#
|
7
9
|
# Handles the block and provides convenience methods for the drip
|
8
10
|
class Drip
|
9
|
-
class OptionEvaluator
|
10
|
-
def initialize(thing, drip, mailing)
|
11
|
-
@thing = thing
|
12
|
-
@drip = drip
|
13
|
-
@mailing = mailing
|
14
|
-
end
|
15
|
-
|
16
|
-
def call
|
17
|
-
if @thing.is_a?(Symbol)
|
18
|
-
@drip.dripper.new.send(@thing, @drip, @mailing)
|
19
|
-
elsif @thing.is_a?(Proc)
|
20
|
-
@mailing.instance_exec(&@thing)
|
21
|
-
elsif @thing.is_a?(String)
|
22
|
-
Time.parse(@thing)
|
23
|
-
else
|
24
|
-
@thing
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
11
|
attr_reader :dripper, :action, :options, :block
|
30
12
|
|
31
13
|
def initialize(dripper, action, options, &block)
|
@@ -41,29 +23,7 @@ module Caffeinate
|
|
41
23
|
end
|
42
24
|
|
43
25
|
def send_at(mailing = nil)
|
44
|
-
|
45
|
-
start = mailing.instance_exec(&options[:start])
|
46
|
-
start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
|
47
|
-
date = start.from_now
|
48
|
-
elsif options[:on]
|
49
|
-
date = OptionEvaluator.new(options[:on], self, mailing).call
|
50
|
-
else
|
51
|
-
date = OptionEvaluator.new(options[:delay], self, mailing).call
|
52
|
-
if date.respond_to?(:from_now)
|
53
|
-
date = date.from_now
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
if options[:at]
|
58
|
-
time = OptionEvaluator.new(options[:at], self, mailing).call
|
59
|
-
return date.change(hour: time.hour, min: time.min, sec: time.sec)
|
60
|
-
end
|
61
|
-
|
62
|
-
date
|
63
|
-
end
|
64
|
-
|
65
|
-
def periodical?
|
66
|
-
options[:every].present?
|
26
|
+
::Caffeinate::ScheduleEvaluator.call(self, mailing)
|
67
27
|
end
|
68
28
|
|
69
29
|
# Checks if the drip is enabled
|
@@ -30,7 +30,22 @@ module Caffeinate
|
|
30
30
|
# @option options [String] :mailer_class The mailer_class
|
31
31
|
# @option options [Integer] :step The order in which the drip is executed
|
32
32
|
# @option options [ActiveSupport::Duration] :delay When the drip should be ran
|
33
|
-
# @option options [Symbol] :
|
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(
|
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
|
-
#
|
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
|
data/lib/caffeinate/version.rb
CHANGED
@@ -17,10 +17,10 @@ Caffeinate.setup do |config|
|
|
17
17
|
#
|
18
18
|
# Default:
|
19
19
|
# config.async_delivery = false
|
20
|
-
# config.
|
20
|
+
# config.async_delivery_class = nil
|
21
21
|
#
|
22
22
|
# config.async_delivery = true
|
23
|
-
# config.
|
23
|
+
# config.async_delivery_class = 'MyCustomCaffeinateJob'
|
24
24
|
#
|
25
25
|
# == Batching
|
26
26
|
#
|
@@ -4,7 +4,7 @@ class CreateCaffeinateMailings < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def change
|
5
5
|
create_table :caffeinate_mailings do |t|
|
6
6
|
t.references :caffeinate_campaign_subscription, null: false, foreign_key: true, index: { name: 'index_caffeinate_mailings_on_campaign_subscription' }
|
7
|
-
t.datetime :send_at
|
7
|
+
t.datetime :send_at, null: false
|
8
8
|
t.datetime :sent_at
|
9
9
|
t.datetime :skipped_at
|
10
10
|
t.string :mailer_class, null: false
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: caffeinate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 2.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Josh Brody
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
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
|
-
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: codecov
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
description: Create, manage, and send scheduled email sequences and drip campaigns
|
140
|
+
from your Rails app.
|
126
141
|
email:
|
127
142
|
- josh@josh.mn
|
128
143
|
executables: []
|
@@ -169,6 +184,7 @@ files:
|
|
169
184
|
- lib/caffeinate/engine.rb
|
170
185
|
- lib/caffeinate/helpers.rb
|
171
186
|
- lib/caffeinate/mail_ext.rb
|
187
|
+
- lib/caffeinate/schedule_evaluator.rb
|
172
188
|
- lib/caffeinate/url_helpers.rb
|
173
189
|
- lib/caffeinate/version.rb
|
174
190
|
- lib/generators/caffeinate/install_generator.rb
|
@@ -184,7 +200,7 @@ homepage: https://github.com/joshmn/caffeinate
|
|
184
200
|
licenses:
|
185
201
|
- MIT
|
186
202
|
metadata: {}
|
187
|
-
post_install_message:
|
203
|
+
post_install_message:
|
188
204
|
rdoc_options: []
|
189
205
|
require_paths:
|
190
206
|
- lib
|
@@ -199,8 +215,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
199
215
|
- !ruby/object:Gem::Version
|
200
216
|
version: '0'
|
201
217
|
requirements: []
|
202
|
-
rubygems_version: 3.
|
203
|
-
signing_key:
|
218
|
+
rubygems_version: 3.1.4
|
219
|
+
signing_key:
|
204
220
|
specification_version: 4
|
205
|
-
summary:
|
221
|
+
summary: Create, manage, and send scheduled email sequences and drip campaigns from
|
222
|
+
your Rails app.
|
206
223
|
test_files: []
|