caffeinate 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +125 -0
- data/Rakefile +24 -0
- data/app/controllers/caffeinate/application_controller.rb +8 -0
- data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +19 -0
- data/app/models/caffeinate/application_record.rb +8 -0
- data/app/models/caffeinate/campaign.rb +20 -0
- data/app/models/caffeinate/campaign_subscription.rb +82 -0
- data/app/models/caffeinate/mailing.rb +99 -0
- data/app/views/layouts/caffeinate/application.html.erb +15 -0
- data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +1 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +14 -0
- data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +22 -0
- data/db/migrate/20201124183419_create_caffeinate_mailings.rb +19 -0
- data/lib/caffeinate.rb +30 -0
- data/lib/caffeinate/action_mailer.rb +6 -0
- data/lib/caffeinate/action_mailer/extension.rb +13 -0
- data/lib/caffeinate/action_mailer/helpers.rb +12 -0
- data/lib/caffeinate/action_mailer/interceptor.rb +17 -0
- data/lib/caffeinate/action_mailer/observer.rb +17 -0
- data/lib/caffeinate/active_record/extension.rb +33 -0
- data/lib/caffeinate/configuration.rb +34 -0
- data/lib/caffeinate/deliver_async.rb +22 -0
- data/lib/caffeinate/drip.rb +56 -0
- data/lib/caffeinate/dripper/base.rb +33 -0
- data/lib/caffeinate/dripper/callbacks.rb +141 -0
- data/lib/caffeinate/dripper/campaign.rb +53 -0
- data/lib/caffeinate/dripper/defaults.rb +32 -0
- data/lib/caffeinate/dripper/delivery.rb +28 -0
- data/lib/caffeinate/dripper/drip.rb +65 -0
- data/lib/caffeinate/dripper/perform.rb +32 -0
- data/lib/caffeinate/dripper/subscriber.rb +66 -0
- data/lib/caffeinate/engine.rb +21 -0
- data/lib/caffeinate/version.rb +5 -0
- data/lib/generators/caffeinate/install_generator.rb +41 -0
- data/lib/generators/caffeinate/templates/application_campaign.rb +4 -0
- data/lib/generators/caffeinate/templates/caffeinate.rb +24 -0
- metadata +185 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 28eb7e2e3a7a394e841eb4fe5a326ca72725281ffe804a4578e7f96bf25ab07b
|
4
|
+
data.tar.gz: 9bac101dc1c3d534d2df59f3befe6e57caa5fc382569e44ce640d8b0592e8342
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f127818a38285471e57cd97a25d1ed9496dcaa7e464cbd75b6641bd513afcde870ce7e71c2afefc56c00d6f8999a29ee0d3638ac865c4077d592f94d6a216bbe
|
7
|
+
data.tar.gz: f94a837c0d8a1669acb8b962d056d41c82f1b9ce02464674dab2038349fa9d16c1b7c5d4bf5b8ac7dd0bf46d6f167855f0eb55b44d51c8f6982466f8aee08c7b
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2020 Josh Brody
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
# Caffeinate
|
2
|
+
|
3
|
+
A drip mailer and management for Ruby on Rails.
|
4
|
+
|
5
|
+
## Docs
|
6
|
+
|
7
|
+
[Since you asked](https://rubydoc.info/github/joshmn/caffeinate).
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
Given a mailer like this:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
class AbandonedCartMailer < ActionMailer::Base
|
15
|
+
def you_forgot_something(cart)
|
16
|
+
mail(to: cart.user.email, subject: "You forgot something!")
|
17
|
+
end
|
18
|
+
|
19
|
+
def selling_out_soon(cart)
|
20
|
+
mail(to: cart.user.email, subject: "Selling out soon!")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
```
|
24
|
+
|
25
|
+
### Create a Campaign
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
Caffeinate::Campaign.create!(name: "Abandoned Cart", slug: "abandoned_cart")
|
29
|
+
```
|
30
|
+
|
31
|
+
### Create a Caffeinate::Dripper
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
class AbandonedCartDripper < Caffeinate::Dripper::Base
|
35
|
+
# This should match a Caffeinate::Campaign#slug
|
36
|
+
campaign :abandoned_cart
|
37
|
+
|
38
|
+
# A block to subscribe your users automatically
|
39
|
+
# You can invoke this by calling `AbandonedCartDripper.subscribe!`,
|
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
|
58
|
+
return false
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
Automatically subscribe eligible carts to it by running:
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
AbandonedCartDripper.subscribe!
|
68
|
+
```
|
69
|
+
|
70
|
+
This would typically run in a background job, queued up at a given interval.
|
71
|
+
|
72
|
+
And then, once it's done, start your engines!
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
AbandonedCartDripper.perform!
|
76
|
+
```
|
77
|
+
|
78
|
+
This, too, would typically run in a background job, queued up at a given interval.
|
79
|
+
|
80
|
+
## Installation
|
81
|
+
|
82
|
+
Add this line to your application's Gemfile:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
gem 'caffeinate'
|
86
|
+
```
|
87
|
+
|
88
|
+
And then do the bundle:
|
89
|
+
|
90
|
+
```bash
|
91
|
+
$ bundle
|
92
|
+
```
|
93
|
+
|
94
|
+
Add do some housekeeping:
|
95
|
+
|
96
|
+
```bash
|
97
|
+
$ rails g caffeinate:install
|
98
|
+
```
|
99
|
+
|
100
|
+
Followed by a migrate:
|
101
|
+
|
102
|
+
```bash
|
103
|
+
$ rails db:migrate
|
104
|
+
```
|
105
|
+
|
106
|
+
## Upcoming features/todo
|
107
|
+
|
108
|
+
* Ability to optionally use relative start time when creating a step
|
109
|
+
* Logo
|
110
|
+
* Conversion tracking
|
111
|
+
* Custom field support on CampaignSubscription
|
112
|
+
* GUI (?)
|
113
|
+
* REST API (?)
|
114
|
+
|
115
|
+
## Contributing
|
116
|
+
|
117
|
+
Just do it.
|
118
|
+
|
119
|
+
## Contributors & thanks
|
120
|
+
|
121
|
+
* Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
|
122
|
+
|
123
|
+
## License
|
124
|
+
|
125
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'bundler/setup'
|
5
|
+
rescue LoadError
|
6
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'rdoc/task'
|
10
|
+
|
11
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
12
|
+
rdoc.rdoc_dir = 'rdoc'
|
13
|
+
rdoc.title = 'Caffeinate'
|
14
|
+
rdoc.options << '--line-numbers'
|
15
|
+
rdoc.rdoc_files.include('README.md')
|
16
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
17
|
+
end
|
18
|
+
|
19
|
+
APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
|
20
|
+
load 'rails/tasks/engine.rake'
|
21
|
+
|
22
|
+
load 'rails/tasks/statistics.rake'
|
23
|
+
|
24
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
class CampaignSubscriptionsController < ApplicationController
|
5
|
+
before_action :find_campaign_subscription!
|
6
|
+
|
7
|
+
def unsubscribe
|
8
|
+
@campaign_subscription.unsubscribe!
|
9
|
+
render plain: 'You have been unsubscribed.'
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def find_campaign_subscription!
|
15
|
+
@campaign_subscription = ::Caffeinate::CampaignSubscription.find_by(token: params[:token])
|
16
|
+
return render plain: '404' if @campaign_subscription.nil?
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
# Campaign.
|
5
|
+
class Campaign < ApplicationRecord
|
6
|
+
self.table_name = 'caffeinate_campaigns'
|
7
|
+
has_many :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
|
8
|
+
has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
|
9
|
+
|
10
|
+
# Poorly-named Campaign class resolver
|
11
|
+
def to_dripper
|
12
|
+
Caffeinate.dripper_to_campaign_class[slug.to_sym].constantize
|
13
|
+
end
|
14
|
+
|
15
|
+
# Subscribes an object to a campaign.
|
16
|
+
def subscribe(subscriber, **args)
|
17
|
+
caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
# CampaignSubscription associates an object and its optional user to a Campaign.
|
5
|
+
class CampaignSubscription < ApplicationRecord
|
6
|
+
self.table_name = 'caffeinate_campaign_subscriptions'
|
7
|
+
|
8
|
+
has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
9
|
+
has_one :next_caffeinate_mailing, -> { upcoming.unsent.limit(1).first }, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
|
10
|
+
belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
|
11
|
+
belongs_to :subscriber, polymorphic: true
|
12
|
+
belongs_to :user, polymorphic: true, optional: true
|
13
|
+
|
14
|
+
# All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
|
15
|
+
scope :active, -> { where(unsubscribed_at: nil, ended_at: nil) }
|
16
|
+
|
17
|
+
# All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
|
18
|
+
scope :subscribed, -> { active }
|
19
|
+
|
20
|
+
scope :unsubscribed, -> { where.not(unsubscribed_at: nil) }
|
21
|
+
|
22
|
+
# All CampaignSubscriptions that where `ended_at` is not nil
|
23
|
+
scope :ended, -> { where.not(ended_at: nil) }
|
24
|
+
|
25
|
+
before_validation :set_token!, on: [:create]
|
26
|
+
validates :token, uniqueness: true, on: [:create]
|
27
|
+
|
28
|
+
after_commit :create_mailings!, on: :create
|
29
|
+
|
30
|
+
# Actually deliver and process the mail
|
31
|
+
def deliver!(mailing)
|
32
|
+
caffeinate_campaign.to_dripper.deliver!(mailing)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Checks if the subscription is not ended and not unsubscribed
|
36
|
+
def subscribed?
|
37
|
+
!ended? && !unsubscribed?
|
38
|
+
end
|
39
|
+
|
40
|
+
# Checks if the CampaignSubscription is not subscribed
|
41
|
+
def unsubscribed?
|
42
|
+
!subscribed?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Checks if the CampaignSubscription is ended
|
46
|
+
def ended?
|
47
|
+
ended_at.present?
|
48
|
+
end
|
49
|
+
|
50
|
+
# Updates `ended_at` and runs `on_complete` callbacks
|
51
|
+
def end!
|
52
|
+
update!(ended_at: ::Caffeinate.config.time_now)
|
53
|
+
|
54
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Updates `unsubscribed_at` and runs `on_subscribe` callbacks
|
58
|
+
def unsubscribe!
|
59
|
+
update!(unsubscribed_at: ::Caffeinate.config.time_now)
|
60
|
+
|
61
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_unsubscribe, self)
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Create mailings according to the drips registered in the Campaign
|
67
|
+
def create_mailings!
|
68
|
+
caffeinate_campaign.to_dripper.drips.each do |drip|
|
69
|
+
mailing = Caffeinate::Mailing.new(caffeinate_campaign_subscription: self).from_drip(drip)
|
70
|
+
mailing.save!
|
71
|
+
end
|
72
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_subscribe, self)
|
73
|
+
end
|
74
|
+
|
75
|
+
def set_token!
|
76
|
+
loop do
|
77
|
+
self.token = SecureRandom.uuid
|
78
|
+
break unless self.class.exists?(token: token)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Caffeinate
|
4
|
+
# Records of the mails sent and to be sent for a given `::Caffeinate::CampaignSubscriber`
|
5
|
+
class Mailing < ApplicationRecord
|
6
|
+
self.table_name = 'caffeinate_mailings'
|
7
|
+
|
8
|
+
belongs_to :caffeinate_campaign_subscription, class_name: 'Caffeinate::CampaignSubscription'
|
9
|
+
has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
|
10
|
+
|
11
|
+
scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
|
12
|
+
scope :unsent, -> { unskipped.where(sent_at: nil) }
|
13
|
+
scope :sent, -> { unskipped.where.not(sent_at: nil) }
|
14
|
+
scope :skipped, -> { where.not(skipped_at: nil) }
|
15
|
+
scope :unskipped, -> { where(skipped_at: nil) }
|
16
|
+
|
17
|
+
# Checks if the Mailing is not skipped and not sent
|
18
|
+
def pending?
|
19
|
+
unskipped? && unsent?
|
20
|
+
end
|
21
|
+
|
22
|
+
# Checks if the Mailing is skipped
|
23
|
+
def skipped?
|
24
|
+
skipped_at.present?
|
25
|
+
end
|
26
|
+
|
27
|
+
# Checks if the Mailing is not skipped
|
28
|
+
def unskipped?
|
29
|
+
!skipped?
|
30
|
+
end
|
31
|
+
|
32
|
+
# Checks if the Mailing is sent
|
33
|
+
def sent?
|
34
|
+
sent_at.present?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Checks if the Mailing is not sent
|
38
|
+
def unsent?
|
39
|
+
!sent?
|
40
|
+
end
|
41
|
+
|
42
|
+
# Updates `skipped_at and runs `on_skip` callbacks
|
43
|
+
def skip!
|
44
|
+
update!(skipped_at: Caffeinate.config.time_now)
|
45
|
+
|
46
|
+
caffeinate_campaign.to_dripper.run_callbacks(:on_skip, caffeinate_campaign_subscription, self)
|
47
|
+
end
|
48
|
+
|
49
|
+
# The associated drip
|
50
|
+
# @todo This can be optimized with a better cache
|
51
|
+
def drip
|
52
|
+
@drip ||= caffeinate_campaign.to_dripper.drips.find { |drip| drip.action.to_s == mailer_action }
|
53
|
+
end
|
54
|
+
|
55
|
+
# The associated Subscriber from `::Caffeinate::CampaignSubscription`
|
56
|
+
def subscriber
|
57
|
+
caffeinate_campaign_subscription.subscriber
|
58
|
+
end
|
59
|
+
|
60
|
+
# The associated Subscriber from `::Caffeinate::CampaignSubscription`
|
61
|
+
def user
|
62
|
+
caffeinate_campaign_subscription.user
|
63
|
+
end
|
64
|
+
|
65
|
+
# Assigns attributes to the Mailing from the Drip
|
66
|
+
def from_drip(drip)
|
67
|
+
self.send_at = drip.options[:delay].from_now
|
68
|
+
self.mailer_class = drip.options[:mailer_class]
|
69
|
+
self.mailer_action = drip.action
|
70
|
+
self
|
71
|
+
end
|
72
|
+
|
73
|
+
# Handles the logic for delivery and delivers
|
74
|
+
def process!
|
75
|
+
if ::Caffeinate.config.async_delivery?
|
76
|
+
deliver_later!
|
77
|
+
else
|
78
|
+
deliver!
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Delivers the Mailing in the foreground
|
83
|
+
def deliver!
|
84
|
+
caffeinate_campaign_subscription.deliver!(self)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Delivers the Mailing in the background
|
88
|
+
def deliver_later!
|
89
|
+
klass = ::Caffeinate.config.mailing_job_class
|
90
|
+
if klass.respond_to?(:perform_later)
|
91
|
+
klass.perform_later(id)
|
92
|
+
elsif klass.respond_to?(:perform_async)
|
93
|
+
klass.perform_async(id)
|
94
|
+
else
|
95
|
+
raise NoMethodError, "Neither perform_later or perform_async are defined on #{klass}."
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
<h5>You have been unsubscribed.</h5>
|