caffeinate 0.1.4 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +58 -43
  3. data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +17 -2
  4. data/app/models/caffeinate/campaign.rb +40 -1
  5. data/app/models/caffeinate/campaign_subscription.rb +52 -11
  6. data/app/models/caffeinate/mailing.rb +26 -3
  7. data/app/views/caffeinate/campaign_subscriptions/subscribe.html.erb +3 -0
  8. data/app/views/caffeinate/campaign_subscriptions/unsubscribe.html.erb +3 -0
  9. data/app/views/layouts/_caffeinate.html.erb +11 -0
  10. data/config/locales/en.yml +6 -0
  11. data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +1 -0
  12. data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +3 -0
  13. data/db/migrate/20201124183419_create_caffeinate_mailings.rb +4 -1
  14. data/lib/caffeinate.rb +2 -0
  15. data/lib/caffeinate/action_mailer.rb +4 -4
  16. data/lib/caffeinate/action_mailer/extension.rb +17 -1
  17. data/lib/caffeinate/action_mailer/interceptor.rb +4 -2
  18. data/lib/caffeinate/action_mailer/observer.rb +5 -4
  19. data/lib/caffeinate/active_record/extension.rb +3 -2
  20. data/lib/caffeinate/configuration.rb +4 -1
  21. data/lib/caffeinate/drip.rb +22 -35
  22. data/lib/caffeinate/drip_evaluator.rb +35 -0
  23. data/lib/caffeinate/dripper/base.rb +13 -14
  24. data/lib/caffeinate/dripper/batching.rb +22 -0
  25. data/lib/caffeinate/dripper/callbacks.rb +74 -6
  26. data/lib/caffeinate/dripper/campaign.rb +8 -9
  27. data/lib/caffeinate/dripper/defaults.rb +4 -2
  28. data/lib/caffeinate/dripper/delivery.rb +8 -8
  29. data/lib/caffeinate/dripper/drip.rb +46 -16
  30. data/lib/caffeinate/dripper/inferences.rb +29 -0
  31. data/lib/caffeinate/dripper/perform.rb +16 -5
  32. data/lib/caffeinate/dripper/periodical.rb +24 -0
  33. data/lib/caffeinate/engine.rb +12 -9
  34. data/lib/caffeinate/helpers.rb +24 -0
  35. data/lib/caffeinate/mail_ext.rb +12 -0
  36. data/lib/caffeinate/url_helpers.rb +10 -0
  37. data/lib/caffeinate/version.rb +1 -1
  38. data/lib/generators/caffeinate/install_generator.rb +5 -1
  39. data/lib/generators/caffeinate/templates/caffeinate.rb +11 -1
  40. metadata +13 -5
  41. data/app/views/layouts/caffeinate/application.html.erb +0 -15
  42. data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +0 -1
  43. data/lib/caffeinate/action_mailer/helpers.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a76e8b67d3a3ab36c7805028f96cc78a7ee734afd966b27711ccbbfc18b0f8f3
4
- data.tar.gz: 5aa9c1bcf06842f1def891e6ca2bd911d5e47aa0f447ace61a3a217ace84eb8a
3
+ metadata.gz: cac59a69d742f7948455f3f3b5787e33841664267eab72a2c6bc296735da5d1e
4
+ data.tar.gz: 826ca15444ba2242f99d8376d0f5a4aae9612846ee61ef53d1a4a53a879d0b54
5
5
  SHA512:
6
- metadata.gz: 162f21354008999da301307dd3ef11d1a5bb3ed8232f54e6abbacf4906fcdf7aa2ad8c6a78683a5ef7816b68a99cdd98cbce1c2da20fa6b52a43543964a672f0
7
- data.tar.gz: 3bd71c2357d9e0a205ad66fe524b450df54832436c2ab08f54be288ea637a9cc878e336a533d4bff7e960f3be0e6e47405a1341f2b656981791e1fd0d36dba5e
6
+ metadata.gz: 2d1cc9c295d50c77c5db6c5f22d585372b513396d54a9a37b05f40af405f8f4e32b1e0d42045a9e5b0e0c40fe592466a4d835a67dd71ad62bab55d24b7586d15
7
+ data.tar.gz: 0ca6801b63b8e50f73654dbc6a7f5e9c5063fd825699bd6698e4af4f6846ef23358ed0955cc2d0ed8d19e705124d0f25094c0980331dd90dd31121ab06c07f3e
data/README.md CHANGED
@@ -1,81 +1,91 @@
1
1
  # Caffeinate
2
2
 
3
- Ruby on Rails drip campaign engine.
3
+ Caffeinate is a drip campaign engine for Ruby on Rails applications.
4
4
 
5
- ## Are there docs?
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
- [Since you asked](https://rubydoc.info/github/joshmn/caffeinate).
9
+ ![Caffeinate logo](logo.png)
8
10
 
9
11
  ## Usage
10
12
 
11
- Given a mailer like this:
13
+ You can probably imagine seeing a Mailer like this:
12
14
 
13
15
  ```ruby
14
- class AbandonedCartMailer < ActionMailer::Base
15
- def you_forgot_something(cart)
16
- mail(to: cart.user.email, subject: "You forgot something!")
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
- def selling_out_soon(cart)
20
- mail(to: cart.user.email, subject: "Selling out soon!")
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: "Abandoned Cart", slug: "abandoned_cart")
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 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
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
- Automatically subscribe eligible carts to it by running:
63
+ ### Add a subscriber to the Campaign
65
64
 
66
65
  ```ruby
67
- AbandonedCartDripper.subscribe!
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
- This would typically run in a background job, queued up at a given interval.
73
+ ### Run the Dripper
71
74
 
72
- And then, once it's done, start your engines!
75
+ You'd normally want to do this in a cron/whenever/scheduled Sidekiq/etc job.
73
76
 
74
- ```ruby
75
- AbandonedCartDripper.perform!
77
+ ```ruby
78
+ OnboardingDripper.perform!
76
79
  ```
77
80
 
78
- This, too, would typically run in a background job, queued up at a given interval.
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
- render plain: 'You have been unsubscribed.'
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
- return render plain: '404' if @campaign_subscription.nil?
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
- # CampaignSubscription associates an object and its optional user to a Campaign.
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.limit(1).first }, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
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
- !subscribed?
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
- update!(ended_at: ::Caffeinate.config.time_now)
73
+ def end!(reason = nil)
74
+ raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed?
53
75
 
54
- caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
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
- update!(unsubscribed_at: ::Caffeinate.config.time_now)
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, caffeinate_campaign_subscription, self)
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.drips.find { |drip| drip.action.to_s == mailer_action }
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.options[:delay].from_now
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
@@ -0,0 +1,3 @@
1
+ <%= t("caffeinate.campaign_subscriptions.subscribe") %>
2
+ <p><%= t("caffeinate.campaign_subscriptions.changed_your_mind")%></p>
3
+ <p><%= link_to "Unsubscribe", caffeinate_subscribe_url %></p>
@@ -0,0 +1,3 @@
1
+ <h5><%= t("caffeinate.campaign_subscriptions.unsubscribe")%></h5>
2
+ <p><%= t("caffeinate.campaign_subscriptions.changed_your_mind")%></p>
3
+ <p><%= link_to "Resubscribe", caffeinate_subscribe_url %></p>
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= Rails.application.class.module_parent_name %></title>
5
+ </head>
6
+ <body>
7
+
8
+ <%= yield %>
9
+
10
+ </body>
11
+ </html>
@@ -0,0 +1,6 @@
1
+ en:
2
+ caffeinate:
3
+ campaign_subscriptions:
4
+ subscribe: "You have been subscribed."
5
+ unsubscribe: "You have been unsubscribed."
6
+ changed_your_mind: "Change your mind?"
@@ -6,6 +6,7 @@ class CreateCaffeinateCampaigns < ActiveRecord::Migration[6.0]
6
6
  create_table :caffeinate_campaigns do |t|
7
7
  t.string :name, null: false
8
8
  t.string :slug, null: false
9
+ t.boolean :active, default: true, null: false
9
10
 
10
11
  t.timestamps
11
12
  end