Git Product home page Git Product logo

joshmn / caffeinate Goto Github PK

View Code? Open in Web Editor NEW
342.0 8.0 12.0 669 KB

A Rails engine for drip campaigns/scheduled sequences and periodical support. Works with ActionMailer, and other things.

Home Page: https://caffeinate.email

License: MIT License

Ruby 96.19% HTML 2.88% JavaScript 0.39% CSS 0.35% Shell 0.18%
email marketing-automation rails-engine scheduled-messages scheduled-notifications drip drip-campaign

caffeinate's Introduction

Caffeinate logo

Caffeinate

Caffeinate is a drip engine for managing, creating, and performing scheduled messages sequences from your Ruby on Rails application. This was originally meant for email, but now supports anything!

Caffeinate provides a simple DSL to create scheduled sequences which can be sent by ActionMailer, or invoked by a Ruby object, without any additional configuration.

There's a cool demo app you can spin up here.

Now supports POROs!

Originally, this was meant for just email, but as of V2.3 supports plain old Ruby objects just as well. Having said, the documentation primarily revolves around using ActionMailer, but it's just as easy to plug in any Ruby class. See Using Without ActionMailer below.

Is this thing dead?

No! Not at all!

There's not a lot of activity here because it's stable and working! I am more than happy to entertain new features.

Oh my gosh, a web UI!

See https://github.com/joshmn/caffeinate-webui for an accompanying lightweight UI for simple administrative tasks and overview.

Do you suffer from ActionMailer tragedies?

If you have anything like this is your codebase, you need Caffeinate:

class User < ApplicationRecord
  after_commit on: :create do
    OnboardingMailer.welcome_to_my_cool_app(self).deliver_later
    OnboardingMailer.some_cool_tips(self).deliver_later(wait: 2.days)
    OnboardingMailer.help_getting_started(self).deliver_later(wait: 3.days)
  end
end
class OnboardingMailer < ActionMailer::Base
  def welcome_to_my_cool_app(user)
    mail(to: user.email, subject: "Welcome to CoolApp!")
  end

  def some_cool_tips(user)
    return if user.unsubscribed_from_onboarding_campaign?

    mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
  end

  def help_getting_started(user)
    return if user.unsubscribed_from_onboarding_campaign?
    return if user.onboarding_completed?

    mail(to: user.email, subject: "Do you need help getting started?")
  end
end

What's wrong with this?

  • You're checking state in a mailer
  • The unsubscribe feature is, most likely, tied to a User, which means...
  • It's going to be so fun to scale when you finally want to add more unsubscribe links for different types of sequences
    • "one of your projects has expired", but which one? Then you have to add a column to projects and manage all that state... ew

Perhaps you suffer from enqueued worker madness

If you have anything like this is your codebase, you need Caffeinate:

class User < ApplicationRecord
  after_commit on: :create do
    OnboardingWorker.perform_later(:welcome, self.id)
    OnboardingWorker.perform_in(2.days, :some_cool_tips, self.id)
    OnboardingWorker.perform_later(3.days, :help_getting_started, self.id)
  end
end
class OnboardingWorker
  include Sidekiq::Worker
  
  def perform(action, user_id)
    user = User.find(user_id)
    user.public_send(action)
  end
end

class User
  def welcome
    send_twilio_message("Welcome to our app!")
  end

  def some_cool_tips
    return if self.unsubscribed_from_onboarding_campaign?

    send_twilio_message("Here are some cool tips for MyCoolApp")
  end

  def help_getting_started
    return if unsubscribed_from_onboarding_campaign?
    return if onboarding_completed?

    send_twilio_message("Do you need help getting started?")
  end
  
  private 
  
  def send_twilio_message(message)
    twilio_client.messages.create(
            body: message,
            to: "+12345678901",
            from: "+15005550006",
    )
  end
  
  def twilio_client
    @twilio_client ||= Twilio::REST::Client.new Rails.application.credentials.twilio[:account_sid], Rails.application.credentials.twilio[:auth_token]
  end
end

I don't even need to tell you why this is smelly!

Do this all better in five minutes

In five minutes you can implement this onboarding campaign:

Install it

Add to Gemfile, run the installer, migrate:

$ bundle add caffeinate
$ rails g caffeinate:install
$ rake db:migrate

Clean up the business logic

Assuming you intend to use Caffeinate to handle emails using ActionMailer, mailers should be responsible for receiving context and creating a mail object. Nothing more. (If you are looking for examples that don't use ActionMailer, see Without ActionMailer.)

The only other change you need to make is the argument that the mailer action receives. It will now receive a Caffeinate::Mailing. Learn more about the data models:

class OnboardingMailer < ActionMailer::Base
  def welcome_to_my_cool_app(mailing)
    @user = mailing.subscriber
    mail(to: @user.email, subject: "Welcome to CoolApp!")
  end

  def some_cool_tips(mailing)
    @user = mailing.subscriber
    mail(to: @user.email, subject: "Here are some cool tips for MyCoolApp")
  end

  def help_getting_started(mailing)
    @user = mailing.subscriber
    mail(to: @user.email, subject: "Do you need help getting started?")
  end
end

Create a Dripper

A Dripper has all the logic for your sequence and coordinates with ActionMailer on what to send.

In app/drippers/onboarding_dripper.rb:

class OnboardingDripper < ApplicationDripper
  # each sequence is a campaign. This will dynamically create one by the given slug
  self.campaign = :onboarding 
  
  # gets called before every time we process a drip
  before_drip do |_drip, mailing| 
    if mailing.subscription.subscriber.onboarding_completed?
      mailing.subscription.unsubscribe!("Completed onboarding")
      throw(:abort)
    end 
  end
  
  # map drips to the mailer
  drip :welcome_to_my_cool_app, mailer: 'OnboardingMailer', delay: 0.hours
  drip :some_cool_tips, mailer: 'OnboardingMailer', delay: 2.days
  drip :help_getting_started, mailer: 'OnboardingMailer', delay: 3.days
end

We want to skip sending the mailing if the subscriber (User) completed onboarding. Let's unsubscribe with #unsubscribe! and give it an optional reason of Completed onboarding so we can reference it later when we look at analytics. throw(:abort) halts the callback chain just like regular Rails callbacks, stopping the mailing from being sent.

Add a subscriber to the Campaign

Call OnboardingDripper.subscribe to subscribe a polymorphic subscriber to the Campaign, which creates a Caffeinate::CampaignSubscription.

class User < ApplicationRecord
  after_commit on: :create do
    OnboardingDripper.subscribe!(self)
  end
end

Run the Dripper

You'll usually do this in a scheduled background job or cron.

OnboardingDripper.perform!

Alternatively, you can run all of the registered drippers with Caffeinate.perform!.

Done

You're done.

Check out the docs for a more in-depth guide that includes all the options you can use for more complex setups, tips, tricks, and shortcuts.

Using Without ActionMailer

Now supports POROs that inherit from a magical class! Using the example above, implementing an SMS client. The same rules apply, just change mailer_class or mailer to action_class, and create a Caffeinate::ActionProxy (acts just like an ActionMailer). See Without ActionMailer.) for more.

But wait, there's more

Caffeinate also...

  • ✅ Works with regular Ruby methods as of V2.3
  • ✅ Allows hyper-precise scheduled times. 9:19AM in the user's timezone? Sure! Only on business days? YES!
  • ✅ Periodicals
  • ✅ Manages unsubscribes
  • ✅ Works with singular and multiple associations
  • ✅ Compatible with every background processor
  • ✅ Tested against large databases at AngelList and is performant as hell
  • ✅ Effortlessly handles complex workflows
    • Need to skip a certain mailing? You can!

Documentation

Upcoming features/todo

Handy dandy roadmap.

Alternatives

Not a fan of Caffeinate? I built it because I wasn't a fan of the alternatives. To each their own:

Contributing

There's so much more that can be done with this. I'd love to see what you're thinking.

If you have general feedback, I'd love to know what you're using Caffeinate for! Please email me (any-thing [at] josh.mn) or tweet me @joshmn or create an issue! I'd love to chat.

Contributors & thanks

  • Thanks to sourdoughdev for releasing the gem name to me. :)
  • Thanks to markokajzer for listening to me talk about this most mornings.

License

The gem is available as open source under the terms of the MIT License.

caffeinate's People

Contributors

jon-sully avatar joshmn avatar markokajzer avatar partytray avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

caffeinate's Issues

Clarify Docs / Workings of "Running Drippers"

Working on some other stuff at the moment but wanted to write this down before I forgot!

I think we probably need stronger / clearer docs around getting the whole system "running". Someone can setup Drippers, subscribe things to them, and have all their Mailers in order, ship all that to prod, then wonder why stuff isn't sending. It's not super clear that they also need to setup a background job (or other system) to continuously "run" the drippers.

It would also be nice if there was a global "run all of them command". Right now I'm using

ApplicationDripper.descendants.each do |klass|
      klass.perform!
    end

But that could come out of the box with Caffeinate

Unsubscribe fails

Both email link and UI unsubscribe fail.
wrong number of arguments (given 2, expected 1)

V3 guide

V3 will:

Changes to database

  • mailer_class will become action_class
  • mailer_action will become action_name

Changes to code

  • deprecate mailer_class, mailer_action until v4

Concerns

  • large table migrations
  • code changes?
    • hopefully nobody was silly enough to monkeypatch my shit code
    • shouldn't need any because the options will be deprecated

Changes to docs

  • hopefully already done?

Things I'd like to do

I have an itch to make Caffeinate a lot better, but I'm struggling to find the time to do so.

I really like Fibery's pricing for startups: https://fibery.io/startup-program, which is "free, if your revenue is less than ours."

I've ran with the idea of making the licensing similar: "free, if your startup's revenue is less than what I make, or $xx/year".

This would allow me to allocate some time for more advanced features, like removing the dependency on ActionMailer and allowing for non-mailing thingies, an optional standalone processor so you don't need to tie it in with your background processor, a JSON API, and more.

Caffeinate is currently the most popular gem for managing email sequences and I'd love to keep it that way. At the same time, Caffeinate is stable, widely-used, and people are happy with it. I don't currently have a need for any of the features above, but others have expressed their desire for them to me, so I'm constantly thinking about it.

Ideas and thoughts are welcome.

Unsubscribe fails

Both email link and UI unsubscribe fail.
wrong number of arguments (given 2, expected 1)

How to set perform_deliveries to true?

Maybe I'm missing something really basic here, but my mail is not getting sent and i'm getting this error: "Skipped delivery of mail as perform_deliveries is false". how do I turn perform_deliveries on?

Love this gem, thank you! ❤️

Rails 7.2: DEPRECATION WARNING: Caffeinate::CampaignSubscription model aliases `caffeinate_campaign`, but `caffeinate_campaign` is not an attribute.

Starting in Rails 7.2, alias_attribute with non-attribute targets will raise. Use alias_method :campaign, :caffeinate_campaign or define the method manually.

backtrace sources this error from the following lines

  /Users/maxwell/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/caffeinate-2.5.0/app/models/caffeinate/campaign.rb:86:in `subscribe!'
  /Users/maxwell/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/caffeinate-2.5.0/lib/caffeinate/dripper/subscriber.rb:27:in `subscribe'

Is `dripper/perform.rb` locating wrong `campaign` when database is MySQL?

I've been struggling with this one non-stop since Saturday. Please pardon me if I have missed something. It was a lot of work to slice this out of my app and find the breaking point. It's very subtle.

I've done my best to create a minimal reproducible test. I'm not sure if it impacts production or only the test harness.

The issue I'm seeing is that when using MySQL, the first call to perform! works fine.

But in subsequent calls, the value of campaign is set incorrectly (I think to the value from the previous call)

.merge(Caffeinate::CampaignSubscription.active.where(caffeinate_campaign: campaign))

The result is upcoming_mailings returns an empty set on subsequent calls to perform, and thus the campaign is only run the first time.

Debugging the `Caffeinate::CampaignSubscription` result set inside of `Perform!`

I modified perform to dump the Caffeinate::CampaignSubscription set as it's filtered so I can see what was happening:

self.class.upcoming_mailings

Print the Caffeinate::CampaignSubscription inside of perform!:

        puts "Caffeinate::CampaignSubscription.active, `campaign.id` == #{campaign.id}"
        tp(Caffeinate::CampaignSubscription.active, :id, :caffeinate_campaign_id, :subscriber_type, :subscriber_id, :user_type, :user_id, :ended_at, :unsubscribed_at)

        puts "Caffeinate::CampaignSubscription.active.where(caffeinate_campaign == #{campaign.id})"
        tp(Caffeinate::CampaignSubscription.active.where(caffeinate_campaign: campaign), :id, :caffeinate_campaign_id, :subscriber_type, :subscriber_id, :user_type, :user_id, :ended_at, :unsubscribed_at)

I created an empty rails 7 project that uses sqlite3 and the following works as expected:

  • Creates a campaign
  • Subscribes to a campaign
  • And then twice calls .perform! on the campaign, to execute both of the drippers connected to the campaign.
  • Then repeat the same in a second test

However, when I do the same thing on a blank rails 7 project that uses MySQL, when perform! is called in the 2nd test, the Caffeinate::CampaignSubscription result set is blank (because campaign is pointing to an invalid record, and thus no drips are sent the second time around.

From inside of calls to perform!, if we print all of the active campaigns with: Caffeinate::CampaignSubscription.active it contains:

Caffeinate::CampaignSubscription.active, `campaign.id` ==  44
                                                          ^^^^
ID | CAFFEINATE_CAMPAIGN_ID | SUBSCRIBER_TYPE | SUBSCRIBER_ID | USER_TYPE | USER_ID   | ENDED_AT | UNSUBSCRIBED_AT
---|------------------------|-----------------|---------------|-----------|-----------|----------|----------------
27 | 45                     | Subscriber      | 980190993     | User      | 980191007 |          |                
    ^^^^

You see how campaign.id does not match CAFFEINATE_CAMPAIGN_ID... So when we print the active campaigns where the campaign id matches, we get an empty set:

Caffeinate::CampaignSubscription.active.where(caffeinate_campaign == 44)
No data.

Reproducing...

Versions:

ubuntu-22-04% ruby -v
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux-gnu]
ubuntu-22-04% rails -v
Rails 7.0.6
ubuntu-22-04% cat /etc/debian_version
bookworm/sid
Create a new rails7 project to reproduce the bug:
rails new --minimal repro -d mysql
cd repro
bin/bundle add caffeinate awesome_print table_print
rails g caffeinate:install
rails g model User name email before_drip_run_counter:integer
rails g model Subscriber name email
sudo mysql -e "CREATE DATABASE repro_development"
sudo mysql -e "CREATE DATABASE repro_test"
sudo mysql mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '';"
rails db:migrate
application_dripper.rb

app/drippers/application_dripper.rb

# frozen_string_literal: true

class ApplicationDripper < ::Caffeinate::Dripper::Base
  def self.process_unsubscribes(_drip,mailing)
    puts "Calling process_unsubscribes..."
    user = mailing.user
    
    user.before_drip_run_counter = user.before_drip_run_counter.to_i + 1
    user.save
  end
end
test_dripper.rb

app/drippers/test_dripper.rb

class TestDripper < ApplicationDripper
  self.campaign = :test

  before_drip do |_drip, mailing|
    process_unsubscribes(_drip, mailing)
  end

  drip :first_reminder,   action_class: 'TestAction', delay: 0.seconds
  drip :second_reminder,  action_class: 'TestAction', delay: 1.seconds
end
test_action.rb

app/actions/test_action.rb

class TestAction < Caffeinate::ActionProxy
  def first_reminder(mailing)
    puts("Running first_reminder")
  end

  def second_reminder(mailing)
    puts("Running second_reminder")
  end
end
caffeinate_test.rb

test/integration/caffeinate_test.rb

require "test_helper"

class CaffeinateTest < ActionDispatch::IntegrationTest
  test "foo" do
    campaign = Caffeinate::Campaign.find_or_create_by!(name: "Test", slug: "test")
    user = User.create!(name: "foo", email: "foo@foo")
    subscriber = Subscriber.create!(name: "bar", email: "bar@bar")
    assert Caffeinate::CampaignSubscription.count == 0

    campaign.subscribe(subscriber, user: user)
    assert user.before_drip_run_counter.to_i == 0
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 1

    sleep 1.seconds
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 2
  end

  test "foo-bar" do
    campaign = Caffeinate::Campaign.find_or_create_by!(name: "Test", slug: "test")
    user = User.create!(name: "bar", email: "bar@bar")
    subscriber = Subscriber.create!(name: "foo", email: "foo@foo")
    campaign.subscribe(subscriber, user: user)
    assert user.before_drip_run_counter.to_i == 0
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 1

    sleep 1.seconds
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 2
  end
end

If you run the above, but do not specify -d mysql (using the default sqlite3), then it will work fine.

But if you run it with mysql, it will fail.

test foo and test foo-bar should be equivalent, producing equivalent output, but running: bin/rails test you will get:

test output for mysql version
ubuntu-22-04% bin/rails test
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 48614

# Running:

Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.F

Failure:
CaffeinateTest#test_foo [repro/test/integration/caffeinate_test.rb:14]:
Expected false to be truthy.


rails test test/integration/caffeinate_test.rb:4

When you run the same thing under sqlite3 though, the test passes as you would expect.

create the sqlite3 version of the same app
rails new --minimal repro2
cd repro2
bin/bundle add caffeinate awesome_print table_print
rails g caffeinate:install
rails g model User name email before_drip_run_counter:integer
rails g model Subscriber name email
rails db:migrate
test output for sqlite3 version
ubuntu-22-04% bin/rails test
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 50242

# Running:

Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.

Finished in 2.118785s, 0.9439 runs/s, 3.3038 assertions/s.
2 runs, 7 assertions, 0 failures, 0 errors, 0 skips

One other issue that I also see when using MySQL but not when using sqlite3...

If we call subscribe as:

    TestDripper.subscribe(subscriber, user: user)

Instead of:

    campaign.subscribe(subscriber, user: user)

In the second test that calls TestDripper.subscribe it will return with:

Error:
CaffeinateTest#test_foo-bar:
NoMethodError: undefined method `to_dripper' for nil:NilClass
    test/integration/caffeinate_test.rb:31:in `block in <class:CaffeinateTest>'

When I run the same using sqlite3 it works fine...

Reproducing second issue...

Use the same content for application_dripper.rb, test_dripper.rb and test_action.rb.

Alternate version of caffeinate_test.rb
require "test_helper"

class CaffeinateTest < ActionDispatch::IntegrationTest
  test "foo" do
    campaign = Caffeinate::Campaign.find_or_create_by!(name: "Test", slug: "test")
    user = User.create!(name: "foo", email: "foo@foo")
    subscriber = Subscriber.create!(name: "bar", email: "bar@bar")
    assert Caffeinate::CampaignSubscription.count == 0

    campaign.subscribe(subscriber, user: user)
    assert user.before_drip_run_counter.to_i == 0
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 1

    sleep 1.seconds
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 2
  end

  test "foo-bar" do
    campaign = Caffeinate::Campaign.find_or_create_by!(name: "Test", slug: "test")
    user = User.create!(name: "bar", email: "bar@bar")
    subscriber = Subscriber.create!(name: "foo", email: "foo@foo")

    ########################################################
    # Here we're calling it as TestDripper.subscribe
    # rather than campaign.subscribe
    TestDripper.subscribe(subscriber, user: user)
    ########################################################
    assert user.before_drip_run_counter.to_i == 0
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 1

    sleep 1.seconds
    TestDripper.perform!
    user.reload
    assert user.before_drip_run_counter == 2
  end
end
output when running under sqlite3
ubuntu-22-04% bin/rails test
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 64571

# Running:

Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.

Finished in 2.134845s, 0.9368 runs/s, 3.2789 assertions/s.
2 runs, 7 assertions, 0 failures, 0 errors, 0 skips
test output when running under mysql
ubuntu-22-04% bin/rails test
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 58143

# Running:

Calling process_unsubscribes...
Running first_reminder
Calling process_unsubscribes...
Running second_reminder
.F

Failure:
CaffeinateTest#test_foo [/home/erwin/Dev/os.cash/repro/test/integration/caffeinate_test.rb:14]:
Expected false to be truthy.


rails test test/integration/caffeinate_test.rb:4



Finished in 1.112693s, 1.7974 runs/s, 5.3923 assertions/s.
2 runs, 6 assertions, 1 failures, 0 errors, 0 skips

Hopefully you made it this far... That's an awful lot to digest. It's been quite a challenge trying to trace exactly what is happening. Hopefully you've got some ideas on how we can patch caffeinate to get these test to pass, or if I'm just misunderstanding something basic and using caffeinate incorrectly.

Thank you.

Generated migrations do not work if you are using UUIDs as primary key type

Hello, and thanks for this awesome gem!

Just wanted to let you know that the default generated migrations will silently fail if the app is using UUIDs as the primary key type. No failure occurs, just that the association values will be 0 since the default ID type is bigint.

Might be worth adding a note in the README that if you're app is using UUIDs then you'll need to edit the generated migrations. Or you could do something fancier.

Happy to submit PRs for either or neither!

Thanks 🙇

Cool Project

Not an issue, just nice to see the effort you've put into the project.

Thanks for the OS.

before_drip is not working

Our mailing queue is getting looped when a user is deleted. Looks like the mailer action still gets evaluated, I checked if the mailing was returning nil and indeed it does.

I could check if the user exists in the mailer but it defeats the purpose of the library.

undefined method set_login_bypass_token' for nil:NilClass`

before_drip do |_drip, mailing|
  if mailing.user.nil?
    mailing.subscription.end!("User not found")
    throw(:abort)
  end
end
def job_available_notification(mailing)
  @job = mailing.subscriber
  @action_url = job_url(@job, login_bypass_token: @user.set_login_bypass_token)

  mail(to: @user.email, subject: "Nuevo trabajo disponible")
end

Missing method and argument count errors

Following the exact installation flow in readme, the first error you'd encounter is undefined method 'subscribe!' for OnboardingDripper:Class (NoMethodError) when OnboardingDripper.subscribe!(self) is called (inside ::User). Looking at ApplicationDripper I can't see any subscribe! methods, but only subscribe.

After changing subscribe! to subscribe, you'd get a undefined method onboarding_completed?'error on::User`

(which is clearly not defined in the app but there is no mention in the docs that it should, or shouldn't be defined, so you can imagine that it can be added to the 'User` class by the gem based on the campaign name)

The view helpers (unsubscribe URL, etc) also don't work as described, since they require arguments that are not passed by the mailer.

I have tried making various changes to make this work, based on that I can't see anyone else having such fundamental issues with the gem and therefore assumed it's something stupid I'm doing, with no luck.

Run `before_drip` before running the action

Curious for your thoughts on this, Josh!

We've had a few instances of bugs popping up in our primary production app that ultimately came from a cognitive mismatch between the before_drip callback / abort and when the markup of the email gets rendered.

(library metaphor) It feels intuitive to write an abort callback into a before_drip hook that says "hey, before_drip, if the book is already checked back in, cancel this drip sequence!" and then when writing the markup for that mailer action, to assume that the book must still be checked out, so therefore writing something like @book.check_in_date.strftime() should be fine... but it's a nefarious bug!

Since the mailer markup is painted before the before_drip callback actually runs, the @book was already checked back in (making @book.check_in_date = nil). So when the markup calls for @book.check_in_date.strftime(), it'll actually be an error of "you can't call strftime() on nil"... even though the drip would've aborted if it had made it past the markup rendering stage (since the before_drip would've seen the book is actually already checked back in and :aborted)


At the technical level this occurs mostly in Caffeinate::Dripper::Delivery.deliver!:

        def deliver!(mailing)
          message = if mailing.drip.parameterized?
                      mailing.mailer_class.constantize.with(mailing: mailing).send(mailing.mailer_action)
                    else
                      mailing.mailer_class.constantize.send(mailing.mailer_action, mailing)
                    end

          message.caffeinate_mailing = mailing
          if ::Caffeinate.config.deliver_later?
            message.deliver_later
          else
            message.deliver
          end

        end

Wherein mailing.mailer_class.constantize.with(mailing: mailing).send(mailing.mailer_action) is called, which instructs ActionMailer to generate the full email and returns the Message wrapper, then message.deliver is called which would ordinarily send that email, except our Caffeinate::ActionMailer::Interceptor:

      def self.delivering_email(message)
        mailing = message.caffeinate_mailing
        return unless mailing

        mailing.caffeinate_campaign.to_dripper.run_callbacks(:before_send, mailing, message)
        drip = mailing.drip
        message.perform_deliveries = drip.enabled?(mailing)
      end

...finally gets involved at the last second to set perform_deliveries = drip.enabled?(mailing) — and since enabled? basically just runs the before_drip callback, all that means that the mailer markup is generated before the before_drip callback runs.

None of that is wrong from a technical standpoint by any means, I mostly want to raise this issue because of the cognitive distortion it leads the developer into: "if I guard for it in a before_drip, it's safe to use in the mailer", but that's not actually true. Maybe we currently have more of a "if I guard for it in a before_drip, I can trust it won't send... but I can't be sure it's safe to use in the mailer, still"


Not totally sure where to go with this! Wonder if we might move (in time) the before_drip callback to inside deliver!? Or maybe we add another callback that's like before_deliver? Mostly just want to find some way to actually guarantee the perceived guarantee described above.

WDYT??

Periodical support and jitter

Discussion continued from #21 (comment)

This is an interesting use case I didn't quite fully think through!

I wonder if it would make sense to support something like:

drip :random_long_term, mailer: ClientMailer, periodical: true, at: :set_time 

def set_time
  index = subscriber.mailings.sent.count 
  subscriber.morning_delivery((index * 2).weeks.from_now + rand(0..2).days) + rand(0..180).minutes 
end

I can't quite remember where/how periodical determines time. It's poorly documented (if that) because I haven't quite used it yet myself... hehe. It could also probably be reworked to hook into the callbacks as well if it's not already.

@jon-sully thoughts?

Expand Periodical API

periodical currently supports every: and start: when it comes to parameters that control its time sequences. I think of these as

# :every object
periodical :remind_users, every: 5.minutes
# :every proc
periodical :drip_our_customers, every: -> { 2.months + rand(-1.day..1.day) }

# :start proc
periodical :check_in_with_user, start: -> { 2.days.from_now }

But sometimes these aren't the most ergonomic. Particularly in that both want a relative amount of time, not an absolute timestamp (which makes sense given their verbiage, 'start' and 'every'). But often times in models and systems it's easier to get to absolute timestamps.

In addition, I think it'd be a nice ergonomics win to add :while as a before-any-time-computations check as to whether or not the next periodical should even fire. I suppose this is no different than a before_drip check that .end!'s the subscription, but I feel like the ergonomics of having a :while parameter that essentially only continues the sequence if it's given a proc that resolves to truthy is a good representation of most systems. I can imagine a lot of folks will use something like

periodical :win_back_attempt, while: -> { subscriber.active? }, every: -> { 4.weeks }

Sure, something like this is essentially equivalent:

before_drip do |_, mailing|
  if !subscriber.active?
    mailing.subscription.end!
    mailing.skip!
    throw(:abort)
  end
end

But the ergonomics and expressiveness of the :while key directly on the periodical feels a lot cleaner... and I suppose it does allow you to have different :while conditions for different periodicals within a single dripper. The before_drip approach would have to differentiate between multiple different periodicals manually in its logic (which would be icky!)

In terms of the time APIs, I think we could add a :next_at keyword that expects to resolve to an absolute time:

periodical :check_in_with_user, while: -> { subscriber.needs_check_in? }, next_at: -> { subscriber.next_morning_delivery_slot }

Now, all of that said, I just wanted to get my thoughts on paper here. I think we may already support some of this functionality and I've just missed it along the way. I'm going to begin this process by really looking over the options that are supported already with periodical and see if / how much would need to change to accomplish the above ideas 👍

Version 3 Roadmap

Between launching RubyOnRails.jobs and being funemployed I've had some ideas as to what I want to make for V3. Please comment or react to the comments below:

  1. API Mode #21 (comment)
  2. Standalone appliance: #21 (comment)
  3. Remove dependency of ActionMailer::Base: #21 (comment)

Error running migrations

Hi,

Running into an issue running the migrations on Rails 5.0.4.

Ran the generator, which showed a line I haven't seen before, wasn't sure if it had consequences:

Running via Spring preloader in process 19482
      create  config/initializers/caffeinate.rb
      create  app/drippers/application_dripper.rb
**File unchanged! The supplied flag value not found!  config/routes.rb**
Copied migration 20210105060001_create_caffeinate_campaigns.caffeinate.rb from caffeinate
Copied migration 20210105060002_create_caffeinate_campaign_subscriptions.caffeinate.rb from caffeinate
Copied migration 20210105060003_create_caffeinate_mailings.caffeinate.rb from caffeinate

Then I ran a rake db:migrate and got the following errors:

rails aborted!
ArgumentError: Unknown migration version "6.0"; expected one of "4.2", "5.0"

Seems the migrations set to Rails 6 only?

Could not find generator 'caffeinate:install'.

After bundling the gem into my rails 6.0.3 project, I try to run the generator and I'm getting this error Could not find generator 'caffeinate:install'. Any idea how to overcome it?

Put logos in readme

If you're using Caffeinate in a production environment, I'd love to hear from you!

Issues during installation on Rails 5

Hi @joshmn,

me again - migrations were fixed thanks!

I'm having some more issues -- put the basics in place from the website homepage and getting the following:

➜  xx git:(ce/drip-campaign) ✗ ruby -v
ruby 2.4.9p362 (2019-10-02 revision 67824) [x86_64-darwin19]
➜  xx git:(ce/drip-campaign) ✗ rails -v
Rails 5.0.7.2
➜  xx git:(ce/drip-campaign) ✗
➜  xx git:(ce/drip-campaign) ✗ rails s
/Users/chris/.rvm/gems/ruby-2.4.9/gems/caffeinate-0.15.0/lib/caffeinate/schedule_evaluator.rb:23:in `<class:ScheduleEvaluator>': undefined method `delegate_missing_to' for Caffeinate::ScheduleEvaluator:Class (NoMethodError)
Did you mean?  DelegateClass
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/caffeinate-0.15.0/lib/caffeinate/schedule_evaluator.rb:22:in `<module:Caffeinate>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/caffeinate-0.15.0/lib/caffeinate/schedule_evaluator.rb:1:in `<main>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:293:in `block in require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:259:in `load_dependency'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:293:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/caffeinate-0.15.0/lib/caffeinate/drip.rb:4:in `<main>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:293:in `block in require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:259:in `load_dependency'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/activesupport-5.0.7.2/lib/active_support/dependencies.rb:293:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/caffeinate-0.15.0/lib/caffeinate.rb:16:in `<main>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler/runtime.rb:81:in `block (2 levels) in require'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler/runtime.rb:76:in `each'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler/runtime.rb:76:in `block in require'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler/runtime.rb:65:in `each'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler/runtime.rb:65:in `require'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/bundler.rb:114:in `require'
	from /Users/chris/Repositories/shopstar/config/application.rb:8:in `<main>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/railties-5.0.7.2/lib/rails/commands/commands_tasks.rb:88:in `block in server'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/railties-5.0.7.2/lib/rails/commands/commands_tasks.rb:85:in `tap'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/railties-5.0.7.2/lib/rails/commands/commands_tasks.rb:85:in `server'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/railties-5.0.7.2/lib/rails/commands/commands_tasks.rb:49:in `run_command!'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/railties-5.0.7.2/lib/rails/commands.rb:18:in `<main>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `require'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:23:in `block in require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require_with_bootsnap_lfi'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/bootsnap-1.7.2/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:31:in `require'
	from /Users/chris/Repositories/shopstar/bin/rails:11:in `<top (required)>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/client/rails.rb:28:in `load'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/client/rails.rb:28:in `call'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/client/command.rb:7:in `call'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/client.rb:30:in `run'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/bin/spring:49:in `<top (required)>'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `load'
	from /Users/chris/.rvm/gems/ruby-2.4.9/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `<top (required)>'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/rubygems/core_ext/kernel_require.rb:65:in `require'
	from /Users/chris/.rvm/rubies/ruby-2.4.9/lib/ruby/site_ruby/2.4.0/rubygems/core_ext/kernel_require.rb:65:in `require'
	from /Users/chris/Repositories/shopstar/bin/spring:14:in `<top (required)>'
	from bin/rails:3:in `load'
	from bin/rails:3:in `<main>'

Seems like delegate_missing_to is only implemented in Rails 5.1. Any way to make this backwards compatible?

Generated migration throws error on rollback

lib/generators/caffeinate/templates/migrations/create_caffeinate_campaign_subscriptions.rb.tt
can be changed as follows to avoid this rollback error:

  #
  #    (3.6ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  #    (1.2ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  #    (1.0ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  #    (0.4ms)  SELECT pg_try_advisory_lock(8721676743767584905)
  #    (1.0ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  # Migrating to CreateCaffeinateCampaignSubscriptions (20211101175308)
  #    (0.5ms)  BEGIN
  # == 20211101175308 CreateCaffeinateCampaignSubscriptions: reverting ============
  #    (0.4ms)  ROLLBACK
  #    (0.3ms)  SELECT pg_advisory_unlock(8721676743767584905)
  # rake aborted!
  # StandardError: An error has occurred, this and all later migrations canceled:
  #
  #
  #
  # To avoid mistakes, drop_table is only reversible if given options or a block (can be empty).

Using up and down methods instead of change

# frozen_string_literal: true

class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[5.2]
  def up
    drop_table :caffeinate_campaign_subscriptions if table_exists?(:caffeinate_campaign_subscriptions)

    create_table :caffeinate_campaign_subscriptions do |t|
      t.references :caffeinate_campaign, null: false, index: { name: :caffeineate_campaign_subscriptions_on_campaign }, foreign_key: true
      t.string :subscriber_type, null: false
      t.integer :subscriber_id, null: false
      t.string :user_type
      t.integer :user_id
      t.string :token, null: false
      t.datetime :ended_at
      t.string :ended_reason
      t.datetime :resubscribed_at
      t.datetime :unsubscribed_at
      t.string :unsubscribe_reason

      t.timestamps
    end
    add_index :caffeinate_campaign_subscriptions, :token, unique: true
    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
  end

  def down
    drop_table :caffeinate_campaign_subscriptions if table_exists?(:caffeinate_campaign_subscriptions)
  end
end

Do you ever need to drop/change a mailing?

Just a question here since for the first time since developing this gem, I ran into this use-case:

  • I created a campaign with 2 drips: "welcome", and "reminder"
  • I subscribed a thing to a campaign
  • I later had to change "reminder", to "suggested" and then I had to Caffeinate::Mailing.upcoming.where(mailer_action: "reminder").update_all(mailer_action: "suggested")

I'll leave this open in case anyone else has similar issues. Open to suggestions!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.