# frozen-string-literal: true
require 'stringio'
require 'mail'
class Roda
module RodaPlugins
# The mailer plugin allows your Roda application to send emails easily.
#
# class Mailer < Roda
# plugin :render
# plugin :mailer
#
# route do |r|
# r.on "albums", Integer do |album_id|
# @album = Album[album_id]
#
# r.mail "added" do
# from 'from@example.com'
# to 'to@example.com'
# cc 'cc@example.com'
# bcc 'bcc@example.com'
# subject 'Album Added'
# add_file "path/to/album_added_img.jpg"
# render(:albums_added_email) # body
# end
# end
# end
# end
#
# The default method for sending a mail is +sendmail+:
#
# Mailer.sendmail("/albums/1/added")
#
# If you want to return the Mail::Message instance for further modification,
# you can just use the +mail+ method:
#
# mail = Mailer.mail("/albums/1/added")
# mail.from 'from2@example.com'
# mail.deliver
#
# The mailer plugin uses the mail gem, so if you want to configure how
# email is sent, you can use Mail.defaults (see the mail gem documentation for
# more details):
#
# Mail.defaults do
# delivery_method :smtp, address: 'smtp.example.com', port: 587
# end
#
# You can support multipart emails using +text_part+ and +html_part+:
#
# r.mail "added" do
# from 'from@example.com'
# to 'to@example.com'
# subject 'Album Added'
# text_part render('album_added.txt') # views/album_added.txt.erb
# html_part render('album_added.html') # views/album_added.html.erb
# end
#
# In addition to allowing you to use Roda's render plugin for rendering
# email bodies, you can use all of Roda's usual routing tree features
# to DRY up your code:
#
# r.on "albums", Integer do |album_id|
# @album = Album[album_id]
# from 'from@example.com'
# to 'to@example.com'
#
# r.mail "added" do
# subject 'Album Added'
# render(:albums_added_email)
# end
#
# r.mail "deleted" do
# subject 'Album Deleted'
# render(:albums_deleted_email)
# end
# end
#
# When sending a mail via +mail+ or +sendmail+, a RodaError will be raised
# if the mail object does not have a body. This is similar to the 404
# status that Roda uses by default for web requests that don't have
# a body. If you want to specifically send an email with an empty body, you
# can use the explicit empty string:
#
# r.mail do
# from 'from@example.com'
# to 'to@example.com'
# subject 'No Body Here'
# ""
# end
#
# If while preparing the email you figure out you don't want to send an
# email, call +no_mail!+:
#
# r.mail 'welcome', Integer do |id|
# no_mail! unless user = User[id]
# # ...
# end
#
# You can pass arguments when calling +mail+ or +sendmail+, and they
# will be yielded as additional arguments to the appropriate +r.mail+ block:
#
# Mailer.sendmail('/welcome/1', 'foo@example.com')
#
# r.mail 'welcome' do |user_id, mail_from|
# from mail_from
# to User[user_id].email
# # ...
# end
#
# By default, the mailer uses text/plain as the Content-Type for emails.
# You can override the default by specifying a :content_type option when
# loading the plugin:
#
# plugin :mailer, content_type: 'text/html'
#
# For backwards compatibility reasons, the +r.mail+ method does not do
# a terminal match by default if provided arguments (unlike +r.get+ and
# +r.post+). You can pass the :terminal option to make +r.mail+ enforce
# a terminal match if provided arguments.
#
# The mailer plugin does support being used inside a Roda application
# that is handling web requests, where the routing block for mails and
# web requests is shared. However, it's recommended that you create a
# separate Roda application for emails. This can be a subclass of your main
# Roda application if you want your helper methods to automatically be
# available in your email views.
module Mailer
# Error raised when the using the mail class method, but the routing
# tree doesn't return the mail object.
class Error < ::Roda::RodaError; end
# Set the options for the mailer. Options:
# :content_type :: The default content type for emails (default: text/plain)
def self.configure(app, opts=OPTS)
app.opts[:mailer] = (app.opts[:mailer]||OPTS).merge(opts).freeze
end
module ClassMethods
# Return a Mail::Message instance for the email for the given request path
# and arguments. Any arguments given are yielded to the appropriate +r.mail+
# block after any usual match block arguments. You can further manipulate the
#returned mail object before calling +deliver+ to send the mail.
def mail(path, *args)
mail = ::Mail.new
catch(:no_mail) do
unless mail.equal?(new("PATH_INFO"=>path, 'SCRIPT_NAME'=>'', "REQUEST_METHOD"=>"MAIL", 'rack.input'=>StringIO.new, 'roda.mail'=>mail, 'roda.mail_args'=>args)._roda_handle_main_route)
raise Error, "route did not return mail instance for #{path.inspect}, #{args.inspect}"
end
mail
end
end
# Calls +mail+ with given arguments and immediately sends the resulting mail.
def sendmail(*args)
if m = mail(*args)
m.deliver
end
end
end
module RequestMethods
# Similar to routing tree methods such as +get+ and +post+, this matches
# only if the request method is MAIL (only set when using the Roda class
# +mail+ or +sendmail+ methods) and the rest of the arguments match
# the request. This yields any of the captures to the block, as well as
# any arguments passed to the +mail+ or +sendmail+ Roda class methods.
def mail(*args)
if @env["REQUEST_METHOD"] == "MAIL"
# RODA4: Make terminal match the default
send(roda_class.opts[:mailer][:terminal] ? :_verb : :if_match, args) do |*vs|
yield(*(vs + @env['roda.mail_args']))
end
end
end
end
module ResponseMethods
# The mail object related to the current request.
attr_accessor :mail
# If the related request was an email request, add any response headers
# to the email, as well as adding the response body to the email.
# Return the email unless no body was set for it, which would indicate
# that the routing tree did not handle the request.
def finish
if m = mail
header_content_type = @headers.delete('Content-Type')
m.headers(@headers)
m.body(@body.join) unless @body.empty?
mail_attachments.each do |a, block|
m.add_file(*a)
block.call if block
end
if content_type = header_content_type || roda_class.opts[:mailer][:content_type]
if mail.multipart?
if /multipart\/mixed/ =~ mail.content_type &&
mail.parts.length >= 2 &&
(part = mail.parts.find{|p| !p.attachment && (p.encoded; /text\/plain/ =~ p.content_type)})
part.content_type = content_type
end
else
mail.content_type = content_type
end
end
unless m.body.to_s.empty? && m.parts.empty? && @body.empty?
m
end
else
super
end
end
# The attachments related to the current mail.
def mail_attachments
@mail_attachments ||= []
end
end
module InstanceMethods
# Add delegates for common email methods.
[:from, :to, :cc, :bcc, :subject].each do |meth|
define_method(meth) do |*args|
env['roda.mail'].public_send(meth, *args)
nil
end
end
[:text_part, :html_part].each do |meth|
define_method(meth) do |*args|
_mail_part(meth, *args)
end
end
# If this is an email request, set the mail object in the response, as well
# as the default content_type for the email.
def initialize(env)
super
if mail = env['roda.mail']
res = @_response
res.mail = mail
res.headers.delete('Content-Type')
end
end
# Delay adding a file to the message until after the message body has been set.
# If a block is given, the block is called after the file has been added, and you
# can access the attachment via response.mail_attachments.last.
def add_file(*a, &block)
response.mail_attachments << [a, block]
nil
end
# Signal that no mail should be sent for this request.
def no_mail!
throw :no_mail
end
private
# Set the text_part or html_part (depending on the method) in the related email,
# using the given body and optional headers.
def _mail_part(meth, body, headers=nil)
env['roda.mail'].public_send(meth) do
body(body)
headers(headers) if headers
end
nil
end
end
end
register_plugin(:mailer, Mailer)
end
end