caffeinate 0.1.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 +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>
|