joshmn / caffeinate

A Rails engine for drip campaigns/scheduled sequences and periodical support. Works with ActionMailer, and other things.
https://caffeinate.email
MIT License
345 stars 13 forks source link

Example of not using ActionMailer #14

Closed joshmn closed 1 year ago

joshmn commented 2 years ago

It's plenty possible, but will be kind of hacky.

Edit: here's a proof of concept. I'll make something more clean for V3, whenever that will be: https://github.com/joshmn/caffeinate/issues/14#issuecomment-1506075117, also https://github.com/joshmn/caffeinate/issues/14#issuecomment-1506196378

erwin commented 1 year ago

I would love to see some documentation for how to use caffeinate without ActionMailer...

In my case, Email Templates are are stored in the database, so curious if I could get that to work!

joshmn commented 1 year ago

How are you sending mail? API? (though, if you're still using ActionMailer, you can still store the templates in the database and have them sent via ActionMailer. I do this for my horse ranch and https://github.com/andreapavoni/panoramic.)

Let me know how you're sending mail and I can figure out some ways to make this work for you!

joshmn commented 1 year ago

Just found some time to put a really icky thing together. You'll miss out on some of the callbacks but they're pretty easy to implement. Here's the basics:

class SomeActionDripper < ApplicationDripper
  self.campaign = :some_action
  drip :tomato, delay: 1.minute, mailer_class: "SomeAction"
  drip :potato, delay: 2.minutes, mailer_class: "SomeAction"
end

It'll still be named mailer_class :( I'll make this change in 3.0.

module NonMailingProcess
  def self.included(klass)
    klass.extend HandleDSL
    klass.attr_accessor :caffeinate_mailing
    klass.attr_accessor :perform_deliveries
    klass.prepend Processable
  end

  def initialize(caffeinate_mailing)
    @caffeinate_mailing = caffeinate_mailing
  end

  module HandleDSL
    def handle(*names)
      names.each do |name|
        define_singleton_method name do |mailing|
          instance = new(mailing)
          instance.handling
          instance.public_send(name)
          instance.observe
        end
      end
    end
  end

  module Processable
    def handling
      ::Caffeinate::ActionMailer::Interceptor.delivering_email(self)
      self
    end

    def observe
      ::Caffeinate::ActionMailer::Observer.delivered_email(self)
      self
    end

    alias_method :deliver, :process
  end
end
class SomeAction
  include NonMailingProcess

  handle :tomato, :potato

  def tomato
    puts "wow i did tomato"
  end

  def potato
    puts "wow i did potato"
  end
end

The rest should take care of itself! This will still give you the callbacks too.

joshmn commented 1 year ago

Also, if you don't like https://github.com/joshmn/caffeinate/issues/14#issuecomment-1506075117 (I don't), you might like this better?

class Caffeinate::MessageHandler < Delegator
  def initialize(action_class, action, mailing) # :nodoc:
    @action_class, @action, @mailing = action_class, action, mailing
  end

  def __getobj__
    processed_action
  end

  private

  def processed_action
    @processed_action ||= @action_class.new.tap do |action_object|
      action_object.process @action, @mailing
    end
  end
end

module Caffeinate
  class AbstractAction
    attr_accessor :caffeinate_mailing
    attr_accessor :perform_deliveries

    class << self
      def action_methods
        @action_methods ||= begin
                              methods = (public_instance_methods(true) -
                                internal_methods +
                                public_instance_methods(false))
                              methods.map!(&:to_s)
                              methods.to_set
                            end
      end

      def internal_methods
        controller = self

        controller = controller.superclass until controller.abstract?
        controller.public_instance_methods(true)
      end

      def method_missing(method_name, *args)
        if action_methods.include?(method_name.to_s)
          ::Caffeinate::MessageHandler.new(self, method_name, *args)
        else
          super
        end
      end
      ruby2_keywords(:method_missing)

      def respond_to_missing?(method, include_all = false)
        action_methods.include?(method.to_s) || super
      end

      def abstract?
        true
      end
    end

    def process(action_name, mailing)
      @action_name = action_name
      self.caffeinate_mailing = mailing
      ::Caffeinate::ActionMailer::Interceptor.delivering_email(self)
    end

    def deliver
      if self.perform_deliveries
        send(@action_name, caffeinate_mailing)
        ::Caffeinate::ActionMailer::Observer.delivered_email(self)
      end
    end
  end
end

class SomeAction < Caffeinate::AbstractAction
  def tomato(mailing)
    puts "wow i did tomato"
  end

  def potato(mailing)
    puts "wow i did potato"
  end
end
erwin commented 1 year ago

Thank you very much for writing two whole versions of this. I will try to run some experiments with it this weekend and post some feedback!

Looks very cool!

joshmn commented 1 year ago

Please do and let me know how it goes! I'd love to chat about how you're using Caffeinate too sometime, just drop me an email at [current_user.username[0...4], '@', current_user.username[0...4 ], '.', current_user.username[4..]].join

jon-sully commented 1 year ago

Ah, I didn't realize this conversation has picked up in the last couple of days! How timely 😛

I just recently built out a custom Texter implementation that spoofs the pieces of the ActionMailer API that Caffeinate uses so that our app can send drip texts and emails! And, along with @erwin, emails are part of our domain model, so we keep them in the DB too (but use ActionMailer for delivery).

Here's our secret sauce in case it helps others: (modified from original code for IP but premise remains)

Persisted Emails + ActionMailer

A note: we store our Email 'bodies' as active storage attachments since they can be.. terrible and long (not bloating the DB) so you'll see us use ActiveStorage params in the full_body portion below. You could just as well call render(layout: false) if you store email bodies directly in the DB and want them there.

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  layout "standard_email"

  def thirty_day_followup(mailing)
    @user = mailing.subscriber.decorate

    @email = Email.new(
      sender: User.internal_bot,
      recipient: @user,
      to_email_addy: @user.full_email,
      from_email_addy: @user.first_contacted_by.full_email,

      email_type: :outgoing,
      subject: "Following up on your new thing",
      full_body: {
        io: StringIO.new(render(layout: false)),
        filename: "full_body.html",
        content_type: "text/html"
      }
    )
    @email.save! unless mailing.is_a?(MockMailing) # Rails Mailer Previews shouldn't actually save off email records

    mail(subject: @email.subject, to: @email. to_email_addy, from: @email. from_email_addy, cc: @email.cc, bcc: @email.bcc)
  end
end

# spec/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def thirty_day_followup
    UserMailer.thirty_day_followup(MockMailing.new(User.last))
  end
end

# in config/initializers/action_mailer.rb
MockMailing = Struct.new(:subscriber)

So, pretty straightforward there. Using ActionMailer but saving the record off in the middle (unless ActionMailer is being invoked from a Preview class via the custom MockMailing struct)!

Caffeinated Texting

We wrote a custom "Texter" class — somewhat a nod to Textris but custom-made to be more easily spoofed to Caffeinate's API. I'm not sure Textris is maintained much anymore either.

The Texters here could definitely be made more like Textris / ActionMailer, but this is our current v1 approach.

# app/texters/application_texter.rb

class ApplicationTexter
  # NOTE: Delegates unknown class methods to a new instance as to support the
  # ActionMailer API of FooMailer.intro_message(xyz) (which sets up a new instance)
  class << self
    delegate_missing_to :new
  end

  attr_accessor :caffeinate_mailing
  attr_writer :perform_deliveries

  # NOTE: This method is built to be at rough-parity with ActionMailer in its
  # callback sequence and underlying sending structure for compatibility with
  # Caffeinate
  def deliver
    # Run ActionMailer-parity/Caffeinate callbacks and hooks
    @perform_deliveries ||= true
    Caffeinate::ActionMailer::Interceptor.delivering_email self
    return unless @perform_deliveries

    message = Sms.create!(
      direction: :outbound,
      user: @user,
      internal_number: @internal_number,
      external_number: @external_number,
      body: @body
    )

    message.send!

    if caffeinate_mailing
      caffeinate_mailing.update!(sent_at: Caffeinate.config.time_now, skipped_at: nil)
      caffeinate_mailing.caffeinate_campaign.to_dripper.run_callbacks(:after_send, caffeinate_mailing, self)
    end
  end

  # Renders out the view template of the given subclass's method, by name,
  # similar-ish to controller-style:
  # UserTexter#introduction_message -> views/texters/user_texter/introduction_message.text.erb
  def render(template_name = nil)
    # Digging into Kernel a bit to get the name of the subclass action that called
    template_name ||= caller_locations(1, 1)[0].label
    class_name = self.class.name.underscore

    assigns = instance_variables.map do |iv|
      key = iv.to_s.delete("@")
      [key, instance_variable_get(iv)]
    end.to_h

    ApplicationController.renderer.new(
      http_host: "example.com",
      https: true
    ).render(
      template: "texters/#{class_name}/#{template_name}",
      layout: false,
      assigns: assigns
    )
  end
end

and a typical sub-class...

# app/texters/user_texter.rb

class UserTexter < ApplicationTexter
  def welcome(texting)
    @user = User.internal_bot

    @internal_number = Rails.config.our_phone_number
    @external_number = @user.phone

    @body = render

    self # Texter actions must return self as parity with ActionMailers
  end
end

So we can now have a dripper like:

# app/drippers/welcome_dripper.rb

class WelcomeDripper < ApplicationDripper
  self.campaign = :welcome_user

  before_drip do |_drip, mailing|
    user = mailing.subscriber

    if user.has_setup_new_assets?
      mailing.subscription.unsubscribe!("User got setup")
      throw(:abort)
    end
  end

  drip :welcome, mailer: :UserTexter
  drip :welcome_email, mailer: :UserMailer
  # etc.
end

which uses both Texters and Mailers 🎉

joshmn commented 1 year ago

Ah, I didn't realize this conversation has picked up in the last couple of days! How timely 😛

I just recently built out a custom Texter implementation that spoofs the pieces of the ActionMailer API that Caffeinate uses so that our app can send drip texts and emails! And, along with @erwin, emails are part of our domain model, so we keep them in the DB too (but use ActionMailer for delivery).

Here's our secret sauce in case it helps others: (modified from original code for IP but premise remains)

Persisted Emails + ActionMailer

A note: we store our Email 'bodies' as active storage attachments since they can be.. terrible and long (not bloating the DB) so you'll see us use ActiveStorage params in the full_body portion below. You could just as well call render(layout: false) if you store email bodies directly in the DB and want them there.

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  layout "standard_email"

  def thirty_day_followup(mailing)
    @user = mailing.subscriber.decorate

    @email = Email.new(
      sender: User.internal_bot,
      recipient: @user,
      to_email_addy: @user.full_email,
      from_email_addy: @user.first_contacted_by.full_email,

      email_type: :outgoing,
      subject: "Following up on your new thing",
      full_body: {
        io: StringIO.new(render(layout: false)),
        filename: "full_body.html",
        content_type: "text/html"
      }
    )
    @email.save! unless mailing.is_a?(MockMailing) # Rails Mailer Previews shouldn't actually save off email records

    mail(subject: @email.subject, to: @email. to_email_addy, from: @email. from_email_addy, cc: @email.cc, bcc: @email.bcc)
  end
end

# spec/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def thirty_day_followup
    UserMailer.thirty_day_followup(MockMailing.new(User.last))
  end
end

# in config/initializers/action_mailer.rb
MockMailing = Struct.new(:subscriber)

So, pretty straightforward there. Using ActionMailer but saving the record off in the middle (unless ActionMailer is being invoked from a Preview class via the custom MockMailing struct)!

Caffeinated Texting

We wrote a custom "Texter" class — somewhat a nod to Textris but custom-made to be more easily spoofed to Caffeinate's API. I'm not sure Textris is maintained much anymore either.

The Texters here could definitely be made more like Textris / ActionMailer, but this is our current v1 approach.

# app/texters/application_texter.rb

class ApplicationTexter
  # NOTE: Delegates unknown class methods to a new instance as to support the
  # ActionMailer API of FooMailer.intro_message(xyz) (which sets up a new instance)
  class << self
    delegate_missing_to :new
  end

  attr_accessor :caffeinate_mailing
  attr_writer :perform_deliveries

  # NOTE: This method is built to be at rough-parity with ActionMailer in its
  # callback sequence and underlying sending structure for compatibility with
  # Caffeinate
  def deliver
    # Run ActionMailer-parity/Caffeinate callbacks and hooks
    @perform_deliveries ||= true
    Caffeinate::ActionMailer::Interceptor.delivering_email self
    return unless @perform_deliveries

    message = Sms.create!(
      direction: :outbound,
      user: @user,
      internal_number: @internal_number,
      external_number: @external_number,
      body: @body
    )

    message.send!

    if caffeinate_mailing
      caffeinate_mailing.update!(sent_at: Caffeinate.config.time_now, skipped_at: nil)
      caffeinate_mailing.caffeinate_campaign.to_dripper.run_callbacks(:after_send, caffeinate_mailing, self)
    end
  end

  # Renders out the view template of the given subclass's method, by name,
  # similar-ish to controller-style:
  # UserTexter#introduction_message -> views/texters/user_texter/introduction_message.text.erb
  def render(template_name = nil)
    # Digging into Kernel a bit to get the name of the subclass action that called
    template_name ||= caller_locations(1, 1)[0].label
    class_name = self.class.name.underscore

    assigns = instance_variables.map do |iv|
      key = iv.to_s.delete("@")
      [key, instance_variable_get(iv)]
    end.to_h

    ApplicationController.renderer.new(
      http_host: "example.com",
      https: true
    ).render(
      template: "texters/#{class_name}/#{template_name}",
      layout: false,
      assigns: assigns
    )
  end
end

and a typical sub-class...

# app/texters/user_texter.rb

class UserTexter < ApplicationTexter
  def welcome(texting)
    @user = User.internal_bot

    @internal_number = Rails.config.our_phone_number
    @external_number = @user.phone

    @body = render

    self # Texter actions must return self as parity with ActionMailers
  end
end

So we can now have a dripper like:

# app/drippers/welcome_dripper.rb

class WelcomeDripper < ApplicationDripper
  self.campaign = :welcome_user

  before_drip do |_drip, mailing|
    user = mailing.subscriber

    if user.has_setup_new_assets?
      mailing.subscription.unsubscribe!("User got setup")
      throw(:abort)
    end
  end

  drip :welcome, mailer: :UserTexter
  drip :welcome_email, mailer: :UserMailer
  # etc.
end

which uses both Texters and Mailers 🎉

this has just pushed me to actually work on getting POROs properly supported.

Edit: not to say that your implementation is wrong or bad, but it makes me feel like it should be native. Will tag you in PR for review, hope you don't mind :)

jon-sully commented 1 year ago

this has just pushed me to actually work on getting POROs properly supported.

Edit: not to say that your implementation is wrong or bad, but it makes me feel like it should be native. Will tag you in PR for review, hope you don't mind :)

Not at all! Happy to take a look and see where it goes. Will never say no to the implementation on our side getting simpler 😆

joshmn commented 1 year ago

@jon-sully invited you as collab; also see (and kindly review) #24 :)

erwin commented 1 year ago

I will try to run some experiments with it this weekend and post some feedback

@joshmn my apologies that I haven't been able to get to testing this yet... I've got a big vacation coming tomorrow, so you know how the week before vacation can end up crazy...

I'm still very excited to test using caffinate as you described as soon as I'm back - May 8th.

joshmn commented 1 year ago

@erwin Very excited for you to try it too! I have already plugged it in for my horse ranch and it's working lovely so hopefully it'll work for you too! :) have fun on your vaca!