caffeinate 0.5.0 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cac59a69d742f7948455f3f3b5787e33841664267eab72a2c6bc296735da5d1e
4
- data.tar.gz: 826ca15444ba2242f99d8376d0f5a4aae9612846ee61ef53d1a4a53a879d0b54
3
+ metadata.gz: bf0c011679cba19db41a44adbbe22b0413473379a38cef42a98a0102ac3552b9
4
+ data.tar.gz: 6120c873207dcca757ede074a9b564f1867b5bc6fa0f3a4613987184a39dbd50
5
5
  SHA512:
6
- metadata.gz: 2d1cc9c295d50c77c5db6c5f22d585372b513396d54a9a37b05f40af405f8f4e32b1e0d42045a9e5b0e0c40fe592466a4d835a67dd71ad62bab55d24b7586d15
7
- data.tar.gz: 0ca6801b63b8e50f73654dbc6a7f5e9c5063fd825699bd6698e4af4f6846ef23358ed0955cc2d0ed8d19e705124d0f25094c0980331dd90dd31121ab06c07f3e
6
+ metadata.gz: cb150ab1506b93d50e09056f70f23ffa6a6fb2e7e5be7694f7d5bfedca8cf217a1e8b54b0ca69f34fedca816dbfaaf184b3fbaaf1629f88bf0e4d85f64e4fa9e
7
+ data.tar.gz: 1c0532f1f40c42b3f746d49f712e61a19dbddc98208a3952aa655c7d757c5320dc15ded22c4d0208e32f48a783cd7570d485aac9c5596a8e09d9db9c2caad884
data/README.md CHANGED
@@ -1,131 +1,201 @@
1
+ <div align="center">
2
+ <img width="450" src="https://github.com/joshmn/caffeinate/raw/master/logo.png" alt="Caffeinate logo" />
3
+ </div>
4
+
5
+ ---
6
+
1
7
  # Caffeinate
2
8
 
3
9
  Caffeinate is a drip campaign engine for Ruby on Rails applications.
4
10
 
5
- Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
11
+ Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
6
12
  and has everything you need to get started and to successfully manage campaigns. It's only dependency is the stack you're
7
13
  already familiar with: Ruby on Rails.
8
14
 
9
- ![Caffeinate logo](logo.png)
15
+ There's a cool demo with all the things included at [caffeinate.email](https://caffeinate.email). You can view the [marketing site source code here](https://github.com/joshmn/caffeinate-marketing).
10
16
 
11
- ## Usage
17
+ ## Do you suffer from ActionMailer tragedies?
12
18
 
13
- You can probably imagine seeing a Mailer like this:
19
+ If you have _anything_ like this is your codebase, **you need Caffeinate**:
20
+
21
+ ```ruby
22
+ class User < ApplicationRecord
23
+ after_commit on: :create do
24
+ OnboardingMailer.welcome_to_my_cool_app(self).deliver_later
25
+ OnboardingMailer.some_cool_tips(self).deliver_later(wait: 2.days)
26
+ OnboardingMailer.help_getting_started(self).deliver_later(wait: 3.days)
27
+ end
28
+ end
29
+ ```
14
30
 
15
- ```ruby
16
- class OnboardingMailer < ActionMailer::Base
31
+ ```ruby
32
+ class OnboardingMailer < ActionMailer::Base
17
33
  # Send on account creation
18
34
  def welcome_to_my_cool_app(user)
19
- mail(to: user.email, subject: "You forgot something!")
35
+ mail(to: user.email, subject: "Welcome to CoolApp!")
20
36
  end
21
37
 
22
38
  # Send 2 days after the user signs up
23
39
  def some_cool_tips(user)
40
+ return if user.unsubscribed_from_onboarding_campaign?
41
+
24
42
  mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
25
- end
43
+ end
26
44
 
27
45
  # Sends 3 days after the user signs up and hasn't added a company profile yet
28
46
  def help_getting_started(user)
29
- return if user.company.present?
47
+ return if user.unsubscribed_from_onboarding_campaign?
48
+ return if user.onboarding_completed?
30
49
 
31
- mail(to: user.email, subject: "Did you know...")
32
- end
33
- end
50
+ mail(to: user.email, subject: "Do you need help getting started?")
51
+ end
52
+ end
34
53
  ```
35
54
 
36
- With background jobs running, checking, and everything else. That's messy. Why are we checking state in the Mailer? Ugh.
55
+ ### What's wrong with this?
37
56
 
38
- We can clean this up with Caffeinate. Here's how we'd do it.
57
+ * You're checking state in a mailer
58
+ * The unsubscribe feature is, most likely, tied to a `User`, which means...
59
+ * It's going to be _so fun_ to scale horizontally
39
60
 
40
- ### Create a Campaign
61
+ ## Caffeinate to the rescue
41
62
 
42
- ```ruby
43
- Caffeinate::Campaign.create!(name: "Onboarding Campaign", slug: "onboarding")
44
- ```
63
+ Caffeinate combines a simple scheduling DSL, ActionMailer, and your data models to create scheduled email sequences.
45
64
 
46
- ### Create a Caffeinate::Dripper
65
+ What can you do with drip campaigns?
66
+ * Onboard new customers with cool tips and tricks
67
+ * Remind customers to use your product
68
+ * Nag customers about using your product
69
+ * Reach their spam folder after you fail to handle their unsubscribe request
70
+ * And more!
47
71
 
48
- Place the contents below in `app/drippers/onboarding_dripper.rb`:
72
+ ## Onboarding in Caffeinate
49
73
 
50
- ```ruby
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!
57
- return false
58
- end
59
- end
60
- end
61
- ```
74
+ In five minutes you can implement this onboarding campaign, and it won't even hijack your entire app!
62
75
 
63
- ### Add a subscriber to the Campaign
76
+ ### Install it
64
77
 
65
- ```ruby
66
- class User < ApplicationRecord
67
- after_create_commit do
68
- Caffeinate::Campaign.find_by(slug: "onboarding").subscribe(self)
69
- end
70
- end
78
+ Add to Gemfile, run the installer, migrate:
79
+
80
+ ```bash
81
+ $ bundle add caffeinate
82
+ $ rails g caffeinate:install
83
+ $ rake db:migrate
71
84
  ```
72
85
 
73
- ### Run the Dripper
86
+ ### Remove that ActionMailer logic
87
+
88
+ Just delete it. Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
74
89
 
75
- You'd normally want to do this in a cron/whenever/scheduled Sidekiq/etc job.
90
+ The only other change you need to make is the argument that the mailer action receives:
76
91
 
77
92
  ```ruby
78
- OnboardingDripper.perform!
93
+ class OnboardingMailer < ActionMailer::Base
94
+ def welcome_to_my_cool_app(mailing)
95
+ @user = mailing.subscriber
96
+ mail(to: @user.email, subject: "Welcome to CoolApp!")
97
+ end
98
+
99
+ def some_cool_tips(mailing)
100
+ @user = mailing.subscriber
101
+ mail(to: @user.email, subject: "Here are some cool tips for MyCoolApp")
102
+ end
103
+
104
+ def help_getting_started(mailing)
105
+ @user = mailing.subscriber
106
+ mail(to: @user.email, subject: "Do you need help getting started?")
107
+ end
108
+ end
79
109
  ```
80
110
 
81
- ### Spend more time building
111
+ While we're there, let's add an unsubscribe link to the views or layout:
82
112
 
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
113
+ ```erb
114
+ <%= link_to "Stop receiving onboarding tips :(", caffeinate_unsubscribe_url %>
115
+ ```
87
116
 
88
- There's a lot more than what you just saw, too! Caffeinate almost makes managing timed email sequences fun.
117
+ ### Create a Dripper
89
118
 
90
- ## Installation
119
+ A Dripper has all the logic for your Campaign and coordinates with ActionMailer on what to send.
91
120
 
92
- Add this line to your application's Gemfile:
121
+ In `app/drippers/onboarding_dripper.rb`:
93
122
 
94
123
  ```ruby
95
- gem 'caffeinate'
124
+ class OnboardingDripper < ApplicationDripper
125
+ drip :welcome_to_my_cool_app, mailer: 'OnboardingMailer', delay: 0.hours
126
+ drip :some_cool_tips, mailer: 'OnboardingMailer', delay: 2.days
127
+ drip :help_getting_started, mailer: 'OnboardingMailer', delay: 3.days
128
+ end
96
129
  ```
97
130
 
98
- And then do the bundle:
131
+ The `drip` syntax is `def drip(mailer_action, options = {})`.
99
132
 
100
- ```bash
101
- $ bundle
102
- ```
133
+ ### Add a subscriber to the Campaign
103
134
 
104
- Add do some housekeeping:
135
+ Call `OnboardingDripper.subscribe` to subscribe a polymorphic `subscriber` to the Campaign, which creates
136
+ a `Caffeinate::CampaignSubscription`.
105
137
 
106
- ```bash
107
- $ rails g caffeinate:install
138
+ ```ruby
139
+ class User < ApplicationRecord
140
+ after_commit on: :create do
141
+ OnboardingDripper.subscribe(self)
142
+ end
143
+
144
+ after_commit on: :update do
145
+ if onboarding_completed? && onboarding_completed_changed?
146
+ if OnboardingDripper.subscribed?(self)
147
+ OnboardingDripper.unsubscribe(self)
148
+ end
149
+ end
150
+ end
151
+ end
108
152
  ```
109
153
 
110
- Followed by a migrate:
154
+ When a `Caffeinate::CampaignSubscription` is created, the relevant Dripper is parsed and `Caffeinate::Mailing` records
155
+ are created from the `drip` DSL. A `Caffeinate::Mailing` record has a `send_at` attribute which tells Caffeinate when we
156
+ can send the mail, which we get from `Caffeiate::Mailing#mailer_class` and `Caffeinate::Mailing#mailer_action`.
111
157
 
112
- ```bash
113
- $ rails db:migrate
158
+ ### Run the Dripper
159
+
160
+ Running `OnboardingDripper.perform!` every `x` minutes will call `Caffeinate::Mailing#process!` on `Caffeinate::Mailing`
161
+ records that have `send_at < Time.now`.
162
+
163
+ ```ruby
164
+ OnboardingDripper.perform!
114
165
  ```
115
166
 
167
+ ### Done. But wait, there's more fun if you want
168
+
169
+ * Automatic subscriptions
170
+ * Campaign-specific unsubscribe links
171
+ * Reasons for unsubscribing so you can have some sort of analytics
172
+ * Periodical emails (daily, weekly, monthly digests, anyone?)
173
+ * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
174
+
175
+ ### Done. But wait, there's more fun if you want
176
+
177
+ * Automatic subscriptions
178
+ * Campaign-specific unsubscribe links
179
+ * Reasons for unsubscribing so you can have some sort of analytics
180
+ * Periodical emails (daily, weekly, monthly digests, anyone?)
181
+ * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
182
+
116
183
  ## Documentation
117
184
 
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)
185
+ * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
186
+ * [Better-than-average code documentation](https://rubydoc.info/gems/caffeinate)
120
187
 
121
188
  ## Upcoming features/todo
122
189
 
123
- * Ability to optionally use relative start time when creating a step
124
- * Logo
125
- * Conversion tracking
126
- * Custom field support on CampaignSubscription
127
- * GUI (?)
128
- * REST API (?)
190
+ [Handy dandy roadmap](https://github.com/joshmn/caffeinate/projects/1).
191
+
192
+ ## Alternatives
193
+
194
+ Not a fan? There are some alternatives!
195
+
196
+ * https://github.com/honeybadger-io/heya
197
+ * https://github.com/tarr11/dripper
198
+ * https://github.com/Sology/maily_herald
129
199
 
130
200
  ## Contributing
131
201
 
@@ -133,8 +203,8 @@ Just do it.
133
203
 
134
204
  ## Contributors & thanks
135
205
 
136
- * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
137
-
206
+ * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
207
+
138
208
  ## License
139
209
 
140
210
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -13,7 +13,7 @@ module Caffeinate
13
13
  end
14
14
 
15
15
  def subscribe
16
- @campaign_subscription.subscribe!
16
+ @campaign_subscription.resubscribe!
17
17
  end
18
18
 
19
19
  private
@@ -22,7 +22,7 @@ module Caffeinate
22
22
  Caffeinate::UrlHelpers.caffeinate_subscribe_url(@campaign_subscription, options)
23
23
  end
24
24
 
25
- def caffeinate_unsubscribe_url
25
+ def caffeinate_unsubscribe_url(**options)
26
26
  Caffeinate::UrlHelpers.caffeinate_unsubscribe_url(@campaign_subscription, options)
27
27
  end
28
28
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- # :nodoc:
5
4
  class ApplicationRecord < ::ActiveRecord::Base
6
5
  self.abstract_class = true
7
6
  end
@@ -14,12 +14,15 @@ module Caffeinate
14
14
  # Campaign ties together subscribers and mailings, and provides one core model for handling your Drippers.
15
15
  class Campaign < ApplicationRecord
16
16
  self.table_name = 'caffeinate_campaigns'
17
+
17
18
  has_many :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
19
+ has_many :subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
18
20
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
21
+ has_many :mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
19
22
 
20
23
  # Poorly-named Campaign class resolver
21
24
  def to_dripper
22
- Caffeinate.dripper_to_campaign_class[slug.to_sym].constantize
25
+ ::Caffeinate.dripper_collection.resolve(self)
23
26
  end
24
27
 
25
28
  # Convenience method for find_by!(slug: value)
@@ -32,10 +35,8 @@ module Caffeinate
32
35
  end
33
36
 
34
37
  # 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
38
  def subscriber(record, **args)
38
- @subscriber ||= caffeinate_campaign_subscriptions.find_by(subscriber: record, **args)
39
+ caffeinate_campaign_subscriptions.find_by(subscriber: record, **args)
39
40
  end
40
41
 
41
42
  # Check if the subscriber exists
@@ -43,17 +44,32 @@ module Caffeinate
43
44
  subscriber(record, **args).present?
44
45
  end
45
46
 
46
- # Subscribes an object to a campaign.
47
+ # Unsubscribes an object from a campaign.
48
+ #
49
+ # Campaign[:onboarding].unsubscribe(Company.first, user: Company.first.admin, reason: "Because I said so")
50
+ #
51
+ # is the same as
52
+ #
53
+ # Campaign.find_by(slug: "onboarding").caffeinate_campaign_subscriptions.find_by(subscriber: Company.first, user: Company.first.admin).unsubscribe!("Because I said so")
54
+ #
55
+ # Just... mintier.
56
+ def unsubscribe(subscriber, **args)
57
+ reason = args.delete(:reason)
58
+ subscription = subscriber(subscriber, **args)
59
+ raise ActiveRecord::RecordInvalid, subscription if subscription.nil?
60
+
61
+ subscription.unsubscribe!(reason)
62
+ end
63
+
64
+ # Creates a `CampaignSubscription` object for the present Campaign. Allows passing `**args` to
65
+ # delegate additional arguments to the record. Uses `find_or_create_by`.
47
66
  def subscribe(subscriber, **args)
48
67
  caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
49
68
  end
50
69
 
51
- # Subscribes an object to a campaign.
70
+ # Subscribes an object to a campaign. Raises `ActiveRecord::RecordInvalid` if the record was invalid.
52
71
  def subscribe!(subscriber, **args)
53
- subscription = subscribe(subscriber, **args)
54
- return subscription if subscribe.persisted?
55
-
56
- raise ActiveRecord::RecordInvalid, subscription
72
+ subscribe(subscriber, **args)
57
73
  end
58
74
  end
59
75
  end
@@ -24,21 +24,23 @@ module Caffeinate
24
24
  # CampaignSubscription associates an object and its optional user to a Campaign
25
25
  # and its relevant Mailings.
26
26
  class CampaignSubscription < ApplicationRecord
27
-
28
27
  self.table_name = 'caffeinate_campaign_subscriptions'
29
28
 
30
29
  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
30
+ has_many :mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
31
+
32
+ has_one :next_caffeinate_mailing, -> { upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
33
+ has_one :next_mailing, -> { upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
34
+
32
35
  belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
36
+ alias_attribute :campaign, :caffeinate_campaign
37
+
33
38
  belongs_to :subscriber, polymorphic: true
34
39
  belongs_to :user, polymorphic: true, optional: true
35
40
 
36
41
  # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
37
42
  scope :active, -> { where(unsubscribed_at: nil, ended_at: nil) }
38
-
39
- # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
40
43
  scope :subscribed, -> { active }
41
-
42
44
  scope :unsubscribed, -> { where.not(unsubscribed_at: nil) }
43
45
 
44
46
  # All CampaignSubscriptions that where `ended_at` is not nil
@@ -49,29 +51,31 @@ module Caffeinate
49
51
 
50
52
  after_commit :create_mailings!, on: :create
51
53
 
54
+ after_commit :on_complete, if: :completed?
55
+
52
56
  # Actually deliver and process the mail
53
57
  def deliver!(mailing)
54
58
  caffeinate_campaign.to_dripper.deliver!(mailing)
55
59
  end
56
60
 
57
- # Checks if the subscription is not ended and not unsubscribed
61
+ # Checks if the `CampaignSubscription` is not ended and not unsubscribed
58
62
  def subscribed?
59
63
  !ended? && !unsubscribed?
60
64
  end
61
65
 
62
- # Checks if the CampaignSubscription is not subscribed by checking the presence of `unsubscribed_at`
66
+ # Checks if the `CampaignSubscription` is not subscribed by checking the presence of `unsubscribed_at`
63
67
  def unsubscribed?
64
68
  unsubscribed_at.present?
65
69
  end
66
70
 
67
- # Checks if the CampaignSubscription is ended by checking the presence of `ended_at`
71
+ # Checks if the `CampaignSubscription` is ended by checking the presence of `ended_at`
68
72
  def ended?
69
73
  ended_at.present?
70
74
  end
71
75
 
72
76
  # Updates `ended_at` and runs `on_complete` callbacks
73
77
  def end!(reason = nil)
74
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed?
78
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
75
79
 
76
80
  update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
77
81
 
@@ -81,7 +85,7 @@ module Caffeinate
81
85
 
82
86
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
83
87
  def unsubscribe!(reason = nil)
84
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already ended." if ended?
88
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended?
85
89
 
86
90
  update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
87
91
 
@@ -92,8 +96,8 @@ module Caffeinate
92
96
  # Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
93
97
  # Use `force` to forcefully reset. Does not create the mailings.
94
98
  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
99
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended? && !force
100
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed? && !force
97
101
 
98
102
  update!(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
99
103
 
@@ -101,8 +105,16 @@ module Caffeinate
101
105
  true
102
106
  end
103
107
 
108
+ def completed?
109
+ caffeinate_mailings.unsent.count.zero?
110
+ end
111
+
104
112
  private
105
113
 
114
+ def on_complete
115
+ caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
116
+ end
117
+
106
118
  # Create mailings according to the drips registered in the Campaign
107
119
  def create_mailings!
108
120
  caffeinate_campaign.to_dripper.drips.each do |drip|
@@ -113,6 +125,7 @@ module Caffeinate
113
125
  true
114
126
  end
115
127
 
128
+ # Sets a unique token
116
129
  def set_token!
117
130
  loop do
118
131
  self.token = SecureRandom.uuid
@@ -17,12 +17,12 @@
17
17
  module Caffeinate
18
18
  # Records of the mails sent and to be sent for a given `::Caffeinate::CampaignSubscriber`
19
19
  class Mailing < ApplicationRecord
20
- CURRENT_THREAD_KEY = :current_caffeinate_mailing
21
-
22
20
  self.table_name = 'caffeinate_mailings'
23
21
 
24
22
  belongs_to :caffeinate_campaign_subscription, class_name: 'Caffeinate::CampaignSubscription'
23
+ alias_attribute :subscription, :caffeinate_campaign_subscription
25
24
  has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
25
+ alias_attribute :campaign, :caffeinate_campaign
26
26
 
27
27
  scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
28
28
  scope :unsent, -> { unskipped.where(sent_at: nil) }
@@ -70,7 +70,6 @@ module Caffeinate
70
70
  end
71
71
 
72
72
  # The associated drip
73
- # @todo This can be optimized with a better cache
74
73
  def drip
75
74
  @drip ||= caffeinate_campaign.to_dripper.drip_collection.for(mailer_action)
76
75
  end
@@ -100,6 +99,8 @@ module Caffeinate
100
99
  else
101
100
  deliver!
102
101
  end
102
+
103
+ caffeinate_campaign_subscription.touch
103
104
  end
104
105
 
105
106
  # Delivers the Mailing in the foreground
@@ -7,9 +7,9 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
7
7
  create_table :caffeinate_campaign_subscriptions do |t|
8
8
  t.references :caffeinate_campaign, null: false, index: { name: :caffeineate_campaign_subscriptions_on_campaign }, foreign_key: true
9
9
  t.string :subscriber_type, null: false
10
- t.string :subscriber_id, null: false
10
+ t.integer :subscriber_id, null: false
11
11
  t.string :user_type
12
- t.string :user_id
12
+ t.integer :user_id
13
13
  t.string :token, null: false
14
14
  t.datetime :ended_at
15
15
  t.string :ended_reason
@@ -20,6 +20,6 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
20
20
  t.timestamps
21
21
  end
22
22
  add_index :caffeinate_campaign_subscriptions, :token, unique: true
23
- add_index :caffeinate_campaign_subscriptions, %i[subscriber_id subscriber_type user_id user_type], name: :index_caffeinate_campaign_subscriptions
23
+ add_index :caffeinate_campaign_subscriptions, %i[caffeinate_campaign_id subscriber_id subscriber_type user_id user_type ended_at resubscribed_at unsubscribed_at], name: :index_caffeinate_campaign_subscriptions
24
24
  end
25
25
  end
@@ -14,9 +14,7 @@ class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
14
14
 
15
15
  t.timestamps
16
16
  end
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
17
+
18
+ add_index :caffeinate_mailings, %i[caffeinate_campaign_subscription_id send_at sent_at skipped_at], name: :index_caffeinate_mailings
21
19
  end
22
20
  end
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails/all'
3
+ require 'active_support'
4
+
5
+ %w(
6
+ active_record/railtie
7
+ action_controller/railtie
8
+ action_view/railtie
9
+ action_mailer/railtie
10
+ ).each do |railtie|
11
+ require railtie
12
+ end
13
+
4
14
  require 'caffeinate/mail_ext'
5
15
  require 'caffeinate/engine'
6
16
  require 'caffeinate/drip'
@@ -8,16 +18,11 @@ require 'caffeinate/url_helpers'
8
18
  require 'caffeinate/configuration'
9
19
  require 'caffeinate/dripper/base'
10
20
  require 'caffeinate/deliver_async'
21
+ require 'caffeinate/dripper_collection'
11
22
 
12
23
  module Caffeinate
13
- # Caches the campaign to the campaign class
14
- def self.dripper_to_campaign_class
15
- @dripper_to_campaign_class ||= {}
16
- end
17
-
18
- # Convenience method for `dripper_to_campaign_class`
19
- def self.register_dripper(name, klass)
20
- dripper_to_campaign_class[name.to_sym] = klass
24
+ def self.dripper_collection
25
+ @drippers ||= DripperCollection.new
21
26
  end
22
27
 
23
28
  # Global configuration
@@ -5,30 +5,35 @@ module Caffeinate
5
5
  # Includes the ActiveRecord association and relevant scopes for an ActiveRecord-backed model
6
6
  module Extension
7
7
  # Adds the associations for a subscriber
8
- def caffeinate_subscriber
8
+ def acts_as_caffeinate_subscriber
9
9
  has_many :caffeinate_campaign_subscriptions, as: :subscriber, class_name: '::Caffeinate::CampaignSubscription', dependent: :destroy
10
10
  has_many :caffeinate_campaigns, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Campaign'
11
11
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Mailing'
12
12
 
13
13
  scope :not_subscribed_to_campaign, lambda { |list|
14
- subscribed = ::Caffeinate::CampaignSubscription.select(:subscriber_id).joins(:caffeinate_campaign).where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
15
- where.not(id: subscribed)
14
+ where.not(id: ::Caffeinate::CampaignSubscription
15
+ .select(:subscriber_id)
16
+ .joins(:caffeinate_campaign)
17
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
16
18
  }
17
19
 
18
20
  scope :unsubscribed_from_campaign, lambda { |list|
19
- unsubscribed = ::Caffeinate::CampaignSubscription
20
- .unsubscribed
21
- .select(:subscriber_id)
22
- .joins(:caffeinate_campaign)
23
- .where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
24
- where(id: unsubscribed)
21
+ where(id: ::Caffeinate::CampaignSubscription
22
+ .unsubscribed
23
+ .select(:subscriber_id)
24
+ .joins(:caffeinate_campaign)
25
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
25
26
  }
26
27
  end
28
+ alias caffeinate_subscriber acts_as_caffeinate_subscriber
27
29
 
28
30
  # Adds the associations for a user
29
- def caffeinate_user
31
+ def acts_as_caffeinate_user
30
32
  has_many :caffeinate_campaign_subscriptions_as_user, as: :user, class_name: '::Caffeinate::CampaignSubscription'
33
+ has_many :caffeinate_campaigns_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
34
+ has_many :caffeinate_mailings_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
31
35
  end
36
+ alias caffeinate_user acts_as_caffeinate_user
32
37
  end
33
38
  end
34
39
  end
@@ -3,20 +3,26 @@
3
3
  module Caffeinate
4
4
  # Global configuration
5
5
  class Configuration
6
- attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path
6
+ attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path, :implicit_campaigns
7
7
 
8
8
  def initialize
9
9
  @now = -> { Time.current }
10
10
  @async_delivery = false
11
11
  @mailing_job = nil
12
12
  @batch_size = 1_000
13
- @drippers_path = "app/drippers"
13
+ @drippers_path = 'app/drippers'
14
+ @implicit_campaigns = true
14
15
  end
15
16
 
16
17
  def now=(val)
17
18
  raise ArgumentError, '#now must be a proc' unless val.respond_to?(:call)
18
19
 
19
- super
20
+ @now = val
21
+ end
22
+
23
+ # Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
24
+ def implicit_campaigns?
25
+ @implicit_campaigns == true
20
26
  end
21
27
 
22
28
  # The current time, for database calls
@@ -7,6 +7,7 @@ module Caffeinate
7
7
  # Handles the block and provides convenience methods for the drip
8
8
  class Drip
9
9
  attr_reader :dripper, :action, :options, :block
10
+
10
11
  def initialize(dripper, action, options, &block)
11
12
  @dripper = dripper
12
13
  @action = action
@@ -22,13 +23,20 @@ module Caffeinate
22
23
  def send_at(mailing = nil)
23
24
  if periodical?
24
25
  start = mailing.instance_exec(&options[:start])
25
- if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count > 0
26
- start += options[:every]
27
- end
28
- start.from_now
26
+ start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
27
+ date = start.from_now
28
+ elsif options[:on]
29
+ date = mailing.instance_exec(&options[:on])
29
30
  else
30
- options[:delay].from_now
31
+ date = options[:delay].from_now
32
+ end
33
+
34
+ if options[:at]
35
+ time = Time.parse(options[:at])
36
+ return date.change(hour: time.hour, min: time.min, sec: time.sec)
31
37
  end
38
+
39
+ date
32
40
  end
33
41
 
34
42
  def periodical?
@@ -37,6 +45,8 @@ module Caffeinate
37
45
 
38
46
  # Checks if the drip is enabled
39
47
  def enabled?(mailing)
48
+ dripper.run_callbacks(:before_drip, self, mailing)
49
+
40
50
  DripEvaluator.new(mailing).call(&@block)
41
51
  end
42
52
  end
@@ -4,6 +4,7 @@ module Caffeinate
4
4
  # Handles evaluating the `drip` block and provides convenience methods for handling the mailing or its campaign.
5
5
  class DripEvaluator
6
6
  attr_reader :mailing
7
+
7
8
  def initialize(mailing)
8
9
  @mailing = mailing
9
10
  end
@@ -38,6 +38,22 @@ module Caffeinate
38
38
  @on_subscribe_blocks ||= []
39
39
  end
40
40
 
41
+ # Callback after a Caffeinate::CampaignSubscription is `#resubscribed!`
42
+ #
43
+ # on_resubscribe do |campaign_subscription|
44
+ # Slack.notify(:caffeinate, "Someone resubscribed to #{campaign_subscription.campaign.name}!")
45
+ # end
46
+ #
47
+ # @yield Caffeinate::CampaignSubscription
48
+ def on_resubscribe(&block)
49
+ on_resubscribe_blocks << block
50
+ end
51
+
52
+ # :nodoc:
53
+ def on_resubscribe_blocks
54
+ @on_resubscribe_blocks ||= []
55
+ end
56
+
41
57
  # Callback before the mailings get processed.
42
58
  #
43
59
  # before_perform do |dripper|
@@ -171,7 +187,6 @@ module Caffeinate
171
187
  @on_unsubscribe_blocks ||= []
172
188
  end
173
189
 
174
-
175
190
  # Callback after a CampaignSubscriber has ended.
176
191
  #
177
192
  # on_end do |campaign_sub|
@@ -31,14 +31,27 @@ module Caffeinate
31
31
  def campaign=(slug)
32
32
  @caffeinate_campaign = nil
33
33
  @_campaign_slug = slug.to_sym
34
- Caffeinate.register_dripper(@_campaign_slug, name)
34
+ Caffeinate.dripper_collection.register(@_campaign_slug, name)
35
35
  end
36
36
 
37
- # Returns the `Caffeinate::Campaign` object for the Dripper
37
+ # Returns the `Caffeinate::Campaign` object for the Dripper.
38
+ #
39
+ # If `config.implicit_campaigns` is true, this will automatically create a `Caffeinate::Campaign` if one is not
40
+ # found via the `campaign_slug`.
38
41
  def caffeinate_campaign
39
42
  return @caffeinate_campaign if @caffeinate_campaign.present?
40
43
 
41
- @caffeinate_campaign = ::Caffeinate::Campaign[campaign_slug]
44
+ if ::Caffeinate.config.implicit_campaigns?
45
+ @caffeinate_campaign = ::Caffeinate::Campaign.find_or_initialize_by(slug: campaign_slug)
46
+ if @caffeinate_campaign.new_record?
47
+ @caffeinate_campaign.name = "#{name.delete_suffix('Dripper').titleize} Campaign"
48
+ @caffeinate_campaign.save!
49
+ end
50
+ else
51
+ @caffeinate_campaign = ::Caffeinate::Campaign[campaign_slug]
52
+ end
53
+
54
+ @caffeinate_campaign
42
55
  end
43
56
  alias campaign caffeinate_campaign
44
57
 
@@ -1,66 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'caffeinate/dripper/drip_collection'
3
4
  module Caffeinate
4
5
  module Dripper
5
6
  # The Drip DSL for registering a drip.
6
7
  module Drip
7
- # A collection of Drip objects for a `Caffeinate::Dripper`
8
- class DripCollection
9
- include Enumerable
10
-
11
- def initialize(dripper)
12
- @dripper = dripper
13
- @drips = {}
14
- end
15
-
16
- def for(action)
17
- @drips[action.to_sym]
18
- end
19
-
20
- # Register the drip
21
- def register(action, options, &block)
22
- options = validate_drip_options(action, options)
23
-
24
- @drips[action.to_sym] = ::Caffeinate::Drip.new(@dripper, action, options, &block)
25
- end
26
-
27
- def each(&block)
28
- @drips.each { |action_name, drip| block.call(action_name, drip) }
29
- end
30
-
31
- def values
32
- @drips.values
33
- end
34
-
35
- def size
36
- @drips.size
37
- end
38
-
39
- def [](val)
40
- @drips[val]
41
- end
42
-
43
- private
44
-
45
- def validate_drip_options(action, options)
46
- options.symbolize_keys!
47
- options.assert_valid_keys(:mailer_class, :step, :delay, :every, :start, :using, :mailer)
48
- options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
49
- options[:using] ||= @dripper.defaults[:using]
50
- options[:step] ||= @dripper.drips.size + 1
51
-
52
- if options[:mailer_class].nil?
53
- raise ArgumentError, "You must define :mailer_class or :mailer in the options for #{action.inspect} on #{@dripper.inspect}"
54
- end
55
-
56
- if options[:every].nil? && options[:delay].nil?
57
- raise ArgumentError, "You must define :delay in the options for #{action.inspect} on #{@dripper.inspect}"
58
- end
59
-
60
- options
61
- end
62
- end
63
-
64
8
  # :nodoc:
65
9
  def self.included(klass)
66
10
  klass.extend ClassMethods
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caffeinate
4
+ module Dripper
5
+ # A collection of Drip objects for a `Caffeinate::Dripper`
6
+ class DripCollection
7
+ include Enumerable
8
+
9
+ def initialize(dripper)
10
+ @dripper = dripper
11
+ @drips = {}
12
+ end
13
+
14
+ def for(action)
15
+ @drips[action.to_sym]
16
+ end
17
+
18
+ # Register the drip
19
+ def register(action, options, &block)
20
+ options = validate_drip_options(action, options)
21
+
22
+ @drips[action.to_sym] = ::Caffeinate::Drip.new(@dripper, action, options, &block)
23
+ end
24
+
25
+ def each(&block)
26
+ @drips.each { |action_name, drip| block.call(action_name, drip) }
27
+ end
28
+
29
+ def values
30
+ @drips.values
31
+ end
32
+
33
+ def size
34
+ @drips.size
35
+ end
36
+
37
+ def [](val)
38
+ @drips[val]
39
+ end
40
+
41
+ private
42
+
43
+ def validate_drip_options(action, options)
44
+ options.symbolize_keys!
45
+ options.assert_valid_keys(:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on)
46
+ options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
47
+ options[:using] ||= @dripper.defaults[:using]
48
+ options[:step] ||= @dripper.drips.size + 1
49
+
50
+ if options[:mailer_class].nil?
51
+ raise ArgumentError, "You must define :mailer_class or :mailer in the options for #{action.inspect} on #{@dripper.inspect}"
52
+ end
53
+
54
+ if options[:every].nil? && options[:delay].nil? && options[:on].nil?
55
+ raise ArgumentError, "You must define :delay or :on or :every in the options for #{action.inspect} on #{@dripper.inspect}"
56
+ end
57
+
58
+ options
59
+ end
60
+ end
61
+ end
62
+ end
@@ -19,7 +19,9 @@ module Caffeinate
19
19
  nil
20
20
  end
21
21
 
22
- # The inferred mailer class
22
+ # The inferred campaign slug
23
+ #
24
+ # MyCoolDripper => my_cool
23
25
  def inferred_campaign_slug
24
26
  name.delete_suffix('Dripper').to_s.underscore
25
27
  end
@@ -17,16 +17,14 @@ module Caffeinate
17
17
  def perform!
18
18
  run_callbacks(:before_perform, self)
19
19
  Caffeinate::Mailing
20
- .upcoming
21
- .unsent
22
- .joins(:caffeinate_campaign_subscription)
23
- .merge(Caffeinate::CampaignSubscription.active)
24
- .in_batches(of: self.class.batch_size)
25
- .each do |batch|
20
+ .upcoming
21
+ .unsent
22
+ .joins(:caffeinate_campaign_subscription)
23
+ .merge(Caffeinate::CampaignSubscription.active)
24
+ .in_batches(of: self.class.batch_size)
25
+ .each do |batch|
26
26
  run_callbacks(:on_perform, self, batch)
27
- batch.each do |mailing|
28
- mailing.process!
29
- end
27
+ batch.each(&:process!)
30
28
  end
31
29
  run_callbacks(:after_perform, self)
32
30
  nil
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Caffeinate
2
4
  module Dripper
3
5
  module Periodical
@@ -18,8 +18,8 @@ module Caffeinate
18
18
  end
19
19
 
20
20
  # Returns the campaign's `Caffeinate::CampaignSubscriber`
21
- def subscribers
22
- caffeinate_campaign.caffeinate_campaign_subscribers
21
+ def subscriptions
22
+ caffeinate_campaign.caffeinate_campaign_subscriptions
23
23
  end
24
24
 
25
25
  # Subscribes to the campaign.
@@ -34,6 +34,18 @@ module Caffeinate
34
34
  caffeinate_campaign.subscribe(subscriber, **args)
35
35
  end
36
36
 
37
+ # Unsubscribes from the campaign.
38
+ #
39
+ # OrderDripper.unsubscribe(order, user: order.user)
40
+ #
41
+ # @param [ActiveRecord::Base] subscriber The object subscribing
42
+ # @option [ActiveRecord::Base] :user The associated user (optional)
43
+ #
44
+ # @return [Caffeinate::CampaignSubscriber] the CampaignSubscriber
45
+ def unsubscribe(subscriber, **args)
46
+ caffeinate_campaign.unsubscribe(subscriber, **args)
47
+ end
48
+
37
49
  # :nodoc:
38
50
  def subscribes_block
39
51
  raise(NotImplementedError, 'Define subscribes') unless @subscribes_block
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caffeinate
4
+ class DripperCollection
5
+ def initialize
6
+ @registry = {}
7
+ end
8
+
9
+ def register(name, klass)
10
+ @registry[name.to_sym] = klass
11
+ end
12
+
13
+ def resolve(campaign)
14
+ @registry[campaign.slug.to_sym].constantize
15
+ end
16
+ end
17
+ end
@@ -9,11 +9,13 @@ module Caffeinate
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace Caffeinate
11
11
 
12
+ # :nocov:
12
13
  config.to_prepare do
13
- Dir.glob(Rails.root.join(Caffeinate.config.drippers_path, "**", "*.rb")).each do |dripper|
14
+ Dir.glob(Rails.root.join(Caffeinate.config.drippers_path, '**', '*.rb')).sort.each do |dripper|
14
15
  require dripper
15
16
  end
16
17
  end
18
+ # :nocov:
17
19
 
18
20
  ActiveSupport.on_load(:action_mailer) do
19
21
  include ::Caffeinate::ActionMailer::Extension
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mail
4
+ def self.from_source(source)
5
+ Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
6
+ end
7
+
4
8
  # Extend Mail::Message to account for a Caffeinate::Mailing
5
9
  class Message
6
10
  attr_accessor :caffeinate_mailing
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- VERSION = '0.5.0'
4
+ VERSION = '0.8.1'
5
5
  end
@@ -31,4 +31,14 @@ Caffeinate.setup do |config|
31
31
  # config.batch_size = 1_000
32
32
  #
33
33
  # config.batch_size = 100
34
+ #
35
+ # == Implicit Campaigns
36
+ #
37
+ # Instead of manually having to create a Campaign, you can let Caffeinate do a `find_or_create_by` at runtime.
38
+ # This is probably dangerous but it hasn't burned me yet so here you go:
39
+ #
40
+ # Default:
41
+ # config.implicit_campaigns = true
42
+ #
43
+ # config.implicit_campaigns = false
34
44
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: caffeinate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-06 00:00:00.000000000 Z
11
+ date: 2020-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: sqlite3
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -149,10 +163,12 @@ files:
149
163
  - lib/caffeinate/dripper/defaults.rb
150
164
  - lib/caffeinate/dripper/delivery.rb
151
165
  - lib/caffeinate/dripper/drip.rb
166
+ - lib/caffeinate/dripper/drip_collection.rb
152
167
  - lib/caffeinate/dripper/inferences.rb
153
168
  - lib/caffeinate/dripper/perform.rb
154
169
  - lib/caffeinate/dripper/periodical.rb
155
170
  - lib/caffeinate/dripper/subscriber.rb
171
+ - lib/caffeinate/dripper_collection.rb
156
172
  - lib/caffeinate/engine.rb
157
173
  - lib/caffeinate/helpers.rb
158
174
  - lib/caffeinate/mail_ext.rb