Closed joshmn closed 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!
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!
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.
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
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!
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
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)
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)!
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 🎉
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
full_body
portion below. You could just as well callrender(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 :)
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 😆
@jon-sully invited you as collab; also see (and kindly review) #24 :)
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.
@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!
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