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.
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