feat(mailer): introduce Prism and Mailer
This commit is contained in:
1 parent
e4165f8cd6
commit
97f8d8e4f8
2 files changed
+175
No files matched your search
@@ -0,0 +1,131 @@
|
|||||||
|
require 'securerandom'
|
||||||
|
require 'time'
|
||||||
|
|
||||||
|
module Prism
|
||||||
|
module Mailer
|
||||||
|
# A single outgoing email: headers, a text and/or HTML body, and
|
||||||
|
# attachments. #to_s renders a real RFC 5322 message (multipart/mixed
|
||||||
|
# wrapping multipart/alternative when both a body variant and
|
||||||
|
# attachments are present) that a real SMTP server or Net::SMTP client
|
||||||
|
# can transmit as-is.
|
||||||
|
class Message
|
||||||
|
attr_accessor :from, :reply_to, :subject, :text_body, :html_body
|
||||||
|
attr_reader :to, :cc, :bcc, :attachments, :headers
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@to = []
|
||||||
|
@cc = []
|
||||||
|
@bcc = []
|
||||||
|
@attachments = []
|
||||||
|
@headers = {}
|
||||||
|
@message_id = "#{SecureRandom.hex(16)}@prism"
|
||||||
|
end
|
||||||
|
|
||||||
|
def to=(value)
|
||||||
|
@to = Array(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cc=(value)
|
||||||
|
@cc = Array(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bcc=(value)
|
||||||
|
@bcc = Array(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def []=(header, value)
|
||||||
|
headers[header.to_s] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_attachment(filename, content, mime_type: 'application/octet-stream')
|
||||||
|
attachments << { filename: filename, content: content, mime_type: mime_type }
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_recipients
|
||||||
|
(to + cc + bcc).compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def multipart_body?
|
||||||
|
!text_body.nil? && !html_body.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
raise ArgumentError, 'message requires a from address' unless from
|
||||||
|
raise ArgumentError, 'message requires at least one recipient (to/cc/bcc)' if all_recipients.empty?
|
||||||
|
raise ArgumentError, 'message requires a text_body or html_body' if text_body.nil? && html_body.nil?
|
||||||
|
|
||||||
|
lines = header_lines
|
||||||
|
body = build_body
|
||||||
|
|
||||||
|
lines.concat(body[:headers])
|
||||||
|
lines << ''
|
||||||
|
lines << body[:content]
|
||||||
|
lines.join("\r\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def header_lines
|
||||||
|
lines = []
|
||||||
|
lines << "From: #{from}"
|
||||||
|
lines << "To: #{to.join(', ')}" unless to.empty?
|
||||||
|
lines << "Cc: #{cc.join(', ')}" unless cc.empty?
|
||||||
|
lines << "Reply-To: #{reply_to}" if reply_to
|
||||||
|
lines << "Subject: #{encode_header(subject.to_s)}"
|
||||||
|
lines << "Date: #{Time.now.httpdate}"
|
||||||
|
lines << "Message-ID: <#{@message_id}>"
|
||||||
|
lines << 'MIME-Version: 1.0'
|
||||||
|
headers.each { |name, value| lines << "#{name}: #{value}" }
|
||||||
|
lines
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_body
|
||||||
|
alternative = build_alternative_part
|
||||||
|
return alternative if attachments.empty?
|
||||||
|
|
||||||
|
boundary = new_boundary('mixed')
|
||||||
|
content = +"--#{boundary}\r\n"
|
||||||
|
content << alternative[:headers].join("\r\n")
|
||||||
|
content << "\r\n\r\n"
|
||||||
|
content << alternative[:content]
|
||||||
|
attachments.each { |attachment| content << "\r\n--#{boundary}\r\n#{attachment_part(attachment)}" }
|
||||||
|
content << "\r\n--#{boundary}--"
|
||||||
|
{ headers: ["Content-Type: multipart/mixed; boundary=\"#{boundary}\""], content: content }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_alternative_part
|
||||||
|
if multipart_body?
|
||||||
|
boundary = new_boundary('alt')
|
||||||
|
content = +"--#{boundary}\r\n"
|
||||||
|
content << "Content-Type: text/plain; charset=UTF-8\r\n\r\n#{text_body}"
|
||||||
|
content << "\r\n--#{boundary}\r\n"
|
||||||
|
content << "Content-Type: text/html; charset=UTF-8\r\n\r\n#{html_body}"
|
||||||
|
content << "\r\n--#{boundary}--"
|
||||||
|
{ headers: ["Content-Type: multipart/alternative; boundary=\"#{boundary}\""], content: content }
|
||||||
|
elsif html_body
|
||||||
|
{ headers: ['Content-Type: text/html; charset=UTF-8'], content: html_body }
|
||||||
|
else
|
||||||
|
{ headers: ['Content-Type: text/plain; charset=UTF-8'], content: text_body }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_part(attachment)
|
||||||
|
header = +"Content-Type: #{attachment[:mime_type]}; name=\"#{attachment[:filename]}\"\r\n"
|
||||||
|
header << "Content-Transfer-Encoding: base64\r\n"
|
||||||
|
header << "Content-Disposition: attachment; filename=\"#{attachment[:filename]}\"\r\n\r\n"
|
||||||
|
header << [attachment[:content]].pack('m0').scan(/.{1,76}/).join("\r\n")
|
||||||
|
header
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_boundary(label)
|
||||||
|
"prism-#{label}-#{SecureRandom.hex(8)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode_header(text)
|
||||||
|
return text if text.ascii_only?
|
||||||
|
|
||||||
|
"=?UTF-8?B?#{[text].pack('m0')}?="
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
module Prism
|
||||||
|
module Mailer
|
||||||
|
# ActionMailer::Preview-lite: subclass and define one instance method per
|
||||||
|
# preview action that returns a mailer Delivery built from fixture data
|
||||||
|
# (`UserMailer.welcome(sample_user)`) instead of a real request/record -
|
||||||
|
# so an email's rendered HTML/text can be inspected without sending it or
|
||||||
|
# ever touching Mailer.deliver!. Every subclass self-registers so a host
|
||||||
|
# app can list and browse them (e.g. mount them under a dev-only route).
|
||||||
|
class Preview
|
||||||
|
class << self
|
||||||
|
def registry
|
||||||
|
@registry ||= []
|
||||||
|
end
|
||||||
|
|
||||||
|
def inherited(subclass)
|
||||||
|
registry << subclass
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_name
|
||||||
|
Inflector.underscore(name.to_s.split('::').last.sub(/Preview\z/, ''))
|
||||||
|
end
|
||||||
|
|
||||||
|
def emails
|
||||||
|
instance_methods(false).map(&:to_s).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders one preview action by name and returns its Delivery
|
||||||
|
# (message.html_body / message.text_body / message.subject, ...);
|
||||||
|
# never calls #deliver_now or #deliver_later.
|
||||||
|
def call(email)
|
||||||
|
email = email.to_s
|
||||||
|
unless emails.include?(email)
|
||||||
|
raise ArgumentError, "unknown preview #{email.inspect} for #{self} (known: #{emails.join(', ')})"
|
||||||
|
end
|
||||||
|
new.public_send(email)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find(preview_name)
|
||||||
|
registry.find { |klass| klass.preview_name == preview_name.to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user