caffeinate 0.1.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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 +52 -11
- 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 +5 -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 +74 -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 +16 -5
- data/lib/caffeinate/dripper/periodical.rb +24 -0
- data/lib/caffeinate/engine.rb +12 -9
- 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: cac59a69d742f7948455f3f3b5787e33841664267eab72a2c6bc296735da5d1e
|
4
|
+
data.tar.gz: 826ca15444ba2242f99d8376d0f5a4aae9612846ee61ef53d1a4a53a879d0b54
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2d1cc9c295d50c77c5db6c5f22d585372b513396d54a9a37b05f40af405f8f4e32b1e0d42045a9e5b0e0c40fe592466a4d835a67dd71ad62bab55d24b7586d15
|
7
|
+
data.tar.gz: 0ca6801b63b8e50f73654dbc6a7f5e9c5063fd825699bd6698e4af4f6846ef23358ed0955cc2d0ed8d19e705124d0f25094c0980331dd90dd31121ab06c07f3e
|
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
|
+

|
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,34 @@
|
|
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
|
-
#
|
20
|
+
# If a record tries to be `unsubscribed!` or `ended!` or `resubscribe!` and it's in a state that is not
|
21
|
+
# correct, raise this
|
22
|
+
class InvalidState < ::ActiveRecord::RecordInvalid; end
|
23
|
+
|
24
|
+
# CampaignSubscription associates an object and its optional user to a Campaign
|
25
|
+
# and its relevant Mailings.
|
5
26
|
class CampaignSubscription < ApplicationRecord
|
27
|
+
|
6
28
|
self.table_name = 'caffeinate_campaign_subscriptions'
|
7
29
|
|
8
|
-
has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
9
|
-
has_one :next_caffeinate_mailing, -> { upcoming.unsent
|
30
|
+
has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
|
31
|
+
has_one :next_caffeinate_mailing, -> { upcoming.unsent }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
10
32
|
belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
|
11
33
|
belongs_to :subscriber, polymorphic: true
|
12
34
|
belongs_to :user, polymorphic: true, optional: true
|
@@ -37,28 +59,46 @@ module Caffeinate
|
|
37
59
|
!ended? && !unsubscribed?
|
38
60
|
end
|
39
61
|
|
40
|
-
# Checks if the CampaignSubscription is not subscribed
|
62
|
+
# Checks if the CampaignSubscription is not subscribed by checking the presence of `unsubscribed_at`
|
41
63
|
def unsubscribed?
|
42
|
-
|
64
|
+
unsubscribed_at.present?
|
43
65
|
end
|
44
66
|
|
45
|
-
# Checks if the CampaignSubscription is ended
|
67
|
+
# Checks if the CampaignSubscription is ended by checking the presence of `ended_at`
|
46
68
|
def ended?
|
47
69
|
ended_at.present?
|
48
70
|
end
|
49
71
|
|
50
72
|
# Updates `ended_at` and runs `on_complete` callbacks
|
51
|
-
def end!
|
52
|
-
|
73
|
+
def end!(reason = nil)
|
74
|
+
raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed?
|
53
75
|
|
54
|
-
|
76
|
+
update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
|
77
|
+
|
78
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_end, self)
|
79
|
+
true
|
55
80
|
end
|
56
81
|
|
57
82
|
# Updates `unsubscribed_at` and runs `on_subscribe` callbacks
|
58
|
-
def unsubscribe!
|
59
|
-
|
83
|
+
def unsubscribe!(reason = nil)
|
84
|
+
raise ::Caffeinate::InvalidState, "CampaignSubscription is already ended." if ended?
|
85
|
+
|
86
|
+
update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
|
60
87
|
|
61
88
|
caffeinate_campaign.to_dripper.run_callbacks(:on_unsubscribe, self)
|
89
|
+
true
|
90
|
+
end
|
91
|
+
|
92
|
+
# Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
|
93
|
+
# Use `force` to forcefully reset. Does not create the mailings.
|
94
|
+
def resubscribe!(force = false)
|
95
|
+
raise ::Caffeinate::InvalidState, "CampaignSubscription is already ended." if ended? && !force
|
96
|
+
raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed? && !force
|
97
|
+
|
98
|
+
update!(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
|
99
|
+
|
100
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
|
101
|
+
true
|
62
102
|
end
|
63
103
|
|
64
104
|
private
|
@@ -70,6 +110,7 @@ module Caffeinate
|
|
70
110
|
mailing.save!
|
71
111
|
end
|
72
112
|
caffeinate_campaign.to_dripper.run_callbacks(:on_subscribe, self)
|
113
|
+
true
|
73
114
|
end
|
74
115
|
|
75
116
|
def set_token!
|
@@ -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
|