caffeinate 0.1.3 → 0.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +58 -43
- data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +17 -2
- data/app/models/caffeinate/campaign.rb +40 -1
- data/app/models/caffeinate/campaign_subscription.rb +34 -10
- data/app/models/caffeinate/mailing.rb +26 -3
- data/app/views/caffeinate/campaign_subscriptions/subscribe.html.erb +3 -0
- data/app/views/caffeinate/campaign_subscriptions/unsubscribe.html.erb +3 -0
- data/app/views/layouts/_caffeinate.html.erb +11 -0
- data/config/locales/en.yml +6 -0
- data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +1 -0
- data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +3 -0
- data/db/migrate/20201124183419_create_caffeinate_mailings.rb +4 -1
- data/lib/caffeinate.rb +2 -0
- data/lib/caffeinate/action_mailer.rb +4 -4
- data/lib/caffeinate/action_mailer/extension.rb +17 -1
- data/lib/caffeinate/action_mailer/interceptor.rb +4 -2
- data/lib/caffeinate/action_mailer/observer.rb +4 -4
- data/lib/caffeinate/active_record/extension.rb +3 -2
- data/lib/caffeinate/configuration.rb +4 -1
- data/lib/caffeinate/drip.rb +22 -35
- data/lib/caffeinate/drip_evaluator.rb +35 -0
- data/lib/caffeinate/dripper/base.rb +13 -14
- data/lib/caffeinate/dripper/batching.rb +22 -0
- data/lib/caffeinate/dripper/callbacks.rb +57 -6
- data/lib/caffeinate/dripper/campaign.rb +8 -9
- data/lib/caffeinate/dripper/defaults.rb +4 -2
- data/lib/caffeinate/dripper/delivery.rb +8 -8
- data/lib/caffeinate/dripper/drip.rb +46 -16
- data/lib/caffeinate/dripper/inferences.rb +29 -0
- data/lib/caffeinate/dripper/perform.rb +24 -5
- data/lib/caffeinate/dripper/periodical.rb +24 -0
- data/lib/caffeinate/engine.rb +12 -1
- data/lib/caffeinate/helpers.rb +24 -0
- data/lib/caffeinate/mail_ext.rb +12 -0
- data/lib/caffeinate/url_helpers.rb +10 -0
- data/lib/caffeinate/version.rb +1 -1
- data/lib/generators/caffeinate/install_generator.rb +5 -1
- data/lib/generators/caffeinate/templates/caffeinate.rb +11 -1
- metadata +13 -5
- data/app/views/layouts/caffeinate/application.html.erb +0 -15
- data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +0 -1
- data/lib/caffeinate/action_mailer/helpers.rb +0 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e9862310b9a457373cfff3e69645b2c07a01f87ba6cafb4a1818a129a757841e
|
4
|
+
data.tar.gz: 3b73b977f565f19dd681a56b585397fa4de24020e1ef320956eb993eec15bc52
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c2aaad4b624a503bc5a0e290373ebe50167ec82446deba5ae693937f59dcd8d9ef88b0796fc5b1263351098c67af32fd9e6eca7067f803b284c9d4f10a17608b
|
7
|
+
data.tar.gz: 1b4f720e186533495905725aaf334087ea83eb5dd4d641ea670951b48cfa8b629188ec9872245958333484b88c5f7e1aed7fb8f37831d2b00822bcd83b4dffcb
|
data/README.md
CHANGED
@@ -1,81 +1,91 @@
|
|
1
1
|
# Caffeinate
|
2
2
|
|
3
|
-
|
3
|
+
Caffeinate is a drip campaign engine for Ruby on Rails applications.
|
4
4
|
|
5
|
-
|
5
|
+
Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
|
6
|
+
and has everything you need to get started and to successfully manage campaigns. It's only dependency is the stack you're
|
7
|
+
already familiar with: Ruby on Rails.
|
6
8
|
|
7
|
-
[
|
9
|
+
![Caffeinate logo](logo.png)
|
8
10
|
|
9
11
|
## Usage
|
10
12
|
|
11
|
-
|
13
|
+
You can probably imagine seeing a Mailer like this:
|
12
14
|
|
13
15
|
```ruby
|
14
|
-
class
|
15
|
-
|
16
|
-
|
16
|
+
class OnboardingMailer < ActionMailer::Base
|
17
|
+
# Send on account creation
|
18
|
+
def welcome_to_my_cool_app(user)
|
19
|
+
mail(to: user.email, subject: "You forgot something!")
|
17
20
|
end
|
18
21
|
|
19
|
-
|
20
|
-
|
22
|
+
# Send 2 days after the user signs up
|
23
|
+
def some_cool_tips(user)
|
24
|
+
mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
|
25
|
+
end
|
26
|
+
|
27
|
+
# Sends 3 days after the user signs up and hasn't added a company profile yet
|
28
|
+
def help_getting_started(user)
|
29
|
+
return if user.company.present?
|
30
|
+
|
31
|
+
mail(to: user.email, subject: "Did you know...")
|
21
32
|
end
|
22
33
|
end
|
23
34
|
```
|
24
35
|
|
36
|
+
With background jobs running, checking, and everything else. That's messy. Why are we checking state in the Mailer? Ugh.
|
37
|
+
|
38
|
+
We can clean this up with Caffeinate. Here's how we'd do it.
|
39
|
+
|
25
40
|
### Create a Campaign
|
26
41
|
|
27
42
|
```ruby
|
28
|
-
Caffeinate::Campaign.create!(name: "
|
43
|
+
Caffeinate::Campaign.create!(name: "Onboarding Campaign", slug: "onboarding")
|
29
44
|
```
|
30
45
|
|
31
46
|
### Create a Caffeinate::Dripper
|
32
47
|
|
48
|
+
Place the contents below in `app/drippers/onboarding_dripper.rb`:
|
49
|
+
|
33
50
|
```ruby
|
34
|
-
class
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
# probably in a background process, run at a given interval
|
41
|
-
subscribes do
|
42
|
-
Cart.left_joins(:cart_items)
|
43
|
-
.includes(:user)
|
44
|
-
.where(completed_at: nil)
|
45
|
-
.where(updated_at: 1.day.ago..2.days.ago)
|
46
|
-
.having('count(cart_items.id) = 0').each do |cart|
|
47
|
-
subscribe(cart, user: cart.user)
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
# Register your drips! Syntax is
|
52
|
-
# drip <mailer_action_name>, mailer: <MailerClass>, delay: <ActiveSupport::Interval>
|
53
|
-
drip :you_forgot_something, mailer: "AbandonedCartMailer", delay: 1.hour
|
54
|
-
drip :selling_out_soon, mailer: "AbandonedCartMailer", delay: 8.hours do
|
55
|
-
cart = mailing.subscriber
|
56
|
-
if cart.completed?
|
57
|
-
end! # you can also invoke `unsubscribe!` to cancel this mailing and all future mailings
|
51
|
+
class OnboardingDripper < ApplicationDripper
|
52
|
+
drip :welcome_to_my_cool_app, delay: 0.hours
|
53
|
+
drip :some_cool_tips, delay: 2.days
|
54
|
+
drip :help_getting_started, delay: 3.days do
|
55
|
+
if mailing.user.company.present?
|
56
|
+
mailing.unsubscribe!
|
58
57
|
return false
|
59
|
-
end
|
60
|
-
end
|
58
|
+
end
|
59
|
+
end
|
61
60
|
end
|
62
61
|
```
|
63
62
|
|
64
|
-
|
63
|
+
### Add a subscriber to the Campaign
|
65
64
|
|
66
65
|
```ruby
|
67
|
-
|
66
|
+
class User < ApplicationRecord
|
67
|
+
after_create_commit do
|
68
|
+
Caffeinate::Campaign.find_by(slug: "onboarding").subscribe(self)
|
69
|
+
end
|
70
|
+
end
|
68
71
|
```
|
69
72
|
|
70
|
-
|
73
|
+
### Run the Dripper
|
71
74
|
|
72
|
-
|
75
|
+
You'd normally want to do this in a cron/whenever/scheduled Sidekiq/etc job.
|
73
76
|
|
74
|
-
```ruby
|
75
|
-
|
77
|
+
```ruby
|
78
|
+
OnboardingDripper.perform!
|
76
79
|
```
|
77
80
|
|
78
|
-
|
81
|
+
### Spend more time building
|
82
|
+
|
83
|
+
Now you can spend more time building your app and less time managing your marketing campaigns.
|
84
|
+
* Centralized logic makes it easy to understand the flow
|
85
|
+
* Subscription management, timings, send history all built-in
|
86
|
+
* Built on the stack you're already familiar with
|
87
|
+
|
88
|
+
There's a lot more than what you just saw, too! Caffeinate almost makes managing timed email sequences fun.
|
79
89
|
|
80
90
|
## Installation
|
81
91
|
|
@@ -103,6 +113,11 @@ Followed by a migrate:
|
|
103
113
|
$ rails db:migrate
|
104
114
|
```
|
105
115
|
|
116
|
+
## Documentation
|
117
|
+
|
118
|
+
* [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
|
119
|
+
* [Better-than-average code documentation](https://rubydoc.info/github/joshmn/caffeinate)
|
120
|
+
|
106
121
|
## Upcoming features/todo
|
107
122
|
|
108
123
|
* Ability to optionally use relative start time when creating a step
|
@@ -2,18 +2,33 @@
|
|
2
2
|
|
3
3
|
module Caffeinate
|
4
4
|
class CampaignSubscriptionsController < ApplicationController
|
5
|
+
layout '_caffeinate'
|
6
|
+
|
7
|
+
helper_method :caffeinate_unsubscribe_url, :caffeinate_subscribe_url
|
8
|
+
|
5
9
|
before_action :find_campaign_subscription!
|
6
10
|
|
7
11
|
def unsubscribe
|
8
12
|
@campaign_subscription.unsubscribe!
|
9
|
-
|
13
|
+
end
|
14
|
+
|
15
|
+
def subscribe
|
16
|
+
@campaign_subscription.subscribe!
|
10
17
|
end
|
11
18
|
|
12
19
|
private
|
13
20
|
|
21
|
+
def caffeinate_subscribe_url(**options)
|
22
|
+
Caffeinate::UrlHelpers.caffeinate_subscribe_url(@campaign_subscription, options)
|
23
|
+
end
|
24
|
+
|
25
|
+
def caffeinate_unsubscribe_url
|
26
|
+
Caffeinate::UrlHelpers.caffeinate_unsubscribe_url(@campaign_subscription, options)
|
27
|
+
end
|
28
|
+
|
14
29
|
def find_campaign_subscription!
|
15
30
|
@campaign_subscription = ::Caffeinate::CampaignSubscription.find_by(token: params[:token])
|
16
|
-
|
31
|
+
raise ::ActiveRecord::RecordNotFound if @campaign_subscription.nil?
|
17
32
|
end
|
18
33
|
end
|
19
34
|
end
|
@@ -1,7 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: caffeinate_campaigns
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# name :string not null
|
9
|
+
# slug :string not null
|
10
|
+
# created_at :datetime not null
|
11
|
+
# updated_at :datetime not null
|
12
|
+
#
|
3
13
|
module Caffeinate
|
4
|
-
# Campaign.
|
14
|
+
# Campaign ties together subscribers and mailings, and provides one core model for handling your Drippers.
|
5
15
|
class Campaign < ApplicationRecord
|
6
16
|
self.table_name = 'caffeinate_campaigns'
|
7
17
|
has_many :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
|
@@ -12,9 +22,38 @@ module Caffeinate
|
|
12
22
|
Caffeinate.dripper_to_campaign_class[slug.to_sym].constantize
|
13
23
|
end
|
14
24
|
|
25
|
+
# Convenience method for find_by!(slug: value)
|
26
|
+
#
|
27
|
+
# ::Caffeinate::Campaign[:onboarding]
|
28
|
+
# # is the same as
|
29
|
+
# ::Caffeinate::Campaign.find_by(slug: :onboarding)
|
30
|
+
def self.[](val)
|
31
|
+
find_by!(slug: val)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Checks to see if the subscriber exists.
|
35
|
+
#
|
36
|
+
# Use `find_by` so that we don't have to load the record twice. Often used with `subscribes?`
|
37
|
+
def subscriber(record, **args)
|
38
|
+
@subscriber ||= caffeinate_campaign_subscriptions.find_by(subscriber: record, **args)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check if the subscriber exists
|
42
|
+
def subscribes?(record, **args)
|
43
|
+
subscriber(record, **args).present?
|
44
|
+
end
|
45
|
+
|
15
46
|
# Subscribes an object to a campaign.
|
16
47
|
def subscribe(subscriber, **args)
|
17
48
|
caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
|
18
49
|
end
|
50
|
+
|
51
|
+
# Subscribes an object to a campaign.
|
52
|
+
def subscribe!(subscriber, **args)
|
53
|
+
subscription = subscribe(subscriber, **args)
|
54
|
+
return subscription if subscribe.persisted?
|
55
|
+
|
56
|
+
raise ActiveRecord::RecordInvalid, subscription
|
57
|
+
end
|
19
58
|
end
|
20
59
|
end
|
@@ -1,12 +1,29 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: caffeinate_campaign_subscriptions
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# caffeinate_campaign_id :integer not null
|
9
|
+
# subscriber_type :string not null
|
10
|
+
# subscriber_id :string not null
|
11
|
+
# user_type :string
|
12
|
+
# user_id :string
|
13
|
+
# token :string not null
|
14
|
+
# ended_at :datetime
|
15
|
+
# unsubscribed_at :datetime
|
16
|
+
# created_at :datetime not null
|
17
|
+
# updated_at :datetime not null
|
18
|
+
#
|
3
19
|
module Caffeinate
|
4
|
-
# CampaignSubscription associates an object and its optional user to a Campaign
|
20
|
+
# CampaignSubscription associates an object and its optional user to a Campaign
|
21
|
+
# and its relevant Mailings.
|
5
22
|
class CampaignSubscription < ApplicationRecord
|
6
23
|
self.table_name = 'caffeinate_campaign_subscriptions'
|
7
24
|
|
8
|
-
has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
9
|
-
has_one :next_caffeinate_mailing, -> { upcoming.unsent
|
25
|
+
has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
|
26
|
+
has_one :next_caffeinate_mailing, -> { upcoming.unsent }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
10
27
|
belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
|
11
28
|
belongs_to :subscriber, polymorphic: true
|
12
29
|
belongs_to :user, polymorphic: true, optional: true
|
@@ -37,30 +54,37 @@ module Caffeinate
|
|
37
54
|
!ended? && !unsubscribed?
|
38
55
|
end
|
39
56
|
|
40
|
-
# Checks if the CampaignSubscription is not subscribed
|
57
|
+
# Checks if the CampaignSubscription is not subscribed by checking the presence of `unsubscribed_at`
|
41
58
|
def unsubscribed?
|
42
|
-
|
59
|
+
unsubscribed_at.present?
|
43
60
|
end
|
44
61
|
|
45
|
-
# Checks if the CampaignSubscription is ended
|
62
|
+
# Checks if the CampaignSubscription is ended by checking the presence of `ended_at`
|
46
63
|
def ended?
|
47
64
|
ended_at.present?
|
48
65
|
end
|
49
66
|
|
50
67
|
# Updates `ended_at` and runs `on_complete` callbacks
|
51
|
-
def end!
|
52
|
-
update!(ended_at: ::Caffeinate.config.time_now)
|
68
|
+
def end!(reason = nil)
|
69
|
+
update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
|
53
70
|
|
54
71
|
caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
|
55
72
|
end
|
56
73
|
|
57
74
|
# Updates `unsubscribed_at` and runs `on_subscribe` callbacks
|
58
|
-
def unsubscribe!
|
59
|
-
update!(unsubscribed_at: ::Caffeinate.config.time_now)
|
75
|
+
def unsubscribe!(reason = nil)
|
76
|
+
update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
|
60
77
|
|
61
78
|
caffeinate_campaign.to_dripper.run_callbacks(:on_unsubscribe, self)
|
62
79
|
end
|
63
80
|
|
81
|
+
# Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks
|
82
|
+
def resubscribe!
|
83
|
+
update!(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
|
84
|
+
|
85
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
|
86
|
+
end
|
87
|
+
|
64
88
|
private
|
65
89
|
|
66
90
|
# Create mailings according to the drips registered in the Campaign
|
@@ -1,8 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: caffeinate_mailings
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# caffeinate_campaign_subscription_id :integer not null
|
9
|
+
# send_at :datetime
|
10
|
+
# sent_at :datetime
|
11
|
+
# skipped_at :datetime
|
12
|
+
# mailer_class :string not null
|
13
|
+
# mailer_action :string not null
|
14
|
+
# created_at :datetime not null
|
15
|
+
# updated_at :datetime not null
|
16
|
+
#
|
3
17
|
module Caffeinate
|
4
18
|
# Records of the mails sent and to be sent for a given `::Caffeinate::CampaignSubscriber`
|
5
19
|
class Mailing < ApplicationRecord
|
20
|
+
CURRENT_THREAD_KEY = :current_caffeinate_mailing
|
21
|
+
|
6
22
|
self.table_name = 'caffeinate_mailings'
|
7
23
|
|
8
24
|
belongs_to :caffeinate_campaign_subscription, class_name: 'Caffeinate::CampaignSubscription'
|
@@ -14,6 +30,13 @@ module Caffeinate
|
|
14
30
|
scope :skipped, -> { where.not(skipped_at: nil) }
|
15
31
|
scope :unskipped, -> { where(skipped_at: nil) }
|
16
32
|
|
33
|
+
def initialize_dup(args)
|
34
|
+
super
|
35
|
+
self.send_at = nil
|
36
|
+
self.sent_at = nil
|
37
|
+
self.skipped_at = nil
|
38
|
+
end
|
39
|
+
|
17
40
|
# Checks if the Mailing is not skipped and not sent
|
18
41
|
def pending?
|
19
42
|
unskipped? && unsent?
|
@@ -43,13 +66,13 @@ module Caffeinate
|
|
43
66
|
def skip!
|
44
67
|
update!(skipped_at: Caffeinate.config.time_now)
|
45
68
|
|
46
|
-
caffeinate_campaign.to_dripper.run_callbacks(:on_skip,
|
69
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_skip, self)
|
47
70
|
end
|
48
71
|
|
49
72
|
# The associated drip
|
50
73
|
# @todo This can be optimized with a better cache
|
51
74
|
def drip
|
52
|
-
@drip ||= caffeinate_campaign.to_dripper.
|
75
|
+
@drip ||= caffeinate_campaign.to_dripper.drip_collection.for(mailer_action)
|
53
76
|
end
|
54
77
|
|
55
78
|
# The associated Subscriber from `::Caffeinate::CampaignSubscription`
|
@@ -64,7 +87,7 @@ module Caffeinate
|
|
64
87
|
|
65
88
|
# Assigns attributes to the Mailing from the Drip
|
66
89
|
def from_drip(drip)
|
67
|
-
self.send_at = drip.
|
90
|
+
self.send_at = drip.send_at(self)
|
68
91
|
self.mailer_class = drip.options[:mailer_class]
|
69
92
|
self.mailer_action = drip.action
|
70
93
|
self
|
@@ -12,7 +12,10 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
|
|
12
12
|
t.string :user_id
|
13
13
|
t.string :token, null: false
|
14
14
|
t.datetime :ended_at
|
15
|
+
t.string :ended_reason
|
16
|
+
t.datetime :resubscribed_at
|
15
17
|
t.datetime :unsubscribed_at
|
18
|
+
t.string :unsubscribe_reason
|
16
19
|
|
17
20
|
t.timestamps
|
18
21
|
end
|
@@ -14,6 +14,9 @@ class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
|
|
14
14
|
|
15
15
|
t.timestamps
|
16
16
|
end
|
17
|
-
add_index :caffeinate_mailings,
|
17
|
+
add_index :caffeinate_mailings, :sent_at
|
18
|
+
add_index :caffeinate_mailings, :send_at
|
19
|
+
add_index :caffeinate_mailings, :skipped_at
|
20
|
+
add_index :caffeinate_mailings, %i[caffeinate_campaign_subscription_id mailer_class mailer_action], name: :index_caffeinate_mailings
|
18
21
|
end
|
19
22
|
end
|