feat(mailer): introduce Prism and Mailer

This commit is contained in:
dualfroz committed 2026-09-07 17:16:23 +02:00
1 parent e4165f8cd6
commit 97f8d8e4f8
2 files changed
+175

No files matched your search

+131
View File
@@ -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
+44
View File
@@ -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