diff --git a/lib/prism/mailer/message.rb b/lib/prism/mailer/message.rb new file mode 100644 index 0000000..277a96a --- /dev/null +++ b/lib/prism/mailer/message.rb @@ -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 diff --git a/lib/prism/mailer/preview.rb b/lib/prism/mailer/preview.rb new file mode 100644 index 0000000..fd74543 --- /dev/null +++ b/lib/prism/mailer/preview.rb @@ -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