diff --git a/lib/prism/mailer/base.rb b/lib/prism/mailer/base.rb new file mode 100644 index 0000000..6b46442 --- /dev/null +++ b/lib/prism/mailer/base.rb @@ -0,0 +1,128 @@ +module Prism + module Mailer + # ActionMailer-lite: subclass, declare `default from: '...'`, define an + # instance method per mailer action that ends with a call to #mail. + # Calling the action as a class method (`WelcomeMailer.welcome(user)`) + # renders the templates immediately and returns a Delivery. + class Base + class << self + def default(options = nil) + @default_options = (@default_options || {}).merge(options) if options + @default_options || {} + end + + def template_roots + @template_roots || [File.join(Dir.pwd, 'app', 'views', 'mailers')] + end + + attr_writer :template_roots + + # Per-mailer-class override of Mailer.default_url_options, merged + # over it (this class's values win) by #url_for. + def default_url_options(options = nil) + @default_url_options = (@default_url_options || {}).merge(options) if options + @default_url_options || {} + end + + def mailer_name + Inflector.underscore(name.to_s.split('::').last.sub(/Mailer\z/, '')) + end + + def method_missing(name, *args, &block) + return super unless instance_methods(false).include?(name) + + instance = new + instance.send(:run_action, name, args) + Delivery.new(instance.message, mailer_class: self, action_name: name.to_s, args: args) + end + + def respond_to_missing?(name, include_private = false) + instance_methods(false).include?(name) || super + end + end + + attr_reader :message, :action_name + + def initialize + @message = Message.new + apply_defaults + end + + def mail(to:, subject:, from: nil, cc: nil, bcc: nil, reply_to: nil, template: nil, locals: {}) + message.to = to + message.subject = subject + message.from = from if from + message.cc = cc if cc + message.bcc = bcc if bcc + message.reply_to = reply_to if reply_to + render_bodies(template || action_name, locals) + message + end + + # Absolute URL for a named route, for use inside mailer actions and + # templates (there is no incoming request to infer a host from, unlike + # Controller#url_for). Requires Mailer.router to be set and a :host + # somewhere in Mailer.default_url_options / self.class.default_url_options + # / the params passed here. + def url_for(name, params = {}) + router = Mailer.router + raise 'no router configured; set Prism::Mailer.router before generating mailer URLs' unless router + + options = Mailer.default_url_options.merge(self.class.default_url_options).merge(params) + host = options.delete(:host) + raise ArgumentError, 'default_url_options must include :host to build a mailer URL' unless host + + protocol = options.delete(:protocol) || 'http' + port = options.delete(:port) + "#{protocol}://#{host}#{port ? ":#{port}" : ''}#{router.path_to(name, options)}" + end + + private + + def run_action(name, args) + @action_name = name.to_s + public_send(name, *args) + end + + def apply_defaults + self.class.default.each do |key, value| + message.public_send("#{key}=", value) + end + end + + def render_bodies(name, locals) + view = Prism::View.new(self.class.template_roots, helpers: self) + merged_locals = auto_locals.merge(locals) + found_any = false + + begin + message.text_body = view.render("#{self.class.mailer_name}/#{name}.text", locals: merged_locals) + found_any = true + rescue Prism::View::TemplateNotFound + nil + end + + begin + message.html_body = view.render("#{self.class.mailer_name}/#{name}.html", locals: merged_locals) + found_any = true + rescue Prism::View::TemplateNotFound + nil + end + + return if found_any + + raise Prism::View::TemplateNotFound, + "no mailer template #{name}.text.erb or #{name}.html.erb for " \ + "#{self.class.mailer_name} in #{self.class.template_roots.join(', ')}" + end + + def auto_locals + instance_variables.each_with_object({}) do |ivar, locals| + next if %i[@message @action_name].include?(ivar) + + locals[ivar.to_s.delete_prefix('@').to_sym] = instance_variable_get(ivar) + end + end + end + end +end diff --git a/lib/prism/mailer/delivery.rb b/lib/prism/mailer/delivery.rb new file mode 100644 index 0000000..a2c5515 --- /dev/null +++ b/lib/prism/mailer/delivery.rb @@ -0,0 +1,28 @@ +module Prism + module Mailer + # Returned by a mailer action call (`UserMailer.welcome(user)`), in the + # spirit of ActionMailer's MessageDelivery: the message is already + # rendered at this point, #deliver_now hands it to Prism::Mailer.deliver! + # immediately, #deliver_later re-enqueues the action itself (mailer + # class, action name, args) as a Prism::Jobs::Job so a Worker performs + # the rendering and delivery out of process. + class Delivery + def initialize(message, mailer_class:, action_name:, args:) + @message = message + @mailer_class = mailer_class + @action_name = action_name + @args = args + end + + attr_reader :message + + def deliver_now + Mailer.deliver!(message) + end + + def deliver_later(queue: nil, run_at: nil) + DeliveryJob.perform_later(@mailer_class.name, @action_name, @args, queue: queue, run_at: run_at) + end + end + end +end diff --git a/lib/prism/mailer/delivery_job.rb b/lib/prism/mailer/delivery_job.rb new file mode 100644 index 0000000..c1b9e9f --- /dev/null +++ b/lib/prism/mailer/delivery_job.rb @@ -0,0 +1,16 @@ +module Prism + module Mailer + # Performs a mailer action out of process. Enqueued by + # Delivery#deliver_later with the mailer class name (resolved back + # through Object.const_get, same pattern Jobs::Worker already uses for + # job classes) so the queue only ever persists plain strings/arrays. + class DeliveryJob < Prism::Jobs::Job + queue_as 'mailers' + + def perform(mailer_class_name, action_name, args) + mailer_class = Object.const_get(mailer_class_name) + mailer_class.public_send(action_name, *args).deliver_now + end + end + end +end