caffeinate 0.1.2 → 0.4.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.
Files changed (44) 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 +34 -10
  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 +4 -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 +57 -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 -15
  30. data/lib/caffeinate/dripper/inferences.rb +29 -0
  31. data/lib/caffeinate/dripper/perform.rb +24 -5
  32. data/lib/caffeinate/dripper/periodical.rb +24 -0
  33. data/lib/caffeinate/dripper/subscriber.rb +2 -2
  34. data/lib/caffeinate/engine.rb +13 -1
  35. data/lib/caffeinate/helpers.rb +24 -0
  36. data/lib/caffeinate/mail_ext.rb +12 -0
  37. data/lib/caffeinate/url_helpers.rb +10 -0
  38. data/lib/caffeinate/version.rb +1 -1
  39. data/lib/generators/caffeinate/install_generator.rb +5 -1
  40. data/lib/generators/caffeinate/templates/caffeinate.rb +11 -1
  41. metadata +13 -5
  42. data/app/views/layouts/caffeinate/application.html.erb +0 -15
  43. data/app/views/layouts/caffeinate/campaign_subscriptions/unsubscribe.html.erb +0 -1
  44. data/lib/caffeinate/action_mailer/helpers.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f326e94a0d8c08f4d34f527ae2f1547b502ec0f92bd13bf4865767c9f03b2afd
4
- data.tar.gz: 87bc25f43d8bb4379fb1c26b81ba509cd8cc848f79db3a13fc9929e3d6d3f52a
3
+ metadata.gz: 3095db5083c76905bf72f39bb96264c4fc90cbe93ef2740614c2275a3a95c87c
4
+ data.tar.gz: 76b7ffad86369d443d44a1bce50a9498547981522c6192d11cb35b300a4ce94e
5
5
  SHA512:
6
- metadata.gz: 0725f8e6619b563a2719fd19a4da66b1f4aecda5d599d93ab0131d816ec014595c0dff3e2f79b899789a46d4c0b99c1628920e967feb7683d1e412756516b396
7
- data.tar.gz: 8bce0950cdc60c9fc96f1487f9b7fccc201b776a03108227b780a219b2db40dffdd933ab5ea4fe9d3d9e89f9c607271f5f490b95b77af087e331264ff85953a8
6
+ metadata.gz: 0c605b54079ebe483d5ade7afa85298ae4d0983863b498732c137f13cf1bf10621338ab2e0d5887996a7d8fa1b84f4c8bedffdf291efc54931f6093abc650646
7
+ data.tar.gz: 2673a9957170a65695487facc9ba68fdcdd01b7fc441b140a7573f7e69248954548cbe5309751783b7c224073b8b424823c912e57eb9e2887cc6e7cffaa53d3f
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,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.limit(1).first }, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
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
- !subscribed?
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, 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
@@ -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, %i[campaign_subscription_id mailer_class mailer_action sent_at send_at skipped_at], name: :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